Skip to content

Conversation

@SapphireRhodonite
Copy link

@SapphireRhodonite SapphireRhodonite commented Jan 7, 2026

Added Support to use the external screen as a Touchpad, Keyboard, or Touchpad+Keyboard

Summary by CodeRabbit

  • New Features
    • Added external display input mode with four options: Off, Touchpad, Keyboard, Hybrid (touchpad+keyboard)
    • Added on-screen keyboard and hybrid touchpad+keyboard UI for secondary displays
    • Added configuration UI controls for external display input in container settings
    • External display input mode is persisted with container defaults and integrated into the screen lifecycle for automatic start/stop and mode application

✏️ Tip: You can customize this high-level summary in your review settings.

SapphireRhodonite and others added 7 commits December 15, 2025 10:40
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.
# Conflicts:
#	app/src/main/res/values/strings.xml
@coderabbitai
Copy link

coderabbitai bot commented Jan 7, 2026

📝 Walkthrough

Walkthrough

Adds external display input support: new controller and presentation for secondary displays, an on-screen keyboard view, UI options to select mode, preference and container persistence for externalDisplayMode, and XServerScreen integration and lifecycle handling.

Changes

Cohort / File(s) Summary
Preferences & Container persistence
app/src/main/java/app/gamenative/PrefManager.kt, app/src/main/java/com/winlator/container/Container.java, app/src/main/java/com/winlator/container/ContainerData.kt, app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Added externalDisplayInputMode preference and EXTERNAL_DISPLAY_INPUT_MODE key; added externalDisplayMode field to Container and ContainerData with getters/setters and persistence wiring; ContainerUtils reads/writes externalDisplayMode in defaults, serialization, and apply paths.
External display feature
app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt, app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt
New ExternalDisplayInputController (Mode enum OFF/TOUCHPAD/KEYBOARD/HYBRID) managing display listener, presentation lifecycle, and mode switching; new ExternalOnScreenKeyboardView implementing an on-screen keyboard that emits key events to XServer.
UI integration
app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt, app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
Added external display mode dropdown in container config dialog and state binding; XServerScreen creates/starts ExternalDisplayInputController, sets initial mode from container, and stops controller on view detach.
Resources
app/src/main/res/values/strings.xml
Added string resources for external display input label, subtitle, and mode options (Off, Touchpad, Keyboard, Hybrid).

Sequence Diagram(s)

sequenceDiagram
  participant UI as ContainerConfigDialog
  participant Screen as XServerScreen
  participant Controller as ExternalDisplayInputController
  participant DisplayMgr as Android DisplayManager
  participant XServer as XServer
  participant Touchpad as TouchpadView

  UI->>Screen: user selects externalDisplayMode
  Screen->>Controller: setMode(mode)
  Controller->>DisplayMgr: get displays / register DisplayListener
  Note right of Controller `#DFF7DF`: find external presentation display\ncreate ExternalInputPresentation
  Controller->>DisplayMgr: show presentation on external display
  Controller->>XServer: bind input targets (touch/keyboard)
  Controller->>Touchpad: request TouchpadView via provider
  Touchpad->>XServer: deliver touch events
  DisplayMgr-->>Controller: display removed/changed (listener)
  Controller->>Controller: dismiss or recreate presentation
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Gbe experimental steamclient #260: Adds new PrefManager persistent preference properties (same area where externalDisplayInputMode was added).
  • Fix env vars #230: Adds container-related configuration wiring and PrefManager changes similar to externalDisplayMode plumbing.
  • Update drivers #183: Modifies Container, ContainerData, ContainerUtils, and UI persistence paths—overlaps with how externalDisplayMode is persisted and applied.

Poem

🐰
A tiny rabbit taps a key so spry,
Screens awaken, keyboards dance nearby.
Touchpads glide and hybrid modes align,
Secondary displays hum in time.
Hooray — input hops across the vine! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Dual screen support' directly reflects the main change: adding external display input functionality with multiple modes (Touchpad, Keyboard, Hybrid) as described in the PR objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a67515c and 5d75f5a.

📒 Files selected for processing (1)
  • app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-25T10:56:46.337Z
Learnt from: utkarshdalal
Repo: utkarshdalal/GameNative PR: 171
File: app/src/main/java/app/gamenative/MainActivity.kt:283-288
Timestamp: 2025-09-25T10:56:46.337Z
Learning: In MainActivity.kt, the back key handling in dispatchKeyEvent only checks for KeyEvent.KEYCODE_BACK (not KEYCODE_BUTTON_B) because it's specifically designed to intercept the device's physical back button when a game is running, not controller input.

