Add hand-built on-screen keyboard to the OpenXR menu quad
build / build (push) Successful in 1m45s

MenuOverlay's key grid forwards every press to the game as real input:
non-printable keys (Esc/Enter/Backspace) as an SDLActivity.onNativeKeyDown()/
onNativeKeyUp() pair, and printable keys (letters, Space) as a synthesized
SDL_TEXTINPUT event pushed directly from native code, since the engine's
pump_events() only picks up printable ASCII from that event type, not from
SDL_KEYDOWN. Confirmed on-device, including that Space now correctly skips
the intro cutscene alongside Esc/Enter.
This commit is contained in:
ml
2026-08-05 08:11:16 +02:00
parent 1be0bebda2
commit 2d8fb49bf5
4 changed files with 269 additions and 31 deletions
@@ -7,9 +7,32 @@
#include <jni.h>
#include <unistd.h>
#include <SDL.h>
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass clazz, jstring path) {
const char *cpath = (*env)->GetStringUTFChars(env, path, NULL);
chdir(cpath);
(*env)->ReleaseStringUTFChars(env, path, cpath);
}
// MenuOverlay's hand-built on-screen keyboard (see
// MenuOverlay.commitPrintableChar()) has no real Android IME session behind
// it, so printable characters can't reach the game via Android's usual IME
// path - instead this builds and pushes an SDL_TEXTINPUT event directly,
// the same event type/shape SDL's own Android backend pushes for typed
// text, using only the public SDL_Event/SDL_PushEvent API. The engine's
// pump_events() (sdl_events.c) only picks up printable characters from
// this event, not from SDL_KEYDOWN (see handleKeyPress() in MenuOverlay.java
// for why).
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeSendPrintableChar(JNIEnv *env, jclass clazz, jchar c) {
SDL_Event event;
SDL_zero(event);
event.type = SDL_TEXTINPUT;
event.text.timestamp = SDL_GetTicks();
event.text.windowID = 0;
event.text.text[0] = (char)c;
event.text.text[1] = '\0';
SDL_PushEvent(&event);
}
+4 -4
View File
@@ -1,8 +1,8 @@
// Menu quad for questshock's immersive Quest build: MenuOverlay.java's
// off-screen-rendered View tree (currently just a "Keyboard" button - see
// the plan's step D for the on-screen keyboard that goes behind it),
// toggled by xr_input.c's menu_toggle action and hit-tested by the same ray
// xr_input.c already computes per hand.
// off-screen-rendered View tree (a "Keyboard" button that swaps to a
// hand-built on-screen key grid - there's no system IME to borrow once
// immersive), toggled by xr_input.c's menu_toggle action and hit-tested by
// the same ray xr_input.c already computes per hand.
//
// The menu has its own independent OpenXR swapchain, sized to exactly
// xr_menu_get_content_size()'s dimensions (see xr_session.c, which creates
@@ -5,13 +5,18 @@ import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.SystemClock;
import android.util.Log;
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
@@ -21,10 +26,22 @@ import java.nio.ByteBuffer;
* 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.
*/
public class MenuOverlay {
private static final String TAG = "QuestShock";
// Matches xr_menu.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.
@@ -32,12 +49,60 @@ public class MenuOverlay {
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 MenuOverlay instance;
private final Activity activity;
private final FrameLayout root;
private final Button keyboardButton;
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;
@@ -46,7 +111,7 @@ public class MenuOverlay {
private byte[] pendingPixels;
// Only ever touched on the UI thread (both nativeUpdateCursor() and
// forceRedraw() run/are posted there) - no lock needed, unlike
// compositeAndPublish() run/are posted there) - no lock needed, unlike
// pendingPixels above.
private boolean cursorVisible = false;
private float cursorX = 0f;
@@ -58,13 +123,16 @@ public class MenuOverlay {
root = new FrameLayout(activity);
root.setBackgroundColor(0xFF202020);
keyboardButton = new Button(activity);
keyboardButton.setText("Keyboard");
keyboardButton.setOnClickListener(v -> Log.i(TAG, "MenuOverlay: Keyboard button clicked"));
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(420, 140);
lp.gravity = Gravity.CENTER;
root.addView(keyboardButton, lp);
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);
@@ -72,12 +140,138 @@ public class MenuOverlay {
cursorPaint.setColor(0xFFFFFFFF);
cursorPaint.setAntiAlias(true);
showPanel(mainPanel);
redrawContent();
}
private FrameLayout buildMainPanel() {
FrameLayout panel = new FrameLayout(activity);
Button keyboardButton = new Button(activity);
keyboardButton.setText("Keyboard");
keyboardButton.setOnClickListener(v -> showPanel(keyboardPanel));
keyboardButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(420, 140);
lp.gravity = Gravity.CENTER;
panel.addView(keyboardButton, lp);
return panel;
}
private LinearLayout buildKeyboardPanel() {
LinearLayout panel = new LinearLayout(activity);
panel.setOrientation(LinearLayout.VERTICAL);
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);
forceRedraw();
}
// Called from xr_menu.c's xr_menu_init(), on the render thread, right
@@ -140,7 +334,7 @@ public class MenuOverlay {
cursorVisible = visible;
cursorX = x;
cursorY = y;
forceRedraw();
compositeAndPublish();
}
private void handleTouch(float u, float v, boolean down) {
@@ -154,12 +348,23 @@ public class MenuOverlay {
} finally {
event.recycle();
}
forceRedraw();
redrawContent();
}
private void forceRedraw() {
canvas.drawColor(0xFF202020);
root.draw(canvas);
// 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);
}