From b1688638a3fc5139d6e0db8544ea37ac0ac1d4b9 Mon Sep 17 00:00:00 2001 From: Greg Meyer Date: Tue, 7 Feb 2017 12:47:33 -0800 Subject: [PATCH] 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 --- doc/users/navigation_toolbar.rst | 6 + doc/users/whats_new/touchscreen_support.rst | 8 + lib/matplotlib/backend_bases.py | 339 ++++++++++++++++++++ lib/matplotlib/backends/backend_qt4.py | 2 + lib/matplotlib/backends/backend_qt5.py | 44 +++ lib/matplotlib/rcsetup.py | 1 + lib/mpl_toolkits/mplot3d/axes3d.py | 77 ++++- matplotlibrc.template | 6 + 8 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 doc/users/whats_new/touchscreen_support.rst diff --git a/doc/users/navigation_toolbar.rst b/doc/users/navigation_toolbar.rst index 47b30d6e7600..ba43dc6c62a4 100644 --- a/doc/users/navigation_toolbar.rst +++ b/doc/users/navigation_toolbar.rst @@ -50,6 +50,12 @@ The ``Pan/Zoom`` button mouse button. The radius scale can be zoomed in and out using the right mouse button. + If your system has a touchscreen, with certain backends the figure can + be panned by touching and dragging, or zoomed by pinching with two fingers. + The Pan/Zoom button does not need to be activated for touchscreen interaction. + As above, the 'x' and 'y' keys will constrain movement to the x or y axes, + and 'CONTROL' preserves aspect ratio. + .. image:: ../../lib/matplotlib/mpl-data/images/zoom_to_rect_large.png The ``Zoom-to-rectangle`` button diff --git a/doc/users/whats_new/touchscreen_support.rst b/doc/users/whats_new/touchscreen_support.rst new file mode 100644 index 000000000000..53a56709a85a --- /dev/null +++ b/doc/users/whats_new/touchscreen_support.rst @@ -0,0 +1,8 @@ +Touchscreen Support +------------------- + +Support for touch-to-drag and pinch-to-zoom have been added for the +Qt4 and Qt5 backends. For other/custom backends, the interface in +`NavigationToolbar2` is general, so that the backends only need to +pass a list of the touch points, and `NavigationToolbar2` will do the rest. +Support is added separately for touch rotating and zooming in `Axes3D`. \ No newline at end of file diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index ba75a447795a..fc2d709e4c91 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1578,6 +1578,89 @@ def __str__(self): self.dblclick, self.inaxes) +class Touch(LocationEvent): + """ + A single touch. + + In addition to the :class:`Event` and :class:`LocationEvent` + attributes, the following attributes are defined: + + Attributes + ---------- + ID : int + A unique ID (generated by the backend) for this touch. + + """ + x = None # x position - pixels from left of canvas + y = None # y position - pixels from right of canvas + inaxes = None # the Axes instance if mouse us over axes + xdata = None # x coord of mouse in data coords + ydata = None # y coord of mouse in data coords + ID = None # unique ID of touch event + + def __init__(self, name, canvas, x, y, ID, guiEvent=None): + """ + x, y in figure coords, 0,0 = bottom, left + """ + LocationEvent.__init__(self, name, canvas, x, y, guiEvent=guiEvent) + self.ID = ID + + def __str__(self): + return ("MPL Touch: xy=(%d,%d) xydata=(%s,%s) inaxes=%s ID=%d" % + (self.x, self.y, self.xdata, self.ydata, self.inaxes, self.ID)) + + +class TouchEvent(Event): + """ + A touch event, with possibly several touches. + + For + ('touch_begin_event', + 'touch_update_event', + 'touch_end_event') + + In addition to the :class:`Event` and :class:`LocationEvent` + attributes, the following attributes are defined: + + Attributes + ---------- + + touches : None, or list + A list of the touches (possibly several), which will be of class Touch. + They are passed to the class as a list of triples of the form (ID,x,y), + where ID is an integer unique to that touch (usually provided by the + backend) and x and y are the touch coordinates + + key : None, or str + The key depressed when this event triggered. + + Example + ------- + Usage:: + + def on_touch(event): + print('touch at', event.touches[0].x, event.touches[0].y) + + cid = fig.canvas.mpl_connect('touch_update_event', on_touch) + + """ + touches = None + key = None + + def __init__(self, name, canvas, touches, key, guiEvent=None): + """ + x, y in figure coords, 0,0 = bottom, left + """ + Event.__init__(self, name, canvas, guiEvent=guiEvent) + self.touches = [Touch(name+'_'+str(n), canvas, x, y, ID, + guiEvent=guiEvent) for n, (ID, x, y) in enumerate(touches)] + self.key = key + + def __str__(self): + return ("MPL TouchEvent: key=" + str(self.key) + ", touches=" + + ' \n'.join(str(t) for t in self.touches)) + + class PickEvent(Event): """ a pick event, fired when the user picks a location on the canvas @@ -1683,6 +1766,9 @@ class FigureCanvasBase(object): 'button_release_event', 'scroll_event', 'motion_notify_event', + 'touch_begin_event', + 'touch_update_event', + 'touch_end_event', 'pick_event', 'idle_event', 'figure_enter_event', @@ -1937,6 +2023,73 @@ def motion_notify_event(self, x, y, guiEvent=None): guiEvent=guiEvent) self.callbacks.process(s, event) + def touch_begin_event(self, touches, guiEvent=None): + """ + Backend derived classes should call this function on first touch. + + This method will call all functions connected to the + 'touch_begin_event' with a :class:`TouchEvent` instance. + + Parameters + ---------- + touches : list + a list of triples of the form (ID,x,y), where ID is a unique + integer ID for each touch, and x and y are the touch's + coordinates. + + guiEvent + the native UI event that generated the mpl event + + """ + s = 'touch_begin_event' + event = TouchEvent(s, self, touches, key=self._key, guiEvent=guiEvent) + self.callbacks.process(s, event) + + def touch_update_event(self, touches, guiEvent=None): + """ + Backend derived classes should call this function on all + touch updates. + + This method will call all functions connected to the + 'touch_update_event' with a :class:`TouchEvent` instance. + + Parameters + ---------- + touches : list + a list of triples of the form (ID,x,y), where ID is a unique + integer ID for each touch, and x and y are the touch's + coordinates. + + guiEvent + the native UI event that generated the mpl event + + """ + s = 'touch_update_event' + event = TouchEvent(s, self, touches, key=self._key, guiEvent=guiEvent) + self.callbacks.process(s, event) + + def touch_end_event(self, touches, guiEvent=None): + """ + Backend derived classes should call this function on touch end. + + This method will be call all functions connected to the + 'touch_end_event' with a :class:`TouchEvent` instance. + + Parameters + ---------- + touches : list + a list of triples of the form (ID,x,y), where ID is a unique + integer ID for each touch, and x and y are the touch's + coordinates. + + guiEvent + the native UI event that generated the mpl event + + """ + s = 'touch_end_event' + event = TouchEvent(s, self, touches, key=self._key, guiEvent=guiEvent) + self.callbacks.process(s, event) + def leave_notify_event(self, guiEvent=None): """ Backend derived classes should call this function when leaving @@ -2788,6 +2941,11 @@ def __init__(self, canvas): self._idDrag = self.canvas.mpl_connect( 'motion_notify_event', self.mouse_move) + self._idTouchBegin = self.canvas.mpl_connect( + 'touch_begin_event', self.handle_touch) + self._idTouchUpdate = None + self._idTouchEnd = None + self._ids_zoom = [] self._zoom_mode = None @@ -2871,6 +3029,187 @@ def _set_cursor(self, event): self._lastCursor = cursors.MOVE + def handle_touch(self, event, prev=None): + if len(event.touches) == 1: + if self._idTouchUpdate is not None: + self.touch_end_disconnect(event) + self.touch_pan_begin(event) + + elif len(event.touches) == 2: + if self._idTouchUpdate is not None: + self.touch_end_disconnect(event) + self.pinch_zoom_begin(event) + + else: + if prev == 'pan': + self.touch_pan_end(event) + elif prev == 'zoom': + self.pinch_zoom_end(event) + if self._idTouchUpdate is None: + self._idTouchUpdate = self.canvas.mpl_connect( + 'touch_update_event', self.handle_touch) + self._idTouchEnd = self.canvas.mpl_connect( + 'touch_end_event', self.touch_end_disconnect) + + def touch_end_disconnect(self, event): + self._idTouchUpdate = self.canvas.mpl_disconnect(self._idTouchUpdate) + self._idTouchEnd = self.canvas.mpl_disconnect(self._idTouchEnd) + + def touch_pan_begin(self, event): + self._idTouchUpdate = self.canvas.mpl_connect( + 'touch_update_event', self.touch_pan) + self._idTouchEnd = self.canvas.mpl_connect( + 'touch_end_event', self.touch_pan_end) + + touch = event.touches[0] + x, y = touch.x, touch.y + + # push the current view to define home if stack is empty + if self._views.empty(): + self.push_current() + + self._xypress = [] + for i, a in enumerate(self.canvas.figure.get_axes()): + if (x is not None and y is not None and a.in_axes(touch) and + a.get_navigate() and a.can_pan()): + a.start_pan(x, y, 1) + self._xypress.append((a, i)) + + def touch_pan(self, event): + if len(event.touches) != 1: # number of touches changed + self.touch_pan_end(event, push_view=False) + self.handle_touch(event, prev='pan') + return + + touch = event.touches[0] + + for a, _ in self._xypress: + a.drag_pan(1, event.key, touch.x, touch.y) + self.dynamic_update() + + def touch_pan_end(self, event, push_view=True): + self.touch_end_disconnect(event) + + for a, _ in self._xypress: + a.end_pan() + + self._xypress = [] + self._button_pressed = None + if push_view: # don't push when going from pan to pinch-to-zoom + self.push_current() + self.draw() + + def pinch_zoom_begin(self, event): + + # push the current view to define home if stack is empty + # this almost never happens because you have a single touch + # first. but good to be safe + if self._views.empty(): + self.push_current() + + self._xypress = [] + for a in self.canvas.figure.get_axes(): + if (all(a.in_axes(t) for t in event.touches) and + a.get_navigate() and a.can_zoom()): + + trans = a.transData + + view = a._get_view() + transview = trans.transform(list(zip(view[:2], view[2:]))) + + self._xypress.append((a, event.touches, + trans.inverted(), transview)) + + self._idTouchUpdate = self.canvas.mpl_connect( + 'touch_update_event', self.pinch_zoom) + self._idTouchEnd = self.canvas.mpl_connect( + 'touch_end_event', self.pinch_zoom_end) + + def pinch_zoom(self, event): + if len(event.touches) != 2: # number of touches changed + self.pinch_zoom_end(event, push_view=False) + self.handle_touch(event, prev='zoom') + return + + if not self._xypress: + return + + # check that these are the same two touches! + e_IDs = {t.ID for t in event.touches} + orig_IDs = {t.ID for t in self._xypress[0][1]} + if e_IDs != orig_IDs: + self.pinch_zoom_end(event) + self.pinch_zoom_begin(event) + + last_a = [] + + for cur_xypress in self._xypress: + a, orig_touches, orig_trans, orig_lims = cur_xypress + + center = (sum(t.x for t in event.touches)/2, + sum(t.y for t in event.touches)/2) + + orig_center = (sum(t.x for t in orig_touches)/2, + sum(t.y for t in orig_touches)/2) + + ot1, ot2 = orig_touches + t1, t2 = event.touches + if (event.key == 'control' or + a.get_aspect() not in ['auto', 'normal']): + global_scale = np.sqrt(((t1.x-t2.x)**2 + (t1.y-t2.y)**2) / + ((ot1.x-ot2.x)**2 + (ot1.y-ot2.y)**2)) + zoom_scales = (global_scale, global_scale) + else: + zoom_scales = (abs((t1.x-t2.x)/(ot1.x-ot2.x)), + abs((t1.y-t2.y)/(ot1.y-ot2.y))) + + # if the zoom is really extreme, make it not crazy + zoom_scales = [z if z > 0.01 else 0.01 for z in zoom_scales] + + if event.key == 'y': + xlims = orig_lims[:, 0] + else: + xlims = [orig_center[0] + (x - center[0])/zoom_scales[0] + for x in orig_lims[:, 0]] + + if event.key == 'x': + ylims = orig_lims[:, 1] + else: + ylims = [orig_center[1] + (y - center[1])/zoom_scales[1] + for y in orig_lims[:, 1]] + + lims = orig_trans.transform(list(zip(xlims, ylims))) + xlims = lims[:, 0] + ylims = lims[:, 1] + + # detect twinx,y axes and avoid double zooming + twinx, twiny = False, False + if last_a: + for la in last_a: + if a.get_shared_x_axes().joined(a, la): + twinx = True + if a.get_shared_y_axes().joined(a, la): + twiny = True + last_a.append(a) + + xmin, xmax, ymin, ymax = a._get_view() + + if twinx and not twiny: # and maybe a key + a._set_view((xmin, xmax, ylims[0], ylims[1])) + elif twiny and not twinx: + a._set_view((xlims[0], xlims[1], ymin, ymax)) + elif not twinx and not twiny: + a._set_view(list(xlims)+list(ylims)) + + self.dynamic_update() + + def pinch_zoom_end(self, event, push_view=True): + self.touch_end_disconnect(event) + + if push_view: # don't push when going from zoom back to pan + self.push_current() + self.draw() + def mouse_move(self, event): self._set_cursor(event) diff --git a/lib/matplotlib/backends/backend_qt4.py b/lib/matplotlib/backends/backend_qt4.py index d19c0433be1b..a3813ab5ceb0 100644 --- a/lib/matplotlib/backends/backend_qt4.py +++ b/lib/matplotlib/backends/backend_qt4.py @@ -66,6 +66,8 @@ def __init__(self, figure): QtWidgets.QWidget.__init__(self) FigureCanvasBase.__init__(self, figure) self.figure = figure + if matplotlib.rcParams['backend.touch']: + self.setAttribute(QtCore.Qt.WA_AcceptTouchEvents, True) self.setMouseTracking(True) self._idle = True w, h = self.get_width_height() diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index 0f23b12462a2..d28f2045b000 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -68,6 +68,12 @@ QtCore.Qt.Key_SysReq: 'sysreq', QtCore.Qt.Key_Clear: 'clear', } +TOUCH_EVENTS = { + QtCore.QEvent.TouchBegin: 'TouchBegin', + QtCore.QEvent.TouchUpdate: 'TouchUpdate', + QtCore.QEvent.TouchEnd: 'TouchEnd', +} + # define which modifier keys are collected on keyboard events. # elements are (mpl names, Modifier Flag, Qt Key) tuples SUPER = 0 @@ -243,6 +249,8 @@ def __init__(self, figure): # http://pyqt.sourceforge.net/Docs/PyQt5/pyqt4_differences.html#cooperative-multi-inheritance super(FigureCanvasQT, self).__init__(figure=figure) self.figure = figure + if matplotlib.rcParams['backend.touch']: + self.setAttribute(QtCore.Qt.WA_AcceptTouchEvents, True) self.setMouseTracking(True) w, h = self.get_width_height() self.resize(w, h) @@ -363,6 +371,42 @@ def resizeEvent(self, event): self.draw_idle() QtWidgets.QWidget.resizeEvent(self, event) + def event(self, event): + ''' + There is no specialized event handler for touch events + So have to reimplement the general event() + ''' + if event.type() in TOUCH_EVENTS: + etype = TOUCH_EVENTS[event.type()] + + touches = [] + + # there is some odd bug (I think in PyQt5) where after a mouse + # event, touches register as QMouseEvent instead of QTouchEvent. + # But their event.type() is still TouchBegin. ?! + + # in that case there is no touchPoints attribute, so we should + # just skip it. + + if not hasattr(event, 'touchPoints'): + return False + + for p in event.touchPoints(): + x, y = self.mouseEventCoords(p.pos()) + touches.append((p.id(), x, y)) + + if etype == 'TouchBegin': + FigureCanvasBase.touch_begin_event(self, touches) + elif etype == 'TouchUpdate': + FigureCanvasBase.touch_update_event(self, touches) + elif etype == 'TouchEnd': + FigureCanvasBase.touch_end_event(self, touches) + + return True + + else: + return QtWidgets.QWidget.event(self, event) + def sizeHint(self): w, h = self.get_width_height() return QtCore.QSize(w, h) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index e7d6bccad962..d6a815d4274f 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -901,6 +901,7 @@ def validate_animation_writer_path(p): 'backend_fallback': [True, validate_bool], # agg is certainly present 'backend.qt4': ['PyQt4', validate_qt4], 'backend.qt5': ['PyQt5', validate_qt5], + 'backend.touch': [True, validate_bool], 'webagg.port': [8988, validate_int], 'webagg.open_in_browser': [True, validate_bool], 'webagg.port_retries': [50, validate_int], diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index aaa705113253..d793c9c46a59 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1023,7 +1023,10 @@ def mouse_init(self, rotate_btn=1, zoom_btn=3): c1 = canv.mpl_connect('motion_notify_event', self._on_move) c2 = canv.mpl_connect('button_press_event', self._button_press) c3 = canv.mpl_connect('button_release_event', self._button_release) - self._cids = [c1, c2, c3] + ct1 = canv.mpl_connect('touch_begin_event', self._touch_begin) + ct2 = canv.mpl_connect('touch_update_event', self._touch_update) + ct3 = canv.mpl_connect('touch_end_event', self._touch_end) + self._cids = [c1, c2, c3, ct1, ct2, ct3] else: warnings.warn('Axes3D.figure.canvas is \'None\', mouse rotation disabled. Set canvas then call Axes3D.mouse_init().') @@ -1137,6 +1140,78 @@ def format_coord(self, xd, yd): zs = self.format_zdata(z) return 'x=%s, y=%s, z=%s' % (xs, ys, zs) + def _touch_begin(self, event): + if any(t.xdata is None for t in event.touches): + return + + self._touches = event.touches + + def _touch_update(self, event): + if any(t.xdata is None for t in event.touches): + return + + if self.M is None: + return + + w = self._pseudo_w + h = self._pseudo_h + + orig_IDs = {t.ID for t in self._touches} + e_IDs = {t.ID for t in event.touches} + if (len(event.touches) != len(self._touches) or + not orig_IDs == e_IDs): + self._touch_begin(event) + + elif len(event.touches) == 1: + # this is a rotation + + touch = event.touches[0] + otouch = self._touches[0] + + dx = touch.xdata - otouch.xdata + dy = touch.ydata - otouch.ydata + + if dx == 0 and dy == 0: + return + self.elev = art3d.norm_angle(self.elev - (dy/h)*180) + self.azim = art3d.norm_angle(self.azim - (dx/w)*180) + self.get_proj() + self.figure.canvas.draw_idle() + + elif len(event.touches) == 2: + + ot1, ot2 = self._touches + t1, t2 = event.touches + + odist2 = (ot1.x-ot2.x)**2 + (ot1.y-ot2.y)**2 + dist2 = (t1.x-t2.x)**2 + (t1.y-t2.y)**2 + df = 1 - np.sqrt(dist2/odist2) + + ocenter = ((ot1.xdata + ot2.xdata)/2, (ot1.ydata + ot2.ydata)/2) + center = ((t1.xdata + t2.xdata)/2, (t1.ydata + t2.ydata)/2) + + cdx = center[0] - ocenter[0] + cdy = center[1] - ocenter[1] + + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + dx = (maxx-minx)*df + dy = (maxy-miny)*df + dz = (maxz-minz)*df + self.set_xlim3d(minx - dx, maxx + dx) + self.set_ylim3d(miny - dy, maxy + dy) + self.set_zlim3d(minz - dz, maxz + dz) + + self.elev = art3d.norm_angle(self.elev - (cdy/h)*180) + self.azim = art3d.norm_angle(self.azim - (cdx/w)*180) + + self.get_proj() + self.figure.canvas.draw_idle() + + self._touches = event.touches + + def _touch_end(self, event): + self._touches = None + def _on_move(self, event): """Mouse moving diff --git a/matplotlibrc.template b/matplotlibrc.template index e57dfd9ada2d..2846ea5fa487 100644 --- a/matplotlibrc.template +++ b/matplotlibrc.template @@ -66,6 +66,12 @@ backend : $TEMPLATE_BACKEND # you if backend_fallback is True #backend_fallback: True +# if you are using Qt4 or Qt5 backend and have a touchscreen or touchpad, +# you can interact with the plot using touch gestures (pinch to zoom, +# touch and drag). if this option is set to false, touch events will +# be interpreted as regular mouse events (disabling touch gestures). +#backend.touch : True + #interactive : False #toolbar : toolbar2 # None | toolbar2 ("classic" is deprecated) #timezone : UTC # a pytz timezone string, e.g., US/Central or Europe/Paris