-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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`. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. right -> bottom |
||
inaxes = None # the Axes instance if mouse us over axes | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unnecessary line breaks. |
||
|
||
In addition to the :class:`Event` and :class:`LocationEvent` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't derive from |
||
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), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
'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:]))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
|
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I've understood this correctly:
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))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
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]] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Loop is not necessary since you're operating on an array: |
||
|
||
if event.key == 'x': | ||
ylims = orig_lims[:, 1] | ||
else: | ||
ylims = [orig_center[1] + (y - center[1])/zoom_scales[1] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd just allocate |
||
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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is |
||
|
||
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) | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ID -> identifier