Applied to files:

  • app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt
🔇 Additional comments (7)
app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt (7)

18-53: LGTM: Clean initialization and state management.

The class structure is well-organized with clear separation of concerns. The use of setMotionEventSplittingEnabled(true) on line 47 enables proper multi-touch support, allowing users to press multiple keys simultaneously, which is essential for keyboard functionality.


55-137: LGTM: Comprehensive keyboard layout.

The keyboard layout follows a standard QWERTY structure with appropriate key weights for visual sizing. The use of isLetter flags and Action enums provides clear semantic distinction between letter keys (which should respond to CAPS LOCK) and other keys.


139-174: LGTM: Proper button setup with correct touch handling.

The touch listener correctly returns false on line 166, allowing the button's onTouchEvent to handle the pressed state visuals via the StateListDrawable, while handleKeyTouch processes the actual key events. This provides both custom event handling and standard button feedback.


176-225: LGTM: Robust touch event handling with proper shift semantics.

The event handling correctly distinguishes between normal releases and cancelled gestures (line 213), preventing unintended shift state changes. The one-shot shift behavior (lines 203-206) where ShiftState.ON automatically resets to OFF after a key press is standard keyboard UX.


227-263: LGTM: Correct CAPS LOCK implementation.

The shift state logic properly differentiates between one-shot shift (ON) and CAPS LOCK, with CAPS only affecting letter keys (lines 255-256, 200). Visual feedback through different background highlights provides clear state indication.


278-322: LGTM: Proper resource cleanup and visual feedback.

The cleanup in onDetachedFromWindow (lines 280-285) correctly releases all pressed keys, preventing stuck key states. Line 281 creates a copy via toList() before iteration since releaseKey modifies the downKeys set. The color blending logic (lines 314-322) and pressed state drawables provide appropriate visual feedback.


265-271: No issues found with modifier mask detection.

The code correctly uses xServer.keyboard.modifiersMask.isSet(1) to check if Shift is pressed. The Bitmask.isSet(int flag) method treats its parameter as a mask value (using bitwise AND: (flag & bits) != 0), and Keyboard.getModifierFlag() returns 1 for both KEY_SHIFT_L and KEY_SHIFT_R keycodes. This implementation is correct for the X11 modifier scheme in use.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/java/com/winlator/container/ContainerData.kt (1)

75-84: Align externalDisplayMode defaults across property and Saver.restore

externalDisplayMode defaults to "hybrid" in the data class, but Saver.restore falls back to "touchpad" when the key is missing:

val externalDisplayMode: String = "hybrid",
...
externalDisplayMode = (savedMap["externalDisplayMode"] as? String) ?: "touchpad",

Given PrefManager.externalDisplayInputMode also defaults to "hybrid", this creates two effective defaults depending on the code path (fresh ContainerData() vs restored state / older saved maps). If "hybrid" is meant to be the canonical default, consider using it in restore as well, or add a brief comment explaining why "touchpad" is preferred here.

Also applies to: 130-138, 183-191

🤖 Fix all issues with AI agents
In @app/src/main/java/com/winlator/container/Container.java:
- Around line 952-959: The getter getExternalDisplayMode, the setter
setExternalDisplayMode, and the externalDisplayMode field initializer use
inconsistent fallback values ("hybrid" vs "touchpad"); pick a single canonical
default (prefer "hybrid" to match the existing field initializer) and update
setExternalDisplayMode to use that default when null is passed, ensuring
getExternalDisplayMode, setExternalDisplayMode, and the externalDisplayMode
field all agree on the same fallback value.
🧹 Nitpick comments (3)
app/src/main/java/app/gamenative/PrefManager.kt (1)

426-430: External display input pref looks correct; consider centralizing allowed values

The key name and "hybrid" default fit existing prefs and the documented off|touchpad|keyboard|hybrid domain. To reduce drift with other callers (e.g., ContainerData.externalDisplayMode and ExternalDisplayInputController.fromConfig), consider centralizing the canonical string values in one place (or mapping via the controller’s enum) instead of repeating literals.

app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt (1)

63-63: ExternalDisplayInputController lifecycle wiring is sound; consider making the no-op callback explicit

The controller is correctly scoped to the FrameLayout lifecycle: it’s created with the right context/xServer, starts immediately, and is stopped via an attach-state listener on view detach, which should ensure the display listener is unregistered.

The only nit is the empty onViewAttachedToWindow override:

