From e6341b390117a8ca58dcf9053b71e6b85330058a Mon Sep 17 00:00:00 2001 From: SapphireRhodonite Date: Mon, 15 Dec 2025 10:40:47 -0600 Subject: [PATCH 1/8] feat: add external display input handling --- .../main/java/app/gamenative/PrefManager.kt | 6 + .../ExternalDisplayInputController.kt | 398 ++++++++++++++++++ .../component/dialog/ContainerConfigDialog.kt | 34 ++ .../ui/screen/xserver/XServerScreen.kt | 18 + .../app/gamenative/utils/ContainerUtils.kt | 5 + .../com/winlator/container/Container.java | 15 + .../com/winlator/container/ContainerData.kt | 4 + app/src/main/res/values/strings.xml | 8 +- 8 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 99296aecc..0d70594d0 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -418,6 +418,12 @@ object PrefManager { get() = getPref(DINPUT_MAPPER_TYPE, 1) set(value) { setPref(DINPUT_MAPPER_TYPE, value) } + // External display input mode (off|touchpad|keyboard|hybrid) + private val EXTERNAL_DISPLAY_INPUT_MODE = stringPreferencesKey("external_display_input_mode") + var externalDisplayInputMode: String + get() = getPref(EXTERNAL_DISPLAY_INPUT_MODE, "hybrid") + set(value) { setPref(EXTERNAL_DISPLAY_INPUT_MODE, value) } + // Disable Mouse Input (prevents external mouse events) private val DISABLE_MOUSE_INPUT = booleanPreferencesKey("disable_mouse_input") var disableMouseInput: Boolean diff --git a/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt new file mode 100644 index 000000000..dff5d2cb7 --- /dev/null +++ b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt @@ -0,0 +1,398 @@ +package app.gamenative.externaldisplay + +import android.app.Presentation +import android.content.Context +import android.hardware.display.DisplayManager +import android.os.Handler +import android.os.Looper +import android.view.Display +import android.view.KeyCharacterMap +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.BaseInputConnection +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputMethodManager +import android.widget.FrameLayout +import com.winlator.widget.InputControlsView +import com.winlator.widget.TouchpadView +import com.winlator.winhandler.WinHandler +import com.winlator.xserver.XServer + +class ExternalDisplayInputController( + private val context: Context, + private val xServer: XServer, + private val winHandler: WinHandler, + private val inputControlsViewProvider: () -> InputControlsView?, + private val touchpadViewProvider: () -> TouchpadView?, +) { + enum class Mode { OFF, TOUCHPAD, KEYBOARD, HYBRID } + + companion object { + fun fromConfig(value: String?): Mode = when (value?.lowercase()) { + "touchpad" -> Mode.TOUCHPAD + "keyboard" -> Mode.KEYBOARD + "hybrid" -> Mode.HYBRID + else -> Mode.OFF + } + } + + private val displayManager = context.getSystemService(DisplayManager::class.java) + private var presentation: ExternalInputPresentation? = null + private var mode: Mode = Mode.OFF + + private val displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) { + updatePresentation() + } + + override fun onDisplayRemoved(displayId: Int) { + if (presentation?.display?.displayId == displayId) { + dismissPresentation() + } + updatePresentation() + } + + override fun onDisplayChanged(displayId: Int) { + if (presentation?.display?.displayId == displayId) { + updatePresentation() + } + } + } + + fun start() { + displayManager?.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) + updatePresentation() + } + + fun stop() { + dismissPresentation() + try { + displayManager?.unregisterDisplayListener(displayListener) + } catch (_: Exception) { + } + } + + fun setMode(mode: Mode) { + this.mode = mode + updatePresentation() + } + + private fun updatePresentation() { + if (mode == Mode.OFF) { + dismissPresentation() + return + } + + val targetDisplay = findPresentationDisplay() ?: run { + dismissPresentation() + return + } + + val needsNewPresentation = presentation?.display?.displayId != targetDisplay.displayId + if (presentation == null || needsNewPresentation) { + dismissPresentation() + presentation = ExternalInputPresentation( + context = context, + display = targetDisplay, + mode = mode, + xServer = xServer, + winHandler = winHandler, + inputControlsViewProvider = inputControlsViewProvider, + touchpadViewProvider = touchpadViewProvider, + ) + presentation?.show() + } else { + presentation?.updateMode(mode) + } + } + + private fun dismissPresentation() { + presentation?.dismiss() + presentation = null + } + + private fun findPresentationDisplay(): Display? { + // Required detection logic for external presentation displays + return displayManager + ?.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION) + ?.firstOrNull { display -> + display.displayId != Display.DEFAULT_DISPLAY && display.name != "HiddenDisplay" + } + } +} + +private class ExternalInputPresentation( + context: Context, + display: Display, + private var mode: ExternalDisplayInputController.Mode, + private val xServer: XServer, + private val winHandler: WinHandler, + private val inputControlsViewProvider: () -> InputControlsView?, + private val touchpadViewProvider: () -> TouchpadView?, +) : Presentation(context, display) { + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + renderContent() + } + + fun updateMode(newMode: ExternalDisplayInputController.Mode) { + if (mode != newMode) { + mode = newMode + renderContent() + } + } + + private fun renderContent() { + when (mode) { + ExternalDisplayInputController.Mode.TOUCHPAD -> { + val pad = TouchpadView(context, xServer, false).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setBackgroundColor(0xFF121212.toInt()) + touchpadViewProvider()?.let { primary -> + setSimTouchScreen(primary.isSimTouchScreen) + } + } + setContentView(pad) + } + ExternalDisplayInputController.Mode.KEYBOARD -> { + val keyboardView = ExternalKeyboardView( + context = context, + xServer = xServer, + winHandler = winHandler, + inputControlsViewProvider = inputControlsViewProvider, + ).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + setContentView(keyboardView) + } + ExternalDisplayInputController.Mode.HYBRID -> { + val hybrid = HybridInputLayout( + context = context, + xServer = xServer, + winHandler = winHandler, + inputControlsViewProvider = inputControlsViewProvider, + touchpadViewProvider = touchpadViewProvider, + ) + setContentView(hybrid) + } + else -> { + setContentView(FrameLayout(context)) + } + } + } +} + +private class HybridInputLayout( + context: Context, + xServer: XServer, + winHandler: WinHandler, + inputControlsViewProvider: () -> InputControlsView?, + touchpadViewProvider: () -> TouchpadView?, +) : FrameLayout(context) { + + private val headerHeightPx = (64 * resources.displayMetrics.density).toInt() + private val touchpad = TouchpadView(context, xServer, false).apply { + layoutParams = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setBackgroundColor(0xFF121212.toInt()) + touchpadViewProvider()?.let { primary -> + setSimTouchScreen(primary.isSimTouchScreen) + } + } + private val keyboard = ExternalKeyboardView( + context = context, + xServer = xServer, + winHandler = winHandler, + inputControlsViewProvider = inputControlsViewProvider, + autoShowImeOnTouch = false, + ).apply { + layoutParams = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + visibility = View.GONE + } + + private val header = View(context).apply { + layoutParams = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + headerHeightPx, + ) + setBackgroundColor(0xFF1E1E1E.toInt()) + setOnClickListener { toggleKeyboard() } + } + + init { + addView(touchpad) + addView(keyboard) + addView(header) + } + + private fun toggleKeyboard() { + if (keyboard.visibility == View.VISIBLE) { + keyboard.visibility = View.GONE + touchpad.visibility = View.VISIBLE + keyboard.hideIme() + } else { + keyboard.visibility = View.VISIBLE + touchpad.visibility = View.GONE + keyboard.requestFocus() + keyboard.showIme() + } + } +} + +private class ExternalKeyboardView( + context: Context, + private val xServer: XServer, + private val winHandler: WinHandler, + private val inputControlsViewProvider: () -> InputControlsView?, + private val autoShowImeOnTouch: Boolean = true, +) : FrameLayout(context) { + + private val inputMethodManager = context.getSystemService(InputMethodManager::class.java) + private val keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD) + + init { + isFocusable = true + isFocusableInTouchMode = true + setBackgroundColor(0xFF0F0F0F.toInt()) + post { if (autoShowImeOnTouch) showIme() } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (autoShowImeOnTouch) showIme() + } + + override fun onCheckIsTextEditor(): Boolean = true + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { + outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN + return KeyboardInputConnection(this, true) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val handledByControls = inputControlsViewProvider()?.onKeyEvent(event) == true + if (handledByControls) return true + return xServer.keyboard.onKeyEvent(event) + } + + override fun onGenericMotionEvent(event: MotionEvent?): Boolean { + event ?: return false + val handledByControls = inputControlsViewProvider()?.onGenericMotionEvent(event) == true + if (handledByControls) return true + return winHandler.onGenericMotionEvent(event) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (autoShowImeOnTouch) showIme() + return super.onTouchEvent(event) + } + + fun showIme() { + requestFocus() + inputMethodManager?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } + + fun hideIme() { + inputMethodManager?.hideSoftInputFromWindow(windowToken, 0) + } + + private inner class KeyboardInputConnection( + targetView: View, + fullEditor: Boolean, + ) : BaseInputConnection(targetView, fullEditor) { + private var composingText: String = "" + + override fun sendKeyEvent(event: KeyEvent): Boolean { + return dispatchKeyEvent(event) + } + + override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { + if (text.isNullOrEmpty()) return true + if (text == "\n") { + composingText = "" + return true // Do not inject newline via commit; let raw enter key events handle it + } + val newText = text.toString() + if (newText != composingText) { + sendChars(newText) + } + composingText = "" + return true + } + + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + composingText = "" + repeat(beforeLength) { + dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) + dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) + } + return true + } + + override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean { + if (text.isNullOrEmpty()) { + composingText = "" + return true + } + val newText = text.toString() + when { + newText.isEmpty() -> composingText = "" + newText.length <= composingText.length && newText.startsWith(composingText.take(newText.length)) -> { + // IME is trimming composition (likely from backspace); do not resend characters + composingText = newText + } + newText.startsWith(composingText) -> { + val delta = newText.substring(composingText.length) + if (delta.isNotEmpty()) sendChars(delta) + composingText = newText + } + else -> { + sendChars(newText) + composingText = newText + } + } + return true + } + + override fun finishComposingText(): Boolean { + composingText = "" + return true + } + + private fun sendChars(text: CharSequence) { + val events = keyCharacterMap.getEvents(text.toString().toCharArray()) + if (events != null) { + events.forEach { dispatchKeyEvent(it) } + } else { + text.forEach { ch -> + val down = KeyEvent( + 0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0, 0, 0, + KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE, ch.code, + ) + val up = KeyEvent( + 0, 0, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_UNKNOWN, 0, 0, 0, 0, + KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE, ch.code, + ) + dispatchKeyEvent(down) + dispatchKeyEvent(up) + } + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 605e0ac4a..94ba74664 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -156,6 +156,12 @@ fun ContainerConfigDialog( val renderingModes = stringArrayResource(R.array.offscreen_rendering_modes).toList() val videoMemSizes = stringArrayResource(R.array.video_memory_size_entries).toList() val mouseWarps = stringArrayResource(R.array.mouse_warp_override_entries).toList() + val externalDisplayModes = listOf( + stringResource(R.string.external_display_mode_off), + stringResource(R.string.external_display_mode_touchpad), + stringResource(R.string.external_display_mode_keyboard), + stringResource(R.string.external_display_mode_hybrid), + ) val winCompOpts = stringArrayResource(R.array.win_component_entries).toList() val box64Versions = stringArrayResource(R.array.box64_version_entries).toList() val wowBox64VersionsBase = stringArrayResource(R.array.wowbox64_version_entries).toList() @@ -657,6 +663,15 @@ fun ContainerConfigDialog( val index = mouseWarps.indexOfFirst { it.lowercase() == config.mouseWarpOverride } mutableIntStateOf(if (index >= 0) index else 0) } + var externalDisplayModeIndex by rememberSaveable { + val index = when (config.externalDisplayMode.lowercase()) { + "touchpad" -> 1 + "keyboard" -> 2 + "hybrid" -> 3 + else -> 0 + } + mutableIntStateOf(index) + } var languageIndex by rememberSaveable { val idx = languages.indexOfFirst { it == config.language.lowercase() } mutableIntStateOf(if (idx >= 0) idx else languages.indexOf("english")) @@ -1634,6 +1649,25 @@ fun ContainerConfigDialog( state = config.touchscreenMode, onCheckedChange = { config = config.copy(touchscreenMode = it) } ) + // External display handling + SettingsListDropdown( + colors = settingsTileColors(), + title = { Text(text = stringResource(R.string.external_display_input)) }, + subtitle = { Text(text = stringResource(R.string.external_display_input_subtitle)) }, + value = externalDisplayModeIndex, + items = externalDisplayModes, + onItemSelected = { index -> + externalDisplayModeIndex = index + config = config.copy( + externalDisplayMode = when (index) { + 1 -> "touchpad" + 2 -> "keyboard" + 3 -> "hybrid" + else -> "off" + }, + ) + }, + ) } if (selectedTab == 4) SettingsGroup() { // TODO: add desktop settings diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index dd15a8d08..815fbe490 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -59,6 +59,7 @@ import app.gamenative.data.LaunchInfo import app.gamenative.data.SteamApp import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent +import app.gamenative.externaldisplay.ExternalDisplayInputController import app.gamenative.service.SteamService import app.gamenative.ui.component.settings.SettingsListDropdown import app.gamenative.ui.data.XServerState @@ -792,6 +793,23 @@ fun XServerScreen( // Add InputControlsView on top of XServerView frameLayout.addView(icView) + val externalDisplayController = ExternalDisplayInputController( + context = context, + xServer = xServerView.getxServer(), + winHandler = xServerView.getxServer().winHandler, + inputControlsViewProvider = { PluviaApp.inputControlsView }, + touchpadViewProvider = { PluviaApp.touchpadView }, + ).apply { + setMode(ExternalDisplayInputController.fromConfig(container.externalDisplayMode)) + start() + } + frameLayout.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) {} + + override fun onViewDetachedFromWindow(v: View) { + externalDisplayController.stop() + } + }) // Don't call hideInputControls() here - let the auto-show logic below handle visibility // so that the view gets measured/laid out and has valid dimensions for element loading diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 4aaa12987..109bcd8a0 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -118,6 +118,7 @@ object ContainerUtils { enableDInput = PrefManager.dinputEnabled, dinputMapperType = PrefManager.dinputMapperType.toByte(), disableMouseInput = PrefManager.disableMouseInput, + externalDisplayMode = PrefManager.externalDisplayInputMode, sharpnessEffect = PrefManager.sharpnessEffect, sharpnessLevel = PrefManager.sharpnessLevel, sharpnessDenoise = PrefManager.sharpnessDenoise, @@ -155,6 +156,7 @@ object ContainerUtils { PrefManager.mouseWarpOverride = containerData.mouseWarpOverride PrefManager.useDRI3 = containerData.useDRI3 PrefManager.disableMouseInput = containerData.disableMouseInput + PrefManager.externalDisplayInputMode = containerData.externalDisplayMode PrefManager.containerLanguage = containerData.language PrefManager.containerVariant = containerData.containerVariant PrefManager.wineVersion = containerData.wineVersion @@ -219,6 +221,7 @@ object ContainerUtils { val disableMouse = container.isDisableMouseInput() // Read touchscreen-mode flag from container val touchscreenMode = container.isTouchscreenMode() + val externalDisplayMode = container.getExternalDisplayMode() return ContainerData( name = container.name, @@ -261,6 +264,7 @@ object ContainerUtils { dinputMapperType = mapperType, disableMouseInput = disableMouse, touchscreenMode = touchscreenMode, + externalDisplayMode = externalDisplayMode, csmt = csmt, videoPciDeviceID = videoPciDeviceID, offScreenRenderingMode = offScreenRenderingMode, @@ -381,6 +385,7 @@ object ContainerUtils { container.setFEXCorePreset(containerData.fexcorePreset) container.setDisableMouseInput(containerData.disableMouseInput) container.setTouchscreenMode(containerData.touchscreenMode) + container.setExternalDisplayMode(containerData.externalDisplayMode) container.setForceDlc(containerData.forceDlc) container.setUseLegacyDRM(containerData.useLegacyDRM) container.putExtra("sharpnessEffect", containerData.sharpnessEffect) diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index 21dc53d7d..383b0d897 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -112,6 +112,8 @@ public enum XrControllerMapping { private boolean disableMouseInput = false; // Touchscreen mode private boolean touchscreenMode = false; + // External display input handling + private String externalDisplayMode = "hybrid"; // Prefer DRI3 WSI path private boolean useDRI3 = true; // Steam client type for selecting appropriate Box64 RC config: normal, light, ultralight @@ -646,6 +648,7 @@ public void saveData() { data.put("disableMouseInput", disableMouseInput); // Touchscreen mode flag data.put("touchscreenMode", touchscreenMode); + data.put("externalDisplayMode", externalDisplayMode); data.put("useDRI3", useDRI3); data.put("installPath", installPath); data.put("steamType", steamType); @@ -816,6 +819,9 @@ public void loadData(JSONObject data) throws JSONException { case "touchscreenMode" : setTouchscreenMode(data.getBoolean(key)); break; + case "externalDisplayMode" : + setExternalDisplayMode(data.getString(key)); + break; case "useDRI3" : setUseDRI3(data.getBoolean(key)); break; @@ -943,6 +949,15 @@ public void setTouchscreenMode(boolean touchscreenMode) { this.touchscreenMode = touchscreenMode; } + // External display mode + public String getExternalDisplayMode() { + return externalDisplayMode != null ? externalDisplayMode : "hybrid"; + } + + public void setExternalDisplayMode(String externalDisplayMode) { + this.externalDisplayMode = externalDisplayMode != null ? externalDisplayMode : "touchpad"; + } + // Use DRI3 WSI public boolean isUseDRI3() { return useDRI3; diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index ef0f2e9df..8a4f2d92e 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -72,6 +72,8 @@ data class ContainerData( val disableMouseInput: Boolean = false, /** Touchscreen mode **/ val touchscreenMode: Boolean = false, + /** External display input handling: off|touchpad|keyboard|hybrid **/ + val externalDisplayMode: String = "hybrid", /** Preferred game language (Goldberg) **/ val language: String = "english", val forceDlc: Boolean = false, @@ -125,6 +127,7 @@ data class ContainerData( "dinputMapperType" to state.dinputMapperType, "disableMouseInput" to state.disableMouseInput, "touchscreenMode" to state.touchscreenMode, + "externalDisplayMode" to state.externalDisplayMode, "useDRI3" to state.useDRI3, "language" to state.language, "forceDlc" to state.forceDlc, @@ -177,6 +180,7 @@ data class ContainerData( dinputMapperType = savedMap["dinputMapperType"] as Byte, disableMouseInput = savedMap["disableMouseInput"] as Boolean, touchscreenMode = savedMap["touchscreenMode"] as Boolean, + externalDisplayMode = (savedMap["externalDisplayMode"] as? String) ?: "touchpad", useDRI3 = (savedMap["useDRI3"] as? Boolean) ?: true, language = (savedMap["language"] as? String) ?: "english", forceDlc = (savedMap["forceDlc"] as? Boolean) ?: false, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 800f74a37..d8f5a6e61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -513,6 +513,12 @@ Disable Mouse Input Touchscreen Mode Direct touch-to-cursor movement (ON) vs touchpad-style relative movement (OFF) + External Display Input + Choose how a connected presentation display should behave + Do not use external display + Use as touchpad surface + Use as full keyboard + Hybrid (touchpad + keyboard bar) Start With On-Screen Controls Hidden On-screen controls will be hidden when the game starts. Toggle via the navigation menu. Emulate keyboard and mouse @@ -929,5 +935,3 @@ No containers are currently using this version. These containers will no longer work if you proceed: - - From 1b6834374c5092c5ccba8f8cb260404200d4bf33 Mon Sep 17 00:00:00 2001 From: SapphireRhodonite Date: Mon, 15 Dec 2025 23:15:40 -0600 Subject: [PATCH 2/8] externaldisplay: do not steal focus from main window --- .../externaldisplay/ExternalDisplayInputController.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt index dff5d2cb7..b4c53913c 100644 --- a/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt +++ b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt @@ -11,6 +11,7 @@ import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.view.WindowManager import android.view.inputmethod.BaseInputConnection import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection @@ -136,6 +137,10 @@ private class ExternalInputPresentation( override fun onCreate(savedInstanceState: android.os.Bundle?) { super.onCreate(savedInstanceState) + window?.setFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + ) renderContent() } From 4ce077930e2562a94bfe5d47ca137d2c6f3c5cfe Mon Sep 17 00:00:00 2001 From: SapphireRhodonite Date: Sun, 4 Jan 2026 03:19:04 -0600 Subject: [PATCH 3/8] Refactor: improve external display keyboard This commit refactors the external display input handling. It removes the `ExternalKeyboardView` and replaces it with a new `ExternalOnScreenKeyboardView` for a cleaner implementation. The `winHandler` and `inputControlsViewProvider` dependencies have been removed from the `ExternalDisplayInputController` and related classes. The "Hybrid" mode UI is updated to use a floating action button to toggle the on-screen keyboard visibility over the touchpad, instead of switching between two full-screen views. --- .../ExternalDisplayInputController.kt | 265 +++++------------- .../ExternalOnScreenKeyboardView.kt | 261 +++++++++++++++++ .../ui/screen/xserver/XServerScreen.kt | 2 - 3 files changed, 330 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt diff --git a/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt index b4c53913c..7ee0eaa9d 100644 --- a/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt +++ b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt @@ -2,31 +2,28 @@ package app.gamenative.externaldisplay import android.app.Presentation import android.content.Context +import android.graphics.drawable.GradientDrawable import android.hardware.display.DisplayManager import android.os.Handler import android.os.Looper import android.view.Display -import android.view.KeyCharacterMap -import android.view.KeyEvent -import android.view.MotionEvent +import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.WindowManager -import android.view.inputmethod.BaseInputConnection -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputConnection -import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout -import com.winlator.widget.InputControlsView +import android.widget.ImageButton +import android.widget.ImageView +import app.gamenative.R import com.winlator.widget.TouchpadView -import com.winlator.winhandler.WinHandler import com.winlator.xserver.XServer +private const val EXTERNAL_TOUCHPAD_BG: Int = 0xFF2B2B2B.toInt() +private const val EXTERNAL_KEYBOARD_BG: Int = 0xFF2B2B2B.toInt() + class ExternalDisplayInputController( private val context: Context, private val xServer: XServer, - private val winHandler: WinHandler, - private val inputControlsViewProvider: () -> InputControlsView?, private val touchpadViewProvider: () -> TouchpadView?, ) { enum class Mode { OFF, TOUCHPAD, KEYBOARD, HYBRID } @@ -100,8 +97,6 @@ class ExternalDisplayInputController( display = targetDisplay, mode = mode, xServer = xServer, - winHandler = winHandler, - inputControlsViewProvider = inputControlsViewProvider, touchpadViewProvider = touchpadViewProvider, ) presentation?.show() @@ -130,8 +125,6 @@ private class ExternalInputPresentation( display: Display, private var mode: ExternalDisplayInputController.Mode, private val xServer: XServer, - private val winHandler: WinHandler, - private val inputControlsViewProvider: () -> InputControlsView?, private val touchpadViewProvider: () -> TouchpadView?, ) : Presentation(context, display) { @@ -159,7 +152,7 @@ private class ExternalInputPresentation( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) - setBackgroundColor(0xFF121212.toInt()) + setBackgroundColor(EXTERNAL_TOUCHPAD_BG) touchpadViewProvider()?.let { primary -> setSimTouchScreen(primary.isSimTouchScreen) } @@ -167,25 +160,42 @@ private class ExternalInputPresentation( setContentView(pad) } ExternalDisplayInputController.Mode.KEYBOARD -> { - val keyboardView = ExternalKeyboardView( - context = context, - xServer = xServer, - winHandler = winHandler, - inputControlsViewProvider = inputControlsViewProvider, - ).apply { + val root = FrameLayout(context).apply { layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) + setBackgroundColor(EXTERNAL_KEYBOARD_BG) + } + + val hintIcon = ImageView(context).apply { + val density = resources.displayMetrics.density + val sizePx = (128 * density).toInt() + layoutParams = FrameLayout.LayoutParams(sizePx, sizePx).apply { + gravity = Gravity.CENTER + } + setImageResource(R.drawable.icon_keyboard) + alpha = 0.35f + scaleType = ImageView.ScaleType.FIT_CENTER + } + + val keyboardView = ExternalOnScreenKeyboardView(context, xServer).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + gravity = Gravity.BOTTOM + } } - setContentView(keyboardView) + + root.addView(hintIcon) + root.addView(keyboardView) + setContentView(root) } ExternalDisplayInputController.Mode.HYBRID -> { val hybrid = HybridInputLayout( context = context, xServer = xServer, - winHandler = winHandler, - inputControlsViewProvider = inputControlsViewProvider, touchpadViewProvider = touchpadViewProvider, ) setContentView(hybrid) @@ -200,204 +210,67 @@ private class ExternalInputPresentation( private class HybridInputLayout( context: Context, xServer: XServer, - winHandler: WinHandler, - inputControlsViewProvider: () -> InputControlsView?, touchpadViewProvider: () -> TouchpadView?, ) : FrameLayout(context) { - private val headerHeightPx = (64 * resources.displayMetrics.density).toInt() private val touchpad = TouchpadView(context, xServer, false).apply { layoutParams = LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) - setBackgroundColor(0xFF121212.toInt()) + setBackgroundColor(EXTERNAL_TOUCHPAD_BG) touchpadViewProvider()?.let { primary -> setSimTouchScreen(primary.isSimTouchScreen) } } - private val keyboard = ExternalKeyboardView( - context = context, - xServer = xServer, - winHandler = winHandler, - inputControlsViewProvider = inputControlsViewProvider, - autoShowImeOnTouch = false, - ).apply { + private val keyboardView = ExternalOnScreenKeyboardView(context, xServer).apply { layoutParams = LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + gravity = Gravity.BOTTOM + } visibility = View.GONE } - private val header = View(context).apply { - layoutParams = LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - headerHeightPx, - ) - setBackgroundColor(0xFF1E1E1E.toInt()) + private val keyboardToggleButton = ImageButton(context).apply { + val density = resources.displayMetrics.density + val sizePx = (56 * density).toInt() + val marginPx = (16 * density).toInt() + layoutParams = LayoutParams(sizePx, sizePx).apply { + gravity = Gravity.BOTTOM or Gravity.END + setMargins(marginPx, marginPx, marginPx, marginPx) + } + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(0xFF3A3A3A.toInt()) + } + setImageResource(R.drawable.icon_keyboard) + scaleType = ImageView.ScaleType.CENTER_INSIDE + setPadding(marginPx / 2, marginPx / 2, marginPx / 2, marginPx / 2) setOnClickListener { toggleKeyboard() } } init { addView(touchpad) - addView(keyboard) - addView(header) - } + addView(keyboardView) + addView(keyboardToggleButton) - private fun toggleKeyboard() { - if (keyboard.visibility == View.VISIBLE) { - keyboard.visibility = View.GONE - touchpad.visibility = View.VISIBLE - keyboard.hideIme() - } else { - keyboard.visibility = View.VISIBLE - touchpad.visibility = View.GONE - keyboard.requestFocus() - keyboard.showIme() + keyboardView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + updateToggleButtonPosition() } } -} - -private class ExternalKeyboardView( - context: Context, - private val xServer: XServer, - private val winHandler: WinHandler, - private val inputControlsViewProvider: () -> InputControlsView?, - private val autoShowImeOnTouch: Boolean = true, -) : FrameLayout(context) { - - private val inputMethodManager = context.getSystemService(InputMethodManager::class.java) - private val keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD) - - init { - isFocusable = true - isFocusableInTouchMode = true - setBackgroundColor(0xFF0F0F0F.toInt()) - post { if (autoShowImeOnTouch) showIme() } - } - override fun onAttachedToWindow() { - super.onAttachedToWindow() - if (autoShowImeOnTouch) showIme() - } - - override fun onCheckIsTextEditor(): Boolean = true - - override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { - outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT - outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN - return KeyboardInputConnection(this, true) - } - - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val handledByControls = inputControlsViewProvider()?.onKeyEvent(event) == true - if (handledByControls) return true - return xServer.keyboard.onKeyEvent(event) - } - - override fun onGenericMotionEvent(event: MotionEvent?): Boolean { - event ?: return false - val handledByControls = inputControlsViewProvider()?.onGenericMotionEvent(event) == true - if (handledByControls) return true - return winHandler.onGenericMotionEvent(event) - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - if (autoShowImeOnTouch) showIme() - return super.onTouchEvent(event) - } - - fun showIme() { - requestFocus() - inputMethodManager?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) - } - - fun hideIme() { - inputMethodManager?.hideSoftInputFromWindow(windowToken, 0) + private fun toggleKeyboard() { + val shouldShow = keyboardView.visibility != View.VISIBLE + keyboardView.visibility = if (shouldShow) View.VISIBLE else View.GONE + post { updateToggleButtonPosition() } } - - private inner class KeyboardInputConnection( - targetView: View, - fullEditor: Boolean, - ) : BaseInputConnection(targetView, fullEditor) { - private var composingText: String = "" - - override fun sendKeyEvent(event: KeyEvent): Boolean { - return dispatchKeyEvent(event) - } - - override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { - if (text.isNullOrEmpty()) return true - if (text == "\n") { - composingText = "" - return true // Do not inject newline via commit; let raw enter key events handle it - } - val newText = text.toString() - if (newText != composingText) { - sendChars(newText) - } - composingText = "" - return true - } - - override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { - composingText = "" - repeat(beforeLength) { - dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) - dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) - } - return true - } - - override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean { - if (text.isNullOrEmpty()) { - composingText = "" - return true - } - val newText = text.toString() - when { - newText.isEmpty() -> composingText = "" - newText.length <= composingText.length && newText.startsWith(composingText.take(newText.length)) -> { - // IME is trimming composition (likely from backspace); do not resend characters - composingText = newText - } - newText.startsWith(composingText) -> { - val delta = newText.substring(composingText.length) - if (delta.isNotEmpty()) sendChars(delta) - composingText = newText - } - else -> { - sendChars(newText) - composingText = newText - } - } - return true - } - - override fun finishComposingText(): Boolean { - composingText = "" - return true - } - - private fun sendChars(text: CharSequence) { - val events = keyCharacterMap.getEvents(text.toString().toCharArray()) - if (events != null) { - events.forEach { dispatchKeyEvent(it) } - } else { - text.forEach { ch -> - val down = KeyEvent( - 0, 0, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, 0, 0, 0, 0, - KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE, ch.code, - ) - val up = KeyEvent( - 0, 0, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_UNKNOWN, 0, 0, 0, 0, - KeyEvent.FLAG_SOFT_KEYBOARD or KeyEvent.FLAG_KEEP_TOUCH_MODE, ch.code, - ) - dispatchKeyEvent(down) - dispatchKeyEvent(up) - } - } + private fun updateToggleButtonPosition() { + keyboardToggleButton.translationY = if (keyboardView.visibility == View.VISIBLE) { + -keyboardView.height.toFloat() + } else { + 0f } } } diff --git a/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt b/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt new file mode 100644 index 000000000..fa0204453 --- /dev/null +++ b/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt @@ -0,0 +1,261 @@ +package app.gamenative.externaldisplay + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import com.winlator.xserver.XKeycode +import com.winlator.xserver.XServer + +class ExternalOnScreenKeyboardView( + context: Context, + private val xServer: XServer, +) : LinearLayout(context) { + + private enum class ShiftState { OFF, ON, CAPS } + + private data class KeySpec( + val normalLabel: String, + val shiftedLabel: String? = null, + val keycode: XKeycode? = null, + val weight: Float = 1f, + val isLetter: Boolean = false, + val action: Action = Action.INPUT, + ) + + private enum class Action { INPUT, SHIFT, BACKSPACE, ENTER, SPACE, TAB, ESC, ARROW_LEFT, ARROW_DOWN, ARROW_RIGHT, ARROW_UP } + + private data class KeyButton( + val spec: KeySpec, + val button: Button, + ) + + private val keyButtons = mutableListOf() + private var shiftState: ShiftState = ShiftState.OFF + + init { + orientation = VERTICAL + val padding = dp(8) + setPadding(padding, padding, padding, padding) + setBackgroundColor(0xFF1F1F1F.toInt()) + buildLayout() + refreshLabels() + } + + private fun buildLayout() { + addRow( + listOf( + KeySpec("Esc", keycode = XKeycode.KEY_ESC, weight = 1.25f, action = Action.ESC), + KeySpec("1", "!", XKeycode.KEY_1), + KeySpec("2", "@", XKeycode.KEY_2), + KeySpec("3", "#", XKeycode.KEY_3), + KeySpec("4", "$", XKeycode.KEY_4), + KeySpec("5", "%", XKeycode.KEY_5), + KeySpec("6", "^", XKeycode.KEY_6), + KeySpec("7", "&", XKeycode.KEY_7), + KeySpec("8", "*", XKeycode.KEY_8), + KeySpec("9", "(", XKeycode.KEY_9), + KeySpec("0", ")", XKeycode.KEY_0), + KeySpec("-", "_", XKeycode.KEY_MINUS), + KeySpec("=", "+", XKeycode.KEY_EQUAL), + KeySpec("⌫", keycode = XKeycode.KEY_BKSP, weight = 1.75f, action = Action.BACKSPACE), + ), + ) + + addRow( + listOf( + KeySpec("Tab", keycode = XKeycode.KEY_TAB, weight = 1.5f, action = Action.TAB), + KeySpec("q", "Q", XKeycode.KEY_Q, isLetter = true), + KeySpec("w", "W", XKeycode.KEY_W, isLetter = true), + KeySpec("e", "E", XKeycode.KEY_E, isLetter = true), + KeySpec("r", "R", XKeycode.KEY_R, isLetter = true), + KeySpec("t", "T", XKeycode.KEY_T, isLetter = true), + KeySpec("y", "Y", XKeycode.KEY_Y, isLetter = true), + KeySpec("u", "U", XKeycode.KEY_U, isLetter = true), + KeySpec("i", "I", XKeycode.KEY_I, isLetter = true), + KeySpec("o", "O", XKeycode.KEY_O, isLetter = true), + KeySpec("p", "P", XKeycode.KEY_P, isLetter = true), + KeySpec("[", "{", XKeycode.KEY_BRACKET_LEFT), + KeySpec("]", "}", XKeycode.KEY_BRACKET_RIGHT), + KeySpec("\\", "|", XKeycode.KEY_BACKSLASH, weight = 1.25f), + ), + ) + + addRow( + listOf( + KeySpec("Shift", weight = 1.75f, action = Action.SHIFT), + KeySpec("a", "A", XKeycode.KEY_A, isLetter = true), + KeySpec("s", "S", XKeycode.KEY_S, isLetter = true), + KeySpec("d", "D", XKeycode.KEY_D, isLetter = true), + KeySpec("f", "F", XKeycode.KEY_F, isLetter = true), + KeySpec("g", "G", XKeycode.KEY_G, isLetter = true), + KeySpec("h", "H", XKeycode.KEY_H, isLetter = true), + KeySpec("j", "J", XKeycode.KEY_J, isLetter = true), + KeySpec("k", "K", XKeycode.KEY_K, isLetter = true), + KeySpec("l", "L", XKeycode.KEY_L, isLetter = true), + KeySpec(";", ":", XKeycode.KEY_SEMICOLON), + KeySpec("'", "\"", XKeycode.KEY_APOSTROPHE), + KeySpec("Enter", keycode = XKeycode.KEY_ENTER, weight = 2.0f, action = Action.ENTER), + ), + ) + + addRow( + listOf( + KeySpec("`", "~", XKeycode.KEY_GRAVE, weight = 1.25f), + KeySpec("z", "Z", XKeycode.KEY_Z, isLetter = true), + KeySpec("x", "X", XKeycode.KEY_X, isLetter = true), + KeySpec("c", "C", XKeycode.KEY_C, isLetter = true), + KeySpec("v", "V", XKeycode.KEY_V, isLetter = true), + KeySpec("b", "B", XKeycode.KEY_B, isLetter = true), + KeySpec("n", "N", XKeycode.KEY_N, isLetter = true), + KeySpec("m", "M", XKeycode.KEY_M, isLetter = true), + KeySpec(",", "<", XKeycode.KEY_COMMA), + KeySpec(".", ">", XKeycode.KEY_PERIOD), + KeySpec("/", "?", XKeycode.KEY_SLASH), + KeySpec("↑", keycode = XKeycode.KEY_UP, weight = 1.25f, action = Action.ARROW_UP), + ), + ) + + addRow( + listOf( + KeySpec("Space", keycode = XKeycode.KEY_SPACE, weight = 6f, action = Action.SPACE), + KeySpec("←", keycode = XKeycode.KEY_LEFT, weight = 1.25f, action = Action.ARROW_LEFT), + KeySpec("↓", keycode = XKeycode.KEY_DOWN, weight = 1.25f, action = Action.ARROW_DOWN), + KeySpec("→", keycode = XKeycode.KEY_RIGHT, weight = 1.25f, action = Action.ARROW_RIGHT), + ), + ) + } + + private fun addRow(keys: List) { + val row = LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER + layoutParams = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + } + + val margin = dp(3) + val height = dp(48) + + keys.forEach { spec -> + val button = Button(context).apply { + isAllCaps = false + setTextColor(Color.WHITE) + setTextSize(16f) + typeface = Typeface.DEFAULT_BOLD + text = spec.normalLabel + background = createKeyBackground(normal = true) + setPadding(0, 0, 0, 0) + layoutParams = LayoutParams(0, height, spec.weight).apply { + setMargins(margin, margin, margin, margin) + } + setOnClickListener { handleKeyPress(spec) } + } + keyButtons += KeyButton(spec, button) + row.addView(button) + } + + addView(row) + } + + private fun handleKeyPress(spec: KeySpec) { + when (spec.action) { + Action.SHIFT -> cycleShift() + Action.BACKSPACE -> tapKey(XKeycode.KEY_BKSP) + Action.ENTER -> tapKey(XKeycode.KEY_ENTER) + Action.SPACE -> tapKey(XKeycode.KEY_SPACE) + Action.TAB -> tapKey(XKeycode.KEY_TAB) + Action.ESC -> tapKey(XKeycode.KEY_ESC) + Action.ARROW_LEFT -> tapKey(XKeycode.KEY_LEFT) + Action.ARROW_DOWN -> tapKey(XKeycode.KEY_DOWN) + Action.ARROW_RIGHT -> tapKey(XKeycode.KEY_RIGHT) + Action.ARROW_UP -> tapKey(XKeycode.KEY_UP) + Action.INPUT -> { + val keycode = spec.keycode ?: return + val useShift = when (shiftState) { + ShiftState.OFF -> false + ShiftState.ON -> true + ShiftState.CAPS -> spec.isLetter + } + tapKey(keycode, useShift) + if (shiftState == ShiftState.ON) { + shiftState = ShiftState.OFF + refreshLabels() + } + } + } + } + + private fun cycleShift() { + shiftState = when (shiftState) { + ShiftState.OFF -> ShiftState.ON + ShiftState.ON -> ShiftState.CAPS + ShiftState.CAPS -> ShiftState.OFF + } + refreshLabels() + } + + private fun refreshLabels() { + val shiftForLetters = shiftState != ShiftState.OFF + keyButtons.forEach { (spec, button) -> + if (spec.action == Action.SHIFT) { + val label = when (shiftState) { + ShiftState.OFF -> "Shift" + ShiftState.ON -> "Shift" + ShiftState.CAPS -> "Caps" + } + button.text = label + button.background = when (shiftState) { + ShiftState.OFF -> createKeyBackground(normal = true) + ShiftState.ON -> createKeyBackground(highlight = true) + ShiftState.CAPS -> createKeyBackground(highlight = true, strong = true) + } + return@forEach + } + + val showShifted = when { + spec.isLetter -> shiftForLetters + shiftState == ShiftState.ON -> true + else -> false + } + + button.text = if (showShifted && spec.shiftedLabel != null) spec.shiftedLabel else spec.normalLabel + button.background = createKeyBackground(normal = true) + } + } + + private fun tapKey(key: XKeycode, withShift: Boolean = false) { + if (withShift) xServer.injectKeyPress(XKeycode.KEY_SHIFT_L) + xServer.injectKeyPress(key) + xServer.injectKeyRelease(key) + if (withShift) xServer.injectKeyRelease(XKeycode.KEY_SHIFT_L) + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + private fun createKeyBackground( + normal: Boolean = false, + highlight: Boolean = false, + strong: Boolean = false, + ): GradientDrawable { + val radius = dp(8).toFloat() + val color = when { + highlight && strong -> 0xFF2E5AAC.toInt() + highlight -> 0xFF3D6CC4.toInt() + normal -> 0xFF3A3A3A.toInt() + else -> 0xFF3A3A3A.toInt() + } + return GradientDrawable().apply { + cornerRadius = radius + setColor(color) + } + } +} + diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 815fbe490..2c4f58a84 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -796,8 +796,6 @@ fun XServerScreen( val externalDisplayController = ExternalDisplayInputController( context = context, xServer = xServerView.getxServer(), - winHandler = xServerView.getxServer().winHandler, - inputControlsViewProvider = { PluviaApp.inputControlsView }, touchpadViewProvider = { PluviaApp.touchpadView }, ).apply { setMode(ExternalDisplayInputController.fromConfig(container.externalDisplayMode)) From 17172e2fe74118021685df5eb4a35195265244f7 Mon Sep 17 00:00:00 2001 From: SapphireRhodonite Date: Sun, 4 Jan 2026 03:30:28 -0600 Subject: [PATCH 4/8] refactor: Update external display mode string --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0521ded1d..f51c2a094 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,7 +522,7 @@ Do not use external display Use as touchpad surface Use as full keyboard - Hybrid (touchpad + keyboard bar) + Hybrid (touchpad + keyboard button) Start With On-Screen Controls Hidden On-screen controls will be hidden when the game starts. Toggle via the navigation menu. Emulate keyboard and mouse From 4ac5841a3597f7263513401f45a3a2529508afab Mon Sep 17 00:00:00 2001 From: SapphireRhodonite Date: Wed, 7 Jan 2026 12:37:17 -0600 Subject: [PATCH 5/8] view: add pressed state to on-screen keyboard buttons --- .../ExternalOnScreenKeyboardView.kt | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt b/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt index fa0204453..14ef202e0 100644 --- a/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt +++ b/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -11,6 +12,7 @@ import android.widget.Button import android.widget.LinearLayout import com.winlator.xserver.XKeycode import com.winlator.xserver.XServer +import kotlin.math.roundToInt class ExternalOnScreenKeyboardView( context: Context, @@ -244,18 +246,35 @@ class ExternalOnScreenKeyboardView( normal: Boolean = false, highlight: Boolean = false, strong: Boolean = false, - ): GradientDrawable { + ): StateListDrawable { val radius = dp(8).toFloat() - val color = when { + val baseColor = when { highlight && strong -> 0xFF2E5AAC.toInt() highlight -> 0xFF3D6CC4.toInt() normal -> 0xFF3A3A3A.toInt() else -> 0xFF3A3A3A.toInt() } - return GradientDrawable().apply { + + val pressedColor = blendColor(baseColor, Color.WHITE, 0.18f) + + fun shape(color: Int): GradientDrawable = GradientDrawable().apply { cornerRadius = radius setColor(color) } + + return StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_pressed), shape(pressedColor)) + addState(intArrayOf(), shape(baseColor)) + } } -} + private fun blendColor(from: Int, to: Int, ratio: Float): Int { + val clamped = ratio.coerceIn(0f, 1f) + val inverse = 1f - clamped + val a = (Color.alpha(from) * inverse + Color.alpha(to) * clamped).roundToInt() + val r = (Color.red(from) * inverse + Color.red(to) * clamped).roundToInt() + val g = (Color.green(from) * inverse + Color.green(to) * clamped).roundToInt() + val b = (Color.blue(from) * inverse + Color.blue(to) * clamped).roundToInt() + return Color.argb(a, r, g, b) + } +} From 5d75f5a891767a714414eb81f9920b1e9c4067ff Mon Sep 17 00:00:00 2001 From: SapphireRhodonite Date: Wed, 7 Jan 2026 13:50:25 -0600 Subject: [PATCH 6/8] feat: send key down/up events --- .../ExternalOnScreenKeyboardView.kt | 75 +++++++++++++++---- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt b/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt index 14ef202e0..48d3759f9 100644 --- a/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt +++ b/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt @@ -6,6 +6,7 @@ import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.graphics.drawable.StateListDrawable import android.view.Gravity +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.Button @@ -38,10 +39,12 @@ class ExternalOnScreenKeyboardView( ) private val keyButtons = mutableListOf() + private val downKeys = mutableSetOf() private var shiftState: ShiftState = ShiftState.OFF init { orientation = VERTICAL + setMotionEventSplittingEnabled(true) val padding = dp(8) setPadding(padding, padding, padding, padding) setBackgroundColor(0xFF1F1F1F.toInt()) @@ -158,7 +161,10 @@ class ExternalOnScreenKeyboardView( layoutParams = LayoutParams(0, height, spec.weight).apply { setMargins(margin, margin, margin, margin) } - setOnClickListener { handleKeyPress(spec) } + setOnTouchListener { _, event -> + handleKeyTouch(spec, event) + false + } } keyButtons += KeyButton(spec, button) row.addView(button) @@ -167,18 +173,25 @@ class ExternalOnScreenKeyboardView( addView(row) } - private fun handleKeyPress(spec: KeySpec) { + private fun handleKeyTouch(spec: KeySpec, event: MotionEvent) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> onKeyDown(spec) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> onKeyUp(spec, cancel = event.actionMasked == MotionEvent.ACTION_CANCEL) + } + } + + private fun onKeyDown(spec: KeySpec) { when (spec.action) { - Action.SHIFT -> cycleShift() - Action.BACKSPACE -> tapKey(XKeycode.KEY_BKSP) - Action.ENTER -> tapKey(XKeycode.KEY_ENTER) - Action.SPACE -> tapKey(XKeycode.KEY_SPACE) - Action.TAB -> tapKey(XKeycode.KEY_TAB) - Action.ESC -> tapKey(XKeycode.KEY_ESC) - Action.ARROW_LEFT -> tapKey(XKeycode.KEY_LEFT) - Action.ARROW_DOWN -> tapKey(XKeycode.KEY_DOWN) - Action.ARROW_RIGHT -> tapKey(XKeycode.KEY_RIGHT) - Action.ARROW_UP -> tapKey(XKeycode.KEY_UP) + Action.SHIFT -> Unit + Action.BACKSPACE -> pressKey(XKeycode.KEY_BKSP) + Action.ENTER -> pressKey(XKeycode.KEY_ENTER) + Action.SPACE -> pressKey(XKeycode.KEY_SPACE) + Action.TAB -> pressKey(XKeycode.KEY_TAB) + Action.ESC -> pressKey(XKeycode.KEY_ESC) + Action.ARROW_LEFT -> pressKey(XKeycode.KEY_LEFT) + Action.ARROW_DOWN -> pressKey(XKeycode.KEY_DOWN) + Action.ARROW_RIGHT -> pressKey(XKeycode.KEY_RIGHT) + Action.ARROW_UP -> pressKey(XKeycode.KEY_UP) Action.INPUT -> { val keycode = spec.keycode ?: return val useShift = when (shiftState) { @@ -186,7 +199,7 @@ class ExternalOnScreenKeyboardView( ShiftState.ON -> true ShiftState.CAPS -> spec.isLetter } - tapKey(keycode, useShift) + pressKey(keycode, withShift = useShift) if (shiftState == ShiftState.ON) { shiftState = ShiftState.OFF refreshLabels() @@ -195,6 +208,22 @@ class ExternalOnScreenKeyboardView( } } + private fun onKeyUp(spec: KeySpec, cancel: Boolean) { + when (spec.action) { + Action.SHIFT -> if (!cancel) cycleShift() + Action.BACKSPACE -> releaseKey(XKeycode.KEY_BKSP) + Action.ENTER -> releaseKey(XKeycode.KEY_ENTER) + Action.SPACE -> releaseKey(XKeycode.KEY_SPACE) + Action.TAB -> releaseKey(XKeycode.KEY_TAB) + Action.ESC -> releaseKey(XKeycode.KEY_ESC) + Action.ARROW_LEFT -> releaseKey(XKeycode.KEY_LEFT) + Action.ARROW_DOWN -> releaseKey(XKeycode.KEY_DOWN) + Action.ARROW_RIGHT -> releaseKey(XKeycode.KEY_RIGHT) + Action.ARROW_UP -> releaseKey(XKeycode.KEY_UP) + Action.INPUT -> spec.keycode?.let { releaseKey(it) } + } + } + private fun cycleShift() { shiftState = when (shiftState) { ShiftState.OFF -> ShiftState.ON @@ -233,15 +262,29 @@ class ExternalOnScreenKeyboardView( } } - private fun tapKey(key: XKeycode, withShift: Boolean = false) { - if (withShift) xServer.injectKeyPress(XKeycode.KEY_SHIFT_L) + private fun pressKey(key: XKeycode, withShift: Boolean = false) { + if (!downKeys.add(key)) return + val shiftWasDown = xServer.keyboard.modifiersMask.isSet(1) + if (withShift && !shiftWasDown) xServer.injectKeyPress(XKeycode.KEY_SHIFT_L) xServer.injectKeyPress(key) + if (withShift && !shiftWasDown) xServer.injectKeyRelease(XKeycode.KEY_SHIFT_L) + } + + private fun releaseKey(key: XKeycode) { + if (!downKeys.remove(key)) return xServer.injectKeyRelease(key) - if (withShift) xServer.injectKeyRelease(XKeycode.KEY_SHIFT_L) } private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + override fun onDetachedFromWindow() { + downKeys.toList().forEach { key -> + xServer.injectKeyRelease(key) + } + downKeys.clear() + super.onDetachedFromWindow() + } + private fun createKeyBackground( normal: Boolean = false, highlight: Boolean = false, From ea55cbec8c78bd8d709407d98e83c6153e47807a Mon Sep 17 00:00:00 2001 From: SapphireRhodonite Date: Fri, 16 Jan 2026 16:41:46 -0600 Subject: [PATCH 7/8] Chore: fixed PR 402 externaldisplay: add screen swap support --- .../main/java/app/gamenative/PrefManager.kt | 7 +- .../ExternalDisplayInputController.kt | 23 +-- .../ExternalDisplaySwapController.kt | 150 ++++++++++++++++++ .../ExternalOnScreenKeyboardView.kt | 17 +- .../externaldisplay/SwapInputOverlayView.kt | 119 ++++++++++++++ .../component/dialog/ContainerConfigDialog.kt | 22 ++- .../ui/screen/xserver/XServerScreen.kt | 97 +++++++++-- .../app/gamenative/utils/ContainerUtils.kt | 8 +- .../com/winlator/container/Container.java | 27 +++- .../com/winlator/container/ContainerData.kt | 8 +- app/src/main/res/values/colors.xml | 5 + app/src/main/res/values/strings.xml | 2 + 12 files changed, 441 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/app/gamenative/externaldisplay/ExternalDisplaySwapController.kt create mode 100644 app/src/main/java/app/gamenative/externaldisplay/SwapInputOverlayView.kt diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index f5d83cc95..9b379e7f7 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -426,9 +426,14 @@ object PrefManager { // External display input mode (off|touchpad|keyboard|hybrid) private val EXTERNAL_DISPLAY_INPUT_MODE = stringPreferencesKey("external_display_input_mode") var externalDisplayInputMode: String - get() = getPref(EXTERNAL_DISPLAY_INPUT_MODE, "hybrid") + get() = getPref(EXTERNAL_DISPLAY_INPUT_MODE, Container.DEFAULT_EXTERNAL_DISPLAY_MODE) set(value) { setPref(EXTERNAL_DISPLAY_INPUT_MODE, value) } + private val EXTERNAL_DISPLAY_SWAP = booleanPreferencesKey("external_display_swap") + var externalDisplaySwap: Boolean + get() = getPref(EXTERNAL_DISPLAY_SWAP, false) + set(value) { setPref(EXTERNAL_DISPLAY_SWAP, value) } + // Disable Mouse Input (prevents external mouse events) private val DISABLE_MOUSE_INPUT = booleanPreferencesKey("disable_mouse_input") var disableMouseInput: Boolean diff --git a/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt index 7ee0eaa9d..6b6f65c5f 100644 --- a/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt +++ b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt @@ -14,12 +14,14 @@ import android.view.WindowManager import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView +import androidx.core.content.ContextCompat import app.gamenative.R +import com.winlator.container.Container import com.winlator.widget.TouchpadView import com.winlator.xserver.XServer -private const val EXTERNAL_TOUCHPAD_BG: Int = 0xFF2B2B2B.toInt() -private const val EXTERNAL_KEYBOARD_BG: Int = 0xFF2B2B2B.toInt() +private const val EXTERNAL_SURFACE_BG_RES: Int = R.color.external_display_surface_background +private const val EXTERNAL_KEY_BG_RES: Int = R.color.external_display_key_background class ExternalDisplayInputController( private val context: Context, @@ -30,9 +32,9 @@ class ExternalDisplayInputController( companion object { fun fromConfig(value: String?): Mode = when (value?.lowercase()) { - "touchpad" -> Mode.TOUCHPAD - "keyboard" -> Mode.KEYBOARD - "hybrid" -> Mode.HYBRID + Container.EXTERNAL_DISPLAY_MODE_TOUCHPAD -> Mode.TOUCHPAD + Container.EXTERNAL_DISPLAY_MODE_KEYBOARD -> Mode.KEYBOARD + Container.EXTERNAL_DISPLAY_MODE_HYBRID -> Mode.HYBRID else -> Mode.OFF } } @@ -111,11 +113,12 @@ class ExternalDisplayInputController( } private fun findPresentationDisplay(): Display? { + val currentDisplay = context.display ?: return null // Required detection logic for external presentation displays return displayManager ?.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION) ?.firstOrNull { display -> - display.displayId != Display.DEFAULT_DISPLAY && display.name != "HiddenDisplay" + display.displayId != currentDisplay.displayId && display.name != "HiddenDisplay" } } } @@ -152,7 +155,7 @@ private class ExternalInputPresentation( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) - setBackgroundColor(EXTERNAL_TOUCHPAD_BG) + setBackgroundColor(ContextCompat.getColor(context, EXTERNAL_SURFACE_BG_RES)) touchpadViewProvider()?.let { primary -> setSimTouchScreen(primary.isSimTouchScreen) } @@ -165,7 +168,7 @@ private class ExternalInputPresentation( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) - setBackgroundColor(EXTERNAL_KEYBOARD_BG) + setBackgroundColor(ContextCompat.getColor(context, EXTERNAL_SURFACE_BG_RES)) } val hintIcon = ImageView(context).apply { @@ -218,7 +221,7 @@ private class HybridInputLayout( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) - setBackgroundColor(EXTERNAL_TOUCHPAD_BG) + setBackgroundColor(ContextCompat.getColor(context, EXTERNAL_SURFACE_BG_RES)) touchpadViewProvider()?.let { primary -> setSimTouchScreen(primary.isSimTouchScreen) } @@ -243,7 +246,7 @@ private class HybridInputLayout( } background = GradientDrawable().apply { shape = GradientDrawable.OVAL - setColor(0xFF3A3A3A.toInt()) + setColor(ContextCompat.getColor(context, EXTERNAL_KEY_BG_RES)) } setImageResource(R.drawable.icon_keyboard) scaleType = ImageView.ScaleType.CENTER_INSIDE diff --git a/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplaySwapController.kt b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplaySwapController.kt new file mode 100644 index 000000000..816c0ad90 --- /dev/null +++ b/app/src/main/java/app/gamenative/externaldisplay/ExternalDisplaySwapController.kt @@ -0,0 +1,150 @@ +package app.gamenative.externaldisplay + +import android.app.Presentation +import android.content.Context +import android.hardware.display.DisplayManager +import android.os.Handler +import android.os.Looper +import android.view.Display +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import com.winlator.widget.XServerView + +class ExternalDisplaySwapController( + private val context: Context, + private val xServerViewProvider: () -> XServerView?, + private val internalGameHostProvider: () -> ViewGroup?, + private val onGameOnExternalChanged: (Boolean) -> Unit = {}, +) { + private val displayManager = context.getSystemService(DisplayManager::class.java) + private var presentation: GamePresentation? = null + private var swapEnabled: Boolean = false + private var gameOnExternal: Boolean = false + + private val displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = updatePresentation() + + override fun onDisplayRemoved(displayId: Int) { + if (presentation?.display?.displayId == displayId) { + dismissPresentation() + } + updatePresentation() + } + + override fun onDisplayChanged(displayId: Int) { + if (presentation?.display?.displayId == displayId) { + updatePresentation() + } + } + } + + fun start() { + displayManager?.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) + updatePresentation() + } + + fun stop() { + dismissPresentation() + try { + displayManager?.unregisterDisplayListener(displayListener) + } catch (_: Exception) { + } + } + + fun setSwapEnabled(enabled: Boolean) { + if (swapEnabled == enabled) return + swapEnabled = enabled + updatePresentation() + } + + private fun updatePresentation() { + val targetDisplay = if (swapEnabled) findPresentationDisplay() else null + if (targetDisplay == null) { + moveGameToInternal() + dismissPresentation() + return + } + + val needsNewPresentation = presentation?.display?.displayId != targetDisplay.displayId + if (presentation == null || needsNewPresentation) { + dismissPresentation() + presentation = GamePresentation(context, targetDisplay).also { it.show() } + } + moveGameToExternal() + } + + private fun dismissPresentation() { + presentation?.dismiss() + presentation = null + setGameOnExternal(false) + } + + private fun moveGameToExternal() { + val xServerView = xServerViewProvider() ?: return + val root = presentation?.root ?: return + val parent = xServerView.parent as? ViewGroup + if (parent != null && parent != root) parent.removeView(xServerView) + if (xServerView.parent == null) { + xServerView.layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + root.addView(xServerView) + } + setGameOnExternal(true) + } + + private fun moveGameToInternal() { + val xServerView = xServerViewProvider() ?: return + val internalHost = internalGameHostProvider() ?: return + val parent = xServerView.parent as? ViewGroup + if (parent != null && parent != internalHost) parent.removeView(xServerView) + if (xServerView.parent == null) { + xServerView.layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + internalHost.addView(xServerView) + } + setGameOnExternal(false) + } + + private fun setGameOnExternal(value: Boolean) { + if (gameOnExternal == value) return + gameOnExternal = value + onGameOnExternalChanged(value) + } + + private fun findPresentationDisplay(): Display? { + val currentDisplay = context.display ?: return null + return displayManager + ?.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION) + ?.firstOrNull { display -> + display.displayId != currentDisplay.displayId && display.name != "HiddenDisplay" + } + } +} + +private class GamePresentation( + outerContext: Context, + display: Display, +) : Presentation(outerContext, display) { + val root: FrameLayout by lazy { + FrameLayout(context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + } + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + window?.setFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + ) + setContentView(root) + } +} diff --git a/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt b/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt index 48d3759f9..c7c73848a 100644 --- a/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt +++ b/app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt @@ -11,6 +11,8 @@ import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import app.gamenative.R import com.winlator.xserver.XKeycode import com.winlator.xserver.XServer import kotlin.math.roundToInt @@ -41,13 +43,18 @@ class ExternalOnScreenKeyboardView( private val keyButtons = mutableListOf() private val downKeys = mutableSetOf() private var shiftState: ShiftState = ShiftState.OFF + private val keyboardBackgroundColor: Int = ContextCompat.getColor(context, R.color.external_display_keyboard_background) + private val keyBackgroundColor: Int = ContextCompat.getColor(context, R.color.external_display_key_background) + private val keyHighlightColor: Int = ContextCompat.getColor(context, R.color.external_display_key_highlight_background) + private val keyHighlightStrongColor: Int = + ContextCompat.getColor(context, R.color.external_display_key_highlight_strong_background) init { orientation = VERTICAL setMotionEventSplittingEnabled(true) val padding = dp(8) setPadding(padding, padding, padding, padding) - setBackgroundColor(0xFF1F1F1F.toInt()) + setBackgroundColor(keyboardBackgroundColor) buildLayout() refreshLabels() } @@ -292,10 +299,10 @@ class ExternalOnScreenKeyboardView( ): StateListDrawable { val radius = dp(8).toFloat() val baseColor = when { - highlight && strong -> 0xFF2E5AAC.toInt() - highlight -> 0xFF3D6CC4.toInt() - normal -> 0xFF3A3A3A.toInt() - else -> 0xFF3A3A3A.toInt() + highlight && strong -> keyHighlightStrongColor + highlight -> keyHighlightColor + normal -> keyBackgroundColor + else -> keyBackgroundColor } val pressedColor = blendColor(baseColor, Color.WHITE, 0.18f) diff --git a/app/src/main/java/app/gamenative/externaldisplay/SwapInputOverlayView.kt b/app/src/main/java/app/gamenative/externaldisplay/SwapInputOverlayView.kt new file mode 100644 index 000000000..af457ea1f --- /dev/null +++ b/app/src/main/java/app/gamenative/externaldisplay/SwapInputOverlayView.kt @@ -0,0 +1,119 @@ +package app.gamenative.externaldisplay + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import androidx.core.content.ContextCompat +import app.gamenative.R +import com.winlator.xserver.XServer + +class SwapInputOverlayView( + context: Context, + private val xServer: XServer, +) : FrameLayout(context) { + + private var mode: ExternalDisplayInputController.Mode = ExternalDisplayInputController.Mode.OFF + + private val hintIcon: ImageView = ImageView(context).apply { + val density = resources.displayMetrics.density + val sizePx = (128 * density).toInt() + layoutParams = LayoutParams(sizePx, sizePx).apply { + gravity = Gravity.CENTER + } + setImageResource(R.drawable.icon_keyboard) + alpha = 0.35f + scaleType = ImageView.ScaleType.FIT_CENTER + visibility = View.GONE + isClickable = false + isFocusable = false + } + + private val keyboardView: ExternalOnScreenKeyboardView = ExternalOnScreenKeyboardView(context, xServer).apply { + layoutParams = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + gravity = Gravity.BOTTOM + } + visibility = View.GONE + } + + private val keyboardToggleButton: ImageButton = ImageButton(context).apply { + val density = resources.displayMetrics.density + val sizePx = (56 * density).toInt() + val marginPx = (16 * density).toInt() + layoutParams = LayoutParams(sizePx, sizePx).apply { + gravity = Gravity.BOTTOM or Gravity.END + setMargins(marginPx, marginPx, marginPx, marginPx) + } + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(ContextCompat.getColor(context, R.color.external_display_key_background)) + } + setImageResource(R.drawable.icon_keyboard) + scaleType = ImageView.ScaleType.CENTER_INSIDE + setPadding(marginPx / 2, marginPx / 2, marginPx / 2, marginPx / 2) + visibility = View.GONE + setOnClickListener { toggleKeyboard() } + } + + init { + layoutParams = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + isClickable = false + isFocusable = false + + addView(hintIcon) + addView(keyboardView) + addView(keyboardToggleButton) + + keyboardView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + updateToggleButtonPosition() + } + } + + fun setMode(mode: ExternalDisplayInputController.Mode) { + this.mode = mode + when (mode) { + ExternalDisplayInputController.Mode.KEYBOARD -> { + hintIcon.visibility = View.VISIBLE + keyboardToggleButton.visibility = View.GONE + keyboardView.visibility = View.VISIBLE + updateToggleButtonPosition() + } + ExternalDisplayInputController.Mode.HYBRID -> { + hintIcon.visibility = View.GONE + keyboardToggleButton.visibility = View.VISIBLE + keyboardView.visibility = View.GONE + updateToggleButtonPosition() + } + else -> { + hintIcon.visibility = View.GONE + keyboardToggleButton.visibility = View.GONE + keyboardView.visibility = View.GONE + updateToggleButtonPosition() + } + } + } + + private fun toggleKeyboard() { + if (mode != ExternalDisplayInputController.Mode.HYBRID) return + keyboardView.visibility = if (keyboardView.visibility == View.VISIBLE) View.GONE else View.VISIBLE + post { updateToggleButtonPosition() } + } + + private fun updateToggleButtonPosition() { + keyboardToggleButton.translationY = if (keyboardView.visibility == View.VISIBLE) { + -keyboardView.height.toFloat() + } else { + 0f + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index c3b3985dd..44fdbe3b3 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -686,9 +686,9 @@ fun ContainerConfigDialog( } var externalDisplayModeIndex by rememberSaveable { val index = when (config.externalDisplayMode.lowercase()) { - "touchpad" -> 1 - "keyboard" -> 2 - "hybrid" -> 3 + Container.EXTERNAL_DISPLAY_MODE_TOUCHPAD -> 1 + Container.EXTERNAL_DISPLAY_MODE_KEYBOARD -> 2 + Container.EXTERNAL_DISPLAY_MODE_HYBRID -> 3 else -> 0 } mutableIntStateOf(index) @@ -1730,14 +1730,21 @@ fun ContainerConfigDialog( externalDisplayModeIndex = index config = config.copy( externalDisplayMode = when (index) { - 1 -> "touchpad" - 2 -> "keyboard" - 3 -> "hybrid" - else -> "off" + 1 -> Container.EXTERNAL_DISPLAY_MODE_TOUCHPAD + 2 -> Container.EXTERNAL_DISPLAY_MODE_KEYBOARD + 3 -> Container.EXTERNAL_DISPLAY_MODE_HYBRID + else -> Container.EXTERNAL_DISPLAY_MODE_OFF }, ) }, ) + SettingsSwitch( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.external_display_swap)) }, + subtitle = { Text(text = stringResource(R.string.external_display_swap_subtitle)) }, + state = config.externalDisplaySwap, + onCheckedChange = { config = config.copy(externalDisplaySwap = it) } + ) } if (selectedTab == 4) SettingsGroup() { // TODO: add desktop settings @@ -2103,4 +2110,3 @@ private fun ExecutablePathDropdown( } } } - diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 7ba55018d..72b91e9b3 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -2,9 +2,11 @@ package app.gamenative.ui.screen.xserver import android.app.Activity import android.content.Context +import android.graphics.Color import android.os.Build import android.util.Log import android.view.View +import android.view.ViewGroup import android.view.WindowInsets import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout @@ -48,6 +50,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import app.gamenative.R +import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.LifecycleOwner @@ -61,6 +64,8 @@ import app.gamenative.data.SteamApp import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent import app.gamenative.externaldisplay.ExternalDisplayInputController +import app.gamenative.externaldisplay.ExternalDisplaySwapController +import app.gamenative.externaldisplay.SwapInputOverlayView import app.gamenative.service.SteamService import app.gamenative.service.gog.GOGService import app.gamenative.ui.component.settings.SettingsListDropdown @@ -238,6 +243,8 @@ fun XServerScreen( result } + var swapInputOverlay: SwapInputOverlayView? by remember { mutableStateOf(null) } + var win32AppWorkarounds: Win32AppWorkarounds? by remember { mutableStateOf(null) } var physicalControllerHandler: PhysicalControllerHandler? by remember { mutableStateOf(null) } @@ -500,17 +507,22 @@ fun XServerScreen( modifier = Modifier .fillMaxSize() .pointerHoverIcon(PointerIcon(0)) - .pointerInteropFilter { + .pointerInteropFilter { event -> + val overlayHandled = swapInputOverlay + ?.takeIf { it.visibility == View.VISIBLE } + ?.dispatchTouchEvent(event) == true + if (overlayHandled) return@pointerInteropFilter true + // If controls are visible, let them handle it first val controlsHandled = if (areControlsVisible) { - PluviaApp.inputControlsView?.onTouchEvent(it) ?: false + PluviaApp.inputControlsView?.onTouchEvent(event) ?: false } else { false } // If controls didn't handle it or aren't visible, send to touchMouse if (!controlsHandled) { - PluviaApp.touchpadView?.onTouchEvent(it) + PluviaApp.touchpadView?.onTouchEvent(event) } true @@ -743,9 +755,16 @@ fun XServerScreen( } } } - PluviaApp.xServerView = xServerView; + PluviaApp.xServerView = xServerView - frameLayout.addView(xServerView) + val gameHost = FrameLayout(context).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + frameLayout.addView(gameHost) + gameHost.addView(xServerView) PluviaApp.inputControlsManager = InputControlsManager(context) @@ -812,19 +831,71 @@ fun XServerScreen( // Add InputControlsView on top of XServerView frameLayout.addView(icView) - val externalDisplayController = ExternalDisplayInputController( - context = context, - xServer = xServerView.getxServer(), - touchpadViewProvider = { PluviaApp.touchpadView }, - ).apply { - setMode(ExternalDisplayInputController.fromConfig(container.externalDisplayMode)) - start() + val configuredExternalMode = ExternalDisplayInputController.fromConfig(container.externalDisplayMode) + val swapEnabled = container.isExternalDisplaySwap + + val overlay = SwapInputOverlayView(context, xServerView.getxServer()).apply { + visibility = View.GONE + setMode(ExternalDisplayInputController.Mode.OFF) } + frameLayout.addView(overlay) + swapInputOverlay = overlay + + val externalDisplayController = + if (!swapEnabled && configuredExternalMode != ExternalDisplayInputController.Mode.OFF) { + ExternalDisplayInputController( + context = context, + xServer = xServerView.getxServer(), + touchpadViewProvider = { PluviaApp.touchpadView }, + ).apply { + setMode(configuredExternalMode) + start() + } + } else { + null + } + + val swapController = + if (swapEnabled) { + val surfaceBg = ContextCompat.getColor(context, R.color.external_display_surface_background) + ExternalDisplaySwapController( + context = context, + xServerViewProvider = { xServerView }, + internalGameHostProvider = { gameHost }, + onGameOnExternalChanged = { gameOnExternal -> + if (gameOnExternal) { + PluviaApp.touchpadView?.setBackgroundColor(surfaceBg) + when (configuredExternalMode) { + ExternalDisplayInputController.Mode.KEYBOARD, + ExternalDisplayInputController.Mode.HYBRID, + -> { + overlay.visibility = View.VISIBLE + overlay.setMode(configuredExternalMode) + } + else -> { + overlay.visibility = View.GONE + overlay.setMode(ExternalDisplayInputController.Mode.OFF) + } + } + } else { + PluviaApp.touchpadView?.setBackgroundColor(Color.TRANSPARENT) + overlay.visibility = View.GONE + overlay.setMode(ExternalDisplayInputController.Mode.OFF) + } + }, + ).apply { + setSwapEnabled(true) + start() + } + } else { + null + } frameLayout.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) {} override fun onViewDetachedFromWindow(v: View) { - externalDisplayController.stop() + externalDisplayController?.stop() + swapController?.stop() } }) // Don't call hideInputControls() here - let the auto-show logic below handle visibility diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index fc613b2f4..84d95cfcf 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -119,9 +119,10 @@ object ContainerUtils { useDRI3 = PrefManager.useDRI3, enableXInput = PrefManager.xinputEnabled, enableDInput = PrefManager.dinputEnabled, - dinputMapperType = PrefManager.dinputMapperType.toByte(), + dinputMapperType = PrefManager.dinputMapperType.toByte(), disableMouseInput = PrefManager.disableMouseInput, externalDisplayMode = PrefManager.externalDisplayInputMode, + externalDisplaySwap = PrefManager.externalDisplaySwap, sharpnessEffect = PrefManager.sharpnessEffect, sharpnessLevel = PrefManager.sharpnessLevel, sharpnessDenoise = PrefManager.sharpnessDenoise, @@ -160,6 +161,7 @@ object ContainerUtils { PrefManager.useDRI3 = containerData.useDRI3 PrefManager.disableMouseInput = containerData.disableMouseInput PrefManager.externalDisplayInputMode = containerData.externalDisplayMode + PrefManager.externalDisplaySwap = containerData.externalDisplaySwap PrefManager.containerLanguage = containerData.language PrefManager.containerVariant = containerData.containerVariant PrefManager.wineVersion = containerData.wineVersion @@ -225,6 +227,7 @@ object ContainerUtils { // Read touchscreen-mode flag from container val touchscreenMode = container.isTouchscreenMode() val externalDisplayMode = container.getExternalDisplayMode() + val externalDisplaySwap = container.isExternalDisplaySwap() return ContainerData( name = container.name, @@ -268,6 +271,7 @@ object ContainerUtils { disableMouseInput = disableMouse, touchscreenMode = touchscreenMode, externalDisplayMode = externalDisplayMode, + externalDisplaySwap = externalDisplaySwap, csmt = csmt, videoPciDeviceID = videoPciDeviceID, offScreenRenderingMode = offScreenRenderingMode, @@ -389,6 +393,7 @@ object ContainerUtils { container.setDisableMouseInput(containerData.disableMouseInput) container.setTouchscreenMode(containerData.touchscreenMode) container.setExternalDisplayMode(containerData.externalDisplayMode) + container.setExternalDisplaySwap(containerData.externalDisplaySwap) container.setForceDlc(containerData.forceDlc) container.setUseLegacyDRM(containerData.useLegacyDRM) container.putExtra("sharpnessEffect", containerData.sharpnessEffect) @@ -1088,4 +1093,3 @@ object ContainerUtils { return systemKeywords.any { fileName.contains(it) } } } - diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index 383b0d897..61feb778b 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -26,6 +26,13 @@ public enum XrControllerMapping { THUMBSTICK_UP, THUMBSTICK_DOWN, THUMBSTICK_LEFT, THUMBSTICK_RIGHT } + // External display modes + public static final String EXTERNAL_DISPLAY_MODE_OFF = "off"; + public static final String EXTERNAL_DISPLAY_MODE_TOUCHPAD = "touchpad"; + public static final String EXTERNAL_DISPLAY_MODE_KEYBOARD = "keyboard"; + public static final String EXTERNAL_DISPLAY_MODE_HYBRID = "hybrid"; + public static final String DEFAULT_EXTERNAL_DISPLAY_MODE = EXTERNAL_DISPLAY_MODE_OFF; + public static final String DEFAULT_ENV_VARS = "WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform DXVK_FRAME_RATE=60 PULSE_LATENCY_MSEC=144"; public static final String DEFAULT_SCREEN_SIZE = "1280x720"; public static final String DEFAULT_GRAPHICS_DRIVER = DefaultVersion.DEFAULT_GRAPHICS_DRIVER; @@ -113,7 +120,9 @@ public enum XrControllerMapping { // Touchscreen mode private boolean touchscreenMode = false; // External display input handling - private String externalDisplayMode = "hybrid"; + private String externalDisplayMode = DEFAULT_EXTERNAL_DISPLAY_MODE; + // Swap game/input between internal and external displays + private boolean externalDisplaySwap = false; // Prefer DRI3 WSI path private boolean useDRI3 = true; // Steam client type for selecting appropriate Box64 RC config: normal, light, ultralight @@ -649,6 +658,7 @@ public void saveData() { // Touchscreen mode flag data.put("touchscreenMode", touchscreenMode); data.put("externalDisplayMode", externalDisplayMode); + data.put("externalDisplaySwap", externalDisplaySwap); data.put("useDRI3", useDRI3); data.put("installPath", installPath); data.put("steamType", steamType); @@ -822,6 +832,9 @@ public void loadData(JSONObject data) throws JSONException { case "externalDisplayMode" : setExternalDisplayMode(data.getString(key)); break; + case "externalDisplaySwap" : + setExternalDisplaySwap(data.getBoolean(key)); + break; case "useDRI3" : setUseDRI3(data.getBoolean(key)); break; @@ -951,11 +964,19 @@ public void setTouchscreenMode(boolean touchscreenMode) { // External display mode public String getExternalDisplayMode() { - return externalDisplayMode != null ? externalDisplayMode : "hybrid"; + return externalDisplayMode != null ? externalDisplayMode : DEFAULT_EXTERNAL_DISPLAY_MODE; } public void setExternalDisplayMode(String externalDisplayMode) { - this.externalDisplayMode = externalDisplayMode != null ? externalDisplayMode : "touchpad"; + this.externalDisplayMode = externalDisplayMode != null ? externalDisplayMode : DEFAULT_EXTERNAL_DISPLAY_MODE; + } + + public boolean isExternalDisplaySwap() { + return externalDisplaySwap; + } + + public void setExternalDisplaySwap(boolean externalDisplaySwap) { + this.externalDisplaySwap = externalDisplaySwap; } // Use DRI3 WSI diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index 8a4f2d92e..6e3c35b57 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -73,7 +73,9 @@ data class ContainerData( /** Touchscreen mode **/ val touchscreenMode: Boolean = false, /** External display input handling: off|touchpad|keyboard|hybrid **/ - val externalDisplayMode: String = "hybrid", + val externalDisplayMode: String = Container.DEFAULT_EXTERNAL_DISPLAY_MODE, + /** Swap game/input between internal and external displays **/ + val externalDisplaySwap: Boolean = false, /** Preferred game language (Goldberg) **/ val language: String = "english", val forceDlc: Boolean = false, @@ -128,6 +130,7 @@ data class ContainerData( "disableMouseInput" to state.disableMouseInput, "touchscreenMode" to state.touchscreenMode, "externalDisplayMode" to state.externalDisplayMode, + "externalDisplaySwap" to state.externalDisplaySwap, "useDRI3" to state.useDRI3, "language" to state.language, "forceDlc" to state.forceDlc, @@ -180,7 +183,8 @@ data class ContainerData( dinputMapperType = savedMap["dinputMapperType"] as Byte, disableMouseInput = savedMap["disableMouseInput"] as Boolean, touchscreenMode = savedMap["touchscreenMode"] as Boolean, - externalDisplayMode = (savedMap["externalDisplayMode"] as? String) ?: "touchpad", + externalDisplayMode = (savedMap["externalDisplayMode"] as? String) ?: Container.DEFAULT_EXTERNAL_DISPLAY_MODE, + externalDisplaySwap = (savedMap["externalDisplaySwap"] as? Boolean) ?: false, useDRI3 = (savedMap["useDRI3"] as? Boolean) ?: true, language = (savedMap["language"] as? String) ?: "english", forceDlc = (savedMap["forceDlc"] as? Boolean) ?: false, diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 9831c9b71..07c06509b 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -5,4 +5,9 @@ #455a64 #06B6D4 #FAFAFA + #2B2B2B + #1F1F1F + #3A3A3A + #3D6CC4 + #2E5AAC diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d7d0fbba6..6b44779b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -527,6 +527,8 @@ Use as touchpad surface Use as full keyboard Hybrid (touchpad + keyboard button) + Show Game on External Display + Swap screens so the game renders on the external display and this device becomes the controller surface Start With On-Screen Controls Hidden On-screen controls will be hidden when the game starts. Toggle via the navigation menu. Emulate keyboard and mouse From 662a1eac68e3b09b567445d8de77773bbb8eceea Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Mon, 19 Jan 2026 17:36:09 +0530 Subject: [PATCH 8/8] Added translations for dual screen strings --- app/src/main/res/values-da/strings.xml | 8 ++++++++ app/src/main/res/values-de/strings.xml | 8 ++++++++ app/src/main/res/values-fr/strings.xml | 8 ++++++++ app/src/main/res/values-pt-rBR/strings.xml | 8 ++++++++ app/src/main/res/values-uk/strings.xml | 8 ++++++++ app/src/main/res/values-zh-rCN/strings.xml | 8 ++++++++ app/src/main/res/values-zh-rTW/strings.xml | 8 ++++++++ 7 files changed, 56 insertions(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index bd4e82901..ca28a8d68 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -355,6 +355,14 @@ Deaktivér musinput Touchskærmstilstand Direkte touch-til-cursor-bevægelse (TIL) vs touchpad-stil relativ bevægelse (FRA) + Ekstern skærm-input + Vælg hvordan en tilsluttet præsentationsskærm skal opføre sig + Brug ikke ekstern skærm + Brug som touchpad-overflade + Brug som fuldt tastatur + Hybrid (touchpad + tastaturknap) + Vis spil på ekstern skærm + Byt skærme, så spillet gengives på den eksterne skærm og denne enhed bliver controlleroverfladen Start med on-screen-kontroller skjult On-screen-kontroller vil være skjult når spillet starter. Skift via navigationsmenuen. Emulér tastatur og mus diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8d211c403..de26c88b3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -490,6 +490,14 @@ Mauseingabe deaktivieren Touchscreen-Modus Direkte Zeigerbewegung (AN) vs. Touchpad-ähnliche relative Bewegung (AUS) + Externe Bildschirm-Eingabe + Wähle, wie ein angeschlossener Präsentationsbildschirm sich verhalten soll + Externen Bildschirm nicht verwenden + Als Touchpad-Oberfläche verwenden + Als vollständige Tastatur verwenden + Hybrid (Touchpad + Tastaturtaste) + Spiel auf externem Bildschirm anzeigen + Bildschirme tauschen, damit das Spiel auf dem externen Bildschirm gerendert wird und dieses Gerät zur Controller-Oberfläche wird Mit versteckten On-Screen-Controls starten On-Screen-Controls sind beim Spielstart ausgeblendet. Über das Menü ein-/ausblendbar. Tastatur und Maus emulieren diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aebb899b9..dca43fda3 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -517,6 +517,14 @@ Désactiver l\'entrée souris Mode écran tactile Mouvement tactile direct vers le curseur (ON) vs mouvement relatif style pavé tactile (OFF) + Entrée d\'écran externe + Choisissez comment un écran de présentation connecté doit se comporter + Ne pas utiliser l\'écran externe + Utiliser comme surface de pavé tactile + Utiliser comme clavier complet + Hybride (pavé tactile + bouton clavier) + Afficher le jeu sur l\'écran externe + Permuter les écrans pour que le jeu s\'affiche sur l\'écran externe et que cet appareil devienne la surface de contrôle Démarrer avec les contrôles à l\'écran masqués Les contrôles à l\'écran seront masqués au démarrage du jeu. Basculez via le menu de navigation. Émuler clavier et souris diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index eef60bce1..32c68614a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -355,6 +355,14 @@ Desabilitar entrada do mouse Modo Touchscreen Movimento direto de toque para cursor (LIGADO) vs movimento relativo estilo touchpad (DESLIGADO) + Entrada de exibição externa + Escolha como uma tela de apresentação conectada deve se comportar + Não usar exibição externa + Usar como superfície de touchpad + Usar como teclado completo + Híbrido (touchpad + botão de teclado) + Mostrar o jogo na exibição externa + Trocar as telas para que o jogo seja renderizado na exibição externa e este dispositivo se torne a superfície de controle Iniciar com Controles On-screen Ocultos Controles on-screen estarão ocultos quando o jogo iniciar. Alterne via o menu de navegação. Emular teclado e mouse diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8fafa6ce2..9e94adb8e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -247,6 +247,14 @@ Тип мапера DirectInput Вимкнути введення мишею Режим сенсорного екрану + Ввід зовнішнього дисплея + Виберіть, як має працювати підключений презентаційний дисплей + Не використовувати зовнішній дисплей + Використовувати як поверхню тачпада + Використовувати як повну клавіатуру + Гібрид (тачпад + кнопка клавіатури) + Показувати гру на зовнішньому дисплеї + Поміняти екрани місцями, щоб гра відображалася на зовнішньому дисплеї, а цей пристрій став поверхнею керування Емуляція клавіатури та миші Лівий стік = WASD, Правий стік = Миша. L2 = ЛКМ, R2 = ПКМ. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0be8a9c69..8bceb0f05 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -513,6 +513,14 @@ 禁用鼠标输入 触屏模式 直接触控到光标移动(开启) vs 触摸板式相对移动(关闭) + 外接显示器输入 + 选择已连接的演示显示器的行为方式 + 不使用外接显示器 + 用作触摸板表面 + 用作完整键盘 + 混合(触摸板 + 键盘按钮) + 在外接显示器上显示游戏 + 交换屏幕,使游戏在外接显示器上渲染,此设备成为控制表面 开始时隐藏屏幕控制器 游戏开始时屏幕控制器将被隐藏。可通过导航菜单切换。 模拟键盘与鼠标 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index d0a4aa0a4..b5377b95d 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -516,6 +516,14 @@ 停用滑鼠輸入 觸屏模式 直接觸控到游標移動 (開啟) vs 觸控板式相對移動 (關閉) + 外接顯示器輸入 + 選擇已連接的簡報顯示器應如何運作 + 不使用外接顯示器 + 作為觸控板表面 + 作為完整鍵盤 + 混合(觸控板 + 鍵盤按鈕) + 在外接顯示器上顯示遊戲 + 交換螢幕,讓遊戲在外接顯示器上渲染,此裝置成為控制表面 開始時隱藏螢幕控制器 遊戲開始時螢幕控制器將被隱藏。可透過導航選單切換。 模擬鍵盤與滑鼠