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..6e419ac2156f 100644 --- a/doc/api/index_backend_api.rst +++ b/doc/api/index_backend_api.rst @@ -5,6 +5,7 @@ backends .. toctree:: backend_bases_api.rst + backend_tools_api.rst backend_gtkagg_api.rst backend_qt4agg_api.rst backend_wxagg_api.rst diff --git a/examples/user_interfaces/navigation.py b/examples/user_interfaces/navigation.py new file mode 100644 index 000000000000..8142082d98fd --- /dev/null +++ b/examples/user_interfaces/navigation.py @@ -0,0 +1,63 @@ +import matplotlib +matplotlib.use('GTK3Cairo') +# matplotlib.use('TkAGG') +matplotlib.rcParams['toolbar'] = 'navigation' +import matplotlib.pyplot as plt +from matplotlib.backend_tools import ToolBase + + +# Create a simple tool to list all the tools +class ListTools(ToolBase): + # keyboard shortcut + 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.navigation.tools + for name in sorted(tools.keys()): + if not tools[name].description: + continue + keys = ', '.join(sorted(self.navigation.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.navigation.active_toggle.items(): + print("{0:12} {1:45}").format(group, active) + + +# A simple example of copy canvas +# ref: at https://github.com/matplotlib/matplotlib/issues/1987 +class CopyToolGTK3(ToolBase): + keymap = 'ctrl+c' + description = 'Copy canvas' + + def trigger(self, *args, **kwargs): + from gi.repository import Gtk, Gdk + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + window = self.figure.canvas.get_window() + x, y, width, height = window.get_geometry() + pb = Gdk.pixbuf_get_from_window(window, x, y, width, height) + clipboard.set_image(pb) + + +fig = plt.figure() +plt.plot([1, 2, 3]) + +# Add the custom tools that we created +fig.canvas.manager.navigation.add_tool('List', ListTools) +if matplotlib.rcParams['backend'] == 'GTK3Cairo': + fig.canvas.manager.navigation.add_tool('copy', CopyToolGTK3) + +# Uncomment to remove the forward button +# fig.canvas.manager.navigation.remove_tool('forward') + +plt.show() diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 908c05d4362d..3d601dbf86a0 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -25,6 +25,14 @@ the 'show' callable is then set to Show.__call__, inherited from ShowBase. +:class:`NavigationBase` + The base class for the Navigation class that makes the bridge between + user interaction (key press, toolbar clicks, ..) and the actions in + response to the user inputs. + +:class:`ToolbarBase` + The base class for the Toolbar class of each interactive backend. + """ from __future__ import (absolute_import, division, print_function, @@ -46,6 +54,7 @@ import matplotlib.widgets as widgets #import matplotlib.path as path from matplotlib import rcParams +from matplotlib.rcsetup import validate_stringlist from matplotlib import is_interactive from matplotlib import get_backend from matplotlib._pylab_helpers import Gcf @@ -56,6 +65,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 @@ -2552,8 +2562,10 @@ 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'] != 'navigation': + self.key_press_handler_id = self.canvas.mpl_connect( + 'key_press_event', + self.key_press) """ The returned id from connecting the default key handler via :meth:`FigureCanvasBase.mpl_connnect`. @@ -2589,7 +2601,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'] != 'navigation': + key_press_handler(event, self.canvas, self.canvas.toolbar) def show_popup(self, msg): """ @@ -2612,10 +2625,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): @@ -3195,3 +3205,479 @@ def zoom(self, *args): def set_history_buttons(self): """Enable or disable back/forward button""" pass + + +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 NavigationMessageEvent(object): + """Event carrying messages from navigation + + 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 NavigationBase(object): + """Helper class that groups all the user interactions for a FigureManager + + Attributes + ---------- + manager: `FigureManager` instance + keypresslock: `LockDraw` to know if the `canvas` key_press_event is + locked + messagelock: `LockDraw` 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 nav_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' + + func : function + Function to be called with signature + def func(event) + """ + return self._callbacks.connect(s, func) + + def nav_disconnect(self, cid): + """Disconnect callback id cid + + Example usage:: + + cid = navigation.nav_connect('tool_message_event', on_press) + #...later + navigation.nav_disconnect(cid) + """ + return self._callbacks.disconnect(cid) + + def message_event(self, message, sender=None): + """ Emit a tool_message_event event""" + if sender is None: + sender = self + + s = 'tool_message_event' + event = NavigationMessageEvent(s, sender, message) + self._callbacks.process(s, event) + + @property + def active_toggle(self): + """Toggled Tool + + **dict** : 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 set_tool_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 AttributeError('%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 `Navigation` + + 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, 'navigation') + + self._remove_keys(name) + + s = 'tool_removed_event' + event = ToolEvent(s, self, tool) + self._callbacks.process(s, event) + + del self._tools[name] + + def add_tools(self, tools): + """ Add multiple tools to `Navigation` + + Parameters + ---------- + tools : List + List in the form + [[group1, [(Tool1, name1), (Tool2, name2) ...]][group2...]] + where group1 is the name of the group where the + Tool1, Tool2... are going to be added, and name1, name2... are the + names of the tools + """ + + for group, grouptools in tools: + for position, tool in enumerate(grouptools): + self.add_tool(tool[1], tool[0], group, position) + + def add_tool(self, name, tool, group=None, position=None): + """Add tool to `NavigationBase` + + Add a tool to the tools controlled by Navigation + + 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 : string + Name of the tool, treated as the ID, has to be unique + tool : string or `matplotlib.backend_tools.ToolBase` derived class + Reference to find the class of the Tool to be added + group: String + Group to position the tool in + position : int or None (default) + Position within its group in the toolbar, if None, it goes at the end + """ + + tool_cls = self._get_cls_to_instantiate(tool) + if tool_cls is False: + warnings.warn('Impossible to find class for %s' % str(tool)) + return + + if name in self._tools: + warnings.warn('A tool_cls with the same name already exist, ' + 'not added') + return + + self._tools[name] = tool_cls(self, name) + if tool_cls.keymap is not None: + self.set_tool_keymap(name, tool_cls.keymap) + + # For toggle tools init the radio_group in self._toggled + if getattr(tool_cls, 'toggled', False) is not False: + # None group is not mutually exclusive, a set is used to keep track + # of all toggled tools in this group + if tool_cls.radio_group is None: + self._toggled.setdefault(None, set()) + else: + self._toggled.setdefault(tool_cls.radio_group, None) + + self._tool_added_event(self._tools[name], group, position) + + def _tool_added_event(self, tool, group, position): + s = 'tool_added_event' + event = ToolEvent(s, + self, + tool, + data={'group': group, 'position': position}) + self._callbacks.process(s, event) + + def _handle_toggle(self, tool, sender, *args): + # Toggle tools, need to untoggle prior to using other Toggle tool + # Called from tool_trigger_event + + 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.get(radio_group, None) 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, *args) + toggled = tool.name + + # Keep track of the toggled tool in the radio_group + self._toggled[radio_group] = toggled +# for a in self.canvas.figure.get_axes(): +# a.set_navigate_mode(self._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(): + return globals()[callback_class] + + mod = self.__class__.__module__ + current_module = __import__(mod, + globals(), locals(), [mod], 0) + + return getattr(current_module, callback_class, False) + + return callback_class + + 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 + + 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 `Navigation`""" + + return self._tools + + def get_tool(self, name): + """Return the tool object, also accepts the actual tool for convenience + + Parameters + ----------- + name : String, ToolBase + Name of the tool, or the tool itself + """ + if isinstance(name, tools.ToolBase): + return name + if name not in self._tools: + warnings.warn("%s is not a tool controlled by Navigation" % name) + return None + return self._tools[name] + + +class ToolbarBase(object): + """Base class for `Toolbar` implementation + + Attributes + ---------- + manager : `FigureManager` object that integrates this `Toolbar` + navigation : `NavigationBase` object that hold the tools that + this `Toolbar` wants to communicate with + """ + + def __init__(self, navigation): + self.navigation = navigation + + self.navigation.nav_connect('tool_message_event', self._message_cbk) + self.navigation.nav_connect('tool_added_event', self._add_tool_cbk) + self.navigation.nav_connect('tool_removed_event', + self._remove_tool_cbk) + + def _message_cbk(self, event): + """Captures the 'tool_message_event' to set the message on the toolbar""" + self.set_message(event.message) + + def _tool_triggered_cbk(self, event): + """Captures the 'tool-trigger-toolname + + This only gets used for toggled tools + """ + if event.sender is self: + return + + self.toggle_toolitem(event.tool.name) + + def _add_tool_cbk(self, event): + """Captures 'tool_added_event' and adds the tool to the toolbar""" + image = self._get_image_filename(event.tool.image) + toggle = getattr(event.tool, 'toggled', None) is not None + self.add_toolitem(event.tool.name, + event.data['group'], + event.data['position'], + image, + event.tool.description, + toggle) + if toggle: + event.tool.tool_connect('tool_triggered', self._tool_triggered_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 toolbar + + """ + self.navigation.trigger_tool(name, sender=self) + + def add_toolitem(self, name, group, position, image, description, toggle): + """Add a toolitem to the toolbar + + 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 set_message(self, s): + """Display a message on toolbar or in status bar + + Parameters + ---------- + s : String + Message text + """ + + pass + + def toggle_toolitem(self, name): + """Toggle the toolitem without firing event + + Parameters + ---------- + name : String + Id of the tool to toggle + """ + raise NotImplementedError + + def remove_toolitem(self, name): + """Remove a toolitem from the `Toolbar` + + This method must get implemented per backend + + Called when `NavigationBase` emits a `tool_removed_event` + + Parameters + ---------- + name : string + Name of the tool to remove + + """ + + raise NotImplementedError diff --git a/lib/matplotlib/backend_tools.py b/lib/matplotlib/backend_tools.py new file mode 100644 index 000000000000..894c600bc072 --- /dev/null +++ b/lib/matplotlib/backend_tools.py @@ -0,0 +1,957 @@ +""" +Abstract base classes define the primitives for Tools. +These tools are used by `NavigationBase` + +: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 `Navigation` +""" + + +from matplotlib import rcParams +from matplotlib._pylab_helpers import Gcf +from matplotlib import docstring +import matplotlib.cbook as cbook +from weakref import WeakKeyDictionary +import numpy as np + + +class Cursors(object): + """Simple namespace for cursor reference""" + HAND, POINTER, SELECT_REGION, MOVE = list(range(4)) +cursors = Cursors() + +class ToolTriggerEvent(object): + """Event to inform that a tool has been triggered""" + def __init__(self, name, sender, tool, canvasevent=None, data=None): + self.name, self.sender, self.tool, self.canvasevent, self.data =\ + name, sender, tool, canvasevent, data + +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_bases.NavigationBase` + + Attributes + ---------- + navigation: `matplotlib.backend_bases.NavigationBase` + Navigation 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 + Navigation + """ + + 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, navigation, name): + self._name = name + self.figure = None + self.navigation = navigation + self._callbacks = cbook.CallbackRegistry() + self.set_figure(navigation.canvas.figure) + + @docstring.dedent_interpd + def trigger(self, sender, event, data=None): + """Called when this tool gets used + + This method is called by + `matplotlib.backend_bases.NavigationBase.tool_trigger_event` + + 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 + """ + self._trigger(sender, event, data) + self.trigger_event(sender, event, data) + + def _trigger(self, *args, **kwargs): + """ Tools should Override this method, not trigger. + %(trigger)s + """ + pass + + def set_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_bases.NavigationBase.remove_tool` + """ + pass + + def trigger_event(self, sender, canvasevent, data): + s = 'tool_triggered' + event = ToolTriggerEvent(s, sender, self, canvasevent, data) + self._callbacks.process(s, event) + + def tool_connect(self, s, func): + ''' + Parameters + ----------- + s : String + Name of the event, i.e. 'tool_triggered' + + func : function + Function to be called with signature + def func(event) + ''' + return self._callbacks.connect(s, func) + +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 methond when `toggled` is True. + + This can happen in different circumstances + + * Click on the toolbar tool button + * Call to `matplotlib.backend_bases.NavigationBase.tool_trigger_event` + * Another `ToolToggleBase` derived tool is triggered + (from the same `Navigation`) + """ + + 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.navigation.nav_connect('tool_added_event', self._add_tool_cbk) + + # process current tools + for tool in self.navigation.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) + + # If the tool is toggleable, set the cursor when the tool is triggered + def _add_tool(self, tool): + if getattr(tool, 'cursor', None) is not None: + tool.tool_connect('tool_triggered', self._tool_trigger_cbk) + + # If tool is added, process it + def _add_tool_cbk(self, event): + 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 + else: + if 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 + """ + pass + + +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_bases.NavigationBase.message_event`""" + if self.navigation.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.navigation.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 + """ + pass + + def remove_rubberband(self): + """Remove rubberband + + This method must get implemented per backend + """ + pass + + +class ToolQuit(ToolBase): + """Tool to call the figure manager destroy method""" + + description = 'Quit the figure' + 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 navigation interaction""" + + description = 'Enables all axes navigation' + 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 navigation interaction""" + + description = 'Enables one axes navigation' + 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()): + # consider axes, in which the event was raised + # FIXME: Why only this 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' + 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' + 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' + 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' + 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.navigation.get_tool('viewpos').add_figure() + getattr(self.navigation.get_tool('viewpos'), self._on_trigger)() + self.navigation.get_tool('viewpos').update_view() + + +class ToolHome(ViewsPositionsBase): + """Restore the original view lim""" + + description = 'Reset original view' + image = 'home.png' + 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' + 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' + 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' + 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.navigation.get_tool('viewpos').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' + 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.navigation.trigger_tool('rubberband', self) + self.navigation.get_tool('viewpos').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.navigation.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.navigation.get_tool('viewpos').push_current() + self._cancel_action() + + +class ToolPan(ZoomPanBase): + """Pan axes with left mouse, zoom with right""" + + 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.navigation.messagelock.release(self) + self.navigation.get_tool('viewpos').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.navigation.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.navigation.messagelock.release(self) + + for a, _ind in self._xypress: + a.end_pan() + if not self._xypress: + self._cancel_action() + return + + self.navigation.get_tool('viewpos').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.navigation.canvas.draw_idle() + + +tools = [['navigation', [(ToolHome, 'home'), + (ToolBack, 'back'), + (ToolForward, 'forward')]], + + ['zoompan', [(ToolZoom, 'zoom'), + (ToolPan, 'pan')]], + + ['layout', [('ToolConfigureSubplots', 'subplots'), ]], + + ['io', [('ToolSaveFigure', 'save'), ]], + + [None, [(ToolGrid, 'grid'), + (ToolFullScreen, 'fullscreen'), + (ToolQuit, 'quit'), + (ToolEnableAllNavigation, 'allnav'), + (ToolEnableNavigation, 'nav'), + (ToolXScale, 'xscale'), + (ToolYScale, 'yscale'), + (ToolCursorPosition, 'position'), + (ToolViewsPositions, 'viewpos'), + ('ToolSetCursor', 'cursor'), + ('ToolRubberband', 'rubberband')]]] +"""Default tools""" diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 4d32873f19d7..5aa6474e4ee8 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -30,7 +30,9 @@ 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, ToolbarBase, NavigationBase +from matplotlib.backend_tools import SaveFigureBase, ConfigureSubplotsBase, \ + tools, SetCursorBase, RubberbandBase from matplotlib.cbook import is_string_like, is_writable_file_like from matplotlib.colors import colorConverter @@ -412,7 +414,10 @@ def __init__(self, canvas, num): self.vbox.pack_start(self.canvas, True, True, 0) - self.toolbar = self._get_toolbar(canvas) + self.navigation = self._get_navigation() + self.toolbar = self._get_toolbar() + if matplotlib.rcParams['toolbar'] == 'navigation': + self.navigation.add_tools(tools) # calculate size for window w = int (self.canvas.figure.bbox.width) @@ -435,7 +440,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.navigation 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() @@ -466,15 +474,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'] == 'navigation': + toolbar = ToolbarGTK3(self.navigation) else: toolbar = None return toolbar + def _get_navigation(self): + # must be initialised after toolbar has been setted + if rcParams['toolbar'] != 'toolbar2': + navigation = NavigationGTK3(self.canvas) + else: + navigation = None + return navigation + def get_window_title(self): return self.window.get_title() @@ -699,7 +717,224 @@ def get_filename_from_user (self): return filename, self.ext -class DialogLineprops(object): + +class NavigationGTK3(NavigationBase): + pass + + +class RubberbandGTK3(RubberbandBase): + def __init__(self, *args, **kwargs): + 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() + +ToolRubberband = RubberbandGTK3 + + +class ToolbarGTK3(ToolbarBase, Gtk.Box): + def __init__(self, navigation): + ToolbarBase.__init__(self, navigation) + Gtk.Box.__init__(self) + self.set_property("orientation", Gtk.Orientation.VERTICAL) + + self._toolbar = Gtk.Toolbar() + self._toolbar.set_style(Gtk.ToolbarStyle.ICONS) + self.pack_start(self._toolbar, False, False, 0) + self._toolbar.show_all() + self._toolitems = {} + self._signals = {} + self._setup_message_area() + + def _setup_message_area(self): + box = Gtk.Box() + box.set_property("orientation", Gtk.Orientation.HORIZONTAL) + sep = Gtk.Separator() + sep.set_property("orientation", Gtk.Orientation.VERTICAL) + box.pack_start(sep, False, True, 0) + self.message = Gtk.Label() + box.pack_end(self.message, False, False, 0) + self.pack_end(box, False, False, 5) + box.show_all() + + sep = Gtk.Separator() + sep.set_property("orientation", Gtk.Orientation.HORIZONTAL) + self.pack_end(sep, False, True, 0) + sep.show_all() + + def add_toolitem(self, name, group, position, image_file, description, + toggle): + if group is None: + return + + 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 + # TODO implement groups positions + self._toolbar.insert(tbutton, -1) + signal = tbutton.connect('clicked', self._call_tool, name) + tbutton.set_tooltip_text(description) + tbutton.show_all() + self._toolitems[name] = tbutton + self._signals[name] = signal + + def _call_tool(self, btn, name): + self.trigger_tool(name) + + def set_message(self, s): + self.message.set_label(s) + + def toggle_toolitem(self, name): + if name not in self._toolitems: + return + + status = self._toolitems[name].get_active() + self._toolitems[name].handler_block(self._signals[name]) + self._toolitems[name].set_active(not status) + self._toolitems[name].handler_unblock(self._signals[name]) + + def remove_toolitem(self, name): + if name not in self._toolitems: + self.set_message('%s Not in toolbar' % name) + return + self._toolbar.remove(self._toolitems[name]) + del self._toolitems[name] + + def add_separator(self, pos=-1): + toolitem = Gtk.SeparatorToolItem() + self._toolbar.insert(toolitem, pos) + toolitem.show() + return toolitem + + +class SaveFigureGTK3(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) + +ToolSaveFigure = SaveFigureGTK3 + + +class SetCursorGTK3(SetCursorBase): + def set_cursor(self, cursor): + self.figure.canvas.get_property("window").set_cursor(cursord[cursor]) + +ToolSetCursor = SetCursorGTK3 + + +class ConfigureSubplotsGTK3(ConfigureSubplotsBase, Gtk.Window): + def __init__(self, *args, **kwargs): + 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() + + +ToolConfigureSubplots = ConfigureSubplotsGTK3 + + +class DialogLineprops: """ A GUI dialog for controlling lineprops """ @@ -884,6 +1119,7 @@ def error_msg_gtk(msg, parent=None): dialog.run() dialog.destroy() - +Toolbar = ToolbarGTK3 +Navigation = NavigationGTK3 FigureCanvas = FigureCanvasGTK3 FigureManager = FigureManagerGTK3 diff --git a/lib/matplotlib/backends/backend_tkagg.py b/lib/matplotlib/backends/backend_tkagg.py index 9d8368d25abf..2fb9cba059db 100644 --- a/lib/matplotlib/backends/backend_tkagg.py +++ b/lib/matplotlib/backends/backend_tkagg.py @@ -20,7 +20,8 @@ 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, ToolbarBase, NavigationBase +from matplotlib.backend_tools import SaveFigureBase, ConfigureSubplotsBase, tools from matplotlib._pylab_helpers import Gcf from matplotlib.figure import Figure @@ -541,9 +542,29 @@ def __init__(self, canvas, num, window): def notify_axes_change(fig): 'this will be called whenever the current axes is changed' - if self.toolbar != None: self.toolbar.update() + if self.navigation 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'] == 'navigation': + toolbar = ToolbarTk(self) + else: + toolbar = None + return toolbar + + def _get_navigation(self): + # must be inited after toolbar is setted + if rcParams['toolbar'] != 'toolbar2': + navigation = NavigationTk(self) + else: + navigation = None + return navigation + 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 +892,186 @@ def hidetip(self): if tw: tw.destroy() + +class NavigationTk(NavigationBase): + def __init__(self, *args, **kwargs): + NavigationBase.__init__(self, *args, **kwargs) + + def set_cursor(self, cursor): + self.canvas.manager.window.configure(cursor=cursord[cursor]) + + def draw_rubberband(self, event, caller, x0, y0, x1, y1): + if not self.canvas.widgetlock.available(caller): + return + height = self.canvas.figure.bbox.height + y0 = height - y0 + y1 = height - y1 + try: + self.lastrect + except AttributeError: + pass + else: + self.canvas._tkcanvas.delete(self.lastrect) + self.lastrect = self.canvas._tkcanvas.create_rectangle(x0, y0, x1, y1) + + def remove_rubberband(self, event, caller): + try: + self.lastrect + except AttributeError: + pass + else: + self.canvas._tkcanvas.delete(self.lastrect) + del self.lastrect + + +class ToolbarTk(ToolbarBase, Tk.Frame): + def __init__(self, manager): + ToolbarBase.__init__(self, manager) + xmin, xmax = self.manager.canvas.figure.bbox.intervalx + height, width = 50, xmax - xmin + Tk.Frame.__init__(self, master=self.manager.window, + width=int(width), height=int(height), + borderwidth=2) + self._toolitems = {} + self._add_message() + + def _add_toolitem(self, name, tooltip_text, image_file, position, + toggle): + + button = self._Button(name, image_file, toggle) + if tooltip_text is not None: + ToolTip.createToolTip(button, tooltip_text) + self._toolitems[name] = button + + def _Button(self, text, image_file, toggle): + if image_file is not None: + im = Tk.PhotoImage(master=self, file=image_file) + else: + im = None + + if not toggle: + b = Tk.Button(master=self, text=text, padx=2, pady=2, image=im, + command=lambda: self._button_click(text)) + else: + b = Tk.Checkbutton(master=self, 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.manager.navigation._toolbar_callback(name) + + def _toggle(self, name, callback=False): + if name not in self._toolitems: + self.set_message('%s Not in toolbar' % name) + return + self._toolitems[name].toggle() + if callback: + self._button_click(name) + + def _add_message(self): + 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.BOTTOM, fill=Tk.X) + + def set_message(self, s): + self.message.set(s) + + def _remove_toolitem(self, name): + self._toolitems[name].pack_forget() + del self._toolitems[name] + + def set_toolitem_visibility(self, name, visible): + pass + + +class SaveFigureTk(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(ConfigureSubplotsBase): + def __init__(self, *args, **kwargs): + ConfigureSubplotsBase.__init__(self, *args, **kwargs) + self.window = None + + def trigger(self, event): + 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 + + +SaveFigure = SaveFigureTk +ConfigureSubplots = ConfigureSubplotsTk +Toolbar = ToolbarTk +Navigation = NavigationTk FigureCanvas = FigureCanvasTkAgg FigureManager = FigureManagerTkAgg diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 60ab3e1a3fbb..a08c61072285 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', 'navigation'], ignorecase=True) return validator(s)