override fun onViewAttachedToWindow(v: View) {}

Static analysis flags this as an empty function block. If you don’t need any attach-time behavior, consider making the no-op explicit (e.g., adding a // no-op comment or using = Unit) so the intent is clearer and the rule can be suppressed or configured accordingly.

Also applies to: 815-829

app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (1)

163-168: External display mode dropdown wiring is correct; consider sharing config-value mapping

The UI/state wiring for external display modes looks consistent:

  • Display labels come from externalDisplayModes (off, touchpad, keyboard, hybrid).
  • externalDisplayModeIndex is initialized from config.externalDisplayMode.lowercase() with a sensible fallback to 0 (“off”) for unknown values.
  • The SettingsListDropdown writes back to config.externalDisplayMode using the same index→string mapping.

Functionally this should behave as intended. To reduce duplication and the chance of typos drifting, you might introduce a single parallel list of config values, e.g.:

val externalDisplayConfigValues = listOf("off", "touchpad", "keyboard", "hybrid")
val externalDisplayModes = externalDisplayConfigValues.map { /* map to stringResource label */ }

Then:

  • Initialize externalDisplayModeIndex via externalDisplayConfigValues.indexOf(config.externalDisplayMode.lowercase()).coerceAtLeast(0).
  • On selection, write back externalDisplayMode = externalDisplayConfigValues[index].

That keeps the mapping in one place while preserving current behavior.

Also applies to: 687-695, 1722-1740

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2d30cc4 and a67515c.

📒 Files selected for processing (9)
  • app/src/main/java/app/gamenative/PrefManager.kt
  • app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt
  • app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/app/gamenative/utils/ContainerUtils.kt
  • app/src/main/java/com/winlator/container/Container.java
  • app/src/main/java/com/winlator/container/ContainerData.kt
  • app/src/main/res/values/strings.xml
🧰 Additional context used
🧬 Code graph analysis (3)
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt (1)
app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt (2)
  • setMode (76-79)
  • start (63-66)
app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt (2)
app/src/main/java/app/gamenative/ui/component/settings/SettingsListDropdown.kt (1)
  • SettingsListDropdown (40-125)
app/src/main/java/app/gamenative/ui/theme/Color.kt (1)
  • settingsTileColors (27-32)
app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt (1)
app/src/main/java/com/winlator/widget/TouchpadView.java (1)
  • TouchpadView (28-728)
🪛 detekt (1.23.8)
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt

[warning] 824-824: This empty block of code can be removed.

(detekt.empty-blocks.EmptyFunctionBlock)

🔇 Additional comments (15)
app/src/main/res/values/strings.xml (1)

524-529: LGTM!

The new string resources are well-structured and follow the existing naming conventions. The descriptions are clear and user-friendly.

app/src/main/java/com/winlator/container/Container.java (2)

115-116: LGTM!

The new field follows the existing pattern in the class and the default value of "hybrid" aligns with the feature design.


651-651: LGTM!

The persistence of externalDisplayMode follows the established pattern for other container fields.

app/src/main/java/app/gamenative/externaldisplay/ExternalOnScreenKeyboardView.kt (4)

17-50: LGTM!

Well-structured Kotlin class with clean separation of concerns. The use of data classes for KeySpec and KeyButton, along with the ShiftState enum, makes the code readable and maintainable.


52-134: LGTM!

The keyboard layout implementation is well-structured with appropriate key weights for a balanced visual appearance. The five-row layout covers standard QWERTY keys plus modifiers and navigation arrows.


136-196: LGTM!

The key handling logic is well-implemented. The shift state management correctly handles the three states (OFF, ON, CAPS) with appropriate behavior for letters vs. symbols.


236-241: LGTM!

The tapKey method correctly handles shift key injection around the actual key press/release sequence. The implementation properly injects shift press before the key and releases it after.

app/src/main/java/app/gamenative/utils/ContainerUtils.kt (3)

124-124: LGTM!

The external display mode is correctly sourced from PrefManager.externalDisplayInputMode following the established pattern.


162-162: LGTM!

The persistence of externalDisplayMode to preferences follows the established pattern.


227-227: LGTM!

The external display mode is correctly wired through all three data flow points: reading from container, including in ContainerData construction, and applying back to container.

Also applies to: 270-270, 391-391

app/src/main/java/app/gamenative/externaldisplay/ExternalDisplayInputController.kt (5)

24-38: LGTM!

Clean class structure with well-defined Mode enum. The fromConfig companion function provides a safe default (OFF) for unknown or null values.


63-79: LGTM!

The lifecycle management is well-implemented. The start() method correctly registers the display listener on the main looper for thread safety, and stop() properly cleans up resources. The silent exception catch during unregistration is acceptable for cleanup operations.


81-121: LGTM!

The presentation management logic correctly handles mode changes and display availability. The findPresentationDisplay() appropriately filters for presentation-capable displays while excluding the default and hidden displays.


123-145: LGTM!

The presentation correctly sets FLAG_NOT_FOCUSABLE and FLAG_NOT_TOUCH_MODAL window flags to ensure the external display doesn't steal focus from the main window. This is essential for the feature's usability as noted in the PR objectives.


210-276: LGTM!

The HybridInputLayout implementation is well-designed. The floating action button toggle with dynamic positioning based on keyboard visibility provides a good UX. The keyboard starting hidden (line 233) and the toggle button repositioning logic (lines 269-275) work together smoothly.

Comment on lines +952 to +959
// External display mode
public String getExternalDisplayMode() {
return externalDisplayMode != null ? externalDisplayMode : "hybrid";
}

public void setExternalDisplayMode(String externalDisplayMode) {
this.externalDisplayMode = externalDisplayMode != null ? externalDisplayMode : "touchpad";
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent fallback values for externalDisplayMode.

The getter returns "hybrid" as fallback (line 954), but the setter defaults to "touchpad" when null is passed (line 958). The field initializer also uses "hybrid" (line 116). This inconsistency could lead to unexpected behavior.

🔧 Proposed fix
 public void setExternalDisplayMode(String externalDisplayMode) {
-    this.externalDisplayMode = externalDisplayMode != null ? externalDisplayMode : "touchpad";
+    this.externalDisplayMode = externalDisplayMode != null ? externalDisplayMode : "hybrid";
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// External display mode
public String getExternalDisplayMode() {
return externalDisplayMode != null ? externalDisplayMode : "hybrid";
}
public void setExternalDisplayMode(String externalDisplayMode) {
this.externalDisplayMode = externalDisplayMode != null ? externalDisplayMode : "touchpad";
}
// External display mode
public String getExternalDisplayMode() {
return externalDisplayMode != null ? externalDisplayMode : "hybrid";
}
public void setExternalDisplayMode(String externalDisplayMode) {
this.externalDisplayMode = externalDisplayMode != null ? externalDisplayMode : "hybrid";
}
🤖 Prompt for AI Agents
In @app/src/main/java/com/winlator/container/Container.java around lines 952 -
959, The getter getExternalDisplayMode, the setter setExternalDisplayMode, and
the externalDisplayMode field initializer use inconsistent fallback values
("hybrid" vs "touchpad"); pick a single canonical default (prefer "hybrid" to
match the existing field initializer) and update setExternalDisplayMode to use
that default when null is passed, ensuring getExternalDisplayMode,
setExternalDisplayMode, and the externalDisplayMode field all agree on the same
fallback value.

@joshuatam
Copy link
Contributor

Cool, any device was tested to work?

@SapphireRhodonite
Copy link
Author

Cool, any device was tested to work?

Ayn Thor!

// Touchscreen mode
private boolean touchscreenMode = false;
// External display input handling
private String externalDisplayMode = "hybrid";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend creating a CONST for this that can be passed around so we're not using a hard-coded string everywhere for the different displayInput modes.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Also it shouldn't be hybrid by default right?

Comment on lines +689 to +691
"touchpad" -> 1
"keyboard" -> 2
"hybrid" -> 3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Comment on lines +21 to +22
private const val EXTERNAL_TOUCHPAD_BG: Int = 0xFF2B2B2B.toInt()
private const val EXTERNAL_KEYBOARD_BG: Int = 0xFF2B2B2B.toInt()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good for you to check if we have a theme/colors file and bring it in from there rather than hardcode the hexvalue

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, we do have colors constants

@phobos665
Copy link
Contributor

phobos665 commented Jan 8, 2026

Overall looks good, just added a few QoL changes for maintainability. Will do a final test with my Thor tonight

@phobos665
Copy link
Contributor

Tested on the Thor, looks and works great.

I think we can iterate and adjust it as we go based on any community feedback.

Thanks for the contribution!

Comment on lines +815 to +829
val externalDisplayController = ExternalDisplayInputController(
context = context,
xServer = xServerView.getxServer(),
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()
}
})
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in an if statement? We shouldn't do this if there's only a single screen, right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants