Skip to content

Commit 1a1ba23

Browse files
committed
Added support for gestures (touch input)
(validation required)
1 parent 4862023 commit 1a1ba23

File tree

2 files changed

+209
-6
lines changed

2 files changed

+209
-6
lines changed

plotpy/events.py

Lines changed: 206 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
:class:`plotpy.plot.PlotWidget`.
1616
"""
1717

18+
from __future__ import annotations
19+
1820
import weakref
1921

2022
from qtpy import QtCore as QC
@@ -26,7 +28,17 @@
2628
CursorShape = type(QC.Qt.CursorShape.ArrowCursor)
2729

2830

29-
def buttons_to_str(buttons):
31+
def is_touch_screen_available() -> bool:
32+
"""Check if a touch screen is available.
33+
34+
Returns:
35+
bool: True if a touch screen is available, False otherwise.
36+
"""
37+
touch_devices = QG.QTouchDevice.devices()
38+
return any(device.type() == QG.QTouchDevice.TouchScreen for device in touch_devices)
39+
40+
41+
def buttons_to_str(buttons: int) -> str:
3042
"""Conversion des flags Qt en chaine"""
3143
string = ""
3244
if buttons & QC.Qt.LeftButton:
@@ -38,7 +50,7 @@ def buttons_to_str(buttons):
3850
return string
3951

4052

41-
def evt_type_to_str(type):
53+
def evt_type_to_str(type: int) -> str:
4254
"""Représentation textuelle d'un type d'événement (debug)"""
4355
if type == QC.QEvent.MouseButtonPress:
4456
return "Mpress"
@@ -52,14 +64,14 @@ def evt_type_to_str(type):
5264
return f"{type:d}"
5365

5466

55-
# Sélection d'événements ---------
67+
# Event matching classes ----------
5668
class EventMatch:
5769
"""A callable returning true if it matches an event"""
5870

5971
def __call__(self, event):
6072
raise NotImplementedError
6173

62-
def get_event_types(self):
74+
def get_event_types(self) -> frozenset[int]:
6375
"""Returns a set of event types handled by this
6476
EventMatch.
6577
This is used to quickly optimize events not handled
@@ -102,7 +114,7 @@ def get_event_types(self):
102114
"""
103115
return frozenset((QC.QEvent.KeyPress,))
104116

105-
def __call__(self, event):
117+
def __call__(self, event: QG.QKeyEvent) -> bool:
106118
if event.type() == QC.QEvent.KeyPress:
107119
my_key = event.key()
108120
my_mod = event.modifiers()
@@ -183,7 +195,62 @@ def __call__(self, event):
183195
return False
184196

185197

186-
# Machine d'état ----------
198+
class GestureEventMatch(EventMatch):
199+
"""Base class for matching gesture events"""
200+
201+
def __init__(self, gesture_type, gesture_state):
202+
super().__init__()
203+
self.evt_type = QC.QEvent.Gesture
204+
self.gesture_type = gesture_type
205+
self.gesture_state = gesture_state
206+
207+
@staticmethod
208+
def __get_type_str(gesture_type):
209+
"""Return text representation for gesture type"""
210+
for attr in (
211+
"TapGesture",
212+
"TapAndHoldGesture",
213+
"PanGesture",
214+
"PinchGesture",
215+
"SwipeGesture",
216+
"CustomGesture",
217+
):
218+
if gesture_type == getattr(QC.Qt, attr):
219+
return attr
220+
221+
@staticmethod
222+
def __get_state_str(gesture_state):
223+
"""Return text representation for gesture state"""
224+
for attr in (
225+
"GestureStarted",
226+
"GestureUpdated",
227+
"GestureFinished",
228+
"GestureCanceled",
229+
):
230+
if gesture_state == getattr(QC.Qt, attr):
231+
return attr
232+
233+
def get_event_types(self):
234+
return frozenset((self.evt_type,))
235+
236+
def __call__(self, event):
237+
# print(event)
238+
if event.type() == QC.QEvent.Gesture:
239+
# print(event.gestures()[0].gestureType())
240+
gesture = event.gesture(self.gesture_type)
241+
# print(gesture)
242+
if gesture:
243+
print(gesture.hotSpot(), self.__get_state_str(gesture.state()))
244+
return gesture and gesture.state() == self.gesture_state
245+
return False
246+
247+
def __repr__(self):
248+
type_str = self.__get_type_str(self.gesture_type)
249+
state_str = self.__get_state_str(self.gesture_state)
250+
return "<GestureMatch: %s:%s>" % (type_str, state_str)
251+
252+
253+
# Finite state machine for event handling ----------
187254
class StatefulEventFilter(QC.QObject):
188255
"""Gestion d'une machine d'état pour les événements
189256
d'un canvas
@@ -296,6 +363,12 @@ def mouse_release(self, btn, modifiers=QC.Qt.NoModifier):
296363
MouseEventMatch(QC.QEvent.MouseButtonRelease, btn, modifiers),
297364
)
298365

