Skip to content

Commit 908f70b

Browse files
committed
Touchscreen support
- support for touch-to-drag and pinch-to-zoom in NavigationToolbar2 - pass touchscreen events from Qt4 and Qt5 backends - add option in matplotlibrc to pass touches as mouse events if desired
1 parent d5132fc commit 908f70b

File tree

8 files changed

+484
-1
lines changed

8 files changed

+484
-1
lines changed

doc/users/navigation_toolbar.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ The ``Pan/Zoom`` button
5050
mouse button. The radius scale can be zoomed in and out using the
5151
right mouse button.
5252

53+
If your system has a touchscreen, with certain backends the figure can
54+
be panned by touching and dragging, or zoomed by pinching with two fingers.
55+
The Pan/Zoom button does not need to be activated for touchscreen interaction.
56+
As above, the 'x' and 'y' keys will constrain movement to the x or y axes,
57+
and 'CONTROL' preserves aspect ratio.
58+
5359
.. image:: ../../lib/matplotlib/mpl-data/images/zoom_to_rect_large.png
5460

5561
The ``Zoom-to-rectangle`` button
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Touchscreen Support
2+
-------------------
3+
4+
Support for touch-to-drag and pinch-to-zoom have been added for the
5+
Qt4 and Qt5 backends. For other/custom backends, the interface in
6+
`NavigationToolbar2` is general, so that the backends only need to
7+
pass a list of the touch points, and `NavigationToolbar2` will do the rest.
8+
Support is added separately for touch rotating and zooming in `Axes3D`.

lib/matplotlib/backend_bases.py

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,95 @@ def __str__(self):
15781578
self.dblclick, self.inaxes)
15791579

15801580

