diff --git a/doc/api/backend_managers_api.rst b/doc/api/backend_managers_api.rst new file mode 100644 index 000000000000..86d1c383b966 --- /dev/null +++ b/doc/api/backend_managers_api.rst @@ -0,0 +1,8 @@ + +:mod:`matplotlib.backend_managers` +=================================== + +.. automodule:: matplotlib.backend_managers + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/backend_tools_api.rst b/doc/api/backend_tools_api.rst new file mode 100644 index 000000000000..32babd5844b0 --- /dev/null +++ b/doc/api/backend_tools_api.rst @@ -0,0 +1,8 @@ + +:mod:`matplotlib.backend_tools` +================================ + +.. automodule:: matplotlib.backend_tools + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/index_backend_api.rst b/doc/api/index_backend_api.rst index 6dbccb231280..f02901f04f83 100644 --- a/doc/api/index_backend_api.rst +++ b/doc/api/index_backend_api.rst @@ -5,6 +5,8 @@ backends .. toctree:: backend_bases_api.rst + backend_managers_api.rst + backend_tools_api.rst backend_gtkagg_api.rst backend_qt4agg_api.rst backend_wxagg_api.rst diff --git a/doc/users/whats_new/rcparams.rst b/doc/users/whats_new/rcparams.rst index 4e7d367c46cb..d7641c76b83f 100644 --- a/doc/users/whats_new/rcparams.rst +++ b/doc/users/whats_new/rcparams.rst @@ -15,9 +15,17 @@ Added "figure.titlesize" and "figure.titleweight" keys to rcParams Two new keys were added to rcParams to control the default font size and weight used by the figure title (as emitted by ``pyplot.suptitle()``). + ``image.composite_image`` added to rcParams ``````````````````````````````````````````` Controls whether vector graphics backends (i.e. PDF, PS, and SVG) combine multiple images on a set of axes into a single composite image. Saving each image individually can be useful if you generate vector graphics files in matplotlib and then edit the files further in Inkscape or other programs. + + +Added "toolmanager" to "toolbar" possible values +```````````````````````````````````````````````` + +The new value enables the use of ``ToolManager`` + diff --git a/doc/users/whats_new/toolmanager.rst b/doc/users/whats_new/toolmanager.rst new file mode 100644 index 000000000000..889333e6bace --- /dev/null +++ b/doc/users/whats_new/toolmanager.rst @@ -0,0 +1,68 @@ +ToolManager +----------- + +Federico Ariza wrote the new `matplotlib.backend_managers.ToolManager` that comes as replacement for `NavigationToolbar2` + +`ToolManager` offers a new way of looking at the user interactions with the figures. +Before we had the `NavigationToolbar2` with its own tools like `zoom/pan/home/save/...` and also we had the shortcuts like +`yscale/grid/quit/....` +`Toolmanager` relocate all those actions as `Tools` (located in `matplotlib.backend_tools`), and defines a way to `access/trigger/reconfigure` them. + +The `Toolbars` are replaced for `ToolContainers` that are just GUI interfaces to `trigger` the tools. But don't worry the default backends include a `ToolContainer` called `toolbar` + + +.. note:: + For the moment the `ToolManager` is working only with `GTK3` and `Tk` backends. + Make sure you are using one of those. + Port for the rest of the backends is comming soon. + + To activate the `ToolManager` include the following at the top of your file: + + >>> matplotlib.rcParams['toolbar'] = 'toolmanager' + + +Interact with the ToolContainer +``````````````````````````````` + +The most important feature is the ability to easily reconfigure the ToolContainer (aka toolbar). +For example, if we want to remove the "forward" button we would just do. + + >>> fig.canvas.manager.toolmanager.remove_tool('forward') + +Now if you want to programmatically trigger the "home" button + + >>> fig.canvas.manager.toolmanager.trigger_tool('home') + + +New Tools +````````` + +It is possible to add new tools to the ToolManager + +A very simple tool that prints "You're awesome" would be:: + + from matplotlib.backend_tools import ToolBase + class AwesomeTool(ToolBase): + def trigger(self, *args, **kwargs): + print("You're awesome") + + +To add this tool to `ToolManager` + + >>> fig.canvas.manager.toolmanager.add_tool('Awesome', AwesomeTool) + +If we want to add a shortcut ("d") for the tool + + >>> fig.canvas.manager.toolmanager.update_keymap('Awesome', 'd') + + +To add it to the toolbar inside the group 'foo' + + >>> fig.canvas.manager.toolbar.add_tool('Awesome', 'foo') + + +There is a second class of tools, "Toggleable Tools", this are almost the same as our basic tools, just that belong to a group, and are mutually exclusive inside that group. +For tools derived from `ToolToggleBase` there are two basic methods `enable` and `disable` that are called automatically whenever it is toggled. + + +A full example is located in :ref:`user_interfaces-toolmanager` diff --git a/examples/user_interfaces/toolmanager.py b/examples/user_interfaces/toolmanager.py new file mode 100644 index 000000000000..5240bab239c2 --- /dev/null +++ b/examples/user_interfaces/toolmanager.py @@ -0,0 +1,92 @@ +'''This example demonstrates how to: +* Modify the Toolbar +* Create tools +* Add tools +* Remove tools +Using `matplotlib.backend_managers.ToolManager` +''' + + +from __future__ import print_function +import matplotlib +matplotlib.use('GTK3Cairo') +matplotlib.rcParams['toolbar'] = 'toolmanager' +import matplotlib.pyplot as plt +from matplotlib.backend_tools import ToolBase, ToolToggleBase +from gi.repository import Gtk, Gdk + + +class ListTools(ToolBase): + '''List all the tools controlled by the `ToolManager`''' + # keyboard shortcut + default_keymap = 'm' + description = 'List Tools' + + def trigger(self, *args, **kwargs): + print('_' * 80) + print("{0:12} {1:45} {2}".format('Name (id)', + 'Tool description', + 'Keymap')) + print('-' * 80) + tools = self.toolmanager.tools + for name in sorted(tools.keys()): + if not tools[name].description: + continue + keys = ', '.join(sorted(self.toolmanager.get_tool_keymap(name))) + print("{0:12} {1:45} {2}".format(name, + tools[name].description, + keys)) + print('_' * 80) + print("Active Toggle tools") + print("{0:12} {1:45}".format("Group", "Active")) + print('-' * 80) + for group, active in self.toolmanager.active_toggle.items(): + print("{0:12} {1:45}".format(group, active)) + + +class GroupHideTool(ToolToggleBase): + '''Hide lines with a given gid''' + default_keymap = 'G' + description = 'Hide by gid' + + def __init__(self, *args, **kwargs): + self.gid = kwargs.pop('gid') + ToolToggleBase.__init__(self, *args, **kwargs) + + def enable(self, *args): + self.set_lines_visibility(False) + + def disable(self, *args): + self.set_lines_visibility(True) + + def set_lines_visibility(self, state): + gr_lines = [] + for ax in self.figure.get_axes(): + for line in ax.get_lines(): + if line.get_gid() == self.gid: + line.set_visible(state) + self.figure.canvas.draw() + + +fig = plt.figure() +plt.plot([1, 2, 3], gid='mygroup') +plt.plot([2, 3, 4], gid='unknown') +plt.plot([3, 2, 1], gid='mygroup') + +# Add the custom tools that we created +fig.canvas.manager.toolmanager.add_tool('List', ListTools) +fig.canvas.manager.toolmanager.add_tool('Hide', GroupHideTool, gid='mygroup') + + +# Add an existing tool to new group `foo`. +# It can be added as many times as we want +fig.canvas.manager.toolbar.add_tool('zoom', 'foo') + +# Remove the forward button +fig.canvas.manager.toolmanager.remove_tool('forward') + +# To add a custom tool to the toolbar at specific location inside +# the navigation group +fig.canvas.manager.toolbar.add_tool('Hide', 'navigation', 1) + +plt.show() diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index a3dec56759c4..7ef777540a52 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -25,6 +25,11 @@ the 'show' callable is then set to Show.__call__, inherited from ShowBase. +:class:`ToolContainerBase` + The base class for the Toolbar class of each interactive backend. + +:class:`StatusbarBase` + The base class for the messaging area. """ from __future__ import (absolute_import, division, print_function, @@ -56,6 +61,7 @@ import matplotlib.textpath as textpath from matplotlib.path import Path from matplotlib.cbook import mplDeprecation +import matplotlib.backend_tools as tools try: from importlib import import_module @@ -2570,8 +2576,12 @@ def __init__(self, canvas, num): canvas.manager = self # store a pointer to parent self.num = num - self.key_press_handler_id = self.canvas.mpl_connect('key_press_event', - self.key_press) + if rcParams['toolbar'] != 'toolmanager': + self.key_press_handler_id = self.canvas.mpl_connect( + 'key_press_event', + self.key_press) + else: + self.key_press_handler_id = None """ The returned id from connecting the default key handler via :meth:`FigureCanvasBase.mpl_connnect`. @@ -2607,7 +2617,8 @@ def key_press(self, event): Implement the default mpl key bindings defined at :ref:`key-event-handling` """ - key_press_handler(event, self.canvas, self.canvas.toolbar) + if rcParams['toolbar'] != 'toolmanager': + key_press_handler(event, self.canvas, self.canvas.toolbar) def show_popup(self, msg): """ @@ -2630,10 +2641,7 @@ def set_window_title(self, title): pass -class Cursors(object): - # this class is only used as a simple namespace - HAND, POINTER, SELECT_REGION, MOVE = list(range(4)) -cursors = Cursors() +cursors = tools.cursors class NavigationToolbar2(object): @@ -3213,3 +3221,161 @@ def zoom(self, *args): def set_history_buttons(self): """Enable or disable back/forward button""" pass + + +class ToolContainerBase(object): + """ + Base class for all tool containers, e.g. toolbars. + + Attributes + ---------- + toolmanager : `ToolManager` object that holds the tools that + this `ToolContainer` wants to communicate with. + """ + + def __init__(self, toolmanager): + self.toolmanager = toolmanager + self.toolmanager.toolmanager_connect('tool_removed_event', + self._remove_tool_cbk) + + def _tool_toggled_cbk(self, event): + """ + Captures the 'tool_trigger_[name]' + + This only gets used for toggled tools + """ + self.toggle_toolitem(event.tool.name, event.tool.toggled) + + def add_tool(self, tool, group, position=-1): + """ + Adds a tool to this container + + Parameters + ---------- + tool : tool_like + The tool to add, see `ToolManager.get_tool`. + group : str + The name of the group to add this tool to. + position : int (optional) + The position within the group to place this tool. Defaults to end. + """ + tool = self.toolmanager.get_tool(tool) + image = self._get_image_filename(tool.image) + toggle = getattr(tool, 'toggled', None) is not None + self.add_toolitem(tool.name, group, position, + image, tool.description, toggle) + if toggle: + self.toolmanager.toolmanager_connect('tool_trigger_%s' % tool.name, + self._tool_toggled_cbk) + + def _remove_tool_cbk(self, event): + """Captures the 'tool_removed_event' signal and removes the tool""" + self.remove_toolitem(event.tool.name) + + def _get_image_filename(self, image): + """Find the image based on its name""" + # TODO: better search for images, they are not always in the + # datapath + basedir = os.path.join(rcParams['datapath'], 'images') + if image is not None: + fname = os.path.join(basedir, image) + else: + fname = None + return fname + + def trigger_tool(self, name): + """ + Trigger the tool + + Parameters + ---------- + name : String + Name(id) of the tool triggered from within the container + + """ + self.toolmanager.trigger_tool(name, sender=self) + + def add_toolitem(self, name, group, position, image, description, toggle): + """ + Add a toolitem to the container + + This method must get implemented per backend + + The callback associated with the button click event, + must be **EXACTLY** `self.trigger_tool(name)` + + Parameters + ---------- + name : string + Name of the tool to add, this gets used as the tool's ID and as the + default label of the buttons + group : String + Name of the group that this tool belongs to + position : Int + Position of the tool within its group, if -1 it goes at the End + image_file : String + Filename of the image for the button or `None` + description : String + Description of the tool, used for the tooltips + toggle : Bool + * `True` : The button is a toggle (change the pressed/unpressed + state between consecutive clicks) + * `False` : The button is a normal button (returns to unpressed + state after release) + """ + + raise NotImplementedError + + def toggle_toolitem(self, name, toggled): + """ + Toggle the toolitem without firing event + + Parameters + ---------- + name : String + Id of the tool to toggle + toggled : bool + Whether to set this tool as toggled or not. + """ + raise NotImplementedError + + def remove_toolitem(self, name): + """ + Remove a toolitem from the `ToolContainer` + + This method must get implemented per backend + + Called when `ToolManager` emits a `tool_removed_event` + + Parameters + ---------- + name : string + Name of the tool to remove + + """ + + raise NotImplementedError + + +class StatusbarBase(object): + """Base class for the statusbar""" + def __init__(self, toolmanager): + self.toolmanager = toolmanager + self.toolmanager.toolmanager_connect('tool_message_event', + self._message_cbk) + + def _message_cbk(self, event): + """Captures the 'tool_message_event' and set the message""" + self.set_message(event.message) + + def set_message(self, s): + """ + Display a message on toolbar or in status bar + + Parameters + ---------- + s : str + Message text + """ + + pass diff --git a/lib/matplotlib/backend_managers.py b/lib/matplotlib/backend_managers.py new file mode 100644 index 000000000000..41d80c96a9d1 --- /dev/null +++ b/lib/matplotlib/backend_managers.py @@ -0,0 +1,391 @@ +""" +`ToolManager` + Class that makes the bridge between user interaction (key press, + toolbar clicks, ..) and the actions in response to the user inputs. +""" + +from __future__ import (absolute_import, division, print_function, + unicode_literals) +import six +import warnings + +import matplotlib.cbook as cbook +import matplotlib.widgets as widgets +from matplotlib.rcsetup import validate_stringlist +import matplotlib.backend_tools as tools + + +class ToolEvent(object): + """Event for tool manipulation (add/remove)""" + def __init__(self, name, sender, tool, data=None): + self.name = name + self.sender = sender + self.tool = tool + self.data = data + + +class ToolTriggerEvent(ToolEvent): + """Event to inform that a tool has been triggered""" + def __init__(self, name, sender, tool, canvasevent=None, data=None): + ToolEvent.__init__(self, name, sender, tool, data) + self.canvasevent = canvasevent + + +class ToolManagerMessageEvent(object): + """ + Event carrying messages from toolmanager + + Messages usually get displayed to the user by the toolbar + """ + def __init__(self, name, sender, message): + self.name = name + self.sender = sender + self.message = message + + +class ToolManager(object): + """ + Helper class that groups all the user interactions for a FigureManager + + Attributes + ---------- + manager: `FigureManager` + keypresslock: `widgets.LockDraw` + `LockDraw` object to know if the `canvas` key_press_event is locked + messagelock: `widgets.LockDraw` + `LockDraw` object to know if the message is available to write + """ + + def __init__(self, canvas): + self.canvas = canvas + + self._key_press_handler_id = self.canvas.mpl_connect( + 'key_press_event', self._key_press) + + self._tools = {} + self._keys = {} + self._toggled = {} + self._callbacks = cbook.CallbackRegistry() + + # to process keypress event + self.keypresslock = widgets.LockDraw() + self.messagelock = widgets.LockDraw() + + def toolmanager_connect(self, s, func): + """ + Connect event with string *s* to *func*. + + Parameters + ----------- + s : String + Name of the event + + The following events are recognized + + - 'tool_message_event' + - 'tool_removed_event' + - 'tool_added_event' + + For every tool added a new event is created + + - 'tool_trigger_TOOLNAME` + Where TOOLNAME is the id of the tool. + + func : function + Function to be called with signature + def func(event) + """ + return self._callbacks.connect(s, func) + + def toolmanager_disconnect(self, cid): + """ + Disconnect callback id *cid* + + Example usage:: + + cid = toolmanager.toolmanager_connect('tool_trigger_zoom', + on_press) + #...later + toolmanager.toolmanager_disconnect(cid) + """ + return self._callbacks.disconnect(cid) + + def message_event(self, message, sender=None): + """ Emit a `ToolManagerMessageEvent`""" + if sender is None: + sender = self + + s = 'tool_message_event' + event = ToolManagerMessageEvent(s, sender, message) + self._callbacks.process(s, event) + + @property + def active_toggle(self): + """Currently toggled tools""" + + return self._toggled + + def get_tool_keymap(self, name): + """ + Get the keymap associated with the specified tool + + Parameters + ---------- + name : string + Name of the Tool + + Returns + ---------- + list : list of keys associated with the Tool + """ + + keys = [k for k, i in six.iteritems(self._keys) if i == name] + return keys + + def _remove_keys(self, name): + for k in self.get_tool_keymap(name): + del self._keys[k] + + def update_keymap(self, name, *keys): + """ + Set the keymap to associate with the specified tool + + Parameters + ---------- + name : string + Name of the Tool + keys : keys to associate with the Tool + """ + + if name not in self._tools: + raise KeyError('%s not in Tools' % name) + + self._remove_keys(name) + + for key in keys: + for k in validate_stringlist(key): + if k in self._keys: + warnings.warn('Key %s changed from %s to %s' % + (k, self._keys[k], name)) + self._keys[k] = name + + def remove_tool(self, name): + """ + Remove tool from `ToolManager` + + Parameters + ---------- + name : string + Name of the Tool + """ + + tool = self.get_tool(name) + tool.destroy() + + # If is a toggle tool and toggled, untoggle + if getattr(tool, 'toggled', False): + self.trigger_tool(tool, 'toolmanager') + + self._remove_keys(name) + + s = 'tool_removed_event' + event = ToolEvent(s, self, tool) + self._callbacks.process(s, event) + + del self._tools[name] + + def add_tool(self, name, tool, *args, **kwargs): + """ + Add *tool* to `ToolManager` + + If successful adds a new event `tool_trigger_name` where **name** is + the **name** of the tool, this event is fired everytime + the tool is triggered. + + Parameters + ---------- + name : str + Name of the tool, treated as the ID, has to be unique + tool : class_like, i.e. str or type + Reference to find the class of the Tool to added. + + Notes + ----- + args and kwargs get passed directly to the tools constructor. + + See Also + -------- + matplotlib.backend_tools.ToolBase : The base class for tools. + """ + + tool_cls = self._get_cls_to_instantiate(tool) + if not tool_cls: + raise ValueError('Impossible to find class for %s' % str(tool)) + + if name in self._tools: + warnings.warn('A "Tool class" with the same name already exists, ' + 'not added') + return self._tools[name] + + tool_obj = tool_cls(self, name, *args, **kwargs) + self._tools[name] = tool_obj + + if tool_cls.default_keymap is not None: + self.update_keymap(name, tool_cls.default_keymap) + + # For toggle tools init the radio_group in self._toggled + if isinstance(tool_obj, tools.ToolToggleBase): + # None group is not mutually exclusive, a set is used to keep track + # of all toggled tools in this group + if tool_obj.radio_group is None: + self._toggled.setdefault(None, set()) + else: + self._toggled.setdefault(tool_obj.radio_group, None) + + self._tool_added_event(tool_obj) + return tool_obj + + def _tool_added_event(self, tool): + s = 'tool_added_event' + event = ToolEvent(s, self, tool) + self._callbacks.process(s, event) + + def _handle_toggle(self, tool, sender, canvasevent, data): + """ + Toggle tools, need to untoggle prior to using other Toggle tool + Called from trigger_tool + + Parameters + ---------- + tool: Tool object + sender: object + Object that wishes to trigger the tool + canvasevent : Event + Original Canvas event or None + data : Object + Extra data to pass to the tool when triggering + """ + + radio_group = tool.radio_group + # radio_group None is not mutually exclusive + # just keep track of toggled tools in this group + if radio_group is None: + if tool.toggled: + self._toggled[None].remove(tool.name) + else: + self._toggled[None].add(tool.name) + return + + # If the tool already has a toggled state, untoggle it + if self._toggled[radio_group] == tool.name: + toggled = None + # If no tool was toggled in the radio_group + # toggle it + elif self._toggled[radio_group] is None: + toggled = tool.name + # Other tool in the radio_group is toggled + else: + # Untoggle previously toggled tool + self.trigger_tool(self._toggled[radio_group], + self, + canvasevent, + data) + toggled = tool.name + + # Keep track of the toggled tool in the radio_group + self._toggled[radio_group] = toggled + + def _get_cls_to_instantiate(self, callback_class): + # Find the class that corresponds to the tool + if isinstance(callback_class, six.string_types): + # FIXME: make more complete searching structure + if callback_class in globals(): + callback_class = globals()[callback_class] + else: + mod = 'backend_tools' + current_module = __import__(mod, + globals(), locals(), [mod], -1) + + callback_class = getattr(current_module, callback_class, False) + if callable(callback_class): + return callback_class + else: + return None + + def trigger_tool(self, name, sender=None, canvasevent=None, + data=None): + """ + Trigger a tool and emit the tool_trigger_[name] event + + Parameters + ---------- + name : string + Name of the tool + sender: object + Object that wishes to trigger the tool + canvasevent : Event + Original Canvas event or None + data : Object + Extra data to pass to the tool when triggering + """ + tool = self.get_tool(name) + if tool is None: + return + + if sender is None: + sender = self + + self._trigger_tool(name, sender, canvasevent, data) + + s = 'tool_trigger_%s' % name + event = ToolTriggerEvent(s, sender, tool, canvasevent, data) + self._callbacks.process(s, event) + + def _trigger_tool(self, name, sender=None, canvasevent=None, data=None): + """ + Trigger on a tool + + Method to actually trigger the tool + """ + tool = self.get_tool(name) + + if isinstance(tool, tools.ToolToggleBase): + self._handle_toggle(tool, sender, canvasevent, data) + + # Important!!! + # This is where the Tool object gets triggered + tool.trigger(sender, canvasevent, data) + + def _key_press(self, event): + if event.key is None or self.keypresslock.locked(): + return + + name = self._keys.get(event.key, None) + if name is None: + return + self.trigger_tool(name, canvasevent=event) + + @property + def tools(self): + """Return the tools controlled by `ToolManager`""" + + return self._tools + + def get_tool(self, name, warn=True): + """ + Return the tool object, also accepts the actual tool for convenience + + Parameters + ----------- + name : str, ToolBase + Name of the tool, or the tool itself + warn : bool, optional + If this method should give warnings. + """ + if isinstance(name, tools.ToolBase) and name.name in self._tools: + return name + if name not in self._tools: + if warn: + warnings.warn("ToolManager does not control tool %s" % name) + return None + return self._tools[name] diff --git a/lib/matplotlib/backend_tools.py b/lib/matplotlib/backend_tools.py new file mode 100644 index 000000000000..1d952f2417da --- /dev/null +++ b/lib/matplotlib/backend_tools.py @@ -0,0 +1,991 @@ +""" +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 matplotlib import rcParams +from matplotlib._pylab_helpers import Gcf +import matplotlib.cbook as cbook +from weakref import WeakKeyDictionary +import numpy as np +import six + + +class Cursors(object): + """Simple namespace for cursor reference""" + HAND, POINTER, SELECT_REGION, MOVE = list(range(4)) +cursors = Cursors() + +# Views positions tool +_views_positions = 'viewpos' + + +class ToolBase(object): + """ + 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: String + 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 emited + """ + + 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): + self._name = name + self._figure = None + self.toolmanager = toolmanager + self.figure = toolmanager.canvas.figure + + @property + def figure(self): + return self._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 + + @figure.setter + def figure(self, figure): + """ + Set the figure + + Set the figure to be affected by this tool + + Parameters + ---------- + figure: `Figure` + """ + + self._figure = figure + + @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 + """ + + 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""" + + def __init__(self, *args, **kwargs): + ToolBase.__init__(self, *args, **kwargs) + self._toggled = False + + 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 + + +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._idDrag = self.figure.canvas.mpl_connect( + 'motion_notify_event', self._set_cursor_cbk) + 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 _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): + ToolBase.__init__(self, *args, **kwargs) + self._idDrag = self.figure.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: + message = s + self.toolmanager.message_event(message, self) + + +class RubberbandBase(ToolBase): + """Draw and remove 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 = rcParams['keymap.quit'] + + def trigger(self, sender, event, data=None): + Gcf.destroy_fig(self.figure) + + +class ToolEnableAllNavigation(ToolBase): + """Tool to enable all axes for toolmanager interaction""" + + description = 'Enables all axes toolmanager' + default_keymap = rcParams['keymap.all_axes'] + + def trigger(self, sender, event, data=None): + if event.inaxes is None: + return + + for a in self.figure.get_axes(): + if (event.x is not None and event.y is not None + and a.in_axes(event)): + a.set_navigate(True) + + +class ToolEnableNavigation(ToolBase): + """Tool to enable a specific axes for toolmanager interaction""" + + description = 'Enables one axes toolmanager' + default_keymap = (1, 2, 3, 4, 5, 6, 7, 8, 9) + + def trigger(self, sender, event, data=None): + if event.inaxes is None: + return + + n = int(event.key) - 1 + for i, a in enumerate(self.figure.get_axes()): + if (event.x is not None and event.y is not None + and a.in_axes(event)): + a.set_navigate(i == n) + + +class ToolGrid(ToolToggleBase): + """Tool to toggle the grid of the figure""" + + description = 'Toogle Grid' + default_keymap = rcParams['keymap.grid'] + + def trigger(self, sender, event, data=None): + if event.inaxes is None: + return + ToolToggleBase.trigger(self, sender, event, data) + + def enable(self, event): + event.inaxes.grid(True) + self.figure.canvas.draw_idle() + + def disable(self, event): + event.inaxes.grid(False) + self.figure.canvas.draw_idle() + + +class ToolFullScreen(ToolToggleBase): + """Tool to toggle full screen""" + + description = 'Toogle Fullscreen mode' + default_keymap = 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 = 'Toogle Scale Y axis' + default_keymap = 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 = 'Toogle Scale X axis' + default_keymap = 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() + ToolBase.__init__(self, *args, **kwargs) + + def add_figure(self): + """Add the current figure to the stack of views and positions""" + if self.figure not in self.views: + self.views[self.figure] = cbook.Stack() + self.positions[self.figure] = cbook.Stack() + # Define Home + self.push_current() + # Adding the clear method as axobserver, removes this burden from + # the backend + self.figure.add_axobserver(self.clear) + + def clear(self, figure): + """Reset the axes stack""" + if figure in self.views: + self.views[figure].clear() + self.positions[figure].clear() + + def update_view(self): + """ + Update the viewlim and position from the view and + position stack for each axes + """ + + lims = self.views[self.figure]() + if lims is None: + return + pos = self.positions[self.figure]() + if pos is None: + return + for i, a in enumerate(self.figure.get_axes()): + xmin, xmax, ymin, ymax = lims[i] + a.set_xlim((xmin, xmax)) + a.set_ylim((ymin, ymax)) + # Restore both the original and modified positions + a.set_position(pos[i][0], 'original') + a.set_position(pos[i][1], 'active') + + self.figure.canvas.draw_idle() + + def push_current(self): + """push the current view limits and position onto the stack""" + + lims = [] + pos = [] + for a in self.figure.get_axes(): + xmin, xmax = a.get_xlim() + ymin, ymax = a.get_ylim() + lims.append((xmin, xmax, ymin, ymax)) + # Store both the original and modified positions + pos.append(( + a.get_position(True).frozen(), + a.get_position().frozen())) + self.views[self.figure].push(lims) + self.positions[self.figure].push(pos) + + def refresh_locators(self): + """Redraw the canvases, update the locators""" + 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: + loc.refresh() + 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() + 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 lim""" + + description = 'Reset original view' + image = 'home.png' + default_keymap = rcParams['keymap.home'] + _on_trigger = 'home' + + +class ToolBack(ViewsPositionsBase): + """Move back up the view lim stack""" + + description = 'Back to previous view' + image = 'back.png' + default_keymap = rcParams['keymap.back'] + _on_trigger = 'back' + + +class ToolForward(ViewsPositionsBase): + """Move forward in the view lim stack""" + + description = 'Forward to next view' + image = 'forward.png' + default_keymap = rcParams['keymap.forward'] + _on_trigger = 'forward' + + +class ConfigureSubplotsBase(ToolBase): + """Base tool for the configuration of subplots""" + + description = 'Configure subplots' + image = 'subplots.png' + + +class SaveFigureBase(ToolBase): + """Base tool for figure saving""" + + description = 'Save the figure' + image = 'filesave.png' + default_keymap = 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. + + 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() + ToolToggleBase.trigger(self, sender, event, data) + + def scroll_zoom(self, event): + # https://gist.github.com/tacaswell/3144287 + if event.inaxes is None: + return + ax = event.inaxes + cur_xlim = ax.get_xlim() + cur_ylim = ax.get_ylim() + # set the range + cur_xrange = (cur_xlim[1] - cur_xlim[0])*.5 + cur_yrange = (cur_ylim[1] - cur_ylim[0])*.5 + xdata = event.xdata # get event x location + ydata = event.ydata # get event y location + if event.button == 'up': + # deal with zoom in + scale_factor = 1 / self.base_scale + elif event.button == 'down': + # deal with zoom out + scale_factor = self.base_scale + else: + # deal with something that should never happen + scale_factor = 1 + # set new limits + ax.set_xlim([xdata - cur_xrange*scale_factor, + xdata + cur_xrange*scale_factor]) + ax.set_ylim([ydata - cur_yrange*scale_factor, + ydata + cur_yrange*scale_factor]) + self.figure.canvas.draw_idle() # force re-draw + + +class ToolZoom(ZoomPanBase): + """Zoom to rectangle""" + + description = 'Zoom to rectangle' + image = 'zoom_to_rect.png' + default_keymap = 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): + """the _press mouse button in zoom to rect mode callback""" + + # 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.viewLim.frozen(), + a.transData.frozen())) + + 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): + """the drag callback in zoom mode""" + + if self._xypress: + x, y = event.x, event.y + lastx, lasty, a, _ind, _lim, _trans = self._xypress[0] + + # adjust x, last, y, last + x1, y1, x2, y2 = a.bbox.extents + x, lastx = max(min(x, lastx), x1), min(max(x, lastx), x2) + y, lasty = max(min(y, lasty), y1), min(max(y, lasty), y2) + + if self._zoom_mode == "x": + x1, y1, x2, y2 = a.bbox.extents + y, lasty = y1, y2 + elif self._zoom_mode == "y": + x1, y1, x2, y2 = a.bbox.extents + x, lastx = x1, x2 + + self.toolmanager.trigger_tool('rubberband', + self, + data=(x, y, lastx, lasty)) + + def _release(self, event): + """the release mouse button callback in zoom to rect 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, lim, _trans = cur_xypress + # ignore singular clicks - 5 pixels is a threshold + if abs(x - lastx) < 5 or abs(y - lasty) < 5: + self._cancel_action() + return + + x0, y0, x1, y1 = lim.extents + + # zoom to rect + inverse = a.transData.inverted() + lastx, lasty = inverse.transform_point((lastx, lasty)) + x, y = inverse.transform_point((x, y)) + Xmin, Xmax = a.get_xlim() + Ymin, Ymax = a.get_ylim() + + # 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) + + if twinx: + x0, x1 = Xmin, Xmax + else: + if Xmin < Xmax: + if x < lastx: + x0, x1 = x, lastx + else: + x0, x1 = lastx, x + if x0 < Xmin: + x0 = Xmin + if x1 > Xmax: + x1 = Xmax + else: + if x > lastx: + x0, x1 = x, lastx + else: + x0, x1 = lastx, x + if x0 > Xmin: + x0 = Xmin + if x1 < Xmax: + x1 = Xmax + + if twiny: + y0, y1 = Ymin, Ymax + else: + if Ymin < Ymax: + if y < lasty: + y0, y1 = y, lasty + else: + y0, y1 = lasty, y + if y0 < Ymin: + y0 = Ymin + if y1 > Ymax: + y1 = Ymax + else: + if y > lasty: + y0, y1 = y, lasty + else: + y0, y1 = lasty, y + if y0 > Ymin: + y0 = Ymin + if y1 < Ymax: + y1 = Ymax + + if self._button_pressed == 1: + if self._zoom_mode == "x": + a.set_xlim((x0, x1)) + elif self._zoom_mode == "y": + a.set_ylim((y0, y1)) + else: + a.set_xlim((x0, x1)) + a.set_ylim((y0, y1)) + elif self._button_pressed == 3: + if a.get_xscale() == 'log': + alpha = np.log(Xmax / Xmin) / np.log(x1 / x0) + rx1 = pow(Xmin / x0, alpha) * Xmin + rx2 = pow(Xmax / x0, alpha) * Xmin + else: + alpha = (Xmax - Xmin) / (x1 - x0) + rx1 = alpha * (Xmin - x0) + Xmin + rx2 = alpha * (Xmax - x0) + Xmin + if a.get_yscale() == 'log': + alpha = np.log(Ymax / Ymin) / np.log(y1 / y0) + ry1 = pow(Ymin / y0, alpha) * Ymin + ry2 = pow(Ymax / y0, alpha) * Ymin + else: + alpha = (Ymax - Ymin) / (y1 - y0) + ry1 = alpha * (Ymin - y0) + Ymin + ry2 = alpha * (Ymax - y0) + Ymin + + if self._zoom_mode == "x": + a.set_xlim((rx1, rx2)) + elif self._zoom_mode == "y": + a.set_ylim((ry1, ry2)) + else: + a.set_xlim((rx1, rx2)) + a.set_ylim((ry1, ry2)) + + 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 = rcParams['keymap.pan'] + description = 'Pan axes with left mouse, zoom with right' + image = 'move.png' + cursor = cursors.MOVE + radio_group = 'default' + + def __init__(self, *args): + ZoomPanBase.__init__(self, *args) + self._idDrag = None + + def _cancel_action(self): + self._button_pressed = None + self._xypress = [] + self.figure.canvas.mpl_disconnect(self._idDrag) + 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._idDrag = 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._idDrag) + 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() + + +default_tools = {'home': ToolHome, 'back': ToolBack, 'forward': ToolForward, + 'zoom': ToolZoom, 'pan': ToolPan, + 'subplots': 'ToolConfigureSubplots', + 'save': 'ToolSaveFigure', + 'grid': ToolGrid, + 'fullscreen': ToolFullScreen, + 'quit': ToolQuit, + 'allnav': ToolEnableAllNavigation, + 'nav': ToolEnableNavigation, + 'xscale': ToolXScale, + 'yscale': ToolYScale, + 'position': ToolCursorPosition, + _views_positions: ToolViewsPositions, + 'cursor': 'ToolSetCursor', + 'rubberband': 'ToolRubberband', + } +"""Default tools""" + +default_toolbar_tools = [['navigation', ['home', 'back', 'forward']], + ['zoompan', ['pan', 'zoom']], + ['layout', ['subplots']], + ['io', ['save']]] +"""Default tools in the toolbar""" + + +def add_tools_to_manager(toolmanager, tools=default_tools): + """ + Add multiple tools to `ToolManager` + + Parameters + ---------- + toolmanager: ToolManager + `backend_managers.ToolManager` object that will get the tools 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 six.iteritems(tools): + 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 given by tool1, and 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) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 0600da2fb8a5..32d15fb4fbcc 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -30,7 +30,10 @@ def fn_name(): return sys._getframe(1).f_code.co_name from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import RendererBase, GraphicsContextBase, \ FigureManagerBase, FigureCanvasBase, NavigationToolbar2, cursors, TimerBase -from matplotlib.backend_bases import ShowBase +from matplotlib.backend_bases import (ShowBase, ToolContainerBase, + StatusbarBase) +from matplotlib.backend_managers import ToolManager +from matplotlib import backend_tools from matplotlib.cbook import is_string_like, is_writable_file_like from matplotlib.colors import colorConverter @@ -414,18 +417,31 @@ def __init__(self, canvas, num): self.canvas.show() self.vbox.pack_start(self.canvas, True, True, 0) - - self.toolbar = self._get_toolbar(canvas) - # calculate size for window w = int (self.canvas.figure.bbox.width) h = int (self.canvas.figure.bbox.height) + self.toolmanager = self._get_toolmanager() + self.toolbar = self._get_toolbar() + self.statusbar = None + + def add_widget(child, expand, fill, padding): + child.show() + self.vbox.pack_end(child, False, False, 0) + size_request = child.size_request() + return size_request.height + + if self.toolmanager: + backend_tools.add_tools_to_manager(self.toolmanager) + if self.toolbar: + backend_tools.add_tools_to_container(self.toolbar) + self.statusbar = StatusbarGTK3(self.toolmanager) + h += add_widget(self.statusbar, False, False, 0) + h += add_widget(Gtk.HSeparator(), False, False, 0) + if self.toolbar is not None: self.toolbar.show() - self.vbox.pack_end(self.toolbar, False, False, 0) - size_request = self.toolbar.size_request() - h += size_request.height + h += add_widget(self.toolbar, False, False, 0) self.window.set_default_size (w, h) @@ -438,7 +454,10 @@ def destroy(*args): def notify_axes_change(fig): 'this will be called whenever the current axes is changed' - if self.toolbar is not None: self.toolbar.update() + if self.toolmanager is not None: + pass + elif self.toolbar is not None: + self.toolbar.update() self.canvas.figure.add_axobserver(notify_axes_change) self.canvas.grab_focus() @@ -469,15 +488,25 @@ def full_screen_toggle (self): _full_screen_flag = False - def _get_toolbar(self, canvas): + def _get_toolbar(self): # must be inited after the window, drawingArea and figure # attrs are set if rcParams['toolbar'] == 'toolbar2': - toolbar = NavigationToolbar2GTK3 (canvas, self.window) + toolbar = NavigationToolbar2GTK3 (self.canvas, self.window) + elif rcParams['toolbar'] == 'toolmanager': + toolbar = ToolbarGTK3(self.toolmanager) else: toolbar = None return toolbar + def _get_toolmanager(self): + # must be initialised after toolbar has been setted + if rcParams['toolbar'] != 'toolbar2': + toolmanager = ToolManager(self.canvas) + else: + toolmanager = None + return toolmanager + def get_window_title(self): return self.window.get_title() @@ -702,6 +731,212 @@ def get_filename_from_user (self): return filename, self.ext + +class RubberbandGTK3(backend_tools.RubberbandBase): + def __init__(self, *args, **kwargs): + backend_tools.RubberbandBase.__init__(self, *args, **kwargs) + self.ctx = None + + def draw_rubberband(self, x0, y0, x1, y1): + # 'adapted from http://aspn.activestate.com/ASPN/Cookbook/Python/ + # Recipe/189744' + self.ctx = self.figure.canvas.get_property("window").cairo_create() + + # todo: instead of redrawing the entire figure, copy the part of + # the figure that was covered by the previous rubberband rectangle + self.figure.canvas.draw() + + height = self.figure.bbox.height + y1 = height - y1 + y0 = height - y0 + w = abs(x1 - x0) + h = abs(y1 - y0) + rect = [int(val) for val in (min(x0, x1), min(y0, y1), w, h)] + + self.ctx.new_path() + self.ctx.set_line_width(0.5) + self.ctx.rectangle(rect[0], rect[1], rect[2], rect[3]) + self.ctx.set_source_rgb(0, 0, 0) + self.ctx.stroke() + + +class ToolbarGTK3(ToolContainerBase, Gtk.Box): + def __init__(self, toolmanager): + ToolContainerBase.__init__(self, toolmanager) + Gtk.Box.__init__(self) + self.set_property("orientation", Gtk.Orientation.VERTICAL) + + self._toolarea = Gtk.Box() + self._toolarea.set_property('orientation', Gtk.Orientation.HORIZONTAL) + self.pack_start(self._toolarea, False, False, 0) + self._toolarea.show_all() + self._groups = {} + self._toolitems = {} + + def add_toolitem(self, name, group, position, image_file, description, + toggle): + if toggle: + tbutton = Gtk.ToggleToolButton() + else: + tbutton = Gtk.ToolButton() + tbutton.set_label(name) + + if image_file is not None: + image = Gtk.Image() + image.set_from_file(image_file) + tbutton.set_icon_widget(image) + + if position is None: + position = -1 + + self._add_button(tbutton, group, position) + signal = tbutton.connect('clicked', self._call_tool, name) + tbutton.set_tooltip_text(description) + tbutton.show_all() + self._toolitems.setdefault(name, []) + self._toolitems[name].append((tbutton, signal)) + + def _add_button(self, button, group, position): + if group not in self._groups: + if self._groups: + self._add_separator() + toolbar = Gtk.Toolbar() + toolbar.set_style(Gtk.ToolbarStyle.ICONS) + self._toolarea.pack_start(toolbar, False, False, 0) + toolbar.show_all() + self._groups[group] = toolbar + self._groups[group].insert(button, position) + + def _call_tool(self, btn, name): + self.trigger_tool(name) + + def toggle_toolitem(self, name, toggled): + if name not in self._toolitems: + return + for toolitem, signal in self._toolitems[name]: + toolitem.handler_block(signal) + toolitem.set_active(toggled) + toolitem.handler_unblock(signal) + + def remove_toolitem(self, name): + if name not in self._toolitems: + self.toolmanager.message_event('%s Not in toolbar' % name, self) + return + + for group in self._groups: + for toolitem, _signal in self._toolitems[name]: + if toolitem in self._groups[group]: + self._groups[group].remove(toolitem) + del self._toolitems[name] + + def _add_separator(self): + sep = Gtk.Separator() + sep.set_property("orientation", Gtk.Orientation.VERTICAL) + self._toolarea.pack_start(sep, False, True, 0) + sep.show_all() + + +class StatusbarGTK3(StatusbarBase, Gtk.Statusbar): + def __init__(self, *args, **kwargs): + StatusbarBase.__init__(self, *args, **kwargs) + Gtk.Statusbar.__init__(self) + self._context = self.get_context_id('message') + + def set_message(self, s): + self.pop(self._context) + self.push(self._context, s) + + +class SaveFigureGTK3(backend_tools.SaveFigureBase): + + def get_filechooser(self): + fc = FileChooserDialog( + title='Save the figure', + parent=self.figure.canvas.manager.window, + path=os.path.expanduser(rcParams.get('savefig.directory', '')), + filetypes=self.figure.canvas.get_supported_filetypes(), + default_filetype=self.figure.canvas.get_default_filetype()) + fc.set_current_name(self.figure.canvas.get_default_filename()) + return fc + + def trigger(self, *args, **kwargs): + chooser = self.get_filechooser() + fname, format_ = chooser.get_filename_from_user() + chooser.destroy() + if fname: + startpath = os.path.expanduser( + rcParams.get('savefig.directory', '')) + if startpath == '': + # explicitly missing key or empty str signals to use cwd + rcParams['savefig.directory'] = startpath + else: + # save dir for next time + rcParams['savefig.directory'] = os.path.dirname( + six.text_type(fname)) + try: + self.figure.canvas.print_figure(fname, format=format_) + except Exception as e: + error_msg_gtk(str(e), parent=self) + + +class SetCursorGTK3(backend_tools.SetCursorBase): + def set_cursor(self, cursor): + self.figure.canvas.get_property("window").set_cursor(cursord[cursor]) + + +class ConfigureSubplotsGTK3(backend_tools.ConfigureSubplotsBase, Gtk.Window): + def __init__(self, *args, **kwargs): + backend_tools.ConfigureSubplotsBase.__init__(self, *args, **kwargs) + self.window = None + + def init_window(self): + if self.window: + return + self.window = Gtk.Window(title="Subplot Configuration Tool") + + try: + self.window.window.set_icon_from_file(window_icon) + except (SystemExit, KeyboardInterrupt): + # re-raise exit type Exceptions + raise + except: + # we presumably already logged a message on the + # failure of the main plot, don't keep reporting + pass + + self.vbox = Gtk.Box() + self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL) + self.window.add(self.vbox) + self.vbox.show() + self.window.connect('destroy', self.destroy) + + toolfig = Figure(figsize=(6, 3)) + canvas = self.figure.canvas.__class__(toolfig) + + toolfig.subplots_adjust(top=0.9) + SubplotTool(self.figure, toolfig) + + w = int(toolfig.bbox.width) + h = int(toolfig.bbox.height) + + self.window.set_default_size(w, h) + + canvas.show() + self.vbox.pack_start(canvas, True, True, 0) + self.window.show() + + def destroy(self, *args): + self.window.destroy() + self.window = None + + def _get_canvas(self, fig): + return self.canvas.__class__(fig) + + def trigger(self, sender, event, data=None): + self.init_window() + self.window.present() + + class DialogLineprops(object): """ A GUI dialog for controlling lineprops @@ -888,5 +1123,11 @@ def error_msg_gtk(msg, parent=None): dialog.destroy() +backend_tools.ToolSaveFigure = SaveFigureGTK3 +backend_tools.ToolConfigureSubplots = ConfigureSubplotsGTK3 +backend_tools.ToolSetCursor = SetCursorGTK3 +backend_tools.ToolRubberband = RubberbandGTK3 + +Toolbar = ToolbarGTK3 FigureCanvas = FigureCanvasGTK3 FigureManager = FigureManagerGTK3 diff --git a/lib/matplotlib/backends/backend_tkagg.py b/lib/matplotlib/backends/backend_tkagg.py index f652d13412be..8ffc8a85318e 100644 --- a/lib/matplotlib/backends/backend_tkagg.py +++ b/lib/matplotlib/backends/backend_tkagg.py @@ -20,7 +20,10 @@ from matplotlib.backend_bases import RendererBase, GraphicsContextBase from matplotlib.backend_bases import FigureManagerBase, FigureCanvasBase from matplotlib.backend_bases import NavigationToolbar2, cursors, TimerBase -from matplotlib.backend_bases import ShowBase +from matplotlib.backend_bases import (ShowBase, ToolContainerBase, + StatusbarBase) +from matplotlib.backend_managers import ToolManager +from matplotlib import backend_tools from matplotlib._pylab_helpers import Gcf from matplotlib.figure import Figure @@ -529,21 +532,45 @@ def __init__(self, canvas, num, window): self.window.withdraw() self.set_window_title("Figure %d" % num) self.canvas = canvas - self._num = num - if matplotlib.rcParams['toolbar']=='toolbar2': - self.toolbar = NavigationToolbar2TkAgg( canvas, self.window ) - else: - self.toolbar = None - if self.toolbar is not None: - self.toolbar.update() self.canvas._tkcanvas.pack(side=Tk.TOP, fill=Tk.BOTH, expand=1) + self._num = num + + self.toolmanager = self._get_toolmanager() + self.toolbar = self._get_toolbar() + self.statusbar = None + + if self.toolmanager: + backend_tools.add_tools_to_manager(self.toolmanager) + if self.toolbar: + backend_tools.add_tools_to_container(self.toolbar) + self.statusbar = StatusbarTk(self.window, self.toolmanager) + self._shown = False def notify_axes_change(fig): 'this will be called whenever the current axes is changed' - if self.toolbar != None: self.toolbar.update() + if self.toolmanager is not None: + pass + elif self.toolbar is not None: + self.toolbar.update() self.canvas.figure.add_axobserver(notify_axes_change) + def _get_toolbar(self): + if matplotlib.rcParams['toolbar'] == 'toolbar2': + toolbar = NavigationToolbar2TkAgg(self.canvas, self.window) + elif matplotlib.rcParams['toolbar'] == 'toolmanager': + toolbar = ToolbarTk(self.toolmanager, self.window) + else: + toolbar = None + return toolbar + + def _get_toolmanager(self): + if rcParams['toolbar'] != 'toolbar2': + toolmanager = ToolManager(self.canvas) + else: + toolmanager = None + return toolmanager + def resize(self, width, height=None): # before 09-12-22, the resize method takes a single *event* # parameter. On the other hand, the resize method of other @@ -871,5 +898,209 @@ def hidetip(self): if tw: tw.destroy() + +class RubberbandTk(backend_tools.RubberbandBase): + def __init__(self, *args, **kwargs): + backend_tools.RubberbandBase.__init__(self, *args, **kwargs) + + def draw_rubberband(self, x0, y0, x1, y1): + height = self.figure.canvas.figure.bbox.height + y0 = height - y0 + y1 = height - y1 + try: + self.lastrect + except AttributeError: + pass + else: + self.figure.canvas._tkcanvas.delete(self.lastrect) + self.lastrect = self.figure.canvas._tkcanvas.create_rectangle(x0, y0, x1, y1) + + def remove_rubberband(self): + try: + self.lastrect + except AttributeError: + pass + else: + self.figure.canvas._tkcanvas.delete(self.lastrect) + del self.lastrect + + +class SetCursorTk(backend_tools.SetCursorBase): + def set_cursor(self, cursor): + self.figure.canvas.manager.window.configure(cursor=cursord[cursor]) + + +class ToolbarTk(ToolContainerBase, Tk.Frame): + def __init__(self, toolmanager, window): + ToolContainerBase.__init__(self, toolmanager) + xmin, xmax = self.toolmanager.canvas.figure.bbox.intervalx + height, width = 50, xmax - xmin + Tk.Frame.__init__(self, master=window, + width=int(width), height=int(height), + borderwidth=2) + self._toolitems = {} + self.pack(side=Tk.TOP, fill=Tk.X) + self._groups = {} + + def add_toolitem(self, name, group, position, image_file, description, + toggle): + frame = self._get_groupframe(group) + button = self._Button(name, image_file, toggle, frame) + if description is not None: + ToolTip.createToolTip(button, description) + self._toolitems.setdefault(name, []) + self._toolitems[name].append(button) + + def _get_groupframe(self, group): + if group not in self._groups: + if self._groups: + self._add_separator() + frame = Tk.Frame(master=self, borderwidth=0) + frame.pack(side=Tk.LEFT, fill=Tk.Y) + self._groups[group] = frame + return self._groups[group] + + def _add_separator(self): + separator = Tk.Frame(master=self, bd=5, width=1, bg='black') + separator.pack(side=Tk.LEFT, fill=Tk.Y, padx=2) + + def _Button(self, text, image_file, toggle, frame): + if image_file is not None: + im = Tk.PhotoImage(master=self, file=image_file) + else: + im = None + + if not toggle: + b = Tk.Button(master=frame, text=text, padx=2, pady=2, image=im, + command=lambda: self._button_click(text)) + else: + b = Tk.Checkbutton(master=frame, text=text, padx=2, pady=2, + image=im, indicatoron=False, + command=lambda: self._button_click(text)) + b._ntimage = im + b.pack(side=Tk.LEFT) + return b + + def _button_click(self, name): + self.trigger_tool(name) + + def toggle_toolitem(self, name, toggled): + if name not in self._toolitems: + return + for toolitem in self._toolitems[name]: + if toggled: + toolitem.select() + else: + toolitem.deselect() + + def remove_toolitem(self, name): + for toolitem in self._toolitems[name]: + toolitem.pack_forget() + del self._toolitems[name] + + +class StatusbarTk(StatusbarBase, Tk.Frame): + def __init__(self, window, *args, **kwargs): + StatusbarBase.__init__(self, *args, **kwargs) + xmin, xmax = self.toolmanager.canvas.figure.bbox.intervalx + height, width = 50, xmax - xmin + Tk.Frame.__init__(self, master=window, + width=int(width), height=int(height), + borderwidth=2) + self._message = Tk.StringVar(master=self) + self._message_label = Tk.Label(master=self, textvariable=self._message) + self._message_label.pack(side=Tk.RIGHT) + self.pack(side=Tk.TOP, fill=Tk.X) + + def set_message(self, s): + self._message.set(s) + + +class SaveFigureTk(backend_tools.SaveFigureBase): + def trigger(self, *args): + from six.moves import tkinter_tkfiledialog, tkinter_messagebox + filetypes = self.figure.canvas.get_supported_filetypes().copy() + default_filetype = self.figure.canvas.get_default_filetype() + + # Tk doesn't provide a way to choose a default filetype, + # so we just have to put it first + default_filetype_name = filetypes[default_filetype] + del filetypes[default_filetype] + + sorted_filetypes = list(six.iteritems(filetypes)) + sorted_filetypes.sort() + sorted_filetypes.insert(0, (default_filetype, default_filetype_name)) + + tk_filetypes = [ + (name, '*.%s' % ext) for (ext, name) in sorted_filetypes] + + # adding a default extension seems to break the + # asksaveasfilename dialog when you choose various save types + # from the dropdown. Passing in the empty string seems to + # work - JDH! + # defaultextension = self.figure.canvas.get_default_filetype() + defaultextension = '' + initialdir = rcParams.get('savefig.directory', '') + initialdir = os.path.expanduser(initialdir) + initialfile = self.figure.canvas.get_default_filename() + fname = tkinter_tkfiledialog.asksaveasfilename( + master=self.figure.canvas.manager.window, + title='Save the figure', + filetypes=tk_filetypes, + defaultextension=defaultextension, + initialdir=initialdir, + initialfile=initialfile, + ) + + if fname == "" or fname == (): + return + else: + if initialdir == '': + # explicitly missing key or empty str signals to use cwd + rcParams['savefig.directory'] = initialdir + else: + # save dir for next time + rcParams['savefig.directory'] = os.path.dirname( + six.text_type(fname)) + try: + # This method will handle the delegation to the correct type + self.figure.canvas.print_figure(fname) + except Exception as e: + tkinter_messagebox.showerror("Error saving file", str(e)) + + +class ConfigureSubplotsTk(backend_tools.ConfigureSubplotsBase): + def __init__(self, *args, **kwargs): + backend_tools.ConfigureSubplotsBase.__init__(self, *args, **kwargs) + self.window = None + + def trigger(self, *args): + self.init_window() + self.window.lift() + + def init_window(self): + if self.window: + return + + toolfig = Figure(figsize=(6, 3)) + self.window = Tk.Tk() + + canvas = FigureCanvasTkAgg(toolfig, master=self.window) + toolfig.subplots_adjust(top=0.9) + _tool = SubplotTool(self.figure, toolfig) + canvas.show() + canvas.get_tk_widget().pack(side=Tk.TOP, fill=Tk.BOTH, expand=1) + self.window.protocol("WM_DELETE_WINDOW", self.destroy) + + def destroy(self, *args, **kwargs): + self.window.destroy() + self.window = None + + +backend_tools.ToolSaveFigure = SaveFigureTk +backend_tools.ToolConfigureSubplots = ConfigureSubplotsTk +backend_tools.ToolSetCursor = SetCursorTk +backend_tools.ToolRubberband = RubberbandTk +Toolbar = ToolbarTk FigureCanvas = FigureCanvasTkAgg FigureManager = FigureManagerTkAgg diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 1fdf121b32d8..f7263ca7e383 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -168,7 +168,7 @@ def validate_backend(s): def validate_toolbar(s): validator = ValidateInStrings( 'toolbar', - ['None', 'toolbar2'], + ['None', 'toolbar2', 'toolmanager'], ignorecase=True) return validator(s)