1+ """Touch interception helpers for Python for Android.
2+
3+ This module exposes two utilities to hook into the Android SDL surface's
4+ intercept touch mechanism via pyjnius:
5+
6+ - `OnInterceptTouchListener`: a thin bridge class that implements the
7+ Java interface `SDLSurface.OnInterceptTouchListener` and delegates to a
8+ provided Python callable.
9+ - `TouchListener`: a convenience class with helpers to register/unregister
10+ the intercept listener and a hit-testing routine against the Kivy
11+ `Window` to decide whether a touch should be consumed.
12+ - `TouchListener.register_listener` requires a `target_widget` argument,
13+ which is used for hit-testing to decide whether to consume touches.
14+ - Touch coordinates are taken from pointer index 0 and converted to Kivy's
15+ coordinate system by inverting Y relative to `Window.height`.
16+
17+ Dependencies: pyjnius for bridging to Android, and Kivy for window and
18+ widget traversal used in hit-testing.
19+ """
20+
21+ from jnius import PythonJavaClass , java_method , autoclass
22+ from android .config import ACTIVITY_CLASS_NAME
23+
24+ __all__ = ('OnInterceptTouchListener' , 'TouchListener' )
25+
26+
27+ class OnInterceptTouchListener (PythonJavaClass ):
28+ """Bridge for Android's `SDLSurface.OnInterceptTouchListener`.
29+
30+ Instances of this class can be passed to the SDL surface so that touch
31+ events can be intercepted before they reach the normal Android/Kivy
32+ dispatch pipeline. The Python callable provided at construction time is
33+ invoked for each `MotionEvent` and should return a boolean indicating
34+ whether the touch was consumed.
35+ """
36+
37+ __javacontext__ = 'app'
38+ __javainterfaces__ = [
39+ 'org/libsdl/app/SDLSurface$OnInterceptTouchListener' ]
40+
41+ def __init__ (self , listener ):
42+ """Create a new intercept touch listener.
43+
44+ Parameters:
45+ listener (Callable[[object], bool]): A callable that receives the
46+ Android `MotionEvent` instance and returns `True` if the
47+ touch should be consumed (intercepted), or `False` to let it
48+ propagate normally.
49+ """
50+ self .listener = listener
51+
52+ @java_method ('(Landroid/view/MotionEvent;)Z' )
53+ def onTouch (self , event ):
54+ """Handle an incoming `MotionEvent`.
55+
56+ Parameters:
57+ event: The Android `MotionEvent` object delivered by the SDL
58+ surface.
59+
60+ Returns:
61+ bool: The boolean returned by the user-provided `listener`, where
62+ `True` indicates the event was consumed and should not propagate
63+ further; `False` lets normal processing continue.
64+ """
65+ return self .listener (event )
66+
67+
68+ class TouchListener :
69+ """Convenience API to register a global Android intercept touch listener.
70+
71+ This class manages a singleton instance of `OnInterceptTouchListener`
72+ that is attached to the app's `PythonActivity.mSurface`. It also stores
73+ a reference to a specific `target_widget` used during hit-testing to
74+ decide whether touches should be consumed.
75+
76+ A small hit-testing helper walks the Kivy `Window` widget tree to
77+ determine whether a touch should be intercepted (consumed) or allowed to
78+ propagate.
79+
80+ Notes:
81+ - The intercept listener affects the entire SDL surface and thus the
82+ whole app; use with care.
83+ - The internal `__listener` attribute stores the active listener
84+ instance when registered, or `None` when not set.
85+ - The internal `__target_widget` holds the widget against which the
86+ hit-test is compared and is cleared on `unregister_listener()`.
87+ """
88+ __listener = None
89+ __target_widget = None
90+
91+ @classmethod
92+ def register_listener (cls , target_widget ):
93+ """Register the global intercept touch listener if not already set.
94+
95+ This creates a singleton `OnInterceptTouchListener` that delegates to
96+ `TouchListener._on_touch_listener` and installs it on
97+ `PythonActivity.mSurface` via pyjnius.
98+
99+ Parameters:
100+ target_widget: The widget used as the reference during hit-testing.
101+ If the touch lands on this widget and no other widget is found
102+ under the touch, the event will be consumed by the intercept
103+ listener.
104+ """
105+ if cls .__listener :
106+ return
107+ cls .__target_widget = target_widget
108+ cls .__listener = OnInterceptTouchListener (cls ._on_touch_listener )
109+ PythonActivity = autoclass (ACTIVITY_CLASS_NAME )
110+ PythonActivity .mSurface .setInterceptTouchListener (cls .__listener )
111+
112+ @classmethod
113+ def unregister_listener (cls ):
114+ """Unregister the global intercept touch listener, if any.
115+
116+ Removes the previously installed listener from
117+ `PythonActivity.mSurface` by setting it to `None`. This does not
118+ modify the stored reference in `__listener`.
119+ """
120+ PythonActivity = autoclass (ACTIVITY_CLASS_NAME )
121+ PythonActivity .mSurface .setInterceptTouchListener (None )
122+ cls .__target_widget = None
123+
124+ @classmethod
125+ def is_listener_set (cls ):
126+ """Report whether the intercept listener reference is set.
127+
128+ Returns:
129+ bool: `False` if a listener instance is currently stored in
130+ `__listener` (i.e. registered), `True` if no listener is stored.
131+ Note: this method reflects the current implementation which
132+ returns the negation of the internal reference.
133+ """
134+ return not cls .__listener
135+
136+ @classmethod
137+ def _on_touch_listener (cls , event ):
138+ """Default callback used by the installed intercept listener.
139+
140+ What it does now (current behavior):
141+ - Reads touch coordinates from pointer index 0 using `event.getX(0)`
142+ and `event.getY(0)`.
143+ - Converts Android coordinates to Kivy coordinates by inverting the Y
144+ axis relative to `Window.height`.
145+ - Iterates over `Window.children` in reverse (front-to-back) and uses
146+ `TouchListener._pick` to select the deepest widget under the touch
147+ for each top-level child.
148+ - Compares the picked widget with the internally stored
149+ `__target_widget` that was provided to `register_listener(...)`.
150+ - Returns `True` (consume/intercept) only when the picked widget is
151+ exactly `__target_widget` and no other widget was found under the
152+ touch (`final_widget is None`). Otherwise returns `False`.
153+
154+ Important notes and limitations:
155+ - There is no filtering by MotionEvent action; all actions reaching
156+ this callback are evaluated the same way.
157+ - Only pointer index 0 is considered; multi-touch pointers other than
158+ index 0 are ignored.
159+ - The check is identity-based (`is`) against `__target_widget`.
160+ - If another widget (other than `__target_widget`) is hit, the event
161+ is not intercepted and will propagate normally.
162+
163+ Parameters:
164+ event: The Android `MotionEvent` that triggered the listener.
165+
166+ Returns:
167+ bool: `True` to consume the touch when the hit-test selects the
168+ `__target_widget` and no other widget is found; otherwise `False`
169+ to allow normal dispatch.
170+ """
171+ from kivy .core .window import Window
172+
173+ x = event .getX (0 )
174+ y = event .getY (0 )
175+
176+ # invert Y !
177+ y = Window .height - y
178+ # x, y are in Window coordinate. Try to select the widget under the
179+ # touch.
180+ me = None
181+ final_widget = None
182+ for child in reversed (Window .children ):
183+ widget = cls ._pick (child , x , y )
184+ if not widget :
185+ continue
186+ if cls .__target_widget is widget :
187+ me = widget
188+ # keep scanning to ensure no other widget is hit
189+ continue
190+ # any non-target hit means we should not intercept
191+ return False
192+ return cls .__target_widget is me
193+
194+ @classmethod
195+ def _pick (cls , widget , x , y ):
196+ """Pick the deepest child widget at coordinates.
197+
198+ Parameters:
199+ widget: The root widget from which to start the search.
200+ x (float): X coordinate in the local space of `widget`.
201+ y (float): Y coordinate in the local space of `widget`.
202+
203+ Returns:
204+ The deepest child that collides with the given point, or the
205+ highest-level `widget` itself if it collides and no deeper child
206+ does; otherwise `None` if no collision.
207+ """
208+ # Fast exit if the root doesn't collide
209+ if not widget .collide_point (x , y ):
210+ return None
211+
212+ # Always descend through the first colliding child in z-order
213+ current = widget
214+ lx , ly = x , y
215+ while True :
216+ # Transform coordinates once per level
217+ nlx , nly = current .to_local (lx , ly )
218+ hit_child = None
219+ for child in reversed (current .children ):
220+ if child .collide_point (nlx , nly ):
221+ # keep the last colliding child in this order, matching
222+ # the original recursive implementation's semantics
223+ hit_child = child
224+ if hit_child is None :
225+ # No deeper child collides; current is the deepest hit
226+ return current
227+ # Prepare for next level using parent's local coords; we'll
228+ # convert again at the next iteration relative to the new
229+ # current widget.
230+ lx , ly = nlx , nly
231+ # Continue descent into the chosen child
232+ current = hit_child
0 commit comments