366+
def gesture(self, kind, state):
367+
"""Création d'un filtre pour l'événement pincement"""
368+
return self.events.setdefault(
369+
("gesture", kind, state), GestureEventMatch(kind, state)
370+
)
371+
299372
def nothing(self, filter, event):
300373
"""A nothing filter, provided to help removing duplicate handlers"""
301374
pass
@@ -450,6 +523,126 @@ def move(self, filter, event):
450523
filter.plot.do_zoom_view(x_state, y_state)
451524

452525

526+
class GestureHandler(QC.QObject):
527+
"""Classe de base pour les gestionnaires d'événements du type tactile"""
528+
529+
kind = None
530+
531+
def __init__(self, filter, start_state=0):
532+
super(GestureHandler, self).__init__()
533+
filter.plot.canvas().grabGesture(self.kind)
534+
self.state0 = filter.add_event(
535+
start_state,
536+
filter.gesture(self.kind, QC.Qt.GestureStarted),
537+
self.start_tracking,
538+
)
539+
self.state1 = filter.add_event(
540+
self.state0,
541+
filter.gesture(self.kind, QC.Qt.GestureUpdated),
542+
self.start_moving,
543+
)
544+
filter.add_event(
545+
self.state1,
546+
filter.gesture(self.kind, QC.Qt.GestureUpdated),
547+
self.move,
548+
self.state1,
549+
)
550+
filter.add_event(
551+
self.state0,
552+
filter.gesture(self.kind, QC.Qt.GestureFinished),
553+
self.stop_notmoving,
554+
start_state,
555+
)
556+
filter.add_event(
557+
self.state1,
558+
filter.gesture(self.kind, QC.Qt.GestureFinished),
559+
self.stop_moving,
560+
start_state,
561+
)
562+
filter.add_event(
563+
self.state0,
564+
filter.gesture(self.kind, QC.Qt.GestureCanceled),
565+
self.stop_notmoving,
566+
start_state,
567+
)
568+
filter.add_event(
569+
self.state1,
570+
filter.gesture(self.kind, QC.Qt.GestureCanceled),
571+
self.stop_notmoving,
572+
start_state,
573+
)
574+
self.start = None # first gesture position
575+
self.last = None # gesture position seen during last event
576+
self.parent_tracking = None
577+
578+
def get_gesture(self, event):
579+
return event.gesture(self.kind)
580+
581+
def get_move_state(self, filter, gesture):
582+
raise NotImplementedError
583+
584+
def start_tracking(self, filter, event):
585+
# print("Getting event for start tracking")
586+
origin = self.get_gesture(event).hotSpot()
587+
self.start = self.last = filter.plot.mapFromGlobal(origin.toPoint())
588+
589+
def start_moving(self, filter, event):
590+
return self.move(filter, event)
591+
592+
def stop_tracking(self, _filter, _event):
593+
pass
594+
# filter.plot.canvas().setMouseTracking(self.parent_tracking)
595+
596+
def stop_notmoving(self, filter, event):
597+
self.stop_tracking(filter, event)
598+
599+
def stop_moving(self, filter, event):
600+
self.stop_tracking(filter, event)
601+
602+
def move(self, filter, event):
603+
raise NotImplementedError
604+
605+
606+
class PinchZoomHandler(GestureHandler):
607+
"""Classe de base pour les gestionnaires d'événements du type tactile"""
608+
609+
kind = QC.Qt.PinchGesture
610+
611+
def get_move_state(self, filter, gesture):
612+
rct = filter.plot.contentsRect()
613+
xshift = rct.width() * (gesture.scaleFactor() - 1)
614+
yshift = rct.height() * (gesture.scaleFactor() - 1)
615+
pos = QC.QPointF(self.last.x() + xshift, self.last.y() + yshift)
616+
dx = (pos.x(), self.last.x(), self.start.x(), rct.width())
617+
dy = (pos.y(), self.last.y(), self.start.y(), rct.height())
618+
self.last = QC.QPointF(pos)
619+
return dx, dy
620+
621+
def move(self, filter, event):
622+
gesture = self.get_gesture(event)
623+
x_state, y_state = self.get_move_state(filter, gesture)
624+
filter.plot.do_zoom_view(x_state, y_state)
625+
626+
627+
class PanGestureHandler(GestureHandler):
628+
"""Classe de base pour les gestionnaires d'événements du type tactile"""
629+
630+
kind = QC.Qt.PanGesture
631+
632+
def get_move_state(self, filter, gesture):
633+
rct = filter.plot.contentsRect()
634+
pos = gesture.offset()
635+
self.last = gesture.lastOffset()
636+
dx = (pos.x(), self.last.x(), self.start.x(), rct.width())
637+
dy = (pos.y(), self.last.y(), self.start.y(), rct.height())
638+
return dx, dy
639+
640+
def move(self, filter, event):
641+
gesture = self.get_gesture(event)
642+
x_state, y_state = self.get_move_state(filter, gesture)
643+
filter.plot.do_pan_view(x_state, y_state)
644+
645+
453646
class MenuHandler(ClickHandler):
454647
def click(self, filter, event):
455648
"""
@@ -962,6 +1155,13 @@ def setup_standard_tool_filter(filter, start_state):
9621155
ZoomHandler(filter, QC.Qt.RightButton, start_state=start_state)
9631156
MenuHandler(filter, QC.Qt.RightButton, start_state=start_state)
9641157

1158+
if is_touch_screen_available():
1159+
# Gestes
1160+
PinchZoomHandler(filter, start_state=start_state)
1161+
# FIXME: Pinch/PanZoomHandler are currently mutually exclusive: when both
1162+
# are enabled, it doesn't work ; when only one is enabled, it works
1163+
PanGestureHandler(filter, start_state=start_state)
1164+
9651165
# Autres (touches, move)
9661166
MoveHandler(filter, start_state=start_state)
9671167
MoveHandler(filter, start_state=start_state, mods=QC.Qt.ShiftModifier)

plotpy/tests/items/test_image.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,18 @@
1010

1111
# guitest: show
1212

13+
from guidata.env import execenv
1314
from guidata.qthelpers import qt_app_context
1415

1516
from plotpy.builder import make
17+
from plotpy.events import is_touch_screen_available
1618
from plotpy.tests import data as ptd
1719
from plotpy.tests import vistools as ptv
1820

1921

2022
def test_image():
2123
"""Testing ImageItem object"""
24+
execenv.print(f"Touch screen available: {is_touch_screen_available()}")
2225
for index, func in enumerate((ptd.gen_image1, ptd.gen_image2, ptd.gen_image3)):
2326
title = test_image.__doc__ + f" #{index+1}"
2427
data = func()

0 commit comments

Comments
 (0)