1"""
2Abstract base classes define the primitives for Tools.
3These tools are used by `matplotlib.backend_managers.ToolManager`
4
5:class:`ToolBase`
6 Simple stateless tool
7
8:class:`ToolToggleBase`
9 Tool that has two states, only one Toggle tool can be
10 active at any given time for the same
11 `matplotlib.backend_managers.ToolManager`
12"""
13
14import enum
15import functools
16import re
17import time
18from types import SimpleNamespace
19import uuid
20from weakref import WeakKeyDictionary
21
22import numpy as np
23
24import matplotlib as mpl
25from matplotlib._pylab_helpers import Gcf
26from matplotlib import _api, cbook
27
28
29class Cursors(enum.IntEnum): # Must subclass int for the macOS backend.
30 """Backend-independent cursor types."""
31 POINTER = enum.auto()
32 HAND = enum.auto()
33 SELECT_REGION = enum.auto()
34 MOVE = enum.auto()
35 WAIT = enum.auto()
36 RESIZE_HORIZONTAL = enum.auto()
37 RESIZE_VERTICAL = enum.auto()
38cursors = Cursors # Backcompat.
39
40
41# _tool_registry, _register_tool_class, and _find_tool_class implement a
42# mechanism through which ToolManager.add_tool can determine whether a subclass
43# of the requested tool class has been registered (either for the current
44# canvas class or for a parent class), in which case that tool subclass will be
45# instantiated instead. This is the mechanism used e.g. to allow different
46# GUI backends to implement different specializations for ConfigureSubplots.
47
48
49_tool_registry = set()
50
51
52def _register_tool_class(canvas_cls, tool_cls=None):
53 """Decorator registering *tool_cls* as a tool class for *canvas_cls*."""
54 if tool_cls is None:
55 return functools.partial(_register_tool_class, canvas_cls)
56 _tool_registry.add((canvas_cls, tool_cls))
57 return tool_cls
58
59
60def _find_tool_class(canvas_cls, tool_cls):
61 """Find a subclass of *tool_cls* registered for *canvas_cls*."""
62 for canvas_parent in canvas_cls.__mro__:
63 for tool_child in _api.recursive_subclasses(tool_cls):
64 if (canvas_parent, tool_child) in _tool_registry:
65 return tool_child
66 return tool_cls
67
68
69# Views positions tool
70_views_positions = 'viewpos'
71
72
73class ToolBase:
74 """
75 Base tool class.
76
77 A base tool, only implements `trigger` method or no method at all.
78 The tool is instantiated by `matplotlib.backend_managers.ToolManager`.
79 """
80
81 default_keymap = None
82 """
83 Keymap to associate with this tool.
84
85 ``list[str]``: List of keys that will trigger this tool when a keypress
86 event is emitted on ``self.figure.canvas``. Note that this attribute is
87 looked up on the instance, and can therefore be a property (this is used
88 e.g. by the built-in tools to load the rcParams at instantiation time).
89 """
90
91 description = None
92 """
93 Description of the Tool.
94
95 `str`: Tooltip used if the Tool is included in a Toolbar.
96 """
97
98 image = None
99 """
100 Icon filename.
101
102 ``str | None``: Filename of the Toolbar icon; either absolute, or relative to the
103 directory containing the Python source file where the ``Tool.image`` class attribute
104 is defined (in the latter case, this cannot be defined as an instance attribute).
105 In either case, the extension is optional; leaving it off lets individual backends
106 select the icon format they prefer. If None, the *name* is used as a label in the
107 toolbar button.
108 """
109
110 def __init__(self, toolmanager, name):
111 self._name = name
112 self._toolmanager = toolmanager
113 self._figure = None
114
115 name = property(
116 lambda self: self._name,
117 doc="The tool id (str, must be unique among tools of a tool manager).")
118 toolmanager = property(
119 lambda self: self._toolmanager,
120 doc="The `.ToolManager` that controls this tool.")
121 canvas = property(
122 lambda self: self._figure.canvas if self._figure is not None else None,
123 doc="The canvas of the figure affected by this tool, or None.")
124
125 def set_figure(self, figure):
126 self._figure = figure
127
128 figure = property(
129 lambda self: self._figure,
130 # The setter must explicitly call self.set_figure so that subclasses can
131 # meaningfully override it.
132 lambda self, figure: self.set_figure(figure),
133 doc="The Figure affected by this tool, or None.")
134
135 def _make_classic_style_pseudo_toolbar(self):
136 """
137 Return a placeholder object with a single `canvas` attribute.
138
139 This is useful to reuse the implementations of tools already provided
140 by the classic Toolbars.
141 """
142 return SimpleNamespace(canvas=self.canvas)
143
144 def trigger(self, sender, event, data=None):
145 """
146 Called when this tool gets used.
147
148 This method is called by `.ToolManager.trigger_tool`.
149
150 Parameters
151 ----------
152 event : `.Event`
153 The canvas event that caused this tool to be called.
154 sender : object
155 Object that requested the tool to be triggered.
156 data : object
157 Extra data.
158 """
159 pass
160
161
162class ToolToggleBase(ToolBase):
163 """
164 Toggleable tool.
165
166 Every time it is triggered, it switches between enable and disable.
167
168 Parameters
169 ----------
170 ``*args``
171 Variable length argument to be used by the Tool.
172 ``**kwargs``
173 `toggled` if present and True, sets the initial state of the Tool
174 Arbitrary keyword arguments to be consumed by the Tool
175 """
176
177 radio_group = None
178 """
179 Attribute to group 'radio' like tools (mutually exclusive).
180
181 `str` that identifies the group or **None** if not belonging to a group.
182 """
183
184 cursor = None
185 """Cursor to use when the tool is active."""
186
187 default_toggled = False
188 """Default of toggled state."""
189
190 def __init__(self, *args, **kwargs):
191 self._toggled = kwargs.pop('toggled', self.default_toggled)
192 super().__init__(*args, **kwargs)
193
194 def trigger(self, sender, event, data=None):
195 """Calls `enable` or `disable` based on `toggled` value."""
196 if self._toggled:
197 self.disable(event)
198 else:
199 self.enable(event)
200 self._toggled = not self._toggled
201
202 def enable(self, event=None):
203 """
204 Enable the toggle tool.
205
206 `trigger` calls this method when `toggled` is False.
207 """
208 pass
209
210 def disable(self, event=None):
211 """
212 Disable the toggle tool.
213
214 `trigger` call this method when `toggled` is True.
215
216 This can happen in different circumstances.
217
218 * Click on the toolbar tool button.
219 * Call to `matplotlib.backend_managers.ToolManager.trigger_tool`.
220 * Another `ToolToggleBase` derived tool is triggered
221 (from the same `.ToolManager`).
222 """
223 pass
224
225 @property
226 def toggled(self):
227 """State of the toggled tool."""
228 return self._toggled
229
230 def set_figure(self, figure):
231 toggled = self.toggled
232 if toggled:
233 if self.figure:
234 self.trigger(self, None)
235 else:
236 # if no figure the internal state is not changed
237 # we change it here so next call to trigger will change it back
238 self._toggled = False
239 super().set_figure(figure)
240 if toggled:
241 if figure:
242 self.trigger(self, None)
243 else:
244 # if there is no figure, trigger won't change the internal
245 # state we change it back
246 self._toggled = True
247
248
249class ToolSetCursor(ToolBase):
250 """
251 Change to the current cursor while inaxes.
252
253 This tool, keeps track of all `ToolToggleBase` derived tools, and updates
254 the cursor when a tool gets triggered.
255 """
256 def __init__(self, *args, **kwargs):
257 super().__init__(*args, **kwargs)
258 self._id_drag = None
259 self._current_tool = None
260 self._default_cursor = cursors.POINTER
261 self._last_cursor = self._default_cursor
262 self.toolmanager.toolmanager_connect('tool_added_event',
263 self._add_tool_cbk)
264 # process current tools
265 for tool in self.toolmanager.tools.values():
266 self._add_tool(tool)
267
268 def set_figure(self, figure):
269 if self._id_drag:
270 self.canvas.mpl_disconnect(self._id_drag)
271 super().set_figure(figure)
272 if figure:
273 self._id_drag = self.canvas.mpl_connect(
274 'motion_notify_event', self._set_cursor_cbk)
275
276 def _tool_trigger_cbk(self, event):
277 if event.tool.toggled:
278 self._current_tool = event.tool
279 else:
280 self._current_tool = None
281 self._set_cursor_cbk(event.canvasevent)
282
283 def _add_tool(self, tool):
284 """Set the cursor when the tool is triggered."""
285 if getattr(tool, 'cursor', None) is not None:
286 self.toolmanager.toolmanager_connect('tool_trigger_%s' % tool.name,
287 self._tool_trigger_cbk)
288
289 def _add_tool_cbk(self, event):
290 """Process every newly added tool."""
291 if event.tool is self:
292 return
293 self._add_tool(event.tool)
294
295 def _set_cursor_cbk(self, event):
296 if not event or not self.canvas:
297 return
298 if (self._current_tool and getattr(event, "inaxes", None)
299 and event.inaxes.get_navigate()):
300 if self._last_cursor != self._current_tool.cursor:
301 self.canvas.set_cursor(self._current_tool.cursor)
302 self._last_cursor = self._current_tool.cursor
303 elif self._last_cursor != self._default_cursor:
304 self.canvas.set_cursor(self._default_cursor)
305 self._last_cursor = self._default_cursor
306
307
308class ToolCursorPosition(ToolBase):
309 """
310 Send message with the current pointer position.
311
312 This tool runs in the background reporting the position of the cursor.
313 """
314 def __init__(self, *args, **kwargs):
315 self._id_drag = None
316 super().__init__(*args, **kwargs)
317
318 def set_figure(self, figure):
319 if self._id_drag:
320 self.canvas.mpl_disconnect(self._id_drag)
321 super().set_figure(figure)
322 if figure:
323 self._id_drag = self.canvas.mpl_connect(
324 'motion_notify_event', self.send_message)
325
326 def send_message(self, event):
327 """Call `matplotlib.backend_managers.ToolManager.message_event`."""
328 if self.toolmanager.messagelock.locked():
329 return
330
331 from matplotlib.backend_bases import NavigationToolbar2
332 message = NavigationToolbar2._mouse_event_to_message(event)
333 self.toolmanager.message_event(message, self)
334
335
336class RubberbandBase(ToolBase):
337 """Draw and remove a rubberband."""
338 def trigger(self, sender, event, data=None):
339 """Call `draw_rubberband` or `remove_rubberband` based on data."""
340 if not self.figure.canvas.widgetlock.available(sender):
341 return
342 if data is not None:
343 self.draw_rubberband(*data)
344 else:
345 self.remove_rubberband()
346
347 def draw_rubberband(self, *data):
348 """
349 Draw rubberband.
350
351 This method must get implemented per backend.
352 """
353 raise NotImplementedError
354
355 def remove_rubberband(self):
356 """
357 Remove rubberband.
358
359 This method should get implemented per backend.
360 """
361 pass
362
363
364class ToolQuit(ToolBase):
365 """Tool to call the figure manager destroy method."""
366
367 description = 'Quit the figure'
368 default_keymap = property(lambda self: mpl.rcParams['keymap.quit'])
369
370 def trigger(self, sender, event, data=None):
371 Gcf.destroy_fig(self.figure)
372
373
374class ToolQuitAll(ToolBase):
375 """Tool to call the figure manager destroy method."""
376
377 description = 'Quit all figures'
378 default_keymap = property(lambda self: mpl.rcParams['keymap.quit_all'])
379
380 def trigger(self, sender, event, data=None):
381 Gcf.destroy_all()
382
383
384class ToolGrid(ToolBase):
385 """Tool to toggle the major grids of the figure."""
386
387 description = 'Toggle major grids'
388 default_keymap = property(lambda self: mpl.rcParams['keymap.grid'])
389
390 def trigger(self, sender, event, data=None):
391 sentinel = str(uuid.uuid4())
392 # Trigger grid switching by temporarily setting :rc:`keymap.grid`
393 # to a unique key and sending an appropriate event.
394 with cbook._setattr_cm(event, key=sentinel), \
395 mpl.rc_context({'keymap.grid': sentinel}):
396 mpl.backend_bases.key_press_handler(event, self.figure.canvas)
397
398
399class ToolMinorGrid(ToolBase):
400 """Tool to toggle the major and minor grids of the figure."""
401
402 description = 'Toggle major and minor grids'
403 default_keymap = property(lambda self: mpl.rcParams['keymap.grid_minor'])
404
405 def trigger(self, sender, event, data=None):
406 sentinel = str(uuid.uuid4())
407 # Trigger grid switching by temporarily setting :rc:`keymap.grid_minor`
408 # to a unique key and sending an appropriate event.
409 with cbook._setattr_cm(event, key=sentinel), \
410 mpl.rc_context({'keymap.grid_minor': sentinel}):
411 mpl.backend_bases.key_press_handler(event, self.figure.canvas)
412
413
414class ToolFullScreen(ToolBase):
415 """Tool to toggle full screen."""
416
417 description = 'Toggle fullscreen mode'
418 default_keymap = property(lambda self: mpl.rcParams['keymap.fullscreen'])
419
420 def trigger(self, sender, event, data=None):
421 self.figure.canvas.manager.full_screen_toggle()
422
423
424class AxisScaleBase(ToolToggleBase):
425 """Base Tool to toggle between linear and logarithmic."""
426
427 def trigger(self, sender, event, data=None):
428 if event.inaxes is None:
429 return
430 super().trigger(sender, event, data)
431
432 def enable(self, event=None):
433 self.set_scale(event.inaxes, 'log')
434 self.figure.canvas.draw_idle()
435
436 def disable(self, event=None):
437 self.set_scale(event.inaxes, 'linear')
438 self.figure.canvas.draw_idle()
439
440
441class ToolYScale(AxisScaleBase):
442 """Tool to toggle between linear and logarithmic scales on the Y axis."""
443
444 description = 'Toggle scale Y axis'
445 default_keymap = property(lambda self: mpl.rcParams['keymap.yscale'])
446
447 def set_scale(self, ax, scale):
448 ax.set_yscale(scale)
449
450
451class ToolXScale(AxisScaleBase):
452 """Tool to toggle between linear and logarithmic scales on the X axis."""
453
454 description = 'Toggle scale X axis'
455 default_keymap = property(lambda self: mpl.rcParams['keymap.xscale'])
456
457 def set_scale(self, ax, scale):
458 ax.set_xscale(scale)
459
460
461class ToolViewsPositions(ToolBase):
462 """
463 Auxiliary Tool to handle changes in views and positions.
464
465 Runs in the background and should get used by all the tools that
466 need to access the figure's history of views and positions, e.g.
467
468 * `ToolZoom`
469 * `ToolPan`
470 * `ToolHome`
471 * `ToolBack`
472 * `ToolForward`
473 """
474
475 def __init__(self, *args, **kwargs):
476 self.views = WeakKeyDictionary()
477 self.positions = WeakKeyDictionary()
478 self.home_views = WeakKeyDictionary()
479 super().__init__(*args, **kwargs)
480
481 def add_figure(self, figure):
482 """Add the current figure to the stack of views and positions."""
483
484 if figure not in self.views:
485 self.views[figure] = cbook._Stack()
486 self.positions[figure] = cbook._Stack()
487 self.home_views[figure] = WeakKeyDictionary()
488 # Define Home
489 self.push_current(figure)
490 # Make sure we add a home view for new Axes as they're added
491 figure.add_axobserver(lambda fig: self.update_home_views(fig))
492
493 def clear(self, figure):
494 """Reset the Axes stack."""
495 if figure in self.views:
496 self.views[figure].clear()
497 self.positions[figure].clear()
498 self.home_views[figure].clear()
499 self.update_home_views()
500
501 def update_view(self):
502 """
503 Update the view limits and position for each Axes from the current
504 stack position. If any Axes are present in the figure that aren't in
505 the current stack position, use the home view limits for those Axes and
506 don't update *any* positions.
507 """
508
509 views = self.views[self.figure]()
510 if views is None:
511 return
512 pos = self.positions[self.figure]()
513 if pos is None:
514 return
515 home_views = self.home_views[self.figure]
516 all_axes = self.figure.get_axes()
517 for a in all_axes:
518 if a in views:
519 cur_view = views[a]
520 else:
521 cur_view = home_views[a]
522 a._set_view(cur_view)
523
524 if set(all_axes).issubset(pos):
525 for a in all_axes:
526 # Restore both the original and modified positions
527 a._set_position(pos[a][0], 'original')
528 a._set_position(pos[a][1], 'active')
529
530 self.figure.canvas.draw_idle()
531
532 def push_current(self, figure=None):
533 """
534 Push the current view limits and position onto their respective stacks.
535 """
536 if not figure:
537 figure = self.figure
538 views = WeakKeyDictionary()
539 pos = WeakKeyDictionary()
540 for a in figure.get_axes():
541 views[a] = a._get_view()
542 pos[a] = self._axes_pos(a)
543 self.views[figure].push(views)
544 self.positions[figure].push(pos)
545
546 def _axes_pos(self, ax):
547 """
548 Return the original and modified positions for the specified Axes.
549
550 Parameters
551 ----------
552 ax : matplotlib.axes.Axes
553 The `.Axes` to get the positions for.
554
555 Returns
556 -------
557 original_position, modified_position
558 A tuple of the original and modified positions.
559 """
560
561 return (ax.get_position(True).frozen(),
562 ax.get_position().frozen())
563
564 def update_home_views(self, figure=None):
565 """
566 Make sure that ``self.home_views`` has an entry for all Axes present
567 in the figure.
568 """
569
570 if not figure:
571 figure = self.figure
572 for a in figure.get_axes():
573 if a not in self.home_views[figure]:
574 self.home_views[figure][a] = a._get_view()
575
576 def home(self):
577 """Recall the first view and position from the stack."""
578 self.views[self.figure].home()
579 self.positions[self.figure].home()
580
581 def back(self):
582 """Back one step in the stack of views and positions."""
583 self.views[self.figure].back()
584 self.positions[self.figure].back()
585
586 def forward(self):
587 """Forward one step in the stack of views and positions."""
588 self.views[self.figure].forward()
589 self.positions[self.figure].forward()
590
591
592class ViewsPositionsBase(ToolBase):
593 """Base class for `ToolHome`, `ToolBack` and `ToolForward`."""
594
595 _on_trigger = None
596
597 def trigger(self, sender, event, data=None):
598 self.toolmanager.get_tool(_views_positions).add_figure(self.figure)
599 getattr(self.toolmanager.get_tool(_views_positions),
600 self._on_trigger)()
601 self.toolmanager.get_tool(_views_positions).update_view()
602
603
604class ToolHome(ViewsPositionsBase):
605 """Restore the original view limits."""
606
607 description = 'Reset original view'
608 image = 'mpl-data/images/home'
609 default_keymap = property(lambda self: mpl.rcParams['keymap.home'])
610 _on_trigger = 'home'
611
612
613class ToolBack(ViewsPositionsBase):
614 """Move back up the view limits stack."""
615
616 description = 'Back to previous view'
617 image = 'mpl-data/images/back'
618 default_keymap = property(lambda self: mpl.rcParams['keymap.back'])
619 _on_trigger = 'back'
620
621
622class ToolForward(ViewsPositionsBase):
623 """Move forward in the view lim stack."""
624
625 description = 'Forward to next view'
626 image = 'mpl-data/images/forward'
627 default_keymap = property(lambda self: mpl.rcParams['keymap.forward'])
628 _on_trigger = 'forward'
629
630
631class ConfigureSubplotsBase(ToolBase):
632 """Base tool for the configuration of subplots."""
633
634 description = 'Configure subplots'
635 image = 'mpl-data/images/subplots'
636
637
638class SaveFigureBase(ToolBase):
639 """Base tool for figure saving."""
640
641 description = 'Save the figure'
642 image = 'mpl-data/images/filesave'
643 default_keymap = property(lambda self: mpl.rcParams['keymap.save'])
644
645
646class ZoomPanBase(ToolToggleBase):
647 """Base class for `ToolZoom` and `ToolPan`."""
648 def __init__(self, *args):
649 super().__init__(*args)
650 self._button_pressed = None
651 self._xypress = None
652 self._idPress = None
653 self._idRelease = None
654 self._idScroll = None
655 self.base_scale = 2.
656 self.scrollthresh = .5 # .5 second scroll threshold
657 self.lastscroll = time.time()-self.scrollthresh
658
659 def enable(self, event=None):
660 """Connect press/release events and lock the canvas."""
661 self.figure.canvas.widgetlock(self)
662 self._idPress = self.figure.canvas.mpl_connect(
663 'button_press_event', self._press)
664 self._idRelease = self.figure.canvas.mpl_connect(
665 'button_release_event', self._release)
666 self._idScroll = self.figure.canvas.mpl_connect(
667 'scroll_event', self.scroll_zoom)
668
669 def disable(self, event=None):
670 """Release the canvas and disconnect press/release events."""
671 self._cancel_action()
672 self.figure.canvas.widgetlock.release(self)
673 self.figure.canvas.mpl_disconnect(self._idPress)
674 self.figure.canvas.mpl_disconnect(self._idRelease)
675 self.figure.canvas.mpl_disconnect(self._idScroll)
676
677 def trigger(self, sender, event, data=None):
678 self.toolmanager.get_tool(_views_positions).add_figure(self.figure)
679 super().trigger(sender, event, data)
680 new_navigate_mode = self.name.upper() if self.toggled else None
681 for ax in self.figure.axes:
682 ax.set_navigate_mode(new_navigate_mode)
683
684 def scroll_zoom(self, event):
685 # https://gist.github.com/tacaswell/3144287
686 if event.inaxes is None:
687 return
688
689 if event.button == 'up':
690 # deal with zoom in
691 scl = self.base_scale
692 elif event.button == 'down':
693 # deal with zoom out
694 scl = 1/self.base_scale
695 else:
696 # deal with something that should never happen
697 scl = 1
698
699 ax = event.inaxes
700 ax._set_view_from_bbox([event.x, event.y, scl])
701
702 # If last scroll was done within the timing threshold, delete the
703 # previous view
704 if (time.time()-self.lastscroll) < self.scrollthresh:
705 self.toolmanager.get_tool(_views_positions).back()
706
707 self.figure.canvas.draw_idle() # force re-draw
708
709 self.lastscroll = time.time()
710 self.toolmanager.get_tool(_views_positions).push_current()
711
712
713class ToolZoom(ZoomPanBase):
714 """A Tool for zooming using a rectangle selector."""
715
716 description = 'Zoom to rectangle'
717 image = 'mpl-data/images/zoom_to_rect'
718 default_keymap = property(lambda self: mpl.rcParams['keymap.zoom'])
719 cursor = cursors.SELECT_REGION
720 radio_group = 'default'
721
722 def __init__(self, *args):
723 super().__init__(*args)
724 self._ids_zoom = []
725
726 def _cancel_action(self):
727 for zoom_id in self._ids_zoom:
728 self.figure.canvas.mpl_disconnect(zoom_id)
729 self.toolmanager.trigger_tool('rubberband', self)
730 self.figure.canvas.draw_idle()
731 self._xypress = None
732 self._button_pressed = None
733 self._ids_zoom = []
734 return
735
736 def _press(self, event):
737 """Callback for mouse button presses in zoom-to-rectangle mode."""
738
739 # If we're already in the middle of a zoom, pressing another
740 # button works to "cancel"
741 if self._ids_zoom:
742 self._cancel_action()
743
744 if event.button == 1:
745 self._button_pressed = 1
746 elif event.button == 3:
747 self._button_pressed = 3
748 else:
749 self._cancel_action()
750 return
751
752 x, y = event.x, event.y
753
754 self._xypress = []
755 for i, a in enumerate(self.figure.get_axes()):
756 if (x is not None and y is not None and a.in_axes(event) and
757 a.get_navigate() and a.can_zoom()):
758 self._xypress.append((x, y, a, i, a._get_view()))
759
760 id1 = self.figure.canvas.mpl_connect(
761 'motion_notify_event', self._mouse_move)
762 id2 = self.figure.canvas.mpl_connect(
763 'key_press_event', self._switch_on_zoom_mode)
764 id3 = self.figure.canvas.mpl_connect(
765 'key_release_event', self._switch_off_zoom_mode)
766
767 self._ids_zoom = id1, id2, id3
768 self._zoom_mode = event.key
769
770 def _switch_on_zoom_mode(self, event):
771 self._zoom_mode = event.key
772 self._mouse_move(event)
773
774 def _switch_off_zoom_mode(self, event):
775 self._zoom_mode = None
776 self._mouse_move(event)
777
778 def _mouse_move(self, event):
779 """Callback for mouse moves in zoom-to-rectangle mode."""
780
781 if self._xypress:
782 x, y = event.x, event.y
783 lastx, lasty, a, ind, view = self._xypress[0]
784 (x1, y1), (x2, y2) = np.clip(
785 [[lastx, lasty], [x, y]], a.bbox.min, a.bbox.max)
786 if self._zoom_mode == "x":
787 y1, y2 = a.bbox.intervaly
788 elif self._zoom_mode == "y":
789 x1, x2 = a.bbox.intervalx
790 self.toolmanager.trigger_tool(
791 'rubberband', self, data=(x1, y1, x2, y2))
792
793 def _release(self, event):
794 """Callback for mouse button releases in zoom-to-rectangle mode."""
795
796 for zoom_id in self._ids_zoom:
797 self.figure.canvas.mpl_disconnect(zoom_id)
798 self._ids_zoom = []
799
800 if not self._xypress:
801 self._cancel_action()
802 return
803
804 done_ax = []
805
806 for cur_xypress in self._xypress:
807 x, y = event.x, event.y
808 lastx, lasty, a, _ind, view = cur_xypress
809 # ignore singular clicks - 5 pixels is a threshold
810 if abs(x - lastx) < 5 or abs(y - lasty) < 5:
811 self._cancel_action()
812 return
813
814 # detect twinx, twiny Axes and avoid double zooming
815 twinx = any(a.get_shared_x_axes().joined(a, a1) for a1 in done_ax)
816 twiny = any(a.get_shared_y_axes().joined(a, a1) for a1 in done_ax)
817 done_ax.append(a)
818
819 if self._button_pressed == 1:
820 direction = 'in'
821 elif self._button_pressed == 3:
822 direction = 'out'
823 else:
824 continue
825
826 a._set_view_from_bbox((lastx, lasty, x, y), direction,
827 self._zoom_mode, twinx, twiny)
828
829 self._zoom_mode = None
830 self.toolmanager.get_tool(_views_positions).push_current()
831 self._cancel_action()
832
833
834class ToolPan(ZoomPanBase):
835 """Pan Axes with left mouse, zoom with right."""
836
837 default_keymap = property(lambda self: mpl.rcParams['keymap.pan'])
838 description = 'Pan axes with left mouse, zoom with right'
839 image = 'mpl-data/images/move'
840 cursor = cursors.MOVE
841 radio_group = 'default'
842
843 def __init__(self, *args):
844 super().__init__(*args)
845 self._id_drag = None
846
847 def _cancel_action(self):
848 self._button_pressed = None
849 self._xypress = []
850 self.figure.canvas.mpl_disconnect(self._id_drag)
851 self.toolmanager.messagelock.release(self)
852 self.figure.canvas.draw_idle()
853
854 def _press(self, event):
855 if event.button == 1:
856 self._button_pressed = 1
857 elif event.button == 3:
858 self._button_pressed = 3
859 else:
860 self._cancel_action()
861 return
862
863 x, y = event.x, event.y
864
865 self._xypress = []
866 for i, a in enumerate(self.figure.get_axes()):
867 if (x is not None and y is not None and a.in_axes(event) and
868 a.get_navigate() and a.can_pan()):
869 a.start_pan(x, y, event.button)
870 self._xypress.append((a, i))
871 self.toolmanager.messagelock(self)
872 self._id_drag = self.figure.canvas.mpl_connect(
873 'motion_notify_event', self._mouse_move)
874
875 def _release(self, event):
876 if self._button_pressed is None:
877 self._cancel_action()
878 return
879
880 self.figure.canvas.mpl_disconnect(self._id_drag)
881 self.toolmanager.messagelock.release(self)
882
883 for a, _ind in self._xypress:
884 a.end_pan()
885 if not self._xypress:
886 self._cancel_action()
887 return
888
889 self.toolmanager.get_tool(_views_positions).push_current()
890 self._cancel_action()
891
892 def _mouse_move(self, event):
893 for a, _ind in self._xypress:
894 # safer to use the recorded button at the _press than current
895 # button: # multiple button can get pressed during motion...
896 a.drag_pan(self._button_pressed, event.key, event.x, event.y)
897 self.toolmanager.canvas.draw_idle()
898
899
900class ToolHelpBase(ToolBase):
901 description = 'Print tool list, shortcuts and description'
902 default_keymap = property(lambda self: mpl.rcParams['keymap.help'])
903 image = 'mpl-data/images/help'
904
905 @staticmethod
906 def format_shortcut(key_sequence):
907 """
908 Convert a shortcut string from the notation used in rc config to the
909 standard notation for displaying shortcuts, e.g. 'ctrl+a' -> 'Ctrl+A'.
910 """
911 return (key_sequence if len(key_sequence) == 1 else
912 re.sub(r"\+[A-Z]", r"+Shift\g<0>", key_sequence).title())
913
914 def _format_tool_keymap(self, name):
915 keymaps = self.toolmanager.get_tool_keymap(name)
916 return ", ".join(self.format_shortcut(keymap) for keymap in keymaps)
917
918 def _get_help_entries(self):
919 return [(name, self._format_tool_keymap(name), tool.description)
920 for name, tool in sorted(self.toolmanager.tools.items())
921 if tool.description]
922
923 def _get_help_text(self):
924 entries = self._get_help_entries()
925 entries = ["{}: {}\n\t{}".format(*entry) for entry in entries]
926 return "\n".join(entries)
927
928 def _get_help_html(self):
929 fmt = "<tr><td>{}</td><td>{}</td><td>{}</td></tr>"
930 rows = [fmt.format(
931 "<b>Action</b>", "<b>Shortcuts</b>", "<b>Description</b>")]
932 rows += [fmt.format(*row) for row in self._get_help_entries()]
933 return ("<style>td {padding: 0px 4px}</style>"
934 "<table><thead>" + rows[0] + "</thead>"
935 "<tbody>".join(rows[1:]) + "</tbody></table>")
936
937
938class ToolCopyToClipboardBase(ToolBase):
939 """Tool to copy the figure to the clipboard."""
940
941 description = 'Copy the canvas figure to clipboard'
942 default_keymap = property(lambda self: mpl.rcParams['keymap.copy'])
943
944 def trigger(self, *args, **kwargs):
945 message = "Copy tool is not available"
946 self.toolmanager.message_event(message, self)
947
948
949default_tools = {'home': ToolHome, 'back': ToolBack, 'forward': ToolForward,
950 'zoom': ToolZoom, 'pan': ToolPan,
951 'subplots': ConfigureSubplotsBase,
952 'save': SaveFigureBase,
953 'grid': ToolGrid,
954 'grid_minor': ToolMinorGrid,
955 'fullscreen': ToolFullScreen,
956 'quit': ToolQuit,
957 'quit_all': ToolQuitAll,
958 'xscale': ToolXScale,
959 'yscale': ToolYScale,
960 'position': ToolCursorPosition,
961 _views_positions: ToolViewsPositions,
962 'cursor': ToolSetCursor,
963 'rubberband': RubberbandBase,
964 'help': ToolHelpBase,
965 'copy': ToolCopyToClipboardBase,
966 }
967
968default_toolbar_tools = [['navigation', ['home', 'back', 'forward']],
969 ['zoompan', ['pan', 'zoom', 'subplots']],
970 ['io', ['save', 'help']]]
971
972
973def add_tools_to_manager(toolmanager, tools=default_tools):
974 """
975 Add multiple tools to a `.ToolManager`.
976
977 Parameters
978 ----------
979 toolmanager : `.backend_managers.ToolManager`
980 Manager to which the tools are added.
981 tools : {str: class_like}, optional
982 The tools to add in a {name: tool} dict, see
983 `.backend_managers.ToolManager.add_tool` for more info.
984 """
985
986 for name, tool in tools.items():
987 toolmanager.add_tool(name, tool)
988
989
990def add_tools_to_container(container, tools=default_toolbar_tools):
991 """
992 Add multiple tools to the container.
993
994 Parameters
995 ----------
996 container : Container
997 `.backend_bases.ToolContainerBase` object that will get the tools
998 added.
999 tools : list, optional
1000 List in the form ``[[group1, [tool1, tool2 ...]], [group2, [...]]]``
1001 where the tools ``[tool1, tool2, ...]`` will display in group1.
1002 See `.backend_bases.ToolContainerBase.add_tool` for details.
1003 """
1004
1005 for group, grouptools in tools:
1006 for position, tool in enumerate(grouptools):
1007 container.add_tool(tool, group, position)