Skip to content

Commit b168863

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 b168863

File tree

8 files changed

+482
-1
lines changed

8 files changed

+482
-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,89 @@ 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" %
1610+
(self.x, self.y, self.xdata, self.ydata, self.inaxes, self.ID))
1611+
1612+
1613+
class TouchEvent(Event):
1614+
"""
1615+
A touch event, with possibly several touches.
1616+
1617+
For
1618+
('touch_begin_event',
1619+
'touch_update_event',
1620+
'touch_end_event')
1621+
1622+
In addition to the :class:`Event` and :class:`LocationEvent`
1623+
attributes, the following attributes are defined:
1624+
1625+
Attributes
1626+
----------
1627+
1628+
touches : None, or list
1629+
A list of the touches (possibly several), which will be of class Touch.
1630+
They are passed to the class as a list of triples of the form (ID,x,y),
1631+
where ID is an integer unique to that touch (usually provided by the
1632+
backend) and x and y are the touch coordinates
1633+
1634+
key : None, or str
1635+
The key depressed when this event triggered.
1636+
1637+
Example
1638+
-------
1639+
Usage::
1640+
1641+
def on_touch(event):
1642+
print('touch at', event.touches[0].x, event.touches[0].y)
1643+
1644+
cid = fig.canvas.mpl_connect('touch_update_event', on_touch)
1645+
1646+
"""
1647+
touches = None
1648+
key = None
1649+
1650+
def __init__(self, name, canvas, touches, key, guiEvent=None):
1651+
"""
1652+
x, y in figure coords, 0,0 = bottom, left
1653+
"""
1654+
Event.__init__(self, name, canvas, guiEvent=guiEvent)
1655+
self.touches = [Touch(name+'_'+str(n), canvas, x, y, ID,
1656+
guiEvent=guiEvent) for n, (ID, x, y) in enumerate(touches)]
1657+
self.key = key
1658+
1659+
def __str__(self):
1660+
return ("MPL TouchEvent: key=" + str(self.key) + ", touches=" +
1661+
' \n'.join(str(t) for t in self.touches))
1662+
1663+
15811664
class PickEvent(Event):
15821665
"""
15831666
a pick event, fired when the user picks a location on the canvas
@@ -1683,6 +1766,9 @@ class FigureCanvasBase(object):
16831766
'button_release_event',
16841767
'scroll_event',
16851768
'motion_notify_event',
1769+
'touch_begin_event',
1770+
'touch_update_event',
1771+
'touch_end_event',
16861772
'pick_event',
16871773
'idle_event',
16881774
'figure_enter_event',
@@ -1937,6 +2023,73 @@ def motion_notify_event(self, x, y, guiEvent=None):
19372023
guiEvent=guiEvent)
19382024
self.callbacks.process(s, event)
19392025

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

2944+
self._idTouchBegin = self.canvas.mpl_connect(
2945+
'touch_begin_event', self.handle_touch)
2946+
self._idTouchUpdate = None
2947+
self._idTouchEnd = None
2948+
27912949
self._ids_zoom = []
27922950
self._zoom_mode = None
27932951

@@ -2871,6 +3029,187 @@ def _set_cursor(self, event):
28713029

28723030
self._lastCursor = cursors.MOVE
28733031

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