Overhaul the VR menu UI: header, tabs, live video options, hover/click feedback, i18n, and dragging
build / build (push) Successful in 2m25s

- Header with title, git-tag-derived version, and a live FPS/frame-time/
  CPU/GPU-utilization/free-memory stats ribbon below the tab bar (FPS via
  a new per-frame tracker in xr_session.c; CPU/GPU via the OpenXR
  XR_META_performance_metrics extension, gracefully unavailable on
  runtimes that don't support it)
- Bottom tab bar with a live-editable Video Options tab (renderer/
  resolution), applying changes immediately via qs_config.c
- Hover and press feedback on every menu/keyboard button via a
  StateListDrawable (Android's default ripple never animates on this
  permanently unattached view tree), with a lighter default button color
  for visibility
- i18n for all menu/keyboard chrome text, following the game's own
  language setting (Strings.java)
- The main menu is now draggable via its header, matching the keyboard;
  xr_input.c's drag handling is generalized to per-overlay state instead
  of being keyboard-only
- The keyboard's title bar now matches the menu header's style
This commit is contained in:
ml
2026-09-05 07:02:30 +02:00
parent c4fd4eb3dd
commit 09280dd410
14 changed files with 1150 additions and 148 deletions
+34 -3
View File
@@ -29,9 +29,33 @@ if (prebuiltDir == null) {
}
// Set by build-apk.sh (see build-image/version.sh, shared with the
// desktop tarball's versioning) via -PquestshockVersionName/Code. Default
// here only covers a direct, unsupported `gradlew` invocation.
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : '0.0.0-dev'
// desktop tarball's versioning) via -PquestshockVersionName/Code. Building
// directly from Android Studio (the normal day-to-day workflow - see
// README) never passes that property, so the default below has to carry
// its own weight rather than just being a fallback for some rare, other,
// unsupported path: it runs `git describe` itself, the same way
// engine/CMakeLists.txt's own PROJECT_REVERSION_STRING does for the
// engine's *own* separate version string - "vX.Y.Z" on a clean tagged
// commit, or "vX.Y.Z-N-gHASH[-dirty]" otherwise, so a build between
// releases is still identifiable from the in-headset menu (see
// MenuOverlay.java's header, which reads BuildConfig.VERSION_NAME).
def gitDescribeVersionName = {
try {
def proc = ["git", "-C", projectDir.toString(), "describe", "--tags", "--always", "--dirty"].execute()
proc.waitFor()
if (proc.exitValue() == 0) {
def out = proc.text.trim()
if (out) {
return out
}
}
} catch (Exception ignored) {
// git not on PATH, or this isn't a git checkout at all (e.g. a
// source tarball) - fall through to the placeholder below.
}
return "0.0.0-dev"
}
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : gitDescribeVersionName()
def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1
// Auto-refreshes the staged, patched engine/ and gl4es-src/ copies and
@@ -189,6 +213,13 @@ android {
}
}
// Generates BuildConfig (specifically BuildConfig.VERSION_NAME, read by
// MenuOverlay.java's header) - off by default since AGP 8.0, so this
// has to be requested explicitly.
buildFeatures {
buildConfig true
}
lint {
abortOnError false
}
+52 -6
View File
@@ -442,6 +442,21 @@ static const char *qs_current_resolution(void) {
return "640x480"; // shouldn't happen - safe fallback for Save
}
// ===========================================================================
// Read-only queries for the Video Options tab (see qs_config.h)
// ===========================================================================
bool qs_config_current_renderer_is_opengl(void) { return gShockPrefs.doUseOpenGL; }
int qs_config_current_resolution_index(void) {
for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++)
if (kResolutions[i].mode == mode_id)
return (int)i;
return 3; // shouldn't happen - 640x480, matches qs_current_resolution()'s own fallback
}
int qs_config_current_language(void) { return gShockPrefs.goLanguage; }
// ===========================================================================
// Apply / Save orchestration
// ===========================================================================
@@ -557,16 +572,21 @@ static void qs_show_restart_dialog(const char *restart_needed) {
static Uint32 s_save_event_type;
static Uint32 s_load_event_type;
static Uint32 s_set_renderer_event_type;
static Uint32 s_set_resolution_event_type;
void qs_config_load_startup(void) {
// Registers the custom SDL event types qs_config_request_save()/_load()
// enqueue - done here, once, on the engine's own thread and before the
// menu/its buttons could possibly exist yet, rather than lazily from
// those functions themselves (which run on Android's UI thread and
// could otherwise race each other doing this lazily).
Uint32 base = SDL_RegisterEvents(2);
// Registers the custom SDL event types qs_config_request_save()/_load()/
// _set_renderer()/_set_resolution() enqueue - done here, once, on the
// engine's own thread and before the menu/its buttons could possibly
// exist yet, rather than lazily from those functions themselves (which
// run on Android's UI thread and could otherwise race each other doing
// this lazily).
Uint32 base = SDL_RegisterEvents(4);
s_save_event_type = base;
s_load_event_type = base + 1;
s_set_renderer_event_type = base + 2;
s_set_resolution_event_type = base + 3;
QsParsedConfig parsed;
if (qs_config_read_file(&parsed))
@@ -587,6 +607,22 @@ void qs_config_request_load(void) {
SDL_PushEvent(&event);
}
void qs_config_request_set_renderer(bool opengl) {
SDL_Event event;
SDL_zero(event);
event.type = s_set_renderer_event_type;
event.user.code = opengl ? 1 : 0;
SDL_PushEvent(&event);
}
void qs_config_request_set_resolution(int resolution_index) {
SDL_Event event;
SDL_zero(event);
event.type = s_set_resolution_event_type;
event.user.code = resolution_index;
SDL_PushEvent(&event);
}
bool qs_config_handle_event(const SDL_Event *event) {
if (event->type == s_save_event_type) {
if (qs_config_write_file())
@@ -609,5 +645,15 @@ bool qs_config_handle_event(const SDL_Event *event) {
message_info("Configuration loaded");
return true;
}
if (event->type == s_set_renderer_event_type) {
qs_apply_renderer(event->user.code ? "opengl" : "software", /*live=*/true);
return true;
}
if (event->type == s_set_resolution_event_type) {
int index = event->user.code;
if (index >= 0 && (size_t)index < QS_NUM_RESOLUTIONS)
qs_apply_resolution(kResolutions[index].name, /*live=*/true);
return true;
}
return false;
}
+39 -6
View File
@@ -37,14 +37,47 @@ void qs_config_load_startup(void);
void qs_config_request_save(void);
void qs_config_request_load(void);
// Returns true and handles the event if it's one of the two custom events
// qs_config_request_save()/_load() enqueue - called from pump_events()
// (sdl_events.c, see android/engine-patches/21-android-config.patch) on the
// engine's own thread, once per polled event, before its normal switch on
// well-known SDL_* event types. Returns false (no-op) for any other event
// type, including before the two custom types have even been registered.
// Same cross-thread-dispatch reasoning as qs_config_request_save()/_load()
// above - called from the Video Options tab's controls (MenuOverlay.java),
// which are just as much on Android's UI thread as the Save/Load buttons.
// opengl=true/false selects "opengl"/"software"; resolution_index is 0-4,
// indexing the same table qs_config_current_resolution_index() below
// reads. Applies live immediately (no restart involved - see CONFIG.md);
// unlike Load Configuration, these don't post a message_info() HUD
// notification for every single tap - the tab's own selection highlight is
// the feedback.
void qs_config_request_set_renderer(bool opengl);
void qs_config_request_set_resolution(int resolution_index);
// Returns true and handles the event if it's one of the four custom events
// qs_config_request_save()/_load()/_set_renderer()/_set_resolution()
// enqueue - called from pump_events() (sdl_events.c, see
// android/engine-patches/21-android-config.patch) on the engine's own
// thread, once per polled event, before its normal switch on well-known
// SDL_* event types. Returns false (no-op) for any other event type,
// including before the four custom types have even been registered.
bool qs_config_handle_event(const SDL_Event *event);
// Read-only queries for the Video Options tab's own selection highlight
// (MenuOverlay.java re-reads these whenever the tab is (re)shown, so it
// reflects the live value even if it was last changed some other way, e.g.
// the Ctrl+G hotkey or Load Configuration). Safe to call directly from
// Android's UI thread despite not going through the event-queue dispatch
// the mutating calls above use - same low-risk reasoning already used for
// Save Configuration's own reads of gShockPrefs/mode_id: these are simple,
// independent POD scalar reads, not a sequence that needs to be atomic
// with anything else.
bool qs_config_current_renderer_is_opengl(void);
int qs_config_current_resolution_index(void);
// gShockPrefs.goLanguage (0 English/1 French/2 German, Prefs.h) - read by
// MenuOverlay.java's/KeyboardOverlay.java's periodic header-refresh timer
// to pick which language's chrome text (Strings.java) to display, so a
// language change made mid-session via the game's own options menu is
// picked up without needing to reopen either panel. Same
// off-thread-scalar-read reasoning as the two getters above.
int qs_config_current_language(void);
#ifdef __cplusplus
}
#endif
@@ -103,3 +103,56 @@ JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestLoadConfig(JNIEnv *env, jclass clazz) {
qs_config_request_load();
}
// MenuOverlay's Video Options tab - live-appliable controls, see
// qs_config.h. Same enqueue-only reasoning as the Save/Load buttons above.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetRenderer(JNIEnv *env, jclass clazz,
jboolean opengl) {
qs_config_request_set_renderer(opengl);
}
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetResolution(JNIEnv *env, jclass clazz,
jint resolutionIndex) {
qs_config_request_set_resolution(resolutionIndex);
}
// Read-only queries - safe to call directly (no enqueue needed, see
// qs_config.h's own comment on each of these).
JNIEXPORT jboolean JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetRendererIsOpenGl(JNIEnv *env, jclass clazz) {
return qs_config_current_renderer_is_opengl();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetResolutionIndex(JNIEnv *env, jclass clazz) {
return qs_config_current_resolution_index();
}
JNIEXPORT jint JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetLanguage(JNIEnv *env, jclass clazz) {
return qs_config_current_language();
}
// MenuOverlay's header - polled on a periodic timer (see its own comment),
// not event-driven, so these are plain synchronous reads too.
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetFps(JNIEnv *env, jclass clazz) {
return xr_session_get_fps();
}
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetFrameTimeMs(JNIEnv *env, jclass clazz) {
return xr_session_get_frame_ms();
}
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetCpuUtilization(JNIEnv *env, jclass clazz) {
return xr_session_get_cpu_utilization();
}
JNIEXPORT jfloat JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeGetGpuUtilization(JNIEnv *env, jclass clazz) {
return xr_session_get_gpu_utilization();
}
+85 -70
View File
@@ -26,17 +26,24 @@ static const float kHandColor[2][4] = {
{1.0f, 0.85f, 0.1f, 1.0f}, // right: amber
};
// The keyboard overlay's title bar (drag handle + Close button) is handled
// entirely here, before a hit would normally be dispatched to
// KeyboardOverlay as a touch - these fractions must agree with
// KeyboardOverlay.java's TITLE_BAR_HEIGHT/CLOSE_BUTTON_WIDTH constants (in
// pixels, out of KeyboardOverlay.WIDTH/HEIGHT there). A hit in the top
// TITLE_BAR_V_FRACTION of the quad, at or left of CLOSE_BUTTON_U_FRACTION,
// is the drag handle; right of that (still within the title bar) is the
// Close button, which is dispatched as an ordinary click instead. The menu
// launcher overlay has no title bar - it's a plain click-only panel.
#define TITLE_BAR_V_FRACTION (90.0f / 768.0f)
#define CLOSE_BUTTON_U_FRACTION (1.0f - 160.0f / 1024.0f)
// Both overlays' title bars (drag handle, plus the keyboard's Close button)
// are handled entirely here, before a hit would normally be dispatched to
// the Java side as a touch. A hit in the top *_TITLE_BAR_V_FRACTION of a
// quad, at or left of its own drag-handle-U-fraction, is the drag handle;
// right of that (still within the title bar) is dispatched as an ordinary
// click instead - the keyboard's Close button, or (the menu has no such
// button - MENU_DRAG_HANDLE_U_FRACTION is 1.0f, so its whole title bar
// width is the drag handle) nothing.
//
// KEYBOARD_TITLE_BAR_V_FRACTION/KEYBOARD_CLOSE_BUTTON_U_FRACTION must agree
// with KeyboardOverlay.java's TITLE_BAR_HEIGHT/CLOSE_BUTTON_WIDTH constants
// (in pixels, out of KeyboardOverlay.WIDTH/HEIGHT there);
// MENU_TITLE_BAR_V_FRACTION with MenuOverlay.java's HEADER_HEIGHT (out of
// MenuOverlay.HEIGHT).
#define KEYBOARD_TITLE_BAR_V_FRACTION (90.0f / 768.0f)
#define KEYBOARD_CLOSE_BUTTON_U_FRACTION (1.0f - 160.0f / 1024.0f)
#define MENU_TITLE_BAR_V_FRACTION (56.0f / 552.0f)
#define MENU_DRAG_HANDLE_U_FRACTION 1.0f
static XrInstance g_instance = XR_NULL_HANDLE;
static XrSession g_session = XR_NULL_HANDLE;
@@ -75,17 +82,24 @@ static bool g_menu_touch_active[2] = {false, false};
static bool g_keyboard_prev_hit[2] = {false, false};
static bool g_keyboard_touch_active[2] = {false, false};
// Drag state for repositioning the keyboard overlay via its title bar (see
// TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION above) - the menu launcher
// has no title bar, so it's never draggable. g_keyboard_drag_hand is -1
// when g_keyboard_dragging is false. The offset is the vector from the
// quad's center to the grabbed point at drag-start, kept constant for the
// whole gesture so the quad follows the hand rigidly rather than
// re-centering under the ray each frame.
static bool g_keyboard_dragging = false;
static int g_keyboard_drag_hand = -1;
static float g_keyboard_drag_offset_x = 0.0f;
static float g_keyboard_drag_offset_y = 0.0f;
// Drag state for repositioning an overlay via its title bar (see the
// *_TITLE_BAR_V_FRACTION/*_DRAG_HANDLE_U_FRACTION or
// *_CLOSE_BUTTON_U_FRACTION defines above). hand is -1 when dragging is
// false. offset_x/offset_y is the vector from the quad's center to the
// grabbed point at drag-start, kept constant for the whole gesture so the
// quad follows the hand rigidly rather than re-centering under the ray
// each frame. Each draggable overlay (keyboard, menu) gets its own
// instance so they can be dragged independently/simultaneously by
// different hands.
typedef struct {
bool dragging;
int hand;
float offset_x;
float offset_y;
} XrDragState;
static XrDragState g_keyboard_drag = {false, -1, 0.0f, 0.0f};
static XrDragState g_menu_drag = {false, -1, 0.0f, 0.0f};
// A "view" reference space, located once per frame (not per hand) to get
// an approximate head position for the laser-beam billboard math below -
@@ -341,17 +355,21 @@ bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_for
// Hit-tests/dispatches this hand's ray against one overlay quad - shared
// by the menu launcher and keyboard overlays in xr_input_sync_and_draw()
// below. dragCapable enables the keyboard-only title-bar/drag-handle
// handling (see TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION); the menu
// launcher has no title bar, so it's always false there and every hit is a
// plain click. Returns true only when this hand's ray is actually
// relevant to this overlay this frame - landing on it now, or continuing
// a touch/drag begun on a previous frame for this hand that hasn't been
// released yet - not merely because the overlay is visible. The caller
// should only stop trying other targets (menu/keyboard/game quad) when
// this returns true; a visible-but-unclaimed overlay falls through so a
// lower-priority target (ultimately the game quad) can still be aimed at.
static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
// below. titleBarVFraction/dragHandleUFraction and dragState select the
// per-overlay title-bar/drag-handle geometry and drag bookkeeping (see the
// *_TITLE_BAR_V_FRACTION/*_DRAG_HANDLE_U_FRACTION/*_CLOSE_BUTTON_U_FRACTION
// defines above and g_keyboard_drag/g_menu_drag) - both overlays are
// draggable via their title bar, and any hit below titleBarVFraction is
// always a plain click regardless of overlay. Returns true only when this
// hand's ray is actually relevant to this overlay this frame - landing on
// it now, or continuing a touch/drag begun on a previous frame for this
// hand that hasn't been released yet - not merely because the overlay is
// visible. The caller should only stop trying other targets (menu/
// keyboard/game quad) when this returns true; a visible-but-unclaimed
// overlay falls through so a lower-priority target (ultimately the game
// quad) can still be aimed at.
static bool xr_input_try_overlay(XrOverlay *overlay, float titleBarVFraction,
float dragHandleUFraction, XrDragState *dragState, int hand,
const XrSpaceLocation *location, float fx, float fy, float fz,
bool selectDownEdge, bool selectUpEdge, bool *touchActive,
bool *prevHit, const char *quadName, float *outCursorU,
@@ -367,7 +385,7 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
// the ray this frame even if it has strayed off the panel's current
// bounds - see `claims` below.
bool hadTouch = *touchActive;
bool hadDrag = dragCapable && g_keyboard_dragging && g_keyboard_drag_hand == hand;
bool hadDrag = dragState->dragging && dragState->hand == hand;
float quadCenterX, quadCenterY, distance, halfWidth, halfHeight;
xr_overlay_get_quad_extent(overlay, &quadCenterX, &quadCenterY, &distance, &halfWidth,
@@ -417,34 +435,23 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
// it's visible.
bool claims = hit || hadTouch || hadDrag;
if (!dragCapable) {
if (selectDownEdge && hit) {
xr_overlay_touch(overlay, u, v, true);
*touchActive = true;
} else if (selectUpEdge && *touchActive) {
xr_overlay_touch(overlay, u, v, false);
*touchActive = false;
}
return claims;
}
bool onTitleBar = hit && v < TITLE_BAR_V_FRACTION;
bool onDragHandle = onTitleBar && u <= CLOSE_BUTTON_U_FRACTION;
bool onTitleBar = hit && v < titleBarVFraction;
bool onDragHandle = onTitleBar && u <= dragHandleUFraction;
if (selectDownEdge) {
if (onDragHandle) {
g_keyboard_dragging = true;
g_keyboard_drag_hand = hand;
g_keyboard_drag_offset_x = worldX - quadCenterX;
g_keyboard_drag_offset_y = worldY - quadCenterY;
dragState->dragging = true;
dragState->hand = hand;
dragState->offset_x = worldX - quadCenterX;
dragState->offset_y = worldY - quadCenterY;
} else if (hit) {
xr_overlay_touch(overlay, u, v, true);
*touchActive = true;
}
} else if (selectUpEdge) {
if (g_keyboard_dragging && g_keyboard_drag_hand == hand) {
g_keyboard_dragging = false;
g_keyboard_drag_hand = -1;
if (dragState->dragging && dragState->hand == hand) {
dragState->dragging = false;
dragState->hand = -1;
} else if (*touchActive) {
xr_overlay_touch(overlay, u, v, false);
*touchActive = false;
@@ -454,9 +461,9 @@ static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
// Re-intersects against the same fixed-distance plane every frame the
// drag continues, so the quad tracks the hand in real time rather than
// only jumping on the down/up edges above.
if (g_keyboard_dragging && g_keyboard_drag_hand == hand && planeHit) {
xr_overlay_set_position(overlay, worldX - g_keyboard_drag_offset_x,
worldY - g_keyboard_drag_offset_y);
if (dragState->dragging && dragState->hand == hand && planeHit) {
xr_overlay_set_position(overlay, worldX - dragState->offset_x,
worldY - dragState->offset_y);
}
return claims;
}
@@ -630,8 +637,12 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
// A drag can't get stuck active across a hide/reopen - e.g. Close
// being hit on one hand mid-drag on the other, or the session ending.
if (!keyboardVisible) {
g_keyboard_dragging = false;
g_keyboard_drag_hand = -1;
g_keyboard_drag.dragging = false;
g_keyboard_drag.hand = -1;
}
if (!menuVisible) {
g_menu_drag.dragging = false;
g_menu_drag.hand = -1;
}
// A hand only ever gets a laser-beam quad this frame if it actually
@@ -813,11 +824,12 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
bool keyboardPlaneHit = false;
float keyboardPlaneDistance = 0.0f;
if (!g_game_touch_active[hand] &&
xr_input_try_overlay(keyboardOverlay, true, hand, &location, fx, fy, fz,
selectDownEdge, selectUpEdge, &g_keyboard_touch_active[hand],
&g_keyboard_prev_hit[hand], "keyboard", &keyboardCursorU,
&keyboardCursorV, &keyboardCursorHit, &keyboardPlaneHit,
&keyboardPlaneDistance)) {
xr_input_try_overlay(keyboardOverlay, KEYBOARD_TITLE_BAR_V_FRACTION,
KEYBOARD_CLOSE_BUTTON_U_FRACTION, &g_keyboard_drag, hand,
&location, fx, fy, fz, selectDownEdge, selectUpEdge,
&g_keyboard_touch_active[hand], &g_keyboard_prev_hit[hand],
"keyboard", &keyboardCursorU, &keyboardCursorV,
&keyboardCursorHit, &keyboardPlaneHit, &keyboardPlaneDistance)) {
if (haveHead && keyboardPlaneHit)
xr_input_build_beam(hand, &location, fx, fy, fz,
fminf(keyboardPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
@@ -826,10 +838,11 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
bool menuPlaneHit = false;
float menuPlaneDistance = 0.0f;
if (!g_game_touch_active[hand] &&
xr_input_try_overlay(menuOverlay, false, hand, &location, fx, fy, fz, selectDownEdge,
selectUpEdge, &g_menu_touch_active[hand], &g_menu_prev_hit[hand],
"menu", &menuCursorU, &menuCursorV, &menuCursorHit, &menuPlaneHit,
&menuPlaneDistance)) {
xr_input_try_overlay(menuOverlay, MENU_TITLE_BAR_V_FRACTION,
MENU_DRAG_HANDLE_U_FRACTION, &g_menu_drag, hand, &location, fx,
fy, fz, selectDownEdge, selectUpEdge, &g_menu_touch_active[hand],
&g_menu_prev_hit[hand], "menu", &menuCursorU, &menuCursorV,
&menuCursorHit, &menuPlaneHit, &menuPlaneDistance)) {
if (haveHead && menuPlaneHit)
xr_input_build_beam(hand, &location, fx, fy, fz,
fminf(menuPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
@@ -920,6 +933,8 @@ void xr_input_shutdown(void) {
g_prev_menu = false;
g_prev_space_key = false;
g_prev_forward_key = false;
g_keyboard_dragging = false;
g_keyboard_drag_hand = -1;
g_keyboard_drag.dragging = false;
g_keyboard_drag.hand = -1;
g_menu_drag.dragging = false;
g_menu_drag.hand = -1;
}
+200 -4
View File
@@ -1,5 +1,6 @@
#include "xr_session.h"
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
@@ -39,8 +40,14 @@
// xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately
// positioned apart by default so they don't start out overlapping.
#define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay"
#define MENU_WIDTH 512
#define MENU_HEIGHT 256
// 768x552 (was 512x256, then 768x512) - grown to fit the header/tabs/Video
// Options content added on top of the original single "Keyboard" button,
// then again to fit the stats ribbon below the tab bar; taller in world
// space too since MENU_WIDTH_METERS (the physical size) stays fixed and
// height_m is derived from it by aspect ratio (see xr_overlay.c's
// xr_overlay_init()).
#define MENU_WIDTH 768
#define MENU_HEIGHT 552
#define MENU_DISTANCE_METERS 1.3f
#define MENU_WIDTH_METERS 0.35f
#define MENU_DEFAULT_CENTER_X_METERS 0.0f
@@ -79,6 +86,22 @@ static XrTime g_predicted_display_time = 0;
static bool g_frame_should_render = false;
static bool g_have_acquired_game_image = false;
// XR_META_performance_metrics support (see xr_session_init_performance_metrics()
// below) - only present if the runtime advertised it at xrCreateInstance
// time (checked via xr_instance_extension_supported()), since unlike the
// two always-required KHR extensions above, this one is a device-capability
// extra that a build shouldn't hard-depend on.
static bool g_performance_metrics_supported = false;
static PFN_xrSetPerformanceMetricsStateMETA g_xrSetPerformanceMetricsStateMETA = NULL;
static PFN_xrQueryPerformanceMetricsCounterMETA g_xrQueryPerformanceMetricsCounterMETA = NULL;
// Resolved once, at startup, by string-matching the runtime's own
// human-readable counter path names (see xr_session_init_performance_metrics())
// rather than hardcoding the exact path strings the spec defines - a
// substring match is robust to spec/runtime wording we can't verify without
// on-device testing. XR_NULL_PATH (0) if no match was found.
static XrPath g_cpu_utilization_path = XR_NULL_PATH;
static XrPath g_gpu_utilization_path = XR_NULL_PATH;
static bool xr_check(XrResult result, const char *what) {
if (XR_SUCCEEDED(result))
return true;
@@ -99,6 +122,106 @@ static PFN_xrVoidFunction xr_get_proc(const char *name) {
return fn;
}
// Queried before xrCreateInstance (which is why this takes no instance
// handle - xrEnumerateInstanceExtensionProperties is one of the few
// functions that works without one) to decide whether it's safe to add
// `name` to enabledExtensionNames - passing an unsupported extension there
// would fail instance creation outright, unlike a missing optional feature
// discovered afterward.
static bool xr_instance_extension_supported(const char *name) {
uint32_t count = 0;
if (!xr_check(xrEnumerateInstanceExtensionProperties(NULL, 0, &count, NULL),
"xrEnumerateInstanceExtensionProperties"))
return false;
XrExtensionProperties *props = (XrExtensionProperties *)malloc(sizeof(XrExtensionProperties) * count);
for (uint32_t i = 0; i < count; i++)
props[i].type = XR_TYPE_EXTENSION_PROPERTIES;
bool supported = false;
if (xr_check(xrEnumerateInstanceExtensionProperties(NULL, count, &count, props),
"xrEnumerateInstanceExtensionProperties")) {
for (uint32_t i = 0; i < count; i++) {
if (strcmp(props[i].extensionName, name) == 0) {
supported = true;
break;
}
}
}
free(props);
return supported;
}
// Case-insensitively checks whether `needle` occurs anywhere in `haystack` -
// used to identify a counter path (e.g. ".../device/cpu_utilization_average")
// by a short, stable substring instead of matching its exact full spelling.
static bool xr_path_name_contains(const char *haystack, const char *needle) {
size_t hlen = strlen(haystack);
char lower[256];
if (hlen >= sizeof(lower))
hlen = sizeof(lower) - 1;
for (size_t i = 0; i < hlen; i++)
lower[i] = (char)tolower((unsigned char)haystack[i]);
lower[hlen] = '\0';
return strstr(lower, needle) != NULL;
}
// Enables XR_META_performance_metrics (if the runtime advertised it - see
// xr_instance_extension_supported() above) and resolves the CPU/GPU
// device-utilization counter paths xr_session_update_frame_stats() polls
// every frame. Called once, right after xrCreateSession succeeds. Leaves
// g_cpu_utilization_path/g_gpu_utilization_path at XR_NULL_PATH (their
// getters then report -1.0f) if anything here doesn't pan out - this is a
// nice-to-have stats display, not something worth failing session bring-up
// over.
static void xr_session_init_performance_metrics(void) {
if (!g_performance_metrics_supported)
return;
PFN_xrEnumeratePerformanceMetricsCounterPathsMETA enumeratePaths =
(PFN_xrEnumeratePerformanceMetricsCounterPathsMETA)xr_get_proc(
"xrEnumeratePerformanceMetricsCounterPathsMETA");
g_xrSetPerformanceMetricsStateMETA = (PFN_xrSetPerformanceMetricsStateMETA)xr_get_proc(
"xrSetPerformanceMetricsStateMETA");
g_xrQueryPerformanceMetricsCounterMETA = (PFN_xrQueryPerformanceMetricsCounterMETA)xr_get_proc(
"xrQueryPerformanceMetricsCounterMETA");
if (enumeratePaths == NULL || g_xrSetPerformanceMetricsStateMETA == NULL ||
g_xrQueryPerformanceMetricsCounterMETA == NULL)
return;
XrPerformanceMetricsStateMETA state = {XR_TYPE_PERFORMANCE_METRICS_STATE_META};
state.enabled = XR_TRUE;
if (!xr_check(g_xrSetPerformanceMetricsStateMETA(g_session, &state),
"xrSetPerformanceMetricsStateMETA"))
return;
uint32_t pathCount = 0;
if (!xr_check(enumeratePaths(g_instance, 0, &pathCount, NULL),
"xrEnumeratePerformanceMetricsCounterPathsMETA"))
return;
XrPath *paths = (XrPath *)malloc(sizeof(XrPath) * pathCount);
if (!xr_check(enumeratePaths(g_instance, pathCount, &pathCount, paths),
"xrEnumeratePerformanceMetricsCounterPathsMETA")) {
free(paths);
return;
}
for (uint32_t i = 0; i < pathCount; i++) {
char pathName[256];
uint32_t pathNameLen = 0;
if (!xr_check(xrPathToString(g_instance, paths[i], sizeof(pathName), &pathNameLen, pathName),
"xrPathToString"))
continue;
if (g_cpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "cpu_utilization"))
g_cpu_utilization_path = paths[i];
if (g_gpu_utilization_path == XR_NULL_PATH && xr_path_name_contains(pathName, "gpu_utilization"))
g_gpu_utilization_path = paths[i];
}
free(paths);
LOGI("XR: performance metrics cpu_utilization=%s gpu_utilization=%s",
g_cpu_utilization_path != XR_NULL_PATH ? "found" : "not found",
g_gpu_utilization_path != XR_NULL_PATH ? "found" : "not found");
}
// The Android OpenXR loader needs the JavaVM/context before anything else -
// separate from XR_KHR_android_create_instance below, which only affects
// xrCreateInstance itself. Skipping this is a common cause of
@@ -146,9 +269,13 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
if (!xr_initialize_android_loader(vm, activity))
return false;
const char *extensions[] = {
g_performance_metrics_supported =
xr_instance_extension_supported(XR_META_PERFORMANCE_METRICS_EXTENSION_NAME);
const char *extensions[3] = {
XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME,
XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME,
XR_META_PERFORMANCE_METRICS_EXTENSION_NAME,
};
XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
@@ -157,7 +284,7 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO};
createInfo.next = &androidInfo;
createInfo.enabledExtensionCount = sizeof(extensions) / sizeof(extensions[0]);
createInfo.enabledExtensionCount = g_performance_metrics_supported ? 3 : 2;
createInfo.enabledExtensionNames = extensions;
strncpy(createInfo.applicationInfo.applicationName, "QuestShock",
XR_MAX_APPLICATION_NAME_SIZE - 1);
@@ -206,6 +333,8 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
if (!xr_check(xrCreateSession(g_instance, &sessionInfo, &g_session), "xrCreateSession"))
return false;
xr_session_init_performance_metrics();
XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
spaceInfo.poseInReferenceSpace.orientation.w = 1.0f;
@@ -354,6 +483,65 @@ bool xr_frame_begin(void) {
return g_have_acquired_game_image;
}
// Backs xr_session_get_fps()/get_frame_ms() (see xr_session.h) - exponential
// smoothing (rather than a plain per-frame instantaneous value) so
// MenuOverlay's header display doesn't visibly jitter every single frame.
// Called once per xr_frame_end() that reaches its own end (i.e. once per
// real XR frame, whether or not the game itself actually rendered anything
// new into it this frame - matches the OpenXR compositor's own frame
// cadence, which is what "FPS" means to someone looking at this display).
static float s_fps = 0.0f;
static float s_frame_ms = 0.0f;
static float s_cpu_utilization = -1.0f;
static float s_gpu_utilization = -1.0f;
// Reads one XR_META_performance_metrics counter (if it was found at
// startup - see xr_session_init_performance_metrics()) and folds it into
// *smoothed with the same exponential smoothing as FPS/frame time above.
// Leaves *smoothed at -1.0f (its "unsupported" sentinel) untouched if the
// path wasn't found, the query fails, or the runtime didn't mark its
// floatValue valid this call.
static void xr_session_update_utilization(XrPath path, float *smoothed) {
if (path == XR_NULL_PATH || g_xrQueryPerformanceMetricsCounterMETA == NULL)
return;
XrPerformanceMetricsCounterMETA counter = {XR_TYPE_PERFORMANCE_METRICS_COUNTER_META};
if (!xr_check(g_xrQueryPerformanceMetricsCounterMETA(g_session, path, &counter),
"xrQueryPerformanceMetricsCounterMETA"))
return;
if (!(counter.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META))
return;
const float kSmoothing = 0.1f;
*smoothed =
(*smoothed < 0.0f) ? counter.floatValue : *smoothed * (1.0f - kSmoothing) + counter.floatValue * kSmoothing;
}
static void xr_session_update_frame_stats(void) {
static Uint32 s_last_ticks = 0;
Uint32 now = SDL_GetTicks();
if (s_last_ticks != 0) {
Uint32 delta_ms = now - s_last_ticks;
if (delta_ms > 0) {
float instant_fps = 1000.0f / (float)delta_ms;
const float kSmoothing = 0.1f;
s_fps = (s_fps == 0.0f) ? instant_fps : s_fps * (1.0f - kSmoothing) + instant_fps * kSmoothing;
s_frame_ms =
(s_frame_ms == 0.0f) ? (float)delta_ms : s_frame_ms * (1.0f - kSmoothing) + (float)delta_ms * kSmoothing;
}
}
s_last_ticks = now;
xr_session_update_utilization(g_cpu_utilization_path, &s_cpu_utilization);
xr_session_update_utilization(g_gpu_utilization_path, &s_gpu_utilization);
}
float xr_session_get_fps(void) { return s_fps; }
float xr_session_get_frame_ms(void) { return s_frame_ms; }
float xr_session_get_cpu_utilization(void) { return s_cpu_utilization; }
float xr_session_get_gpu_utilization(void) { return s_gpu_utilization; }
void xr_frame_end(void) {
if (g_session == XR_NULL_HANDLE)
return;
@@ -444,6 +632,8 @@ void xr_frame_end(void) {
endInfo.layers = layerCount > 0 ? layers : NULL;
xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame");
xr_session_update_frame_stats();
}
void xr_shutdown(void) {
@@ -470,6 +660,12 @@ void xr_shutdown(void) {
g_session_state = XR_SESSION_STATE_UNKNOWN;
g_session_running = false;
g_performance_metrics_supported = false;
g_xrSetPerformanceMetricsStateMETA = NULL;
g_xrQueryPerformanceMetricsCounterMETA = NULL;
g_cpu_utilization_path = XR_NULL_PATH;
g_gpu_utilization_path = XR_NULL_PATH;
}
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) {
+15
View File
@@ -83,6 +83,21 @@ void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half
// swapchain.
void xr_get_game_resolution(int *width, int *height);
// Exponentially-smoothed frame rate/frame time, updated once per real
// rendered frame in xr_frame_end() - read by MenuOverlay's header (see
// questshock_native.c's nativeGetFps()/nativeGetFrameTimeMs()). 0 before
// the second frame has run (nothing to compute a delta from yet).
float xr_session_get_fps(void);
float xr_session_get_frame_ms(void);
// Device-wide CPU/GPU utilization percentage (0-100), via the
// XR_META_performance_metrics extension - same per-frame update cadence as
// the FPS/frame-time getters above. Returns -1.0f if the extension isn't
// supported by the runtime, or its cpu_utilization/gpu_utilization counter
// couldn't be found among the ones it advertises.
float xr_session_get_cpu_utilization(void);
float xr_session_get_gpu_utilization(void);
#ifdef __cplusplus
}
#endif
@@ -59,11 +59,12 @@ public class KeyboardOverlay extends OverlayPanel {
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.
// button - xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
// KEYBOARD_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;
@@ -76,6 +77,17 @@ public class KeyboardOverlay extends OverlayPanel {
private TextView typedTextView;
private final StringBuilder typedText = new StringBuilder();
// Title bar chrome text - the only pieces of this panel that get
// translated (see Strings.java's own comment on why the actual key
// labels don't). Set from MenuOverlay.nativeGetLanguage() 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) and kept in sync afterward by applyTranslations()
// below, called from MenuOverlay's own periodic language-refresh check.
private int currentLanguage = Strings.ENGLISH;
private TextView titleLabel;
private Button closeButton;
// 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
@@ -142,6 +154,7 @@ public class KeyboardOverlay extends OverlayPanel {
@Override
protected View buildContent() {
charKeys = new ArrayList<>();
currentLanguage = MenuOverlay.nativeGetLanguage();
LinearLayout panel = new LinearLayout(activity);
panel.setOrientation(LinearLayout.VERTICAL);
@@ -214,26 +227,31 @@ public class KeyboardOverlay extends OverlayPanel {
// 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.
// xr_input.c's KEYBOARD_TITLE_BAR_V_FRACTION/
// KEYBOARD_CLOSE_BUTTON_U_FRACTION.
private LinearLayout buildTitleBar() {
LinearLayout bar = new LinearLayout(activity);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setBackgroundColor(0xFF3A3A3A);
// Matches MenuOverlay's own header bar style.
bar.setBackgroundColor(0xFF000080);
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(
titleLabel = new TextView(activity);
titleLabel.setText(Strings.get("keyboard_drag_title", currentLanguage));
titleLabel.setTextColor(0xFFFFFFFF);
titleLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
titleLabel.setGravity(Gravity.CENTER_VERTICAL);
titleLabel.setPadding(24, 0, 0, 0);
bar.addView(titleLabel, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
Button close = new Button(activity);
close.setText("Close");
close.setOnClickListener(v -> nativeRequestClose());
close.setOnTouchListener(CLICK_ON_TOUCH_UP);
bar.addView(close,
closeButton = new Button(activity);
closeButton.setText(Strings.get("close", currentLanguage));
closeButton.setOnClickListener(v -> nativeRequestClose());
closeButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
closeButton.setBackground(buttonBackground());
closeButton.setTextColor(buttonTextColors());
registerHoverable(closeButton);
bar.addView(closeButton,
new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT));
return bar;
@@ -253,6 +271,9 @@ public class KeyboardOverlay extends OverlayPanel {
row.setOrientation(LinearLayout.HORIZONTAL);
for (Object item : items) {
Button key = new Button(activity);
key.setBackground(buttonBackground());
key.setTextColor(buttonTextColors());
registerHoverable(key);
float weight;
if (item instanceof CharKey) {
CharKey charKey = (CharKey) item;
@@ -517,6 +538,26 @@ public class KeyboardOverlay extends OverlayPanel {
// the keyboard (see this class's doc comment and xr_input.c).
private static native void nativeRequestClose();
// Called from MenuOverlay's own periodic header-refresh timer when it
// detects the game's language preference changed mid-session (via its
// own options menu) - re-labels this panel's title bar chrome to match.
// A plain Java call (both classes are in this same package), not JNI -
// runOnUiThread() here is defensive/self-documenting more than
// strictly needed today, since MenuOverlay's own timer already runs on
// the UI thread itself (Activity.runOnUiThread() is a no-op wrapper
// when already on that thread).
public static void applyTranslations(final int language) {
if (instance == null) {
return;
}
instance.activity.runOnUiThread(() -> {
instance.currentLanguage = language;
instance.titleLabel.setText(Strings.get("keyboard_drag_title", language));
instance.closeButton.setText(Strings.get("close", language));
instance.redrawContent();
});
}
// 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
@@ -1,68 +1,205 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.StateListDrawable;
import android.os.Handler;
import android.os.Looper;
import android.util.TypedValue;
import android.view.Gravity;
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.util.Locale;
/**
* The small main menu launcher quad (see OverlayPanel for the shared off-
* screen-render/touch/cursor plumbing this builds on). Toggled by the
* controller's menu button (see xr_input.c); none of its buttons ever touch
* this panel's own visibility, so it can stay open alongside whatever they
* open/do.
* The main menu launcher quad (see OverlayPanel for the shared off-screen-
* render/touch/cursor plumbing this builds on). Toggled by the controller's
* menu button (see xr_input.c); none of its buttons ever touch this panel's
* own visibility, so it can stay open alongside whatever they open/do.
*
* Two top-level children share this panel, only one visible at a time:
* buttonRow (the normal "Keyboard"/"Save Configuration"/"Load Configuration"
* row) and restartDialog (shown only when "Load Configuration" applies at
* least one live-appliable change but finds another flagged requires-restart
* - see CONFIG.md and qs_config.c's comments on the *_REQUIRES_RESTART
* flags). "Keyboard" opens the (fully independent) keyboard overlay quad via
* nativeShowKeyboard() - see KeyboardOverlay for the keyboard itself.
* Layout, top to bottom (see res/sketch_main_menu.svg): a header (title +
* version, FPS/frame-time stats, refreshed on a periodic timer), a content
* area holding one tab's content at a time, and a bottom tab bar. Tab 1
* ("Main Menu") is the original "Keyboard"/"Save Configuration"/"Load
* Configuration" row plus the restart-confirmation view (shown instead of
* that row - see CONFIG.md and qs_config.c's *_REQUIRES_RESTART comments).
* Tab 2 ("Video Options") has live-appliable controls for the config file's
* video_options group, mirroring the same buttons' selection to whatever's
* actually active (queried fresh whenever the tab is shown, and on the same
* periodic timer as the header stats while it's the visible tab, so e.g.
* pressing Ctrl+G while looking at this tab is reflected here too).
*
* "Keyboard" opens the (fully independent) keyboard overlay quad via
* nativeShowKeyboard() - see KeyboardOverlay for the keyboard itself, which
* this class also pushes language changes to (see refreshStats()).
*/
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.
private static final int WIDTH = 512;
private static final int HEIGHT = 256;
// Grown from the original 512x256 (still just a single "Keyboard"
// button back then) to fit the header/tabs/Video Options content added
// since, then again to fit the stats ribbon below the tab bar - see
// xr_session.c's matching MENU_WIDTH/MENU_HEIGHT and xr_input.c's
// matching MENU_TITLE_BAR_V_FRACTION.
private static final int WIDTH = 768;
private static final int HEIGHT = 552;
private static final int HEADER_HEIGHT = 56;
private static final int TAB_BAR_HEIGHT = 48;
private static final int STATS_BAR_HEIGHT = 40;
private static final long STATS_REFRESH_MS = 500;
private static final String[] RESOLUTION_LABELS = {"320x200", "320x400", "640x400", "640x480", "1024x768"};
private static MenuOverlay instance;
// Kept so relayoutAndRedraw() (see its own comment) can re-measure/re-layout the
// same View object OverlayPanel's own private "root" field also points
// at - toggling a child's visibility on this permanently-unattached
// tree doesn't get a layout pass for free the way it would on a real
// window (nothing ever schedules one), so this class has to force one
// itself on every toggle.
// Kept so relayoutAndRedraw() (see its own comment) can re-measure/
// re-layout the same View object OverlayPanel's own private "root"
// field also points at - toggling a child's visibility on this
// permanently-unattached tree doesn't get a layout pass for free the
// way it would on a real window (nothing ever schedules one), so this
// class has to force one itself on every toggle.
private View root;
// Header
private TextView titleView;
private TextView statsView;
// Content area (tabs) - only one of mainMenuTab/videoOptionsTab is
// VISIBLE at a time; currentTab tracks which.
private View mainMenuTab;
private View videoOptionsTab;
private View currentTab;
private Button mainMenuTabButton;
private Button videoOptionsTabButton;
// Main Menu tab's own two states (only one VISIBLE at a time, within
// mainMenuTab)
private LinearLayout buttonRow;
private LinearLayout restartDialog;
private TextView restartMessageView;
private Button keyboardButton;
private Button saveButton;
private Button loadButton;
private Button dismissButton;
private Button restartNowButton;
// Video Options tab controls
private TextView rendererLabel;
private TextView resolutionLabel;
private Button softwareButton;
private Button openglButton;
private Button[] resolutionButtons;
private int currentLanguage = Strings.ENGLISH;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable statsRefresh = new Runnable() {
@Override
public void run() {
refreshStats();
handler.postDelayed(this, STATS_REFRESH_MS);
}
};
private MenuOverlay(Activity activity) {
super(activity, WIDTH, HEIGHT);
handler.postDelayed(statsRefresh, STATS_REFRESH_MS);
}
@Override
protected View buildContent() {
FrameLayout panel = new FrameLayout(activity);
// Read before anything below uses it - buildContent() runs from
// OverlayPanel's own constructor, before this subclass's field
// initializers have (see OverlayPanel's contentAlpha() comment for
// the general caveat this same construction order creates).
currentLanguage = nativeGetLanguage();
LinearLayout panel = new LinearLayout(activity);
panel.setOrientation(LinearLayout.VERTICAL);
panel.setBackgroundColor(0xFF202020);
panel.addView(buildHeader(), new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, HEADER_HEIGHT));
FrameLayout content = new FrameLayout(activity);
mainMenuTab = buildMainMenuTab();
videoOptionsTab = buildVideoOptionsTab();
content.addView(mainMenuTab, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
content.addView(videoOptionsTab, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
videoOptionsTab.setVisibility(View.GONE);
currentTab = mainMenuTab;
panel.addView(content, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
panel.addView(buildTabBar(), new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, TAB_BAR_HEIGHT));
panel.addView(buildStatsBar(), new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, STATS_BAR_HEIGHT));
root = panel;
return panel;
}
private View buildHeader() {
LinearLayout header = new LinearLayout(activity);
header.setOrientation(LinearLayout.HORIZONTAL);
header.setBackgroundColor(0xFF000080);
header.setGravity(Gravity.CENTER_VERTICAL);
header.setPadding(16, 0, 16, 0);
titleView = new TextView(activity);
titleView.setTextColor(0xFFFFFFFF);
titleView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
titleView.setText("Questshock (" + BuildConfig.VERSION_NAME + ")");
header.addView(titleView,
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
return header;
}
// A separate ribbon below the tab bar (rather than sharing the header
// row with the title, as before) - the FPS/frame-time/CPU/GPU/memory
// text got too long for the title bar once CPU/GPU/memory were added,
// crowding the title.
private View buildStatsBar() {
LinearLayout bar = new LinearLayout(activity);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setBackgroundColor(0xFF000080);
bar.setGravity(Gravity.CENTER);
statsView = new TextView(activity);
statsView.setTextColor(0xFFFFFFFF);
statsView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
bar.addView(statsView, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
return bar;
}
private View buildMainMenuTab() {
FrameLayout tab = new FrameLayout(activity);
buttonRow = new LinearLayout(activity);
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
buttonRow.setGravity(Gravity.CENTER);
addRowButton(buttonRow, "Keyboard", v -> nativeShowKeyboard());
addRowButton(buttonRow, "Save Configuration", v -> nativeRequestSaveConfig());
addRowButton(buttonRow, "Load Configuration", v -> nativeRequestLoadConfig());
FrameLayout.LayoutParams buttonRowParams =
new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT);
keyboardButton = addRowButton(buttonRow, Strings.get("keyboard", currentLanguage),
v -> nativeShowKeyboard());
saveButton = addRowButton(buttonRow, Strings.get("save_configuration", currentLanguage),
v -> nativeRequestSaveConfig());
loadButton = addRowButton(buttonRow, Strings.get("load_configuration", currentLanguage),
v -> nativeRequestLoadConfig());
FrameLayout.LayoutParams buttonRowParams = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT);
buttonRowParams.gravity = Gravity.CENTER;
panel.addView(buttonRow, buttonRowParams);
tab.addView(buttonRow, buttonRowParams);
restartMessageView = new TextView(activity);
restartMessageView.setTextColor(0xFFFFFFFF);
@@ -72,8 +209,10 @@ public class MenuOverlay extends OverlayPanel {
LinearLayout restartButtonRow = new LinearLayout(activity);
restartButtonRow.setOrientation(LinearLayout.HORIZONTAL);
restartButtonRow.setGravity(Gravity.CENTER);
addRowButton(restartButtonRow, "Dismiss", v -> showButtonRow());
addRowButton(restartButtonRow, "Restart Now", v -> restartApp());
dismissButton = addRowButton(restartButtonRow, Strings.get("dismiss", currentLanguage),
v -> showButtonRow());
restartNowButton = addRowButton(restartButtonRow, Strings.get("restart_now", currentLanguage),
v -> restartApp());
restartDialog = new LinearLayout(activity);
restartDialog.setOrientation(LinearLayout.VERTICAL);
@@ -84,17 +223,101 @@ public class MenuOverlay extends OverlayPanel {
restartDialog.addView(restartButtonRow);
FrameLayout.LayoutParams restartDialogParams = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT);
panel.addView(restartDialog, restartDialogParams);
tab.addView(restartDialog, restartDialogParams);
root = panel;
return panel;
return tab;
}
private View buildVideoOptionsTab() {
LinearLayout tab = new LinearLayout(activity);
tab.setOrientation(LinearLayout.VERTICAL);
tab.setPadding(24, 16, 24, 16);
rendererLabel = new TextView(activity);
rendererLabel.setTextColor(0xFFFFFFFF);
rendererLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
rendererLabel.setText(Strings.get("renderer", currentLanguage));
tab.addView(rendererLabel);
LinearLayout rendererRow = new LinearLayout(activity);
rendererRow.setOrientation(LinearLayout.HORIZONTAL);
softwareButton = addSelectableButton(rendererRow, Strings.get("software", currentLanguage),
v -> nativeRequestSetRenderer(false));
// "OpenGL" is a proper/brand name - not translated, see Strings.java.
openglButton = addSelectableButton(rendererRow, "OpenGL", v -> nativeRequestSetRenderer(true));
tab.addView(rendererRow);
resolutionLabel = new TextView(activity);
resolutionLabel.setTextColor(0xFFFFFFFF);
resolutionLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
resolutionLabel.setPadding(0, 16, 0, 0);
resolutionLabel.setText(Strings.get("resolution", currentLanguage));
tab.addView(resolutionLabel);
LinearLayout resolutionRow = new LinearLayout(activity);
resolutionRow.setOrientation(LinearLayout.HORIZONTAL);
resolutionButtons = new Button[RESOLUTION_LABELS.length];
for (int i = 0; i < RESOLUTION_LABELS.length; i++) {
final int index = i;
resolutionButtons[i] = addSelectableButton(resolutionRow, RESOLUTION_LABELS[i],
v -> nativeRequestSetResolution(index));
}
tab.addView(resolutionRow);
return tab;
}
private View buildTabBar() {
LinearLayout tabBar = new LinearLayout(activity);
tabBar.setOrientation(LinearLayout.HORIZONTAL);
tabBar.setBackgroundColor(0xFF000080);
mainMenuTabButton = new Button(activity);
mainMenuTabButton.setText(Strings.get("main_menu", currentLanguage));
mainMenuTabButton.setBackground(buttonBackground());
mainMenuTabButton.setTextColor(buttonTextColors());
mainMenuTabButton.setOnClickListener(v -> switchTab(mainMenuTab));
mainMenuTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
registerHoverable(mainMenuTabButton);
tabBar.addView(mainMenuTabButton,
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
View separator = new View(activity);
separator.setBackgroundColor(0xFFFFFFFF);
tabBar.addView(separator, new LinearLayout.LayoutParams(2, ViewGroup.LayoutParams.MATCH_PARENT));
videoOptionsTabButton = new Button(activity);
videoOptionsTabButton.setText(Strings.get("video_options", currentLanguage));
videoOptionsTabButton.setBackground(buttonBackground());
videoOptionsTabButton.setTextColor(buttonTextColors());
videoOptionsTabButton.setOnClickListener(v -> switchTab(videoOptionsTab));
videoOptionsTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
registerHoverable(videoOptionsTabButton);
tabBar.addView(videoOptionsTabButton,
new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
return tabBar;
}
private void switchTab(View tab) {
if (tab == currentTab) {
return;
}
currentTab.setVisibility(View.GONE);
tab.setVisibility(View.VISIBLE);
currentTab = tab;
resetHover();
if (tab == videoOptionsTab) {
refreshVideoOptionsSelection();
}
relayoutAndRedraw();
}
// Forces a fresh measure/layout pass over the whole tree before
// redrawing - required after any visibility toggle (showButtonRow()/
// showRestartDialog() below), since restartDialog starts GONE and most
// ViewGroups (FrameLayout included) skip measuring/laying out GONE
// children entirely; without this, the first time it's ever shown it
// redrawing - required after any visibility toggle (switchTab()/
// showButtonRow()/showRestartDialog() below), since a hidden child
// starts/becomes GONE and most ViewGroups skip measuring/laying out
// GONE children entirely; without this, the first time it's shown it
// would draw with a stale/zero layout from having never been measured.
// Mirrors the exact measure()/layout() calls OverlayPanel's own
// constructor already makes once, up front, on the same View object.
@@ -106,14 +329,138 @@ public class MenuOverlay extends OverlayPanel {
redrawContent();
}
private void addRowButton(LinearLayout row, String text, View.OnClickListener onClick) {
private Button addRowButton(LinearLayout row, String text, View.OnClickListener onClick) {
Button button = new Button(activity);
button.setText(text);
button.setOnClickListener(onClick);
button.setOnTouchListener(CLICK_ON_TOUCH_UP);
button.setBackground(buttonBackground());
button.setTextColor(buttonTextColors());
registerHoverable(button);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, 100, 1f);
lp.setMargins(8, 8, 8, 8);
row.addView(button, lp);
return button;
}
// Like addRowButton(), but with a background that also has a distinct
// (persistent, not just transient hover/press) "selected" appearance -
// used for the Video Options tab's own controls, where exactly one
// button per row always represents the currently-active value (see
// refreshVideoOptionsSelection()). Same normal/hovered/pressed colors
// as OverlayPanel.buttonBackground(), plus one more state.
private static Drawable selectableButtonBackground() {
StateListDrawable drawable = new StateListDrawable();
drawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(PRESSED_COLOR));
drawable.addState(new int[]{android.R.attr.state_hovered}, new ColorDrawable(HOVERED_COLOR));
drawable.addState(new int[]{android.R.attr.state_selected}, new ColorDrawable(0xFF2A6E2A));
drawable.addState(new int[]{}, new ColorDrawable(NORMAL_COLOR));
return drawable;
}
private Button addSelectableButton(LinearLayout row, String text, View.OnClickListener onClick) {
Button button = new Button(activity);
button.setText(text);
button.setOnClickListener(onClick);
button.setOnTouchListener(CLICK_ON_TOUCH_UP);
button.setBackground(selectableButtonBackground());
button.setTextColor(buttonTextColors());
registerHoverable(button);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, 90, 1f);
lp.setMargins(4, 8, 4, 4);
row.addView(button, lp);
return button;
}
// Reflects the currently-active renderer/resolution (queried fresh via
// the native getters, so it stays correct even if changed some other
// way, e.g. the Ctrl+G hotkey) onto the Video Options tab's own
// buttons' persistent "selected" state - called when the tab is shown
// (switchTab()) and, while it's the visible tab, on the same periodic
// timer as the header stats (refreshStats()).
private void refreshVideoOptionsSelection() {
boolean opengl = nativeGetRendererIsOpenGl();
softwareButton.setSelected(!opengl);
openglButton.setSelected(opengl);
int index = nativeGetResolutionIndex();
for (int i = 0; i < resolutionButtons.length; i++) {
resolutionButtons[i].setSelected(i == index);
}
}
// Polled every STATS_REFRESH_MS while this instance exists (started
// from the constructor) rather than only while the menu is actually
// visible - simpler than plumbing a new native->Java "menu became
// visible" callback, and cheap enough not to matter (a couple of JNI
// calls, a text update, and a redraw of a panel that isn't even being
// sampled into the XR compositor while hidden).
private void refreshStats() {
float fps = nativeGetFps();
float frameMs = nativeGetFrameTimeMs();
// -1 (xr_session.h's sentinel) means the XR_META_performance_metrics
// extension wasn't available/didn't expose this counter on this
// runtime - omit it rather than showing a nonsense "-1%".
float cpuUtilization = nativeGetCpuUtilization();
float gpuUtilization = nativeGetGpuUtilization();
StringBuilder stats = new StringBuilder();
stats.append(String.format(Locale.US, "FPS: %.0f | %.1fms", fps, frameMs));
if (cpuUtilization >= 0.0f) {
stats.append(String.format(Locale.US, " | CPU: %.0f%%", cpuUtilization));
}
if (gpuUtilization >= 0.0f) {
stats.append(String.format(Locale.US, " | GPU: %.0f%%", gpuUtilization));
}
stats.append(String.format(Locale.US, " | Free: %dMB", freeMemoryMb()));
statsView.setText(stats.toString());
int language = nativeGetLanguage();
if (language != currentLanguage) {
currentLanguage = language;
applyTranslations();
KeyboardOverlay.applyTranslations(language);
}
if (currentTab == videoOptionsTab) {
refreshVideoOptionsSelection();
}
// The stats text's width changes every tick (digit count, "ms"
// widths, ...) - this view tree is never attached to a real window,
// so nothing re-measures/lays it out on its own the way a real
// window's layout pass would; see relayoutAndRedraw()'s own comment
// for the GONE-child variant of the same underlying issue.
relayoutAndRedraw();
}
// System-wide available RAM, in megabytes - a plain Android API query
// (no native/XR involvement, unlike the CPU/GPU stats above), matching
// the "Free=NNNNMB" figure in the VrApi performance-HUD logcat line this
// header's stats display was modeled on.
private long freeMemoryMb() {
ActivityManager activityManager =
(ActivityManager) activity.getSystemService(Context.ACTIVITY_SERVICE);
ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
activityManager.getMemoryInfo(memoryInfo);
return memoryInfo.availMem / (1024 * 1024);
}
// Re-labels every piece of chrome text this class owns - called when
// refreshStats() (above) detects the game's language preference
// changed mid-session (via its own options menu).
private void applyTranslations() {
keyboardButton.setText(Strings.get("keyboard", currentLanguage));
saveButton.setText(Strings.get("save_configuration", currentLanguage));
loadButton.setText(Strings.get("load_configuration", currentLanguage));
dismissButton.setText(Strings.get("dismiss", currentLanguage));
restartNowButton.setText(Strings.get("restart_now", currentLanguage));
mainMenuTabButton.setText(Strings.get("main_menu", currentLanguage));
videoOptionsTabButton.setText(Strings.get("video_options", currentLanguage));
rendererLabel.setText(Strings.get("renderer", currentLanguage));
resolutionLabel.setText(Strings.get("resolution", currentLanguage));
softwareButton.setText(Strings.get("software", currentLanguage));
// openglButton's "OpenGL" label is never translated - see
// buildVideoOptionsTab()'s own comment on it.
}
// "Dismiss" on the restart dialog - the live-appliable subset was
@@ -123,6 +470,7 @@ public class MenuOverlay extends OverlayPanel {
private void showButtonRow() {
buttonRow.setVisibility(View.VISIBLE);
restartDialog.setVisibility(View.GONE);
resetHover();
relayoutAndRedraw();
}
@@ -155,6 +503,27 @@ public class MenuOverlay extends OverlayPanel {
private static native void nativeRequestSaveConfig();
private static native void nativeRequestLoadConfig();
// Video Options tab's controls - same enqueue-only reasoning as above.
private static native void nativeRequestSetRenderer(boolean opengl);
private static native void nativeRequestSetResolution(int resolutionIndex);
// Read-only queries - safe to call directly, see qs_config.h's own
// comment on each of these.
private static native boolean nativeGetRendererIsOpenGl();
private static native int nativeGetResolutionIndex();
// Package-visible (not private): KeyboardOverlay also calls this
// directly, once, at its own construction time, to show the right
// language from the start rather than waiting for this class's own
// first periodic refresh to push a correction via applyTranslations().
static native int nativeGetLanguage();
private static native float nativeGetFps();
private static native float nativeGetFrameTimeMs();
// -1.0f if XR_META_performance_metrics (or its cpu/gpu_utilization
// counter specifically) isn't available on this runtime - see
// xr_session_get_cpu_utilization()/get_gpu_utilization()'s own comment.
private static native float nativeGetCpuUtilization();
private static native float nativeGetGpuUtilization();
// 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
@@ -178,6 +547,7 @@ public class MenuOverlay extends OverlayPanel {
instance.restartMessageView.setText(message);
instance.buttonRow.setVisibility(View.GONE);
instance.restartDialog.setVisibility(View.VISIBLE);
instance.resetHover();
instance.relayoutAndRedraw();
});
}
@@ -1,13 +1,20 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.content.res.ColorStateList;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.StateListDrawable;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* Shared machinery for an off-screen, never-attached-to-a-window View tree
@@ -59,11 +66,59 @@ abstract class OverlayPanel {
return true;
};
// A plain, instantly-drawn StateListDrawable rather than the platform
// default Button background (a Material ripple): ripples animate via
// continuous invalidation callbacks tied to a real attached window's
// vsync, which never fires here (this tree is never attached to a
// window) - so setPressed()/setHovered() state changes would otherwise
// produce no visible change at all, independent of this project's own
// touch handling (which already calls setPressed() correctly - see
// CLICK_ON_TOUCH_UP above). Every button this project creates
// (MenuOverlay, KeyboardOverlay) should use this (or
// MenuOverlay.selectableButtonBackground(), which adds a persistent
// "selected" state on top of the same normal/hovered/pressed colors) -
// paired with buttonTextColors() below, since the normal/hovered state
// is light enough to need dark text while pressed/selected need white.
protected static Drawable buttonBackground() {
StateListDrawable drawable = new StateListDrawable();
drawable.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(PRESSED_COLOR));
drawable.addState(new int[]{android.R.attr.state_hovered}, new ColorDrawable(HOVERED_COLOR));
drawable.addState(new int[]{}, new ColorDrawable(NORMAL_COLOR));
return drawable;
}
// See buttonBackground() above - light gray (normal/hovered) needs dark
// text; the accent colors (pressed, and MenuOverlay's own "selected"
// state) need white. Every button should get both this and
// buttonBackground()/selectableButtonBackground() together.
protected static ColorStateList buttonTextColors() {
return new ColorStateList(
new int[][]{
new int[]{android.R.attr.state_pressed},
new int[]{android.R.attr.state_selected},
new int[]{},
},
new int[]{0xFFFFFFFF, 0xFFFFFFFF, 0xFF202020});
}
protected static final int NORMAL_COLOR = 0xFFC8C8C8;
protected static final int HOVERED_COLOR = 0xFFAEC2E8;
protected static final int PRESSED_COLOR = 0xFF3355AA;
protected final Activity activity;
private final int width;
private final int height;
private final View root;
// Views registered via registerHoverable() (every button, from both
// buildContent() and any later-added content) - hit-tested against the
// cursor position on every updateCursor() call so aiming at (not
// clicking) one visibly highlights it via android.R.attr.state_hovered
// (a real View state since API 14, paired with buttonBackground()
// above).
private final List<View> hoverableViews = new ArrayList<>();
private View hoveredView = null;
// 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
@@ -183,9 +238,88 @@ abstract class OverlayPanel {
cursorVisible = visible;
cursorX = x;
cursorY = y;
if (updateHoverState(x, y, visible)) {
// redrawContent() (called inside updateHoverState()) already
// composited and published this frame's pixels - nothing more
// to do.
return;
}
compositeAndPublish();
}
// Registers a button (or any View) to be hit-tested against the cursor
// position - called once per button, alongside buttonBackground()/
// setOnTouchListener(), from buildContent() (or any later content
// rebuild).
protected final void registerHoverable(View view) {
hoverableViews.add(view);
}
// Clears whatever's currently hovered without waiting for the cursor to
// actually move off of it - call after hiding/showing content (e.g. a
// tab switch), since a view that was hovered right before becoming
// GONE would otherwise still be internally tracked as hovered (and
// wrongly show that way again) if it's later made VISIBLE again before
// the cursor position next changes.
protected final void resetHover() {
if (hoveredView != null) {
hoveredView.setHovered(false);
hoveredView = null;
}
}
// Absolute bounds of view within this panel's own coordinate space
// (i.e. relative to root, which is always laid out at (0,0,width,
// height) - see the constructor) - View's own getLeft()/getTop() are
// parent-relative, so this walks up the parent chain summing them.
// Assumes view is actually part of this panel's tree (every registered
// view always is, since registerHoverable() is only ever called from
// buildContent()).
private Rect absoluteBounds(View view) {
int left = 0;
int top = 0;
View current = view;
while (true) {
left += current.getLeft();
top += current.getTop();
if (current == root || !(current.getParent() instanceof View)) {
break;
}
current = (View) current.getParent();
}
return new Rect(left, top, left + view.getWidth(), top + view.getHeight());
}
// Returns true if the hovered view changed (and so redrawContent() -
// which always publishes too - already ran); false if there's nothing
// more for updateCursor() to do beyond its own compositeAndPublish().
private boolean updateHoverState(float x, float y, boolean visible) {
View newHovered = null;
if (visible) {
for (View candidate : hoverableViews) {
if (candidate.getVisibility() != View.VISIBLE) {
continue;
}
if (absoluteBounds(candidate).contains((int) x, (int) y)) {
newHovered = candidate;
break;
}
}
}
if (newHovered == hoveredView) {
return false;
}
if (hoveredView != null) {
hoveredView.setHovered(false);
}
if (newHovered != null) {
newHovered.setHovered(true);
}
hoveredView = newHovered;
redrawContent();
return true;
}
private void handleTouch(float u, float v, boolean down) {
float x = u * width;
float y = v * height;
@@ -0,0 +1,68 @@
package de.ladkau.questshock;
import java.util.HashMap;
import java.util.Map;
/**
* Best-effort (not professionally reviewed) translations for this app's own
* UI chrome text - the menu/keyboard panels this project draws itself, not
* the game's own resource-string text (which already has its own separate
* English/French/German data files). Selected by gShockPrefs.goLanguage (0
* English/1 French/2 German, Prefs.h) via MenuOverlay's native language
* query (qs_config_current_language()) - see MenuOverlay's periodic header-
* refresh timer, which also pushes it to KeyboardOverlay.
*
* Deliberately excludes the keyboard's own alphanumeric key labels (a-z,
* Tab, Space, Esc, ...) - those represent physical key positions/characters
* rather than translatable UI copy - and the restart-confirmation dialog's
* message text, which is built in native code (qs_config.c's
* qs_show_restart_dialog()) with an interpolated option-name list and is
* not currently reachable in practice (see CONFIG.md - neither Video
* Options option is flagged restart-required today).
*/
final class Strings {
static final int ENGLISH = 0;
static final int FRENCH = 1;
static final int GERMAN = 2;
private static final Map<String, String[]> TABLE = new HashMap<>();
private static void put(String key, String en, String fr, String de) {
TABLE.put(key, new String[]{en, fr, de});
}
static {
put("keyboard", "Keyboard", "Clavier", "Tastatur");
put("save_configuration", "Save Configuration", "Enregistrer la configuration",
"Konfiguration speichern");
put("load_configuration", "Load Configuration", "Charger la configuration",
"Konfiguration laden");
put("main_menu", "Main Menu", "Menu principal", "Hauptmenü");
put("video_options", "Video Options", "Options vidéo", "Videooptionen");
put("dismiss", "Dismiss", "Fermer", "Schließen");
put("restart_now", "Restart Now", "Redémarrer maintenant", "Jetzt neu starten");
put("renderer", "Renderer", "Moteur de rendu", "Renderer");
put("resolution", "Resolution", "Résolution", "Auflösung");
put("software", "Software", "Logiciel", "Software");
// "OpenGL" is a proper/brand name - not translated, same in every
// language, so it has no entry here; call sites just use it as a
// literal.
put("keyboard_drag_title", "Keyboard - drag here to move",
"Clavier - faites glisser ici pour déplacer",
"Tastatur - hier ziehen zum Verschieben");
put("close", "Close", "Fermer", "Schließen");
}
private Strings() {}
static String get(String key, int language) {
String[] variants = TABLE.get(key);
if (variants == null) {
return key;
}
if (language < 0 || language >= variants.length) {
language = ENGLISH;
}
return variants[language];
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB