Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Multitouch touchscreen support #8041

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/users/navigation_toolbar.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions doc/users/whats_new/touchscreen_support.rst
Original file line number Diff line number Diff line change
@@ -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`.
339 changes: 339 additions & 0 deletions lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ID -> identifier


"""
x = None # x position - pixels from left of canvas
y = None # y position - pixels from right of canvas
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right -> bottom

inaxes = None # the Axes instance if mouse us over axes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

us -> is (but there's no mouse in this event, is there?)

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bottom, left -> left, bottom

"""
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')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary line breaks.


In addition to the :class:`Event` and :class:`LocationEvent`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't derive from 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),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How they are passed to the class has nothing to do with how the attribute is accessed. This would be something to go in docs for __init__, maybe.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs to be Examples.

-------
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bottom, left -> left, bottom

"""
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)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be aligned with the opening parenthesis. And I would break before the for.

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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ID -> identifier

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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First line in docstring shouldn't wrap; maybe "Backend method to process 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ID -> identifier

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ID -> identifier

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
Expand Down Expand Up @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the others follow the bad naming scheme, but these are private and new, so please use snake_case.

'touch_begin_event', self.handle_touch)
self._idTouchUpdate = None
self._idTouchEnd = None

self._ids_zoom = []
self._zoom_mode = None

Expand Down Expand Up @@ -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:])))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This list(zip( looks unnecessary; I think you want to reshape and maybe .T(ranspose).


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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to be a constant that could be outside the loop.


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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I've understood this correctly:

global_scale = (np.hypot(t1.x - t2.x, t1.y - t2.y) /
                np.hypot(ot1.x - ot2.x, ot1.y - ot2.y))

but maybe the numerator should be calculated outside the loop (depends on how much this happens.)

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)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs more spacing around operators.


# 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]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zoom_scales = [max(z, 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]]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loop is not necessary since you're operating on an array: orig_center[0] + (orig_lims[:, 0] - center[0]) / zoom_scales[0].


if event.key == 'x':
ylims = orig_lims[:, 1]
else:
ylims = [orig_center[1] + (y - center[1])/zoom_scales[1]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above comment.

for y in orig_lims[:, 1]]

lims = orig_trans.transform(list(zip(xlims, ylims)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd just allocate lims as a 2D array from the beginning and assign to it directly instead of using list(zip()).

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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is lims.T.flatten().


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)

Expand Down
Loading