1103 lines
34 KiB
Python
1103 lines
34 KiB
Python
"""
|
|
Abstract base classes define the primitives for Tools.
|
|
These tools are used by `matplotlib.backend_managers.ToolManager`
|
|
|
|
:class:`ToolBase`
|
|
Simple stateless tool
|
|
|
|
:class:`ToolToggleBase`
|
|
Tool that has two states, only one Toggle tool can be
|
|
active at any given time for the same
|
|
`matplotlib.backend_managers.ToolManager`
|
|
"""
|
|
|
|
from enum import IntEnum
|
|
import logging
|
|
import re
|
|
import time
|
|
from types import SimpleNamespace
|
|
import uuid
|
|
from weakref import WeakKeyDictionary
|
|
|
|
import numpy as np
|
|
|
|
import matplotlib as mpl
|
|
from matplotlib._pylab_helpers import Gcf
|
|
import matplotlib.cbook as cbook
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
class Cursors(IntEnum): # Must subclass int for the macOS backend.
|
|
"""Backend-independent cursor types."""
|
|
HAND, POINTER, SELECT_REGION, MOVE, WAIT = range(5)
|
|
cursors = Cursors # Backcompat.
|
|
|
|
# Views positions tool
|
|
_views_positions = 'viewpos'
|
|
|
|
|
|
class ToolBase:
|
|
"""
|
|
Base tool class.
|
|
|
|
A base tool, only implements `trigger` method or not method at all.
|
|
The tool is instantiated by `matplotlib.backend_managers.ToolManager`.
|
|
|
|
Attributes
|
|
----------
|
|
toolmanager : `matplotlib.backend_managers.ToolManager`
|
|
ToolManager that controls this Tool.
|
|
figure : `FigureCanvas`
|
|
Figure instance that is affected by this Tool.
|
|
name : str
|
|
Used as **Id** of the tool, has to be unique among tools of the same
|
|
ToolManager.
|
|
"""
|
|
|
|
default_keymap = None
|
|
"""
|
|
Keymap to associate with this tool.
|
|
|
|
**String**: List of comma separated keys that will be used to call this
|
|
tool when the keypress event of ``self.figure.canvas`` is emitted.
|
|
"""
|
|
|
|
description = None
|
|
"""
|
|
Description of the Tool.
|
|
|
|
**String**: If the Tool is included in the Toolbar this text is used
|
|
as a Tooltip.
|
|
"""
|
|
|
|
image = None
|
|
"""
|
|
Filename of the image.
|
|
|
|
**String**: Filename of the image to use in the toolbar. If None, the
|
|
*name* is used as a label in the toolbar button.
|
|
"""
|
|
|
|
def __init__(self, toolmanager, name):
|
|
cbook._warn_external(
|
|
'The new Tool classes introduced in v1.5 are experimental; their '
|
|
'API (including names) will likely change in future versions.')
|
|
self._name = name
|
|
self._toolmanager = toolmanager
|
|
self._figure = None
|
|
|
|
@property
|
|
def figure(self):
|
|
return self._figure
|
|
|
|
@figure.setter
|
|
def figure(self, figure):
|
|
self.set_figure(figure)
|
|
|
|
@property
|
|
def canvas(self):
|
|
if not self._figure:
|
|
return None
|
|
return self._figure.canvas
|
|
|
|
@property
|
|
def toolmanager(self):
|
|
return self._toolmanager
|
|
|
|
def _make_classic_style_pseudo_toolbar(self):
|
|
"""
|
|
Return a placeholder object with a single `canvas` attribute.
|
|
|
|
This is useful to reuse the implementations of tools already provided
|
|
by the classic Toolbars.
|
|
"""
|
|
return SimpleNamespace(canvas=self.canvas)
|
|
|
|
def set_figure(self, figure):
|
|
"""
|
|
Assign a figure to the tool.
|
|
|
|
Parameters
|
|
----------
|
|
figure : `.Figure`
|
|
"""
|
|
self._figure = figure
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
"""
|
|
Called when this tool gets used.
|
|
|
|
This method is called by
|
|
`matplotlib.backend_managers.ToolManager.trigger_tool`.
|
|
|
|
Parameters
|
|
----------
|
|
event : `.Event`
|
|
The canvas event that caused this tool to be called.
|
|
sender : object
|
|
Object that requested the tool to be triggered.
|
|
data : object
|
|
Extra data.
|
|
"""
|
|
pass
|
|
|
|
@property
|
|
def name(self):
|
|
"""Tool Id."""
|
|
return self._name
|
|
|
|
def destroy(self):
|
|
"""
|
|
Destroy the tool.
|
|
|
|
This method is called when the tool is removed by
|
|
`matplotlib.backend_managers.ToolManager.remove_tool`.
|
|
"""
|
|
pass
|
|
|
|
|
|
class ToolToggleBase(ToolBase):
|
|
"""
|
|
Toggleable tool.
|
|
|
|
Every time it is triggered, it switches between enable and disable.
|
|
|
|
Parameters
|
|
----------
|
|
``*args``
|
|
Variable length argument to be used by the Tool.
|
|
``**kwargs``
|
|
`toggled` if present and True, sets the initial state of the Tool
|
|
Arbitrary keyword arguments to be consumed by the Tool
|
|
"""
|
|
|
|
radio_group = None
|
|
"""Attribute to group 'radio' like tools (mutually exclusive).
|
|
|
|
**String** that identifies the group or **None** if not belonging to a
|
|
group.
|
|
"""
|
|
|
|
cursor = None
|
|
"""Cursor to use when the tool is active."""
|
|
|
|
default_toggled = False
|
|
"""Default of toggled state."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self._toggled = kwargs.pop('toggled', self.default_toggled)
|
|
ToolBase.__init__(self, *args, **kwargs)
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
"""Calls `enable` or `disable` based on `toggled` value."""
|
|
if self._toggled:
|
|
self.disable(event)
|
|
else:
|
|
self.enable(event)
|
|
self._toggled = not self._toggled
|
|
|
|
def enable(self, event=None):
|
|
"""
|
|
Enable the toggle tool.
|
|
|
|
`trigger` calls this method when `toggled` is False.
|
|
"""
|
|
pass
|
|
|
|
def disable(self, event=None):
|
|
"""
|
|
Disable the toggle tool.
|
|
|
|
`trigger` call this method when `toggled` is True.
|
|
|
|
This can happen in different circumstances.
|
|
|
|
* Click on the toolbar tool button.
|
|
* Call to `matplotlib.backend_managers.ToolManager.trigger_tool`.
|
|
* Another `ToolToggleBase` derived tool is triggered
|
|
(from the same `.ToolManager`).
|
|
"""
|
|
pass
|
|
|
|
@property
|
|
def toggled(self):
|
|
"""State of the toggled tool."""
|
|
|
|
return self._toggled
|
|
|
|
def set_figure(self, figure):
|
|
toggled = self.toggled
|
|
if toggled:
|
|
if self.figure:
|
|
self.trigger(self, None)
|
|
else:
|
|
# if no figure the internal state is not changed
|
|
# we change it here so next call to trigger will change it back
|
|
self._toggled = False
|
|
ToolBase.set_figure(self, figure)
|
|
if toggled:
|
|
if figure:
|
|
self.trigger(self, None)
|
|
else:
|
|
# if there is no figure, trigger won't change the internal
|
|
# state we change it back
|
|
self._toggled = True
|
|
|
|
|
|
class SetCursorBase(ToolBase):
|
|
"""
|
|
Change to the current cursor while inaxes.
|
|
|
|
This tool, keeps track of all `ToolToggleBase` derived tools, and calls
|
|
`set_cursor` when a tool gets triggered.
|
|
"""
|
|
def __init__(self, *args, **kwargs):
|
|
ToolBase.__init__(self, *args, **kwargs)
|
|
self._id_drag = None
|
|
self._cursor = None
|
|
self._default_cursor = cursors.POINTER
|
|
self._last_cursor = self._default_cursor
|
|
self.toolmanager.toolmanager_connect('tool_added_event',
|
|
self._add_tool_cbk)
|
|
|
|
# process current tools
|
|
for tool in self.toolmanager.tools.values():
|
|
self._add_tool(tool)
|
|
|
|
def set_figure(self, figure):
|
|
if self._id_drag:
|
|
self.canvas.mpl_disconnect(self._id_drag)
|
|
ToolBase.set_figure(self, figure)
|
|
if figure:
|
|
self._id_drag = self.canvas.mpl_connect(
|
|
'motion_notify_event', self._set_cursor_cbk)
|
|
|
|
def _tool_trigger_cbk(self, event):
|
|
if event.tool.toggled:
|
|
self._cursor = event.tool.cursor
|
|
else:
|
|
self._cursor = None
|
|
|
|
self._set_cursor_cbk(event.canvasevent)
|
|
|
|
def _add_tool(self, tool):
|
|
"""Set the cursor when the tool is triggered."""
|
|
if getattr(tool, 'cursor', None) is not None:
|
|
self.toolmanager.toolmanager_connect('tool_trigger_%s' % tool.name,
|
|
self._tool_trigger_cbk)
|
|
|
|
def _add_tool_cbk(self, event):
|
|
"""Process every newly added tool."""
|
|
if event.tool is self:
|
|
return
|
|
self._add_tool(event.tool)
|
|
|
|
def _set_cursor_cbk(self, event):
|
|
if not event:
|
|
return
|
|
|
|
if not getattr(event, 'inaxes', False) or not self._cursor:
|
|
if self._last_cursor != self._default_cursor:
|
|
self.set_cursor(self._default_cursor)
|
|
self._last_cursor = self._default_cursor
|
|
elif self._cursor:
|
|
cursor = self._cursor
|
|
if cursor and self._last_cursor != cursor:
|
|
self.set_cursor(cursor)
|
|
self._last_cursor = cursor
|
|
|
|
def set_cursor(self, cursor):
|
|
"""
|
|
Set the cursor.
|
|
|
|
This method has to be implemented per backend.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
class ToolCursorPosition(ToolBase):
|
|
"""
|
|
Send message with the current pointer position.
|
|
|
|
This tool runs in the background reporting the position of the cursor.
|
|
"""
|
|
def __init__(self, *args, **kwargs):
|
|
self._id_drag = None
|
|
ToolBase.__init__(self, *args, **kwargs)
|
|
|
|
def set_figure(self, figure):
|
|
if self._id_drag:
|
|
self.canvas.mpl_disconnect(self._id_drag)
|
|
ToolBase.set_figure(self, figure)
|
|
if figure:
|
|
self._id_drag = self.canvas.mpl_connect(
|
|
'motion_notify_event', self.send_message)
|
|
|
|
def send_message(self, event):
|
|
"""Call `matplotlib.backend_managers.ToolManager.message_event`."""
|
|
if self.toolmanager.messagelock.locked():
|
|
return
|
|
|
|
message = ' '
|
|
|
|
if event.inaxes and event.inaxes.get_navigate():
|
|
try:
|
|
s = event.inaxes.format_coord(event.xdata, event.ydata)
|
|
except (ValueError, OverflowError):
|
|
pass
|
|
else:
|
|
artists = [a for a in event.inaxes._mouseover_set
|
|
if a.contains(event) and a.get_visible()]
|
|
|
|
if artists:
|
|
a = cbook._topmost_artist(artists)
|
|
if a is not event.inaxes.patch:
|
|
data = a.get_cursor_data(event)
|
|
if data is not None:
|
|
data_str = a.format_cursor_data(data)
|
|
if data_str is not None:
|
|
s = s + ' ' + data_str
|
|
|
|
message = s
|
|
self.toolmanager.message_event(message, self)
|
|
|
|
|
|
class RubberbandBase(ToolBase):
|
|
"""Draw and remove a rubberband."""
|
|
def trigger(self, sender, event, data):
|
|
"""Call `draw_rubberband` or `remove_rubberband` based on data."""
|
|
if not self.figure.canvas.widgetlock.available(sender):
|
|
return
|
|
if data is not None:
|
|
self.draw_rubberband(*data)
|
|
else:
|
|
self.remove_rubberband()
|
|
|
|
def draw_rubberband(self, *data):
|
|
"""
|
|
Draw rubberband.
|
|
|
|
This method must get implemented per backend.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def remove_rubberband(self):
|
|
"""
|
|
Remove rubberband.
|
|
|
|
This method should get implemented per backend.
|
|
"""
|
|
pass
|
|
|
|
|
|
class ToolQuit(ToolBase):
|
|
"""Tool to call the figure manager destroy method."""
|
|
|
|
description = 'Quit the figure'
|
|
default_keymap = mpl.rcParams['keymap.quit']
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
Gcf.destroy_fig(self.figure)
|
|
|
|
|
|
class ToolQuitAll(ToolBase):
|
|
"""Tool to call the figure manager destroy method."""
|
|
|
|
description = 'Quit all figures'
|
|
default_keymap = mpl.rcParams['keymap.quit_all']
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
Gcf.destroy_all()
|
|
|
|
|
|
class _ToolEnableAllNavigation(ToolBase):
|
|
"""Tool to enable all axes for toolmanager interaction."""
|
|
|
|
description = 'Enable all axes toolmanager'
|
|
default_keymap = mpl.rcParams['keymap.all_axes']
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
mpl.backend_bases.key_press_handler(event, self.figure.canvas, None)
|
|
|
|
|
|
@cbook.deprecated("3.3")
|
|
class ToolEnableAllNavigation(_ToolEnableAllNavigation):
|
|
pass
|
|
|
|
|
|
class _ToolEnableNavigation(ToolBase):
|
|
"""Tool to enable a specific axes for toolmanager interaction."""
|
|
|
|
description = 'Enable one axes toolmanager'
|
|
default_keymap = ('1', '2', '3', '4', '5', '6', '7', '8', '9')
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
mpl.backend_bases.key_press_handler(event, self.figure.canvas, None)
|
|
|
|
|
|
@cbook.deprecated("3.3")
|
|
class ToolEnableNavigation(_ToolEnableNavigation):
|
|
pass
|
|
|
|
|
|
class ToolGrid(ToolBase):
|
|
"""Tool to toggle the major grids of the figure."""
|
|
|
|
description = 'Toggle major grids'
|
|
default_keymap = mpl.rcParams['keymap.grid']
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
sentinel = str(uuid.uuid4())
|
|
# Trigger grid switching by temporarily setting :rc:`keymap.grid`
|
|
# to a unique key and sending an appropriate event.
|
|
with cbook._setattr_cm(event, key=sentinel), \
|
|
mpl.rc_context({'keymap.grid': sentinel}):
|
|
mpl.backend_bases.key_press_handler(event, self.figure.canvas)
|
|
|
|
|
|
class ToolMinorGrid(ToolBase):
|
|
"""Tool to toggle the major and minor grids of the figure."""
|
|
|
|
description = 'Toggle major and minor grids'
|
|
default_keymap = mpl.rcParams['keymap.grid_minor']
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
sentinel = str(uuid.uuid4())
|
|
# Trigger grid switching by temporarily setting :rc:`keymap.grid_minor`
|
|
# to a unique key and sending an appropriate event.
|
|
with cbook._setattr_cm(event, key=sentinel), \
|
|
mpl.rc_context({'keymap.grid_minor': sentinel}):
|
|
mpl.backend_bases.key_press_handler(event, self.figure.canvas)
|
|
|
|
|
|
class ToolFullScreen(ToolToggleBase):
|
|
"""Tool to toggle full screen."""
|
|
|
|
description = 'Toggle fullscreen mode'
|
|
default_keymap = mpl.rcParams['keymap.fullscreen']
|
|
|
|
def enable(self, event):
|
|
self.figure.canvas.manager.full_screen_toggle()
|
|
|
|
def disable(self, event):
|
|
self.figure.canvas.manager.full_screen_toggle()
|
|
|
|
|
|
class AxisScaleBase(ToolToggleBase):
|
|
"""Base Tool to toggle between linear and logarithmic."""
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
if event.inaxes is None:
|
|
return
|
|
ToolToggleBase.trigger(self, sender, event, data)
|
|
|
|
def enable(self, event):
|
|
self.set_scale(event.inaxes, 'log')
|
|
self.figure.canvas.draw_idle()
|
|
|
|
def disable(self, event):
|
|
self.set_scale(event.inaxes, 'linear')
|
|
self.figure.canvas.draw_idle()
|
|
|
|
|
|
class ToolYScale(AxisScaleBase):
|
|
"""Tool to toggle between linear and logarithmic scales on the Y axis."""
|
|
|
|
description = 'Toggle scale Y axis'
|
|
default_keymap = mpl.rcParams['keymap.yscale']
|
|
|
|
def set_scale(self, ax, scale):
|
|
ax.set_yscale(scale)
|
|
|
|
|
|
class ToolXScale(AxisScaleBase):
|
|
"""Tool to toggle between linear and logarithmic scales on the X axis."""
|
|
|
|
description = 'Toggle scale X axis'
|
|
default_keymap = mpl.rcParams['keymap.xscale']
|
|
|
|
def set_scale(self, ax, scale):
|
|
ax.set_xscale(scale)
|
|
|
|
|
|
class ToolViewsPositions(ToolBase):
|
|
"""
|
|
Auxiliary Tool to handle changes in views and positions.
|
|
|
|
Runs in the background and should get used by all the tools that
|
|
need to access the figure's history of views and positions, e.g.
|
|
|
|
* `ToolZoom`
|
|
* `ToolPan`
|
|
* `ToolHome`
|
|
* `ToolBack`
|
|
* `ToolForward`
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.views = WeakKeyDictionary()
|
|
self.positions = WeakKeyDictionary()
|
|
self.home_views = WeakKeyDictionary()
|
|
ToolBase.__init__(self, *args, **kwargs)
|
|
|
|
def add_figure(self, figure):
|
|
"""Add the current figure to the stack of views and positions."""
|
|
|
|
if figure not in self.views:
|
|
self.views[figure] = cbook.Stack()
|
|
self.positions[figure] = cbook.Stack()
|
|
self.home_views[figure] = WeakKeyDictionary()
|
|
# Define Home
|
|
self.push_current(figure)
|
|
# Make sure we add a home view for new axes as they're added
|
|
figure.add_axobserver(lambda fig: self.update_home_views(fig))
|
|
|
|
def clear(self, figure):
|
|
"""Reset the axes stack."""
|
|
if figure in self.views:
|
|
self.views[figure].clear()
|
|
self.positions[figure].clear()
|
|
self.home_views[figure].clear()
|
|
self.update_home_views()
|
|
|
|
def update_view(self):
|
|
"""
|
|
Update the view limits and position for each axes from the current
|
|
stack position. If any axes are present in the figure that aren't in
|
|
the current stack position, use the home view limits for those axes and
|
|
don't update *any* positions.
|
|
"""
|
|
|
|
views = self.views[self.figure]()
|
|
if views is None:
|
|
return
|
|
pos = self.positions[self.figure]()
|
|
if pos is None:
|
|
return
|
|
home_views = self.home_views[self.figure]
|
|
all_axes = self.figure.get_axes()
|
|
for a in all_axes:
|
|
if a in views:
|
|
cur_view = views[a]
|
|
else:
|
|
cur_view = home_views[a]
|
|
a._set_view(cur_view)
|
|
|
|
if set(all_axes).issubset(pos):
|
|
for a in all_axes:
|
|
# Restore both the original and modified positions
|
|
a._set_position(pos[a][0], 'original')
|
|
a._set_position(pos[a][1], 'active')
|
|
|
|
self.figure.canvas.draw_idle()
|
|
|
|
def push_current(self, figure=None):
|
|
"""
|
|
Push the current view limits and position onto their respective stacks.
|
|
"""
|
|
if not figure:
|
|
figure = self.figure
|
|
views = WeakKeyDictionary()
|
|
pos = WeakKeyDictionary()
|
|
for a in figure.get_axes():
|
|
views[a] = a._get_view()
|
|
pos[a] = self._axes_pos(a)
|
|
self.views[figure].push(views)
|
|
self.positions[figure].push(pos)
|
|
|
|
def _axes_pos(self, ax):
|
|
"""
|
|
Return the original and modified positions for the specified axes.
|
|
|
|
Parameters
|
|
----------
|
|
ax : matplotlib.axes.Axes
|
|
The `.Axes` to get the positions for.
|
|
|
|
Returns
|
|
-------
|
|
original_position, modified_position
|
|
A tuple of the original and modified positions.
|
|
"""
|
|
|
|
return (ax.get_position(True).frozen(),
|
|
ax.get_position().frozen())
|
|
|
|
def update_home_views(self, figure=None):
|
|
"""
|
|
Make sure that ``self.home_views`` has an entry for all axes present
|
|
in the figure.
|
|
"""
|
|
|
|
if not figure:
|
|
figure = self.figure
|
|
for a in figure.get_axes():
|
|
if a not in self.home_views[figure]:
|
|
self.home_views[figure][a] = a._get_view()
|
|
|
|
@cbook.deprecated("3.3", alternative="self.figure.canvas.draw_idle()")
|
|
def refresh_locators(self):
|
|
"""Redraw the canvases, update the locators."""
|
|
self._refresh_locators()
|
|
|
|
# Can be removed once Locator.refresh() is removed, and replaced by an
|
|
# inline call to self.figure.canvas.draw_idle().
|
|
def _refresh_locators(self):
|
|
for a in self.figure.get_axes():
|
|
xaxis = getattr(a, 'xaxis', None)
|
|
yaxis = getattr(a, 'yaxis', None)
|
|
zaxis = getattr(a, 'zaxis', None)
|
|
locators = []
|
|
if xaxis is not None:
|
|
locators.append(xaxis.get_major_locator())
|
|
locators.append(xaxis.get_minor_locator())
|
|
if yaxis is not None:
|
|
locators.append(yaxis.get_major_locator())
|
|
locators.append(yaxis.get_minor_locator())
|
|
if zaxis is not None:
|
|
locators.append(zaxis.get_major_locator())
|
|
locators.append(zaxis.get_minor_locator())
|
|
|
|
for loc in locators:
|
|
mpl.ticker._if_refresh_overridden_call_and_emit_deprec(loc)
|
|
self.figure.canvas.draw_idle()
|
|
|
|
def home(self):
|
|
"""Recall the first view and position from the stack."""
|
|
self.views[self.figure].home()
|
|
self.positions[self.figure].home()
|
|
|
|
def back(self):
|
|
"""Back one step in the stack of views and positions."""
|
|
self.views[self.figure].back()
|
|
self.positions[self.figure].back()
|
|
|
|
def forward(self):
|
|
"""Forward one step in the stack of views and positions."""
|
|
self.views[self.figure].forward()
|
|
self.positions[self.figure].forward()
|
|
|
|
|
|
class ViewsPositionsBase(ToolBase):
|
|
"""Base class for `ToolHome`, `ToolBack` and `ToolForward`."""
|
|
|
|
_on_trigger = None
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
self.toolmanager.get_tool(_views_positions).add_figure(self.figure)
|
|
getattr(self.toolmanager.get_tool(_views_positions),
|
|
self._on_trigger)()
|
|
self.toolmanager.get_tool(_views_positions).update_view()
|
|
|
|
|
|
class ToolHome(ViewsPositionsBase):
|
|
"""Restore the original view limits."""
|
|
|
|
description = 'Reset original view'
|
|
image = 'home'
|
|
default_keymap = mpl.rcParams['keymap.home']
|
|
_on_trigger = 'home'
|
|
|
|
|
|
class ToolBack(ViewsPositionsBase):
|
|
"""Move back up the view limits stack."""
|
|
|
|
description = 'Back to previous view'
|
|
image = 'back'
|
|
default_keymap = mpl.rcParams['keymap.back']
|
|
_on_trigger = 'back'
|
|
|
|
|
|
class ToolForward(ViewsPositionsBase):
|
|
"""Move forward in the view lim stack."""
|
|
|
|
description = 'Forward to next view'
|
|
image = 'forward'
|
|
default_keymap = mpl.rcParams['keymap.forward']
|
|
_on_trigger = 'forward'
|
|
|
|
|
|
class ConfigureSubplotsBase(ToolBase):
|
|
"""Base tool for the configuration of subplots."""
|
|
|
|
description = 'Configure subplots'
|
|
image = 'subplots'
|
|
|
|
|
|
class SaveFigureBase(ToolBase):
|
|
"""Base tool for figure saving."""
|
|
|
|
description = 'Save the figure'
|
|
image = 'filesave'
|
|
default_keymap = mpl.rcParams['keymap.save']
|
|
|
|
|
|
class ZoomPanBase(ToolToggleBase):
|
|
"""Base class for `ToolZoom` and `ToolPan`."""
|
|
def __init__(self, *args):
|
|
ToolToggleBase.__init__(self, *args)
|
|
self._button_pressed = None
|
|
self._xypress = None
|
|
self._idPress = None
|
|
self._idRelease = None
|
|
self._idScroll = None
|
|
self.base_scale = 2.
|
|
self.scrollthresh = .5 # .5 second scroll threshold
|
|
self.lastscroll = time.time()-self.scrollthresh
|
|
|
|
def enable(self, event):
|
|
"""Connect press/release events and lock the canvas."""
|
|
self.figure.canvas.widgetlock(self)
|
|
self._idPress = self.figure.canvas.mpl_connect(
|
|
'button_press_event', self._press)
|
|
self._idRelease = self.figure.canvas.mpl_connect(
|
|
'button_release_event', self._release)
|
|
self._idScroll = self.figure.canvas.mpl_connect(
|
|
'scroll_event', self.scroll_zoom)
|
|
|
|
def disable(self, event):
|
|
"""Release the canvas and disconnect press/release events."""
|
|
self._cancel_action()
|
|
self.figure.canvas.widgetlock.release(self)
|
|
self.figure.canvas.mpl_disconnect(self._idPress)
|
|
self.figure.canvas.mpl_disconnect(self._idRelease)
|
|
self.figure.canvas.mpl_disconnect(self._idScroll)
|
|
|
|
def trigger(self, sender, event, data=None):
|
|
self.toolmanager.get_tool(_views_positions).add_figure(self.figure)
|
|
ToolToggleBase.trigger(self, sender, event, data)
|
|
|
|
def scroll_zoom(self, event):
|
|
# https://gist.github.com/tacaswell/3144287
|
|
if event.inaxes is None:
|
|
return
|
|
|
|
if event.button == 'up':
|
|
# deal with zoom in
|
|
scl = self.base_scale
|
|
elif event.button == 'down':
|
|
# deal with zoom out
|
|
scl = 1/self.base_scale
|
|
else:
|
|
# deal with something that should never happen
|
|
scl = 1
|
|
|
|
ax = event.inaxes
|
|
ax._set_view_from_bbox([event.x, event.y, scl])
|
|
|
|
# If last scroll was done within the timing threshold, delete the
|
|
# previous view
|
|
if (time.time()-self.lastscroll) < self.scrollthresh:
|
|
self.toolmanager.get_tool(_views_positions).back()
|
|
|
|
self.figure.canvas.draw_idle() # force re-draw
|
|
|
|
self.lastscroll = time.time()
|
|
self.toolmanager.get_tool(_views_positions).push_current()
|
|
|
|
|
|
class ToolZoom(ZoomPanBase):
|
|
"""A Tool for zooming using a rectangle selector."""
|
|
|
|
description = 'Zoom to rectangle'
|
|
image = 'zoom_to_rect'
|
|
default_keymap = mpl.rcParams['keymap.zoom']
|
|
cursor = cursors.SELECT_REGION
|
|
radio_group = 'default'
|
|
|
|
def __init__(self, *args):
|
|
ZoomPanBase.__init__(self, *args)
|
|
self._ids_zoom = []
|
|
|
|
def _cancel_action(self):
|
|
for zoom_id in self._ids_zoom:
|
|
self.figure.canvas.mpl_disconnect(zoom_id)
|
|
self.toolmanager.trigger_tool('rubberband', self)
|
|
self.toolmanager.get_tool(_views_positions)._refresh_locators()
|
|
self._xypress = None
|
|
self._button_pressed = None
|
|
self._ids_zoom = []
|
|
return
|
|
|
|
def _press(self, event):
|
|
"""Callback for mouse button presses in zoom-to-rectangle mode."""
|
|
|
|
# If we're already in the middle of a zoom, pressing another
|
|
# button works to "cancel"
|
|
if self._ids_zoom:
|
|
self._cancel_action()
|
|
|
|
if event.button == 1:
|
|
self._button_pressed = 1
|
|
elif event.button == 3:
|
|
self._button_pressed = 3
|
|
else:
|
|
self._cancel_action()
|
|
return
|
|
|
|
x, y = event.x, event.y
|
|
|
|
self._xypress = []
|
|
for i, a in enumerate(self.figure.get_axes()):
|
|
if (x is not None and y is not None and a.in_axes(event) and
|
|
a.get_navigate() and a.can_zoom()):
|
|
self._xypress.append((x, y, a, i, a._get_view()))
|
|
|
|
id1 = self.figure.canvas.mpl_connect(
|
|
'motion_notify_event', self._mouse_move)
|
|
id2 = self.figure.canvas.mpl_connect(
|
|
'key_press_event', self._switch_on_zoom_mode)
|
|
id3 = self.figure.canvas.mpl_connect(
|
|
'key_release_event', self._switch_off_zoom_mode)
|
|
|
|
self._ids_zoom = id1, id2, id3
|
|
self._zoom_mode = event.key
|
|
|
|
def _switch_on_zoom_mode(self, event):
|
|
self._zoom_mode = event.key
|
|
self._mouse_move(event)
|
|
|
|
def _switch_off_zoom_mode(self, event):
|
|
self._zoom_mode = None
|
|
self._mouse_move(event)
|
|
|
|
def _mouse_move(self, event):
|
|
"""Callback for mouse moves in zoom-to-rectangle mode."""
|
|
|
|
if self._xypress:
|
|
x, y = event.x, event.y
|
|
lastx, lasty, a, ind, view = self._xypress[0]
|
|
(x1, y1), (x2, y2) = np.clip(
|
|
[[lastx, lasty], [x, y]], a.bbox.min, a.bbox.max)
|
|
if self._zoom_mode == "x":
|
|
y1, y2 = a.bbox.intervaly
|
|
elif self._zoom_mode == "y":
|
|
x1, x2 = a.bbox.intervalx
|
|
self.toolmanager.trigger_tool(
|
|
'rubberband', self, data=(x1, y1, x2, y2))
|
|
|
|
def _release(self, event):
|
|
"""Callback for mouse button releases in zoom-to-rectangle mode."""
|
|
|
|
for zoom_id in self._ids_zoom:
|
|
self.figure.canvas.mpl_disconnect(zoom_id)
|
|
self._ids_zoom = []
|
|
|
|
if not self._xypress:
|
|
self._cancel_action()
|
|
return
|
|
|
|
last_a = []
|
|
|
|
for cur_xypress in self._xypress:
|
|
x, y = event.x, event.y
|
|
lastx, lasty, a, _ind, view = cur_xypress
|
|
# ignore singular clicks - 5 pixels is a threshold
|
|
if abs(x - lastx) < 5 or abs(y - lasty) < 5:
|
|
self._cancel_action()
|
|
return
|
|
|
|
# detect twinx, twiny 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)
|
|
|
|
if self._button_pressed == 1:
|
|
direction = 'in'
|
|
elif self._button_pressed == 3:
|
|
direction = 'out'
|
|
else:
|
|
continue
|
|
|
|
a._set_view_from_bbox((lastx, lasty, x, y), direction,
|
|
self._zoom_mode, twinx, twiny)
|
|
|
|
self._zoom_mode = None
|
|
self.toolmanager.get_tool(_views_positions).push_current()
|
|
self._cancel_action()
|
|
|
|
|
|
class ToolPan(ZoomPanBase):
|
|
"""Pan axes with left mouse, zoom with right."""
|
|
|
|
default_keymap = mpl.rcParams['keymap.pan']
|
|
description = 'Pan axes with left mouse, zoom with right'
|
|
image = 'move'
|
|
cursor = cursors.MOVE
|
|
radio_group = 'default'
|
|
|
|
def __init__(self, *args):
|
|
ZoomPanBase.__init__(self, *args)
|
|
self._id_drag = None
|
|
|
|
def _cancel_action(self):
|
|
self._button_pressed = None
|
|
self._xypress = []
|
|
self.figure.canvas.mpl_disconnect(self._id_drag)
|
|
self.toolmanager.messagelock.release(self)
|
|
self.toolmanager.get_tool(_views_positions)._refresh_locators()
|
|
|
|
def _press(self, event):
|
|
if event.button == 1:
|
|
self._button_pressed = 1
|
|
elif event.button == 3:
|
|
self._button_pressed = 3
|
|
else:
|
|
self._cancel_action()
|
|
return
|
|
|
|
x, y = event.x, event.y
|
|
|
|
self._xypress = []
|
|
for i, a in enumerate(self.figure.get_axes()):
|
|
if (x is not None and y is not None and a.in_axes(event) and
|
|
a.get_navigate() and a.can_pan()):
|
|
a.start_pan(x, y, event.button)
|
|
self._xypress.append((a, i))
|
|
self.toolmanager.messagelock(self)
|
|
self._id_drag = self.figure.canvas.mpl_connect(
|
|
'motion_notify_event', self._mouse_move)
|
|
|
|
def _release(self, event):
|
|
if self._button_pressed is None:
|
|
self._cancel_action()
|
|
return
|
|
|
|
self.figure.canvas.mpl_disconnect(self._id_drag)
|
|
self.toolmanager.messagelock.release(self)
|
|
|
|
for a, _ind in self._xypress:
|
|
a.end_pan()
|
|
if not self._xypress:
|
|
self._cancel_action()
|
|
return
|
|
|
|
self.toolmanager.get_tool(_views_positions).push_current()
|
|
self._cancel_action()
|
|
|
|
def _mouse_move(self, event):
|
|
for a, _ind in self._xypress:
|
|
# safer to use the recorded button at the _press than current
|
|
# button: # multiple button can get pressed during motion...
|
|
a.drag_pan(self._button_pressed, event.key, event.x, event.y)
|
|
self.toolmanager.canvas.draw_idle()
|
|
|
|
|
|
class ToolHelpBase(ToolBase):
|
|
description = 'Print tool list, shortcuts and description'
|
|
default_keymap = mpl.rcParams['keymap.help']
|
|
image = 'help'
|
|
|
|
@staticmethod
|
|
def format_shortcut(key_sequence):
|
|
"""
|
|
Converts a shortcut string from the notation used in rc config to the
|
|
standard notation for displaying shortcuts, e.g. 'ctrl+a' -> 'Ctrl+A'.
|
|
"""
|
|
return (key_sequence if len(key_sequence) == 1 else
|
|
re.sub(r"\+[A-Z]", r"+Shift\g<0>", key_sequence).title())
|
|
|
|
def _format_tool_keymap(self, name):
|
|
keymaps = self.toolmanager.get_tool_keymap(name)
|
|
return ", ".join(self.format_shortcut(keymap) for keymap in keymaps)
|
|
|
|
def _get_help_entries(self):
|
|
return [(name, self._format_tool_keymap(name), tool.description)
|
|
for name, tool in sorted(self.toolmanager.tools.items())
|
|
if tool.description]
|
|
|
|
def _get_help_text(self):
|
|
entries = self._get_help_entries()
|
|
entries = ["{}: {}\n\t{}".format(*entry) for entry in entries]
|
|
return "\n".join(entries)
|
|
|
|
def _get_help_html(self):
|
|
fmt = "<tr><td>{}</td><td>{}</td><td>{}</td></tr>"
|
|
rows = [fmt.format(
|
|
"<b>Action</b>", "<b>Shortcuts</b>", "<b>Description</b>")]
|
|
rows += [fmt.format(*row) for row in self._get_help_entries()]
|
|
return ("<style>td {padding: 0px 4px}</style>"
|
|
"<table><thead>" + rows[0] + "</thead>"
|
|
"<tbody>".join(rows[1:]) + "</tbody></table>")
|
|
|
|
|
|
class ToolCopyToClipboardBase(ToolBase):
|
|
"""Tool to copy the figure to the clipboard."""
|
|
|
|
description = 'Copy the canvas figure to clipboard'
|
|
default_keymap = mpl.rcParams['keymap.copy']
|
|
|
|
def trigger(self, *args, **kwargs):
|
|
message = "Copy tool is not available"
|
|
self.toolmanager.message_event(message, self)
|
|
|
|
|
|
default_tools = {'home': ToolHome, 'back': ToolBack, 'forward': ToolForward,
|
|
'zoom': ToolZoom, 'pan': ToolPan,
|
|
'subplots': 'ToolConfigureSubplots',
|
|
'save': 'ToolSaveFigure',
|
|
'grid': ToolGrid,
|
|
'grid_minor': ToolMinorGrid,
|
|
'fullscreen': ToolFullScreen,
|
|
'quit': ToolQuit,
|
|
'quit_all': ToolQuitAll,
|
|
'allnav': _ToolEnableAllNavigation,
|
|
'nav': _ToolEnableNavigation,
|
|
'xscale': ToolXScale,
|
|
'yscale': ToolYScale,
|
|
'position': ToolCursorPosition,
|
|
_views_positions: ToolViewsPositions,
|
|
'cursor': 'ToolSetCursor',
|
|
'rubberband': 'ToolRubberband',
|
|
'help': 'ToolHelp',
|
|
'copy': 'ToolCopyToClipboard',
|
|
}
|
|
"""Default tools"""
|
|
|
|
default_toolbar_tools = [['navigation', ['home', 'back', 'forward']],
|
|
['zoompan', ['pan', 'zoom', 'subplots']],
|
|
['io', ['save', 'help']]]
|
|
"""Default tools in the toolbar"""
|
|
|
|
|
|
def add_tools_to_manager(toolmanager, tools=default_tools):
|
|
"""
|
|
Add multiple tools to a `.ToolManager`.
|
|
|
|
Parameters
|
|
----------
|
|
toolmanager : `.backend_managers.ToolManager`
|
|
Manager to which the tools are added.
|
|
tools : {str: class_like}, optional
|
|
The tools to add in a {name: tool} dict, see `add_tool` for more
|
|
info.
|
|
"""
|
|
|
|
for name, tool in tools.items():
|
|
toolmanager.add_tool(name, tool)
|
|
|
|
|
|
def add_tools_to_container(container, tools=default_toolbar_tools):
|
|
"""
|
|
Add multiple tools to the container.
|
|
|
|
Parameters
|
|
----------
|
|
container : Container
|
|
`backend_bases.ToolContainerBase` object that will get the tools added.
|
|
tools : list, optional
|
|
List in the form ``[[group1, [tool1, tool2 ...]], [group2, [...]]]``
|
|
where the tools ``[tool1, tool2, ...]`` will display in group1.
|
|
See `add_tool` for details.
|
|
"""
|
|
|
|
for group, grouptools in tools:
|
|
for position, tool in enumerate(grouptools):
|
|
container.add_tool(tool, group, position)
|