1581+
class Touch(LocationEvent):
1582+
"""
1583+
A single touch.
1584+
1585+
In addition to the :class:`Event` and :class:`LocationEvent`
1586+
attributes, the following attributes are defined:
1587+
1588+
Attributes
1589+
----------
1590+
ID : int
1591+
A unique ID (generated by the backend) for this touch.
1592+
1593+
"""
1594+
x = None # x position - pixels from left of canvas
1595+
y = None # y position - pixels from right of canvas
1596+
inaxes = None # the Axes instance if mouse us over axes
1597+
xdata = None # x coord of mouse in data coords
1598+
ydata = None # y coord of mouse in data coords
1599+
ID = None # unique ID of touch event
1600+
1601+
def __init__(self, name, canvas, x, y, ID, guiEvent=None):
1602+
"""
1603+
x, y in figure coords, 0,0 = bottom, left
1604+
"""
1605+
LocationEvent.__init__(self, name, canvas, x, y, guiEvent=guiEvent)
1606+
self.ID = ID
1607+
1608+
def __str__(self):
1609+
return ("MPL Touch: xy=(%d,%d) xydata=(%s,%s) inaxes=%s ID=%d") % (self.x,
1610+
self.y,
1611+
self.xdata,
1612+
self.ydata,
1613+
self.inaxes,
1614+
self.ID)
1615+
1616+
1617+
class TouchEvent(Event):
1618+
"""
1619+
A touch event, with possibly several touches.
1620+
1621+
For
1622+
('touch_begin_event',
1623+
'touch_update_event',
1624+
'touch_end_event')
1625+
1626+
In addition to the :class:`Event` and :class:`LocationEvent`
1627+
attributes, the following attributes are defined:
1628+
1629+
Attributes
1630+
----------
1631+
1632+
touches : None, or list
1633+
A list of the touches (possibly several), which will be of class Touch.
1634+
They are passed to the class as a list of triples of the form (ID,x,y),
1635+
where ID is an integer unique to that touch (usually provided by the
1636+
backend) and x and y are the touch coordinates
1637+
1638+
key : None, or str
1639+
The key depressed when this event triggered.
1640+
1641+
Example
1642+
-------
1643+
Usage::
1644+
1645+
def on_touch(event):
1646+
print('touch at', event.touches[0].x, event.touches[0].y)
1647+
1648+
cid = fig.canvas.mpl_connect('touch_update_event', on_touch)
1649+
1650+
"""
1651+
touches = None
1652+
key = None
1653+
1654+
def __init__(self, name, canvas, touches, key, guiEvent=None):
1655+
"""
1656+
x, y in figure coords, 0,0 = bottom, left
1657+
"""
1658+
Event.__init__(self, name, canvas, guiEvent=guiEvent)
1659+
self.touches = [ Touch(name+'_%d'%n,
1660+
canvas,
1661+
x,y,ID,
1662+
guiEvent=guiEvent) for n,(ID,x,y) in enumerate(touches)]
1663+
self.key = key
1664+
1665+
def __str__(self):
1666+
return "MPL TouchEvent: key="+str(self.key)+", touches=" + ' \n'.join(str(t) for t in self.touches)
1667+
1668+
1669+
15811670
class PickEvent(Event):
15821671
"""
15831672
a pick event, fired when the user picks a location on the canvas
@@ -1683,6 +1772,9 @@ class FigureCanvasBase(object):
16831772
'button_release_event',
16841773
'scroll_event',
16851774
'motion_notify_event',
1775+
'touch_begin_event',
1776+
'touch_update_event',
1777+
'touch_end_event',
16861778
'pick_event',
16871779
'idle_event',
16881780
'figure_enter_event',
@@ -1937,6 +2029,73 @@ def motion_notify_event(self, x, y, guiEvent=None):
19372029
guiEvent=guiEvent)
19382030
self.callbacks.process(s, event)
19392031

2032+
def touch_begin_event(self, touches, guiEvent=None):
2033+
"""
2034+
Backend derived classes should call this function on first touch.
2035+
2036+
This method will call all functions connected to the
2037+
'touch_begin_event' with a :class:`TouchEvent` instance.
2038+
2039+
Parameters
2040+
----------
2041+
touches : list
2042+
a list of triples of the form (ID,x,y), where ID is a unique
2043+
integer ID for each touch, and x and y are the touch's
2044+
coordinates.
2045+
2046+
guiEvent
2047+
the native UI event that generated the mpl event
2048+
2049+
"""
2050+
s = 'touch_begin_event'
2051+
event = TouchEvent(s, self, touches, key=self._key, guiEvent=guiEvent)
2052+
self.callbacks.process(s, event)
2053+
2054+
def touch_update_event(self, touches, guiEvent=None):
2055+
"""
2056+
Backend derived classes should call this function on all
2057+
touch updates.
2058+
2059+
This method will call all functions connected to the
2060+
'touch_update_event' with a :class:`TouchEvent` instance.
2061+
2062+
Parameters
2063+
----------
2064+
touches : list
2065+
a list of triples of the form (ID,x,y), where ID is a unique
2066+
integer ID for each touch, and x and y are the touch's
2067+
coordinates.
2068+
2069+
guiEvent
2070+
the native UI event that generated the mpl event
2071+
2072+
"""
2073+
s = 'touch_update_event'
2074+
event = TouchEvent(s, self, touches, key=self._key, guiEvent=guiEvent)
2075+
self.callbacks.process(s, event)
2076+
2077+
def touch_end_event(self, touches, guiEvent=None):
2078+
"""
2079+
Backend derived classes should call this function on touch end.
2080+
2081+
This method will be call all functions connected to the
2082+
'touch_end_event' with a :class:`TouchEvent` instance.
2083+
2084+
Parameters
2085+
----------
2086+
touches : list
2087+
a list of triples of the form (ID,x,y), where ID is a unique
2088+
integer ID for each touch, and x and y are the touch's
2089+
coordinates.
2090+
2091+
guiEvent
2092+
the native UI event that generated the mpl event
2093+
2094+
"""
2095+
s = 'touch_end_event'
2096+
event = TouchEvent(s, self, touches, key=self._key, guiEvent=guiEvent)
2097+
self.callbacks.process(s, event)
2098+
19402099
def leave_notify_event(self, guiEvent=None):
19412100
"""
19422101
Backend derived classes should call this function when leaving
@@ -2788,6 +2947,11 @@ def __init__(self, canvas):
27882947
self._idDrag = self.canvas.mpl_connect(
27892948
'motion_notify_event', self.mouse_move)
27902949

