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 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