Split the keyboard onto its own translucent OpenXR quad
build / build (push) Successful in 1m52s

Expand the on-screen keyboard to a full US layout (letters, digits,
punctuation, Shift layer, Tab, arrows, F1-F12, one-shot Ctrl/Alt
modifiers), make it a persistent, movable, closeable panel via a
title-bar drag handle, and lower its opacity.

Previously the keyboard was just a second panel swapped into the same
quad as the menu launcher, which made the launcher translucent too and
made the keyboard and menu mutually exclusive. Extract the shared
swapchain/JNI/touch-dispatch plumbing into two generic, reusable
modules - xr_swapchain.c (swapchain + per-image FBO setup) and
xr_overlay.c (an off-screen Android View rendered into its own OpenXR
quad, hit-tested via a laser pointer) - and make the menu launcher and
keyboard two independent XrOverlay instances instead of one. The
controller's menu button now only opens/closes the launcher; the
launcher's "Keyboard" button only opens the keyboard - so the keyboard
can stay up while picking something from the menu, and only the
keyboard's own Close button hides it.

xr_menu.c/.h are gone, fully absorbed into xr_overlay.c. On the Java
side, OverlayPanel.java carries the shared off-screen-render/touch/
cursor plumbing that MenuOverlay and the new KeyboardOverlay both
subclass.
This commit is contained in:
ml
2026-08-14 06:43:44 +02:00
parent 2d8fb49bf5
commit 3be310ade6
16 changed files with 1897 additions and 1102 deletions
@@ -0,0 +1,474 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import org.libsdl.app.SDLActivity;
/**
* The on-screen keyboard quad (see OverlayPanel for the shared off-screen-
* render/touch/cursor plumbing this builds on) - a hand-built, full
* US-layout key grid (there's no system IME to borrow once immersive) plus
* a text field echoing what's been sent. Each key press is forwarded
* straight to the game as real input (see handleKeyPress()/
* handleCharKeyPress()) - non-printable keys as an
* SDLActivity.onNativeKeyDown()/onNativeKeyUp() pair, the same one a
* physical Bluetooth keyboard's presses already drive, and printable keys
* via a synthesized SDL_TEXTINPUT event pushed from native code (see
* commitPrintableChar()/nativeSendPrintableChar() in questshock_native.c) -
* except while a Ctrl/Alt "armed" modifier is active, when they go through
* the same onNativeKeyDown()/onNativeKeyUp() path instead, so the modifier
* carries across (see toggleModifier()). Shift is a purely local layer
* toggle (uppercase/symbols vs lowercase/numbers), never itself sent to the
* game.
*
* This is a fully independent overlay quad from MenuOverlay's small
* launcher - opened via that launcher's "Keyboard" button
* (nativeShowKeyboard()) but otherwise self-contained: once open, the
* controller's menu button (which only ever affects the menu launcher, see
* xr_input.c) doesn't hide it, so it stays up as a standing input panel
* while actually playing rather than a modal you open and close. The only
* way to hide it is its own title bar's Close button (buildTitleBar()),
* which also doubles as a drag handle for repositioning it (handled
* entirely on the native side - see xr_input.c - since it never reaches
* this class's own touch dispatch). contentAlpha() renders the whole panel
* semi-transparent so whatever's behind it stays visible while it's up.
*/
public class KeyboardOverlay extends OverlayPanel {
// Matches xr_session.c's KEYBOARD_WIDTH/KEYBOARD_HEIGHT swapchain size -
// fixed, not tied to any real display metric, since this tree is never
// shown in a real window.
private static final int WIDTH = 1024;
private static final int HEIGHT = 768;
private static final int TYPED_TEXT_HEIGHT = 100;
// Height of the title bar (buildTitleBar()) and width of its Close
// button - xr_input.c's TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION
// must be kept in sync with these (as fractions of WIDTH/HEIGHT above),
// since the drag-handle-vs-Close-button-vs-rest-of-the-keyboard
// decision happens entirely on the native side, before a hit ever
// reaches this class's own touch dispatch.
private static final int TITLE_BAR_HEIGHT = 90;
private static final int CLOSE_BUTTON_WIDTH = 160;
// ~70% opaque - readable but lets whatever's behind the panel (the game
// quad, or empty space) show through while it's left up during play.
private static final int PANEL_ALPHA = 180;
private static KeyboardOverlay instance;
private TextView typedTextView;
private final StringBuilder typedText = new StringBuilder();
// Shift is a local-only layer toggle (never itself sent to the game -
// see updateShiftVisual()); Ctrl/Alt are one-shot "armed" modifiers,
// sent to the game the moment they're pressed and released again as
// soon as the next key consumes them (see toggleModifier()/
// releaseArmedModifiers()) - there's no press-and-hold gesture with a
// laser pointer + trigger click, so armed-then-consumed stands in for
// holding the key down.
private boolean shiftActive = false;
private boolean ctrlArmed = false;
private boolean altArmed = false;
private Button shiftButton;
private Button ctrlButton;
private Button altButton;
// Every CharKey created by addRow(), so updateShiftVisual() can relabel
// all of them at once when Shift toggles. Assigned (not a field
// initializer) at the top of buildContent() - see OverlayPanel's
// constructor note on why a field a subclass's buildContent() depends
// on can't rely on normal field-initializer timing.
private List<CharKey> charKeys;
// A key whose printed character (and, while unmodified, whose game
// input) depends on the Shift layer - covers letters, digits, and
// punctuation uniformly (e.g. 'q'/'Q', '1'/'!', '-'/'_'). androidKeyCode
// is only used for the Ctrl/Alt-combo path (see handleCharKeyPress()) -
// the unmodified path goes through commitPrintableChar() instead, which
// doesn't need an Android keycode at all.
private static final class CharKey {
final char base;
final char shifted;
final int androidKeyCode;
Button button;
CharKey(char base, char shifted, int androidKeyCode) {
this.base = base;
this.shifted = shifted;
this.androidKeyCode = androidKeyCode;
}
}
private static CharKey charKey(char base, char shifted, int androidKeyCode) {
return new CharKey(base, shifted, androidKeyCode);
}
// Space is a CharKey like any other (see addRow()), but a literal
// space character makes for an unreadable, blank-looking button - keep
// showing the word "Space" instead, same as before it became a CharKey.
private static String charKeyLabel(CharKey key, boolean shiftActive) {
if (key.base == ' ') {
return "Space";
}
return String.valueOf(shiftActive ? key.shifted : key.base);
}
private KeyboardOverlay(Activity activity) {
super(activity, WIDTH, HEIGHT);
}
@Override
protected int contentAlpha() {
return PANEL_ALPHA;
}
@Override
protected View buildContent() {
charKeys = new ArrayList<>();
LinearLayout panel = new LinearLayout(activity);
panel.setOrientation(LinearLayout.VERTICAL);
panel.setBackgroundColor(0xFF202020);
panel.addView(buildTitleBar(),
new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, TITLE_BAR_HEIGHT));
typedTextView = new TextView(activity);
typedTextView.setTextColor(0xFFFFFFFF);
typedTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28);
typedTextView.setBackgroundColor(0xFF303030);
typedTextView.setPadding(20, 20, 20, 20);
panel.addView(typedTextView, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, TYPED_TEXT_HEIGHT));
// Rows follow real keyboard geometry where it helps (Tab starting
// the QWERTY row, Enter ending the home row, Shift starting the
// bottom-letter row, a compact arrow cluster bottom-right) so the
// layout reads as a familiar keyboard rather than an arbitrary grid.
addRow(panel, "Esc", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10",
"F11", "F12", "Back");
addRow(panel,
charKey('`', '~', KeyEvent.KEYCODE_GRAVE),
charKey('1', '!', KeyEvent.KEYCODE_1),
charKey('2', '@', KeyEvent.KEYCODE_2),
charKey('3', '#', KeyEvent.KEYCODE_3),
charKey('4', '$', KeyEvent.KEYCODE_4),
charKey('5', '%', KeyEvent.KEYCODE_5),
charKey('6', '^', KeyEvent.KEYCODE_6),
charKey('7', '&', KeyEvent.KEYCODE_7),
charKey('8', '*', KeyEvent.KEYCODE_8),
charKey('9', '(', KeyEvent.KEYCODE_9),
charKey('0', ')', KeyEvent.KEYCODE_0),
charKey('-', '_', KeyEvent.KEYCODE_MINUS),
charKey('=', '+', KeyEvent.KEYCODE_EQUALS));
addRow(panel, "Tab",
charKey('q', 'Q', KeyEvent.KEYCODE_Q), charKey('w', 'W', KeyEvent.KEYCODE_W),
charKey('e', 'E', KeyEvent.KEYCODE_E), charKey('r', 'R', KeyEvent.KEYCODE_R),
charKey('t', 'T', KeyEvent.KEYCODE_T), charKey('y', 'Y', KeyEvent.KEYCODE_Y),
charKey('u', 'U', KeyEvent.KEYCODE_U), charKey('i', 'I', KeyEvent.KEYCODE_I),
charKey('o', 'O', KeyEvent.KEYCODE_O), charKey('p', 'P', KeyEvent.KEYCODE_P),
charKey('[', '{', KeyEvent.KEYCODE_LEFT_BRACKET),
charKey(']', '}', KeyEvent.KEYCODE_RIGHT_BRACKET),
charKey('\\', '|', KeyEvent.KEYCODE_BACKSLASH));
addRow(panel, "Ctrl",
charKey('a', 'A', KeyEvent.KEYCODE_A), charKey('s', 'S', KeyEvent.KEYCODE_S),
charKey('d', 'D', KeyEvent.KEYCODE_D), charKey('f', 'F', KeyEvent.KEYCODE_F),
charKey('g', 'G', KeyEvent.KEYCODE_G), charKey('h', 'H', KeyEvent.KEYCODE_H),
charKey('j', 'J', KeyEvent.KEYCODE_J), charKey('k', 'K', KeyEvent.KEYCODE_K),
charKey('l', 'L', KeyEvent.KEYCODE_L),
charKey(';', ':', KeyEvent.KEYCODE_SEMICOLON),
charKey('\'', '"', KeyEvent.KEYCODE_APOSTROPHE),
"Enter");
addRow(panel, "Shift",
charKey('z', 'Z', KeyEvent.KEYCODE_Z), charKey('x', 'X', KeyEvent.KEYCODE_X),
charKey('c', 'C', KeyEvent.KEYCODE_C), charKey('v', 'V', KeyEvent.KEYCODE_V),
charKey('b', 'B', KeyEvent.KEYCODE_B), charKey('n', 'N', KeyEvent.KEYCODE_N),
charKey('m', 'M', KeyEvent.KEYCODE_M),
charKey(',', '<', KeyEvent.KEYCODE_COMMA),
charKey('.', '>', KeyEvent.KEYCODE_PERIOD),
charKey('/', '?', KeyEvent.KEYCODE_SLASH),
"Up");
addRow(panel, "Alt", charKey(' ', ' ', KeyEvent.KEYCODE_SPACE), "Left", "Down", "Right");
return panel;
}
// The title bar: a decorative "drag here" label (not itself
// interactive - see the class doc, dragging is handled entirely by
// xr_input.c before a hit ever reaches here) plus a real Close button,
// which is dispatched as an ordinary click like any other key. Its
// height and the Close button's width must stay in sync with
// xr_input.c's TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION.
private LinearLayout buildTitleBar() {
LinearLayout bar = new LinearLayout(activity);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setBackgroundColor(0xFF3A3A3A);
TextView label = new TextView(activity);
label.setText("Keyboard - drag here to move");
label.setTextColor(0xFFFFFFFF);
label.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22);
label.setGravity(Gravity.CENTER_VERTICAL);
label.setPadding(24, 0, 0, 0);
bar.addView(label, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
Button close = new Button(activity);
close.setText("Close");
close.setOnClickListener(v -> handleKeyPress("Close"));
close.setOnTouchListener(CLICK_ON_TOUCH_UP);
bar.addView(close,
new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT));
return bar;
}
// Each row fills the panel's remaining height evenly (weight 1 on the
// row itself). Accepts a mix of String (a fixed-action control key,
// handled by handleKeyPress()) and CharKey (a Shift-sensitive character
// key, handled by handleCharKeyPress()) per item, so a row can match
// real keyboard geometry (e.g. "Tab" followed by a run of CharKeys).
// Within a row, CharKeys and most control keys share width equally
// except Space (wider, like a real keyboard) and Esc/Back/Enter/Tab/
// Shift/Ctrl/Alt (narrower, see keyWeight()).
private void addRow(LinearLayout panel, Object... items) {
LinearLayout row = new LinearLayout(activity);
row.setOrientation(LinearLayout.HORIZONTAL);
for (Object item : items) {
Button key = new Button(activity);
key.setOnTouchListener(CLICK_ON_TOUCH_UP);
float weight;
if (item instanceof CharKey) {
CharKey charKey = (CharKey) item;
charKey.button = key;
key.setText(charKeyLabel(charKey, shiftActive));
key.setOnClickListener(v -> handleCharKeyPress(charKey));
charKeys.add(charKey);
// Space is a CharKey too (base == shifted == ' '), but keeps
// its traditional wide key like the other control keys do.
weight = (charKey.base == ' ') ? 4f : 1f;
} else {
String label = (String) item;
key.setText(label);
key.setOnClickListener(v -> handleKeyPress(label));
weight = keyWeight(label);
if (label.equals("Shift")) {
shiftButton = key;
} else if (label.equals("Ctrl")) {
ctrlButton = key;
} else if (label.equals("Alt")) {
altButton = key;
}
}
row.addView(key, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, weight));
}
panel.addView(row, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
}
private static float keyWeight(String label) {
if (label.equals("Back") || label.equals("Esc") || label.equals("Enter")
|| label.equals("Tab") || label.equals("Shift") || label.equals("Ctrl")
|| label.equals("Alt")) {
return 1.5f;
}
return 1f;
}
// Maps a control key's label to the Android keycode forwarded via
// SDLActivity.onNativeKeyDown()/onNativeKeyUp() - the same pair a
// physical Bluetooth keyboard's key events already drive (see
// SDLActivity.handleKeyEvent()). F-keys are contiguous in
// android.view.KeyEvent (KEYCODE_F1..KEYCODE_F12), so "F1".."F12" are
// computed rather than listed individually.
private static int controlKeyCode(String label) {
switch (label) {
case "Back": return KeyEvent.KEYCODE_DEL;
case "Esc": return KeyEvent.KEYCODE_ESCAPE;
case "Enter": return KeyEvent.KEYCODE_ENTER;
case "Tab": return KeyEvent.KEYCODE_TAB;
case "Up": return KeyEvent.KEYCODE_DPAD_UP;
case "Down": return KeyEvent.KEYCODE_DPAD_DOWN;
case "Left": return KeyEvent.KEYCODE_DPAD_LEFT;
case "Right": return KeyEvent.KEYCODE_DPAD_RIGHT;
default:
if (label.charAt(0) == 'F') {
int n = Integer.parseInt(label.substring(1));
return KeyEvent.KEYCODE_F1 + (n - 1);
}
throw new IllegalArgumentException("no keycode for " + label);
}
}
// "Close" (the title bar's button - see buildTitleBar()) hides this
// overlay's quad entirely (see nativeRequestClose()) and never reaches
// the game. Shift/Ctrl/Alt are handled separately below (Shift is a
// local layer toggle; Ctrl/Alt are one-shot armed modifiers) since
// neither sends a plain key event of its own the way every other
// control key here does. Everything else (Back/Esc/Enter/Tab/arrows/
// F1-F12) is non-printable, so it's forwarded as a real Android key
// event via controlKeyCode() above, then releases any armed Ctrl/Alt
// modifier (this key just consumed it). This all matters beyond just
// the visible typedTextView below: the engine's cutscene skip handler
// (cutsloop.c's cutscene_key_handler()) only reacts to Esc/Enter/Space,
// so those need to reach the game as actual game input, not just be
// echoed locally.
private void handleKeyPress(String label) {
if (label.equals("Close")) {
nativeRequestClose();
return;
}
if (label.equals("Shift")) {
shiftActive = !shiftActive;
updateShiftVisual();
return;
}
if (label.equals("Ctrl") || label.equals("Alt")) {
toggleModifier(label);
return;
}
int keyCode = controlKeyCode(label);
SDLActivity.onNativeKeyDown(keyCode);
SDLActivity.onNativeKeyUp(keyCode);
releaseArmedModifiers();
if (label.equals("Back")) {
if (typedText.length() > 0) {
typedText.setLength(typedText.length() - 1);
}
} else {
typedText.append('[').append(label).append(']');
}
typedTextView.setText(typedText.toString());
}
// A CharKey's printed character depends only on the Shift layer
// (updateShiftVisual() keeps its button's label in sync). Which game
// input it produces depends on whether a modifier is armed: unmodified,
// it goes through commitPrintableChar() below like a normal typed
// character; with Ctrl/Alt armed, it instead goes through the same
// onNativeKeyDown()/onNativeKeyUp() path handleKeyPress() uses for
// control keys, since that's the only path that carries
// ev.key.keysym.mod through to the engine (sdl_events.c's pump_events()
// reads Ctrl/Alt only off that path, never off SDL_TEXTINPUT).
private void handleCharKeyPress(CharKey key) {
char c = shiftActive ? key.shifted : key.base;
if (ctrlArmed || altArmed) {
SDLActivity.onNativeKeyDown(key.androidKeyCode);
SDLActivity.onNativeKeyUp(key.androidKeyCode);
releaseArmedModifiers();
} else {
commitPrintableChar(c);
}
typedText.append(c);
typedTextView.setText(typedText.toString());
}
// Ctrl/Alt are "armed" rather than held: pressing one immediately sends
// its keydown (so SDL's own modifier tracking picks it up for whatever
// key comes next - see handleCharKeyPress()/handleKeyPress()) and
// brackets its label for feedback; pressing the same key again before
// it's been used cancels it (sends the matching keyup, un-brackets).
// The normal case - actually being consumed by the next key press - is
// handled by releaseArmedModifiers() below, not here.
private void toggleModifier(String label) {
boolean ctrl = label.equals("Ctrl");
Button button = ctrl ? ctrlButton : altButton;
int keyCode = ctrl ? KeyEvent.KEYCODE_CTRL_LEFT : KeyEvent.KEYCODE_ALT_LEFT;
boolean nowArmed = ctrl ? !ctrlArmed : !altArmed;
if (ctrl) {
ctrlArmed = nowArmed;
} else {
altArmed = nowArmed;
}
if (nowArmed) {
SDLActivity.onNativeKeyDown(keyCode);
} else {
SDLActivity.onNativeKeyUp(keyCode);
}
button.setText(nowArmed ? "[" + label + "]" : label);
}
// Called after any key actually reaches the game (a control key in
// handleKeyPress(), or a CharKey in handleCharKeyPress()) so an armed
// Ctrl/Alt only ever applies to the very next key, then releases -
// matching a real Ctrl/Alt+key combo's keyup once the combo is done.
private void releaseArmedModifiers() {
if (ctrlArmed) {
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_CTRL_LEFT);
ctrlArmed = false;
ctrlButton.setText("Ctrl");
}
if (altArmed) {
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_ALT_LEFT);
altArmed = false;
altButton.setText("Alt");
}
}
// Shift never itself reaches the game (see class doc) - it only flips
// which character each CharKey shows/sends, including its own label.
// Note this means a Ctrl+Shift+key chord won't carry Shift to the
// engine (Ctrl/Alt are real forwarded modifier keys, Shift here isn't) -
// not worth solving unless it actually comes up.
private void updateShiftVisual() {
shiftButton.setText(shiftActive ? "[Shift]" : "Shift");
for (CharKey key : charKeys) {
key.button.setText(charKeyLabel(key, shiftActive));
}
}
// Synthesizes the SDL_TEXTINPUT event pump_events() (sdl_events.c)
// requires for printable characters (see handleCharKeyPress() above),
// by building and pushing it directly in native code (see
// nativeSendPrintableChar() in questshock_native.c) rather than through
// Android's IME (there's no real IME session behind this off-screen,
// never-attached grid for that plumbing to hook into).
private static void commitPrintableChar(char c) {
nativeSendPrintableChar(c);
}
private static native void nativeSendPrintableChar(char c);
// Hides this overlay's quad (xr_overlay_set_visible(..., false)) - the
// only way to do so, since the controller's menu button doesn't affect
// the keyboard (see this class's doc comment and xr_input.c).
private static native void nativeRequestClose();
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
// thread, right after it resolves this class via the activity's own
// ClassLoader (plain FindClass() can't see app classes from a thread
// that was attached to the JVM rather than spawned from Java).
public static void nativeInit(Activity activity) {
if (instance == null) {
instance = new KeyboardOverlay(activity);
}
}
public static byte[] nativeTakePixelsIfDirty() {
return instance == null ? null : instance.takePixelsIfDirty();
}
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
if (instance != null) {
instance.dispatchTouch(u, v, down);
}
}
public static void nativeUpdateCursor(final float u, final float v, final boolean visible) {
if (instance != null) {
instance.dispatchUpdateCursor(u, v, visible);
}
}
}
@@ -1,380 +1,76 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.SystemClock;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.nio.ByteBuffer;
import org.libsdl.app.SDLActivity;
/**
* An off-screen, never-attached-to-a-window View tree for questshock's
* OpenXR menu quad (see android/app/src/main/cpp/xr_menu.c, which owns the
* quad's swapchain and drives this class entirely via JNI - construction,
* touch input, and pixel readback). Rendering to a Bitmap and dispatching
* synthetic MotionEvents into an unattached hierarchy both work the same
* way they would for an attached View - draw(Canvas)/dispatchTouchEvent()
* don't require a ViewRootImpl/window, just a measured+laid-out tree.
*
* Holds two same-size panels inside root, only one of which is visible at a
* time (see showPanel()): mainPanel (just the "Keyboard" button so far) and
* keyboardPanel (a hand-built key grid - there's no system IME to borrow
* once immersive - plus a text field echoing what's been sent). Each key
* press is forwarded straight to the game as real input (see
* handleKeyPress()) - non-printable keys as an SDLActivity.onNativeKeyDown()/
* onNativeKeyUp() pair, the same one a physical Bluetooth keyboard's presses
* already drive, and printable keys via a synthesized SDL_TEXTINPUT event
* pushed from native code (see commitPrintableChar()/
* nativeSendPrintableChar() in questshock_native.c) - this keyboard is
* meant as a general stand-in for whatever keyboard-driven functionality
* isn't (yet, or ever) mapped onto the controllers, not just a text-entry
* widget.
* The small main menu launcher quad (see OverlayPanel for the shared off-
* screen-render/touch/cursor plumbing this builds on) - just a "Keyboard"
* button for now. Toggled by the controller's menu button (see
* xr_input.c); its "Keyboard" click only opens the (fully independent)
* keyboard overlay quad via nativeShowKeyboard() - it never touches this
* panel's own visibility, so both can be shown together (see
* KeyboardOverlay for the keyboard itself).
*/
public class MenuOverlay {
// Matches xr_menu.c's MENU_WIDTH/MENU_HEIGHT swapchain size - fixed,
public class MenuOverlay extends OverlayPanel {
// Matches xr_session.c's MENU_WIDTH/MENU_HEIGHT swapchain size - fixed,
// not tied to any real display metric, since this tree is never shown
// in a real window.
static final int WIDTH = 1024;
static final int HEIGHT = 768;
private static final float CURSOR_RADIUS = 10f;
private static final int TYPED_TEXT_HEIGHT = 100;
// Button's own click handling (View.onTouchEvent()'s ACTION_UP case)
// calls View.post(mPerformClick) rather than invoking performClick()
// directly; post() queues the runnable to run once the view is attached
// to a window and returns true immediately even when unattached, so on
// this permanently-unattached tree that queued click silently never
// fires - dispatchTouchEvent() still delivers the down/up events
// correctly, only the click callback is swallowed. Registering this as
// each button's OnTouchListener bypasses that path entirely: returning
// true here skips View's internal onTouchEvent() (see
// ViewGroup/View#dispatchTouchEvent), so performClick() (which still
// runs any OnClickListener set via setOnClickListener()) is called
// directly instead.
private static final View.OnTouchListener CLICK_ON_TOUCH_UP = (v, event) -> {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
v.setPressed(true);
break;
case MotionEvent.ACTION_UP:
v.setPressed(false);
v.performClick();
break;
case MotionEvent.ACTION_CANCEL:
v.setPressed(false);
break;
}
return true;
};
private static final int WIDTH = 512;
private static final int HEIGHT = 256;
private static MenuOverlay instance;
private final Activity activity;
private final FrameLayout root;
private final FrameLayout mainPanel;
private final LinearLayout keyboardPanel;
private final TextView typedTextView;
private final StringBuilder typedText = new StringBuilder();
// contentBitmap holds just the panel's own rendered content (no cursor),
// redrawn only when that content actually changes (showPanel()/
// handleTouch()/handleKeyPress()) via root.draw() - a full off-screen
// View-tree traversal that gets noticeably more expensive on the
// busier keyboardPanel. bitmap is what's actually published to native
// (see nativeTakePixelsIfDirty()): compositeAndPublish() cheaply blits
// contentBitmap plus the cursor circle into it. Splitting these two
// apart matters because nativeUpdateCursor() below fires every single
// frame (~90Hz) while aiming at the menu - if it called the expensive
// root.draw() path every time (as an earlier version did), the
// keyboardPanel's larger view tree made each redraw slow enough that
// runOnUiThread() posts piled up faster than the UI thread could drain
// them, so the cursor visibly lagged minutes behind the controller's
// actual aim instead of tracking it.
private final Bitmap contentBitmap;
private final Canvas contentCanvas;
private final Bitmap bitmap;
private final Canvas canvas;
private final Paint cursorPaint;
private final Object pixelLock = new Object();
private byte[] pendingPixels;
// Only ever touched on the UI thread (both nativeUpdateCursor() and
// compositeAndPublish() run/are posted there) - no lock needed, unlike
// pendingPixels above.
private boolean cursorVisible = false;
private float cursorX = 0f;
private float cursorY = 0f;
private MenuOverlay(Activity activity) {
this.activity = activity;
root = new FrameLayout(activity);
root.setBackgroundColor(0xFF202020);
mainPanel = buildMainPanel();
keyboardPanel = buildKeyboardPanel();
// buildKeyboardPanel() adds the text field as keyboardPanel's first
// child, before any key rows.
typedTextView = (TextView) keyboardPanel.getChildAt(0);
root.addView(mainPanel, new FrameLayout.LayoutParams(WIDTH, HEIGHT));
root.addView(keyboardPanel, new FrameLayout.LayoutParams(WIDTH, HEIGHT));
contentBitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888);
contentCanvas = new Canvas(contentBitmap);
bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888);
canvas = new Canvas(bitmap);
cursorPaint = new Paint();
cursorPaint.setColor(0xFFFFFFFF);
cursorPaint.setAntiAlias(true);
showPanel(mainPanel);
redrawContent();
super(activity, WIDTH, HEIGHT);
}
private FrameLayout buildMainPanel() {
@Override
protected View buildContent() {
FrameLayout panel = new FrameLayout(activity);
panel.setBackgroundColor(0xFF202020);
Button keyboardButton = new Button(activity);
keyboardButton.setText("Keyboard");
keyboardButton.setOnClickListener(v -> showPanel(keyboardPanel));
keyboardButton.setOnClickListener(v -> nativeShowKeyboard());
keyboardButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(420, 140);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(300, 100);
lp.gravity = Gravity.CENTER;
panel.addView(keyboardButton, lp);
return panel;
}
private LinearLayout buildKeyboardPanel() {
LinearLayout panel = new LinearLayout(activity);
panel.setOrientation(LinearLayout.VERTICAL);
// Opens the keyboard overlay quad (questshock_native.c ->
// xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true)) -
// see KeyboardOverlay for the panel itself and its own Close button,
// the only way to hide it again.
private static native void nativeShowKeyboard();
TextView textView = new TextView(activity);
textView.setTextColor(0xFFFFFFFF);
textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28);
textView.setBackgroundColor(0xFF303030);
textView.setPadding(20, 20, 20, 20);
panel.addView(textView, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, TYPED_TEXT_HEIGHT));
addKeyRow(panel, "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P");
addKeyRow(panel, "A", "S", "D", "F", "G", "H", "J", "K", "L");
addKeyRow(panel, "Z", "X", "C", "V", "B", "N", "M");
addKeyRow(panel, "Esc", "Space", "Back", "Enter", "Done");
return panel;
}
// Each row fills the panel's remaining height evenly (weight 1 on the
// row itself); within a row, most keys share width equally except
// "Space" (wider, like a real keyboard) and "Esc"/"Back"/"Enter"/"Done"
// (narrower).
private void addKeyRow(LinearLayout panel, String... labels) {
LinearLayout row = new LinearLayout(activity);
row.setOrientation(LinearLayout.HORIZONTAL);
for (String label : labels) {
Button key = new Button(activity);
key.setText(label);
key.setOnClickListener(v -> handleKeyPress(label));
key.setOnTouchListener(CLICK_ON_TOUCH_UP);
row.addView(key, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, keyWeight(label)));
}
panel.addView(row, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
}
private static float keyWeight(String label) {
if (label.equals("Space")) {
return 4f;
}
if (label.equals("Back") || label.equals("Done") || label.equals("Esc")
|| label.equals("Enter")) {
return 1.5f;
}
return 1f;
}
// "Done" is this overlay's own UI navigation (closes the key grid, no
// game-visible effect). Esc/Back/Enter are non-printable, so they're
// forwarded as real Android key events via SDLActivity.onNativeKeyDown()/
// onNativeKeyUp() - the same pair a physical Bluetooth keyboard's key
// events already drive (see SDLActivity.handleKeyEvent()). Every other
// key (letters, Space) instead goes through commitPrintableChar() below:
// the engine's own pump_events() (sdl_events.c) deliberately ignores
// SDL_KEYDOWN for printable ASCII and only reacts to SDL_TEXTINPUT for
// those, to avoid double-counting a typed character against a system
// IME - so that event has to be synthesized directly (see
// commitPrintableChar()). This all matters beyond just the visible
// typedTextView below: the engine's cutscene skip handler (cutsloop.c's
// cutscene_key_handler()) only reacts to Esc/Enter/Space, so those need
// to reach the game as actual game input, not just be echoed locally.
private void handleKeyPress(String label) {
if (label.equals("Done")) {
showPanel(mainPanel);
return;
}
if (label.equals("Back")) {
SDLActivity.onNativeKeyDown(KeyEvent.KEYCODE_DEL);
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_DEL);
if (typedText.length() > 0) {
typedText.setLength(typedText.length() - 1);
}
} else if (label.equals("Esc") || label.equals("Enter")) {
int keyCode = label.equals("Esc") ? KeyEvent.KEYCODE_ESCAPE : KeyEvent.KEYCODE_ENTER;
SDLActivity.onNativeKeyDown(keyCode);
SDLActivity.onNativeKeyUp(keyCode);
typedText.append('[').append(label).append(']');
} else if (label.equals("Space")) {
commitPrintableChar(' ');
typedText.append(' ');
} else {
commitPrintableChar(Character.toLowerCase(label.charAt(0)));
typedText.append(label);
}
typedTextView.setText(typedText.toString());
}
// Synthesizes the SDL_TEXTINPUT event pump_events() (sdl_events.c)
// requires for printable characters (see handleKeyPress() above), by
// building and pushing it directly in native code (see
// nativeSendPrintableChar() in questshock_native.c) rather than through
// Android's IME (there's no real IME session behind this off-screen,
// never-attached grid for that plumbing to hook into).
private static void commitPrintableChar(char c) {
nativeSendPrintableChar(c);
}
private static native void nativeSendPrintableChar(char c);
// Only one of mainPanel/keyboardPanel is ever visible at a time. Both
// were already measured/laid out once in the constructor's initial
// showPanel() call - re-running measure()/layout() here (rather than
// just flipping visibility) is required every time regardless, since
// FrameLayout skips GONE children during measure/layout, so a panel
// switching from GONE to VISIBLE needs a fresh pass to get valid bounds
// before its content can be drawn or hit-tested.
private void showPanel(View panel) {
mainPanel.setVisibility(panel == mainPanel ? View.VISIBLE : View.GONE);
keyboardPanel.setVisibility(panel == keyboardPanel ? View.VISIBLE : View.GONE);
int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY);
root.measure(widthSpec, heightSpec);
root.layout(0, 0, WIDTH, HEIGHT);
}
// Called from xr_menu.c's xr_menu_init(), on the render thread, right
// after it resolves this class via the activity's own ClassLoader
// (plain FindClass() can't see app classes from a thread that was
// attached to the JVM rather than spawned from Java - see that file's
// xr_menu_find_class()).
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
// thread, right after it resolves this class via the activity's own
// ClassLoader (plain FindClass() can't see app classes from a thread
// that was attached to the JVM rather than spawned from Java).
public static void nativeInit(Activity activity) {
if (instance == null) {
instance = new MenuOverlay(activity);
}
}
// Polled once per frame while the menu quad is visible (see
// xr_menu_render_if_visible()) - returns the current pixels only once
// per redraw (null otherwise), so the native side knows when it can
// skip re-uploading a texture that hasn't actually changed.
public static byte[] nativeTakePixelsIfDirty() {
if (instance == null) {
return null;
}
synchronized (instance.pixelLock) {
byte[] pixels = instance.pendingPixels;
instance.pendingPixels = null;
return pixels;
}
return instance == null ? null : instance.takePixelsIfDirty();
}
// u/v are the menu quad's own hit-test coordinates (0..1, top-left
// origin) - computed by xr_input.c's ray/quad intersection against
// xr_menu.c's reported quad geometry, forwarded here as a synthetic
// tap. Runs on the UI thread since the View/Bitmap/Canvas objects here
// are otherwise only ever touched from there.
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
if (instance == null) {
return;
if (instance != null) {
instance.dispatchTouch(u, v, down);
}
instance.activity.runOnUiThread(() -> instance.handleTouch(u, v, down));
}
// u/v are the same menu-quad hit-test coordinates nativeDispatchTouch()
// uses, but polled once per frame regardless of click state (see
// xr_menu_update_cursor()) rather than only on click edges - lets the
// cursor track the aim ray continuously instead of only jumping when a
// trigger is pressed. visible=false (no hand's ray currently on the
// quad) hides it.
public static void nativeUpdateCursor(final float u, final float v, final boolean visible) {
if (instance == null) {
return;
}
instance.activity.runOnUiThread(() -> instance.updateCursor(u, v, visible));
}
private void updateCursor(float u, float v, boolean visible) {
float x = u * WIDTH;
float y = v * HEIGHT;
if (visible == cursorVisible && x == cursorX && y == cursorY) {
return;
}
cursorVisible = visible;
cursorX = x;
cursorY = y;
compositeAndPublish();
}
private void handleTouch(float u, float v, boolean down) {
float x = u * WIDTH;
float y = v * HEIGHT;
long time = SystemClock.uptimeMillis();
MotionEvent event = MotionEvent.obtain(
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
try {
root.dispatchTouchEvent(event);
} finally {
event.recycle();
}
redrawContent();
}
// Re-runs the full off-screen View-tree draw (expensive - see the
// contentBitmap field comment above) - call only when the panel's
// actual content changed, not for the cursor-only updates
// compositeAndPublish() below handles on its own.
private void redrawContent() {
contentCanvas.drawColor(0xFF202020);
root.draw(contentCanvas);
compositeAndPublish();
}
// Cheap per-frame path: blits the last-rendered contentBitmap (no View
// traversal) plus the cursor circle into bitmap and publishes it.
private void compositeAndPublish() {
canvas.drawBitmap(contentBitmap, 0, 0, null);
if (cursorVisible) {
canvas.drawCircle(cursorX, cursorY, CURSOR_RADIUS, cursorPaint);
}
byte[] pixels = new byte[WIDTH * HEIGHT * 4];
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
synchronized (pixelLock) {
pendingPixels = pixels;
if (instance != null) {
instance.dispatchUpdateCursor(u, v, visible);
}
}
}
@@ -0,0 +1,237 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import java.nio.ByteBuffer;
/**
* Shared machinery for an off-screen, never-attached-to-a-window View tree
* rendered into its own OpenXR quad (see android/app/src/main/cpp/
* xr_overlay.c, which owns the quad's swapchain and drives whichever
* concrete subclass via JNI - construction, touch input, and pixel
* readback). Rendering to a Bitmap and dispatching synthetic MotionEvents
* into an unattached hierarchy both work the same way they would for an
* attached View - draw(Canvas)/dispatchTouchEvent() don't require a
* ViewRootImpl/window, just a measured+laid-out tree.
*
* Subclasses (MenuOverlay, KeyboardOverlay) provide their own content via
* buildContent() and, if they want translucency, contentAlpha() - and, since
* Java has no static virtual dispatch and each overlay needs its own
* singleton, each subclass still declares its own thin nativeInit()/
* nativeTakePixelsIfDirty()/nativeDispatchTouch()/nativeUpdateCursor()
* static methods (matching what xr_overlay.c's JNI glue resolves by class
* name) that just delegate into the instance methods here
* (takePixelsIfDirty()/dispatchTouch()/dispatchUpdateCursor()).
*/
abstract class OverlayPanel {
private static final float CURSOR_RADIUS = 10f;
// Button's own click handling (View.onTouchEvent()'s ACTION_UP case)
// calls View.post(mPerformClick) rather than invoking performClick()
// directly; post() queues the runnable to run once the view is attached
// to a window and returns true immediately even when unattached, so on
// this permanently-unattached tree that queued click silently never
// fires - dispatchTouchEvent() still delivers the down/up events
// correctly, only the click callback is swallowed. Registering this as
// each button's OnTouchListener bypasses that path entirely: returning
// true here skips View's internal onTouchEvent() (see
// ViewGroup/View#dispatchTouchEvent), so performClick() (which still
// runs any OnClickListener set via setOnClickListener()) is called
// directly instead.
protected static final View.OnTouchListener CLICK_ON_TOUCH_UP = (v, event) -> {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
v.setPressed(true);
break;
case MotionEvent.ACTION_UP:
v.setPressed(false);
v.performClick();
break;
case MotionEvent.ACTION_CANCEL:
v.setPressed(false);
break;
}
return true;
};
protected final Activity activity;
private final int width;
private final int height;
private final View root;
// contentBitmap holds just the panel's own rendered content (no cursor),
// redrawn only when that content actually changes (redrawContent(), via
// handleTouch() or a subclass's own key handling) via root.draw() - a
// full off-screen View-tree traversal that gets noticeably more
// expensive on a busier content tree. bitmap is what's actually
// published to native (see takePixelsIfDirty()): compositeAndPublish()
// cheaply blits contentBitmap plus the cursor circle into it. Splitting
// these two apart matters because updateCursor() below fires every
// single frame (~90Hz) while aiming at the panel - if it called the
// expensive root.draw() path every time (as an earlier version did), a
// busy content tree made each redraw slow enough that runOnUiThread()
// posts piled up faster than the UI thread could drain them, so the
// cursor visibly lagged behind the controller's actual aim instead of
// tracking it.
private final Bitmap contentBitmap;
private final Canvas contentCanvas;
private final Bitmap bitmap;
private final Canvas canvas;
private final Paint cursorPaint;
// Draws contentBitmap at contentAlpha() - see compositeAndPublish().
private final Paint contentAlphaPaint;
private final Object pixelLock = new Object();
private byte[] pendingPixels;
// Only ever touched on the UI thread (both dispatchUpdateCursor() and
// compositeAndPublish() run/are posted there) - no lock needed, unlike
// pendingPixels above.
private boolean cursorVisible = false;
private float cursorX = 0f;
private float cursorY = 0f;
protected OverlayPanel(Activity activity, int width, int height) {
this.activity = activity;
this.width = width;
this.height = height;
root = buildContent();
int widthSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
root.measure(widthSpec, heightSpec);
root.layout(0, 0, width, height);
contentBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
contentCanvas = new Canvas(contentBitmap);
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
canvas = new Canvas(bitmap);
cursorPaint = new Paint();
cursorPaint.setColor(0xFFFFFFFF);
cursorPaint.setAntiAlias(true);
// contentAlpha() must not depend on any subclass instance state -
// it's called here, from the superclass constructor, before the
// subclass's own field initializers/constructor body have run (the
// usual Java construction order: this class's fields/constructor
// first, then the subclass's). A static constant is the only thing
// that's safe to return.
contentAlphaPaint = new Paint();
contentAlphaPaint.setAlpha(contentAlpha());
redrawContent();
}
// Builds this overlay's content View tree - called once, from this
// class's own constructor (see the note on contentAlpha() above: don't
// rely on subclass instance fields being initialized yet here; if a
// subclass needs its own mutable state available while building its
// content, e.g. a list of buttons to later relabel, initialize that
// field's value at the top of this method instead of via a field
// initializer).
protected abstract View buildContent();
// 0-255; defaults to fully opaque. Override for translucency (see the
// constructor's note on what's safe to depend on here).
protected int contentAlpha() {
return 255;
}
// Polled once per frame while this overlay's quad is visible (see
// xr_overlay_render_and_build_layer()) - returns the current pixels
// only once per redraw (null otherwise), so the native side knows when
// it can skip re-uploading a texture that hasn't actually changed.
protected final byte[] takePixelsIfDirty() {
synchronized (pixelLock) {
byte[] pixels = pendingPixels;
pendingPixels = null;
return pixels;
}
}
// u/v are this quad's own hit-test coordinates (0..1, top-left origin)
// - computed by xr_input.c's ray/quad intersection against xr_overlay.c's
// reported quad geometry, forwarded here as a synthetic tap. Runs on the
// UI thread since the View/Bitmap/Canvas objects here are otherwise only
// ever touched from there.
protected final void dispatchTouch(final float u, final float v, final boolean down) {
activity.runOnUiThread(() -> handleTouch(u, v, down));
}
// u/v are the same hit-test coordinates dispatchTouch() uses, but
// polled once per frame regardless of click state rather than only on
// click edges - lets the cursor track the aim ray continuously instead
// of only jumping when a trigger is pressed. visible=false (no hand's
// ray currently on the quad) hides it.
protected final void dispatchUpdateCursor(final float u, final float v,
final boolean visible) {
activity.runOnUiThread(() -> updateCursor(u, v, visible));
}
private void updateCursor(float u, float v, boolean visible) {
float x = u * width;
float y = v * height;
if (visible == cursorVisible && x == cursorX && y == cursorY) {
return;
}
cursorVisible = visible;
cursorX = x;
cursorY = y;
compositeAndPublish();
}
private void handleTouch(float u, float v, boolean down) {
float x = u * width;
float y = v * height;
long time = SystemClock.uptimeMillis();
MotionEvent event = MotionEvent.obtain(
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
try {
root.dispatchTouchEvent(event);
} finally {
event.recycle();
}
redrawContent();
}
// Re-runs the full off-screen View-tree draw (expensive - see the
// contentBitmap field comment above) - call only when the panel's
// actual content changed, not for the cursor-only updates
// compositeAndPublish() below handles on its own.
protected final void redrawContent() {
contentCanvas.drawColor(0xFF202020);
root.draw(contentCanvas);
compositeAndPublish();
}
// Cheap per-frame path: blits the last-rendered contentBitmap (no View
// traversal) plus the cursor circle into bitmap and publishes it.
// contentBitmap itself is fully opaque (redrawContent() draws an opaque
// background first); contentAlpha() translucency is applied right here
// instead, via contentAlphaPaint - which needs bitmap cleared to fully
// transparent first, since otherwise each frame's alpha-blended draw
// would blend against whatever was left over in bitmap from the
// previous frame rather than against nothing, compounding into a
// ghosting trail across frames.
private void compositeAndPublish() {
bitmap.eraseColor(0);
canvas.drawBitmap(contentBitmap, 0, 0, contentAlphaPaint);
if (cursorVisible) {
canvas.drawCircle(cursorX, cursorY, CURSOR_RADIUS, cursorPaint);
}
byte[] pixels = new byte[width * height * 4];
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
synchronized (pixelLock) {
pendingPixels = pixels;
}
}
}