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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user