feat(android): expose secondary-screen touch events

This commit is contained in:
AverageConsumer
2026-08-06 22:56:13 +02:00
parent 112120e8fe
commit a98562f46b
5 changed files with 149 additions and 5 deletions
+5 -4
View File
@@ -221,10 +221,11 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`, `worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a `vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the palette-correct blit of either canvas into an arbitrary screen rect, and the
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`) `secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
for driving a second physical display. This is what lets a mod lay the two `setEnabled`) for driving a second physical display. `pollTouch()` returns the
passes out as two stacked Game Boy screens, or push one onto a second screen, oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
without the engine knowing the layout. This is what lets a mod lay the two passes out as two stacked Game Boy screens,
or push one onto a second screen, without the engine knowing the layout.
`screen.render_visible` receives `(next, state)` while the main screen is being `screen.render_visible` receives `(next, state)` while the main screen is being
composed. Return `false` to omit that state from drawing, opacity selection and composed. Return `false` to omit that state from drawing, opacity selection and
@@ -1003,4 +1003,33 @@ void love_android_secondary_enable(int on)
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
} }
extern "C" __attribute__((visibility("default")))
const char *love_android_poll_secondary_touch()
{
static thread_local std::string event;
event.clear();
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch",
"()Ljava/lang/String;");
if (!method)
env->ExceptionClear();
else
{
jstring value = (jstring) env->CallStaticObjectMethod(activity, method);
if (value)
{
const char *utf = env->GetStringUTFChars(value, nullptr);
if (utf)
{
event = utf;
env->ReleaseStringUTFChars(value, utf);
}
env->DeleteLocalRef(value);
}
}
env->DeleteLocalRef(activity);
return event.empty() ? nullptr : event.c_str();
}
#endif // LOVE_ANDROID #endif // LOVE_ANDROID
@@ -1325,6 +1325,9 @@ public class GameActivity extends SDLActivity {
// in src/jni/love/src/common/android.cpp. // in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation; private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false; private static volatile boolean secondaryEnabled = false;
private static final int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque<String> secondaryTouches =
new java.util.ArrayDeque<>();
@Keep @Keep
public static void setSecondaryEnabled(final boolean on) { public static void setSecondaryEnabled(final boolean on) {
@@ -1377,6 +1380,7 @@ public class GameActivity extends SDLActivity {
private static void teardownSecondaryDisplay() { private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation; SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null; secondaryPresentation = null;
synchronized (secondaryTouches) { secondaryTouches.clear(); }
if (p != null) { if (p != null) {
try { p.dismiss(); } catch (Throwable t) {} try { p.dismiss(); } catch (Throwable t) {}
} }
@@ -1395,6 +1399,13 @@ public class GameActivity extends SDLActivity {
} }
} }
@Keep
public static String pollSecondaryDisplayTouch() {
synchronized (secondaryTouches) {
return secondaryTouches.pollFirst();
}
}
private static class SecondaryPresentation extends android.app.Presentation { private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView; private final FrameView frameView;
@@ -1461,6 +1472,7 @@ public class GameActivity extends SDLActivity {
private final android.graphics.Paint paint = new android.graphics.Paint(); private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object(); private final Object lock = new Object();
private int fw, fh; private int fw, fh;
private int activePointer = -1;
FrameView(Context context) { FrameView(Context context) {
super(context); super(context);
@@ -1482,6 +1494,52 @@ public class GameActivity extends SDLActivity {
postInvalidate(); postInvalidate();
} }
private void enqueueTouch(String event) {
synchronized (secondaryTouches) {
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
secondaryTouches.clear();
secondaryTouches.addLast("cancel,0,0");
} else {
secondaryTouches.addLast(event);
}
}
}
private int logicalX(float x) {
return Math.min(fw - 1, Math.max(0,
(int) ((x - dst.left) * fw / dst.width())));
}
private int logicalY(float y) {
return Math.min(fh - 1, Math.max(0,
(int) ((y - dst.top) * fh / dst.height())));
}
@Override
public boolean onTouchEvent(android.view.MotionEvent event) {
synchronized (lock) {
int action = event.getActionMasked();
if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0
&& dst.contains((int) event.getX(), (int) event.getY())) {
activePointer = event.getPointerId(0);
enqueueTouch("down," + logicalX(event.getX()) + ","
+ logicalY(event.getY()));
} else if (action == android.view.MotionEvent.ACTION_UP
&& activePointer >= 0) {
int index = event.findPointerIndex(activePointer);
if (index >= 0 && fw > 0) {
enqueueTouch("up," + logicalX(event.getX(index)) + ","
+ logicalY(event.getY(index)));
}
activePointer = -1;
} else if (action == android.view.MotionEvent.ACTION_CANCEL) {
activePointer = -1;
enqueueTouch("cancel,0,0");
}
}
return true;
}
@Override @Override
protected void onDraw(android.graphics.Canvas canvas) { protected void onDraw(android.graphics.Canvas canvas) {
synchronized (lock) { synchronized (lock) {
+15 -1
View File
@@ -5,13 +5,15 @@
local SecondScreen = {} local SecondScreen = {}
local C = nil local C = nil
local ffi = nil
local function log(msg) local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
end end
do do
local ok, ffi = pcall(require, "ffi") local ok
ok, ffi = pcall(require, "ffi")
if not (ok and ffi) then if not (ok and ffi) then
log("ffi unavailable (not LuaJIT); second display disabled") log("ffi unavailable (not LuaJIT); second display disabled")
else else
@@ -19,6 +21,7 @@ do
int love_android_secondary_ready(); int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h); void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on); void love_android_secondary_enable(int on);
const char *love_android_poll_secondary_touch();
]]) ]])
local okLib, lib = pcall(ffi.load, "love") local okLib, lib = pcall(ffi.load, "love")
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
@@ -51,6 +54,17 @@ function SecondScreen.push(imageData, w, h)
end) end)
end end
-- Returns the oldest queued secondary-display event as "action,x,y", where
-- coordinates are in the submitted frame's pixel space.
function SecondScreen.pollTouch()
if not C then return nil end
local ok, event = pcall(function()
return C.love_android_poll_secondary_touch()
end)
if not ok or event == nil or event == ffi.NULL then return nil end
return ffi.string(event)
end
function SecondScreen.setEnabled(on) function SecondScreen.setEnabled(on)
if not C then return end if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end) pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
+42
View File
@@ -0,0 +1,42 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local name = "src.render.SecondScreen"
local oldModule = package.loaded[name]
local oldFfi = package.loaded.ffi
local oldPreload = package.preload.ffi
local null = {}
local calls = 0
local C = {
love_android_secondary_ready = function() return 1 end,
love_android_push_secondary = function() end,
love_android_secondary_enable = function() end,
love_android_poll_secondary_touch = function()
calls = calls + 1
return calls == 1 and "down,12,34" or null
end,
}
local fakeFfi = {
C = C,
NULL = null,
cdef = function() end,
load = function() return C end,
string = function(value) return value end,
}
package.loaded[name] = nil
package.loaded.ffi = nil
package.preload.ffi = function() return fakeFfi end
local SecondScreen = require(name)
T.eq(SecondScreen.pollTouch(), "down,12,34",
"secondary touch reaches the Lua facade")
T.eq(SecondScreen.pollTouch(), nil, "an empty native touch queue returns nil")
C.love_android_poll_secondary_touch = nil
T.eq(SecondScreen.pollTouch(), nil, "an older native bridge remains safe")
package.loaded[name] = oldModule
package.loaded.ffi = oldFfi
package.preload.ffi = oldPreload
T.finish("second-screen touch facade")