2950+
self._idTouchBegin = self.canvas.mpl_connect(
2951+
'touch_begin_event', self.handle_touch)
2952+
self._idTouchUpdate = None
2953+
self._idTouchEnd = None
2954+
27912955
self._ids_zoom = []
27922956
self._zoom_mode = None
27932957

@@ -2871,6 +3035,181 @@ def _set_cursor(self, event):
28713035

28723036
self._lastCursor = cursors.MOVE
28733037

3038+
def handle_touch(self,event,prev=None):
3039+
if len(event.touches) == 1:
3040+
if self._idTouchUpdate is not None:
3041+
self.touch_end_disconnect(event)
3042+
self.touch_pan_begin(event)
3043+
3044+
elif len(event.touches) == 2:
3045+
if self._idTouchUpdate is not None:
3046+
self.touch_end_disconnect(event)
3047+
self.pinch_zoom_begin(event)
3048+
3049+
else:
3050+
if prev == 'pan':
3051+
self.touch_pan_end(event)
3052+
elif prev == 'zoom':
3053+
self.pinch_zoom_end(event)
3054+
if self._idTouchUpdate is None:
3055+
self._idTouchUpdate = self.canvas.mpl_connect(
3056+
'touch_update_event', self.handle_touch)
3057+
self._idTouchEnd = self.canvas.mpl_connect(
3058+
'touch_end_event', self.touch_end_disconnect)
3059+
3060+
def touch_end_disconnect(self,event):
3061+
self._idTouchUpdate = self.canvas.mpl_disconnect(self._idTouchUpdate)
3062+
self._idTouchEnd = self.canvas.mpl_disconnect(self._idTouchEnd)
3063+
3064+
def touch_pan_begin(self,event):
3065+
self._idTouchUpdate = self.canvas.mpl_connect(
3066+
'touch_update_event', self.touch_pan)
3067+
self._idTouchEnd = self.canvas.mpl_connect(
3068+
'touch_end_event', self.touch_pan_end)
3069+
3070+
touch = event.touches[0]
3071+
x, y = touch.x, touch.y
3072+
3073+
# push the current view to define home if stack is empty
3074+
if self._views.empty():
3075+
self.push_current()
3076+
3077+
self._xypress = []
3078+
for i, a in enumerate(self.canvas.figure.get_axes()):
3079+
if (x is not None and y is not None and a.in_axes(touch) and
3080+
a.get_navigate() and a.can_pan()):
3081+
a.start_pan(x, y, 1)
3082+
self._xypress.append((a, i))
3083+
3084+
def touch_pan(self,event):
3085+
if len(event.touches) != 1: # number of touches changed
3086+
self.touch_pan_end(event,push_view=False)
3087+
self.handle_touch(event,prev='pan')
3088+
return
3089+
3090+
touch = event.touches[0]
3091+
3092+
for a,_ in self._xypress:
3093+
a.drag_pan(1, event.key, touch.x, touch.y)
3094+
self.dynamic_update()
3095+
3096+
def touch_pan_end(self,event,push_view=True):
3097+
self.touch_end_disconnect(event)
3098+
3099+
for a,_ in self._xypress:
3100+
a.end_pan()
3101+
3102+
self._xypress = []
3103+
self._button_pressed = None
3104+
if push_view: # don't push when going from pan to pinch-to-zoom
3105+
self.push_current()
3106+
self.draw()
3107+
3108+
def pinch_zoom_begin(self,event):
3109+
3110+
# push the current view to define home if stack is empty
3111+
# this almost never happens because you have a single touch
3112+
# first. but good to be safe
3113+
if self._views.empty():
3114+
self.push_current()
3115+
3116+
self._xypress = []
3117+
for a in self.canvas.figure.get_axes():
3118+
if (all(a.in_axes(t) for t in event.touches) and
3119+
a.get_navigate() and a.can_zoom()):
3120+
3121+
trans = a.transData
3122+
3123+
view = a._get_view()
3124+
transview = trans.transform(list(zip(view[:2],view[2:])))
3125+
3126+
self._xypress.append((a, event.touches, trans.inverted(), transview ))
3127+
3128+
self._idTouchUpdate = self.canvas.mpl_connect(
3129+
'touch_update_event', self.pinch_zoom)
3130+
self._idTouchEnd = self.canvas.mpl_connect(
3131+
'touch_end_event', self.pinch_zoom_end)
3132+
3133+
def pinch_zoom(self,event):
3134+
if len(event.touches) != 2: # number of touches changed
3135+
self.pinch_zoom_end(event,push_view=False)
3136+
self.handle_touch(event,prev='zoom')
3137+
return
3138+
3139+
if not self._xypress:
3140+
return
3141+
3142+
# check that these are the same two touches!
3143+
e_IDs = {t.ID for t in event.touches}
3144+
orig_IDs = {t.ID for t in self._xypress[0][1]}
3145+
if e_IDs != orig_IDs:
3146+
self.pinch_zoom_end(event)
3147+
self.pinch_zoom_begin(event)
3148+
3149+
last_a = []
3150+
3151+
for cur_xypress in self._xypress:
3152+
a, orig_touches, orig_trans, orig_lims = cur_xypress
3153+
3154+
center = (sum(t.x for t in event.touches)/2,
3155+
sum(t.y for t in event.touches)/2)
3156+
3157+
orig_center = (sum(t.x for t in orig_touches)/2,
3158+
sum(t.y for t in orig_touches)/2)
3159+
3160+
ot1,ot2 = orig_touches
3161+
t1,t2 = event.touches
3162+
if event.key == 'control' or a.get_aspect() not in ['auto','normal']:
3163+
zoom_scales = (np.sqrt(((t1.x-t2.x)**2+(t1.y-t2.y)**2)/((ot1.x-ot2.x)**2 + (ot1.y-ot2.y)**2)),)*2
3164+
else:
3165+
zoom_scales = (abs((t1.x-t2.x)/(ot1.x-ot2.x)),
3166+
abs((t1.y-t2.y)/(ot1.y-ot2.y)))
3167+
3168+
# if the zoom is really extreme, make it not crazy
3169+
zoom_scales = [z if z>0.01 else 0.01 for z in zoom_scales]
3170+
3171+
if event.key == 'y':
3172+
xlims = orig_lims[:,0]
3173+
else:
3174+
xlims = [orig_center[0] + (x - center[0])/zoom_scales[0] for x in orig_lims[:,0]]
3175+
3176+
if event.key == 'x':
3177+
ylims = orig_lims[:,1]
3178+
else:
3179+
ylims = [orig_center[1] + (y - center[1])/zoom_scales[1] for y in orig_lims[:,1]]
3180+
3181+
lims = orig_trans.transform(list(zip(xlims,ylims)))
3182+
xlims = lims[:,0]
3183+
ylims = lims[:,1]
3184+
3185+
# detect twinx,y axes and avoid double zooming
3186+
twinx, twiny = False, False
3187+
if last_a:
3188+
for la in last_a:
3189+
if a.get_shared_x_axes().joined(a, la):
3190+
twinx = True
3191+
if a.get_shared_y_axes().joined(a, la):
3192+
twiny = True
3193+
last_a.append(a)
3194+
3195+
xmin,xmax,ymin,ymax = a._get_view()
3196+
3197+
if twinx and not twiny: # and maybe a key
3198+
a._set_view((xmin,xmax,ylims[0],ylims[1]))
3199+
elif twiny and not twinx:
3200+
a._set_view((xlims[0],xlims[1],ymin,ymax))
3201+
elif not twinx and not twiny:
3202+
a._set_view(list(xlims)+list(ylims))
3203+
3204+
self.dynamic_update()
3205+
3206+
def pinch_zoom_end(self,event,push_view=True):
3207+
self.touch_end_disconnect(event)
3208+
3209+
if push_view: # don't push when going from zoom back to pan
3210+
self.push_current()
3211+
self.draw()
3212+
28743213
def mouse_move(self, event):
28753214
self._set_cursor(event)
28763215

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy