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 5ca377150e2f..295976cbfcab 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..42903b03d9c6 --- /dev/null +++ b/examples/user_interfaces/navigation.py @@ -0,0 +1,59 @@ +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, event): + tools = self.navigation.get_tools() + + print ('_' * 80) + print ("{0:12} {1:45} {2}".format('Name (id)', + 'Tool description', + 'Keymap')) + print ('_' * 80) + for name in sorted(tools.keys()): + keys = ', '.join(sorted(tools[name]['keymap'])) + print ("{0:12} {1:45} {2}".format(name, + tools[name]['description'], + keys)) + print ('_' * 80) + + +# A simple example of copy canvas +# ref: at https://github.com/matplotlib/matplotlib/issues/1987 +class CopyToolGTK3(ToolBase): + keymap = 'ctrl+c' + description = 'Copy canvas' + # It is not added to the toolbar as a button + intoolbar = False + + def trigger(self, event): + 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) + +# Just for fun, lets 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 c7a10904f37e..d7e55beef196 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 @@ -2531,8 +2541,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`. @@ -2568,7 +2580,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): """ @@ -2591,10 +2604,7 @@ def set_window_title(self, title): pass -class Cursors: - # 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): @@ -3171,3 +3181,463 @@ def zoom(self, *args): def set_history_buttons(self): """Enable or disable back/forward button""" pass + + +class NavigationBase(object): + """ Helper class that groups all the user interactions for a FigureManager + + Attributes + ---------- + manager : `FigureManager` instance + toolbar : `Toolbar` instance that is controlled by this `Navigation` + keypresslock : `LockDraw` to know if the `canvas` key_press_event is + locked + messagelock : `LockDraw` to know if the message is available to write + """ + + _default_cursor = cursors.POINTER + + def __init__(self, manager): + """.. automethod:: _toolbar_callback""" + + self.manager = manager + self.canvas = manager.canvas + self.toolbar = manager.toolbar + + self._key_press_handler_id = self.canvas.mpl_connect( + 'key_press_event', self._key_press) + + self._idDrag = self.canvas.mpl_connect( + 'motion_notify_event', self._mouse_move) + + self._tools = {} + self._keys = {} + self._toggled = None + + # to process keypress event + self.keypresslock = widgets.LockDraw() + # to write into toolbar message + self.messagelock = widgets.LockDraw() + + self._last_cursor = self._default_cursor + + @property + def active_toggle(self): + """Toggled Tool + + **string** : Currently toggled tool, or None + """ + + return self._toggled + + def get_tool_keymap(self, name): + """Get the keymap associated with a 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): + keys = [k for k, v in six.iteritems(self._keys) if v == name] + for k in keys: + del self._keys[k] + + def set_tool_keymap(self, name, *keys): + """Set the keymap associated with a tool + + Parameters + ---------- + name : string + Name of the Tool + keys : keys to associated 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 the `Navigation` + + Parameters + ---------- + name : string + Name of the Tool + """ + + tool = self._tools[name] + tool.destroy() + + if self._toggled == name: + self._handle_toggle(name, from_toolbar=False) + + self._remove_keys(name) + + if self.toolbar and tool.intoolbar: + self.toolbar._remove_toolitem(name) + + del self._tools[name] + + def add_tools(self, tools): + """ Add multiple tools to `Navigation` + + Parameters + ---------- + tools : a list of tuples which contains the id of the tool and + a either a reference to the tool Tool class itself, or None to + insert a spacer. See :func:`add_tool`. + """ + for name, tool in tools: + if tool is None: + if self.toolbar is not None: + self.toolbar.add_separator(-1) + else: + self.add_tool(name, tool, None) + + def add_tool(self, name, tool, position=None): + """Add tool to `Navigation` + + Parameters + ---------- + name : string + Name of the tool, treated as the ID, has to be unique + tool : string or `Tool` class + Reference to find the class of the Tool to be added + position : int or None (default) + Position in the toolbar, if None, is positioned 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.canvas.figure, name) + if tool_cls.keymap is not None: + self.set_tool_keymap(name, tool_cls.keymap) + + if self.toolbar and tool_cls.intoolbar: + # TODO: better search for images, they are not always in the + # datapath + basedir = os.path.join(rcParams['datapath'], 'images') + if tool_cls.image is not None: + fname = os.path.join(basedir, tool_cls.image) + else: + fname = None + toggle = issubclass(tool_cls, tools.ToolToggleBase) + self.toolbar._add_toolitem(name, + tool_cls.description, + fname, + position, + toggle) + + def _get_cls_to_instantiate(self, callback_class): + 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, event=None): + """Trigger on a tool + + Method to programatically "click" on Tools + """ + + self._trigger_tool(name, event, False) + + def _trigger_tool(self, name, event, from_toolbar): + if name not in self._tools: + raise AttributeError('%s not in Tools' % name) + + tool = self._tools[name] + if isinstance(tool, tools.ToolToggleBase): + self._handle_toggle(name, event=event, from_toolbar=from_toolbar) + else: + tool.trigger(event) + + 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, event, False) + + def _toolbar_callback(self, name): + """Callback for the `Toolbar` + + All Toolbar implementations have to call this method to signal that a + toolitem was clicked on + + Parameters + ---------- + name : string + Name of the tool that was activated (click) by the user using the + toolbar + """ + + self._trigger_tool(name, None, True) + + def _handle_toggle(self, name, event=None, from_toolbar=False): + # toggle toolbar without callback + if not from_toolbar and self.toolbar: + self.toolbar._toggle(name, False) + + tool = self._tools[name] + if self._toggled is None: + # first trigger of tool + self._toggled = name + elif self._toggled == name: + # second trigger of tool + self._toggled = None + else: + # other tool is triggered so trigger toggled tool + if self.toolbar: + # untoggle the previous toggled tool + self.toolbar._toggle(self._toggled, False) + self._tools[self._toggled].trigger(event) + self._toggled = name + + tool.trigger(event) + + for a in self.canvas.figure.get_axes(): + a.set_navigate_mode(self._toggled) + + # Change the cursor inmediately, don't wait for mouse move + self._set_cursor(event) + + def get_tools(self): + """Return the tools controlled by `Navigation`""" + + d = {} + for name in sorted(self._tools.keys()): + tool = self._tools[name] + keys = [k for k, i in six.iteritems(self._keys) if i == name] + d[name] = {'obj': tool, + 'description': tool.description, + 'keymap': keys} + return d + + def _set_cursor(self, event): + """Call the backend specific set_cursor method, + if the pointer is inaxes + """ + if not event.inaxes or not self._toggled: + if self._last_cursor != self._default_cursor: + self.set_cursor(self._default_cursor) + self._last_cursor = self._default_cursor + else: + if self._toggled: + cursor = self._tools[self._toggled].cursor + if cursor and self._last_cursor != cursor: + self.set_cursor(cursor) + self._last_cursor = cursor + + def _mouse_move(self, event): + self._set_cursor(event) + + if self.toolbar is None or self.messagelock.locked(): + return + + if event.inaxes and event.inaxes.get_navigate(): + + try: + s = event.inaxes.format_coord(event.xdata, event.ydata) + except (ValueError, OverflowError): + pass + else: + if self._toggled: + self.toolbar.set_message('%s, %s' % (self._toggled, s)) + else: + self.toolbar.set_message(s) + else: + self.toolbar.set_message('') + + def set_cursor(self, cursor): + """ + Set the current cursor to one of the :class:`Cursors` + enums values + """ + + pass + + def draw_rubberband(self, event, caller, x0, y0, x1, y1): + """Draw a rectangle rubberband to indicate zoom limits + + Draw a rectanlge in the canvas, if + `self.canvas.widgetlock` is available to **caller** + + Parameters + ---------- + event : `FigureCanvas` event + caller : instance trying to draw the rubberband + x0, y0, x1, y1 : coordinates + """ + + if not self.canvas.widgetlock.available(caller): + warnings.warn("%s doesn't own the canvas widgetlock" % caller) + + def remove_rubberband(self, event, caller): + """Remove the rubberband + + Remove the rubberband if the `self.canvas.widgetlock` is + available to **caller** + + Parameters + ---------- + event : `FigureCanvas` event + caller : instance trying to remove the rubberband + """ + + if not self.canvas.widgetlock.available(caller): + warnings.warn("%s doesn't own the canvas widgetlock" % caller) + + +class ToolbarBase(object): + """Base class for `Toolbar` implementation + + Attributes + ---------- + manager : `FigureManager` instance that integrates this `Toolbar` + """ + + def __init__(self, manager): + """ + .. automethod:: _add_toolitem + .. automethod:: _remove_toolitem + .. automethod:: _toggle + """ + + self.manager = manager + + def _add_toolitem(self, name, description, image_file, position, + toggle): + """Add a toolitem to the toolbar + + The callback associated with the button click event, + must be **EXACTLY** `self.manager.navigation._toolbar_callback(name)` + + Parameters + ---------- + name : string + Name of the tool to add, this is used as ID and as default label + of the buttons + description : string + Description of the tool, used for the tooltips + image_file : string + Filename of the image for the button or `None` + position : integer + Position of the toolitem within the other toolitems + if -1 at the End + 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 add_separator(self, pos): + """Add a separator + + Parameters + ---------- + pos : integer + Position where to add the separator within the toolitems + if -1 at the end + """ + + pass + + def set_message(self, s): + """Display a message on toolbar or in status bar""" + + pass + + def _toggle(self, name, callback=False): + """Toogle a button + + Parameters + ---------- + name : string + Name of the button to toggle + callback : bool + * `True`: call the button callback during toggle + * `False`: toggle the button without calling the callback + + """ + + # carefull, callback means to perform or not the callback while + # toggling + raise NotImplementedError + + def _remove_toolitem(self, name): + """Remove a toolitem from the `Toolbar` + + Parameters + ---------- + name : string + Name of the tool to remove + + """ + + raise NotImplementedError + + def move_toolitem(self, pos_ini, pos_fin): + """Change the position of a toolitem + + Parameters + ---------- + pos_ini : integer + Initial position of the toolitem to move + pos_fin : integer + Final position of the toolitem + """ + + pass + + def set_toolitem_visibility(self, name, visible): + """Change the visibility of a toolitem + + Parameters + ---------- + name : string + Name of the `Tool` + visible : bool + * `True`: set the toolitem visible + * `False`: set the toolitem invisible + """ + + pass diff --git a/lib/matplotlib/backend_tools.py b/lib/matplotlib/backend_tools.py new file mode 100644 index 000000000000..7d9b845ef2dd --- /dev/null +++ b/lib/matplotlib/backend_tools.py @@ -0,0 +1,739 @@ +""" +Abstract base classes define the primitives for Tools. +These tools are used by `NavigationBase` + +:class:`ToolBase` + Simple tool that gets instantiated every time it is used + +: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 +import matplotlib.cbook as cbook +from weakref import WeakKeyDictionary +import numpy as np + + +class Cursors: + # this class is only used as a simple namespace + HAND, POINTER, SELECT_REGION, MOVE = list(range(4)) +cursors = Cursors() + + +class ToolBase(object): + """Base tool class + + Attributes + ---------- + navigation : `NavigationBase` + Navigation that controls this Tool + figure : `FigureCanvas` + Figure instance that is affected by this Tool + """ + + 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 + """ + + intoolbar = True + """Add the tool to the toolbar""" + + cursor = None + """Cursor to use when the tool is active""" + + def __init__(self, figure, name, event=None): + self._name = name + self.figure = None + self.navigation = None + self.set_figure(figure) + + def trigger(self, event): + """Called when this tool gets used + + Parameters + ---------- + event : `Event` + The event that caused this tool to be called + """ + + pass + + def set_figure(self, figure): + """Set the figure and navigation + + Set the figure to be affected by this tool + + Parameters + ---------- + figure : `Figure` + """ + + self.figure = figure + self.navigation = figure.canvas.manager.navigation + + @property + def name(self): + return self._name + + def destroy(self): + pass + + +class ToolToggleBase(ToolBase): + """Toggleable tool + + Every time it is triggered, it switches between enable and disable + """ + + _toggled = False + + def trigger(self, event): + if self._toggled: + self.disable(event) + else: + self.enable(event) + self._toggled = not self._toggled + + def enable(self, event=None): + """Enable the toggle tool + + This method is called when the tool is triggered and not toggled + """ + + pass + + def disable(self, event=None): + """Disable the toggle tool + + This method is called when the tool is triggered and toggled. + * Second click on the toolbar tool button + * Another toogle tool is triggered (from the same `navigation`) + """ + + pass + + @property + def toggled(self): + """State of the toggled tool""" + + return self._toggled + + +class ToolQuit(ToolBase): + """Tool to call the figure manager destroy method""" + + intoolbar = False + description = 'Quit the figure' + keymap = rcParams['keymap.quit'] + + def trigger(self, event): + Gcf.destroy_fig(self.figure) + + +class ToolEnableAllNavigation(ToolBase): + """Tool to enable all axes for navigation interaction""" + + intoolbar = False + description = 'Enables all axes navigation' + keymap = rcParams['keymap.all_axes'] + + def trigger(self, event): + 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""" + + intoolbar = False + description = 'Enables one axes navigation' + keymap = (1, 2, 3, 4, 5, 6, 7, 8, 9) + + def trigger(self, event): + 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 ToolToggleGrid(ToolBase): + """Tool to toggle the grid of the figure""" + + intoolbar = False + description = 'Toogle Grid' + keymap = rcParams['keymap.grid'] + + def trigger(self, event): + if event.inaxes is None: + return + event.inaxes.grid() + self.figure.canvas.draw() + + +class ToolToggleFullScreen(ToolBase): + """Tool to toggle full screen""" + + intoolbar = False + description = 'Toogle Fullscreen mode' + keymap = rcParams['keymap.fullscreen'] + + def trigger(self, event): + self.figure.canvas.manager.full_screen_toggle() + + +class ToolToggleYScale(ToolBase): + """Tool to toggle between linear and logarithmic the Y axis""" + + description = 'Toogle Scale Y axis' + keymap = rcParams['keymap.yscale'] + intoolbar = False + + def trigger(self, event): + ax = event.inaxes + if ax is None: + return + + scale = ax.get_yscale() + if scale == 'log': + ax.set_yscale('linear') + ax.figure.canvas.draw() + elif scale == 'linear': + ax.set_yscale('log') + ax.figure.canvas.draw() + + +class ToolToggleXScale(ToolBase): + """Tool to toggle between linear and logarithmic the X axis""" + + description = 'Toogle Scale X axis' + keymap = rcParams['keymap.xscale'] + intoolbar = False + + def trigger(self, event): + ax = event.inaxes + if ax is None: + return + + scalex = ax.get_xscale() + if scalex == 'log': + ax.set_xscale('linear') + ax.figure.canvas.draw() + elif scalex == 'linear': + ax.set_xscale('log') + ax.figure.canvas.draw() + + +class ViewsPositions(object): + """Auxiliary class to handle changes in views and positions""" + + views = WeakKeyDictionary() + """Record of views with Figure objects as keys""" + + positions = WeakKeyDictionary() + """Record of positions with Figure objects as keys""" + + @classmethod + def add_figure(cls, figure): + """Add a figure to the list of figures handled by this class""" + if figure not in cls.views: + cls.views[figure] = cbook.Stack() + cls.positions[figure] = cbook.Stack() + # Define Home + cls.push_current(figure) + # Adding the clear method as axobserver, removes this burden from + # the backend + figure.add_axobserver(cls.clear) + + @classmethod + def clear(cls, figure): + """Reset the axes stack""" + if figure in cls.views: + cls.views[figure].clear() + cls.positions[figure].clear() + + @classmethod + def update_view(cls, figure): + """Update the viewlim and position from the view and + position stack for each axes + """ + + lims = cls.views[figure]() + if lims is None: + return + pos = cls.positions[figure]() + if pos is None: + return + for i, a in enumerate(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') + + figure.canvas.draw_idle() + + @classmethod + def push_current(cls, figure): + """push the current view limits and position onto the stack""" + + lims = [] + pos = [] + for a in 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())) + cls.views[figure].push(lims) + cls.positions[figure].push(pos) + + @classmethod + def refresh_locators(cls, figure): + """Redraw the canvases, update the locators""" + for a in 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() + figure.canvas.draw_idle() + + @classmethod + def home(cls, figure): + cls.views[figure].home() + cls.positions[figure].home() + + @classmethod + def back(cls, figure): + cls.views[figure].back() + cls.positions[figure].back() + + @classmethod + def forward(cls, figure): + cls.views[figure].forward() + cls.positions[figure].forward() + + +class ViewsPositionsBase(ToolBase): + # Simple base to avoid repeating code on Home, Back and Forward + # Not of much use for other tools, so not documented + _on_trigger = None + + def __init__(self, *args, **kwargs): + ToolBase.__init__(self, *args, **kwargs) + self.viewspos = ViewsPositions() + + def trigger(self, *args): + self.viewspos.add_figure(self.figure) + getattr(self.viewspos, self._on_trigger)(self.figure) + self.viewspos.update_view(self.figure) + + +class ToolHome(ViewsPositionsBase): + """Restore the original view""" + + 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 to group common functionality between zoom and pan + # Not of much use for other tools, so not documented + def __init__(self, *args): + ToolToggleBase.__init__(self, *args) + self._button_pressed = None + self._xypress = None + self._idPress = None + self._idRelease = None + self.viewspos = ViewsPositions() + + def enable(self, event): + 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) + + def disable(self, event): + self._cancel_action() + self.figure.canvas.widgetlock.release(self) + self.figure.canvas.mpl_disconnect(self._idPress) + self.figure.canvas.mpl_disconnect(self._idRelease) + + def trigger(self, *args): + self.viewspos.add_figure(self.figure) + ToolToggleBase.trigger(self, *args) + + +class ToolZoom(ZoomPanBase): + """Zoom to rectangle""" + + description = 'Zoom to rectangle' + image = 'zoom_to_rect.png' + keymap = rcParams['keymap.zoom'] + cursor = cursors.SELECT_REGION + + 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.remove_rubberband(None, self) + self.viewspos.refresh_locators(self.figure) + 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.draw_rubberband(event, self, 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.viewspos.push_current(self.figure) + 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 + + 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.viewspos.refresh_locators(self.figure) + + 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.viewspos.push_current(self.figure) + 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 = (('Grid', ToolToggleGrid), + ('Fullscreen', ToolToggleFullScreen), + ('Quit', ToolQuit), + ('EnableAll', ToolEnableAllNavigation), + ('EnableOne', ToolEnableNavigation), + ('XScale', ToolToggleXScale), + ('YScale', ToolToggleYScale), + ('Home', ToolHome), + ('Back', ToolBack), + ('Forward', ToolForward), + ('Spacer1', None), + ('Zoom', ToolZoom), + ('Pan', ToolPan), + ('Spacer2', None), + ('Subplots', 'ConfigureSubplots'), + ('Save', 'SaveFigure')) +"""Default tools""" diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index eab6564a2667..7428926baf4a 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -30,7 +30,8 @@ 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 from matplotlib.cbook import is_string_like, is_writable_file_like from matplotlib.colors import colorConverter @@ -412,7 +413,10 @@ def __init__(self, canvas, num): self.vbox.pack_start(self.canvas, True, True, 0) - self.toolbar = self._get_toolbar(canvas) + self.toolbar = self._get_toolbar() + self.navigation = self._get_navigation() + if matplotlib.rcParams['toolbar'] == 'navigation': + self.navigation.add_tools(tools) # calculate size for window w = int (self.canvas.figure.bbox.width) @@ -435,7 +439,9 @@ 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 +472,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) else: toolbar = None return toolbar + def _get_navigation(self): + # must be initialised after toolbar has been setted + if rcParams['toolbar'] != 'toolbar2': + navigation = NavigationGTK3(self) + else: + navigation = None + return navigation + def get_window_title(self): return self.window.get_title() @@ -699,6 +715,231 @@ def get_filename_from_user (self): return filename, self.ext + +class NavigationGTK3(NavigationBase): + def __init__(self, *args, **kwargs): + NavigationBase.__init__(self, *args, **kwargs) + self.ctx = None + + def set_cursor(self, cursor): + self.canvas.get_property("window").set_cursor(cursord[cursor]) + + def draw_rubberband(self, event, caller, x0, y0, x1, y1): + if not self.canvas.widgetlock.available(caller): + return + + # 'adapted from http://aspn.activestate.com/ASPN/Cookbook/Python/ + # Recipe/189744' + self.ctx = self.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.canvas.draw() + + height = self.canvas.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(ToolbarBase, Gtk.Box): + def __init__(self, manager): + ToolbarBase.__init__(self, manager) + 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, tooltip_text, image_file, position, + 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._toolbar.insert(tbutton, position) + signal = tbutton.connect('clicked', self._call_tool, name) + tbutton.set_tooltip_text(tooltip_text) + tbutton.show_all() + self._toolitems[name] = tbutton + self._signals[name] = signal + + def _call_tool(self, btn, name): + self.manager.navigation._toolbar_callback(name) + + def set_message(self, s): + self.message.set_label(s) + + def _toggle(self, name, callback=False): + if name not in self._toolitems: + self.set_message('%s Not in toolbar' % name) + return + + status = self._toolitems[name].get_active() + if not callback: + self._toolitems[name].handler_block(self._signals[name]) + + self._toolitems[name].set_active(not status) + + if not callback: + 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 + + def move_toolitem(self, pos_ini, pos_fin): + widget = self._toolbar.get_nth_item(pos_ini) + if not widget: + self.set_message('Impossible to remove tool %d' % pos_ini) + return + self._toolbar.remove(widget) + self._toolbar.insert(widget, pos_fin) + + def set_toolitem_visibility(self, name, visible): + if name not in self._toolitems: + self.set_message('%s Not in toolbar' % name) + return + self._toolitems[name].set_visible(visible) + + +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): + 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) + +SaveFigure = SaveFigureGTK3 + + +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, event): + self.init_window() + self.window.present() + + +ConfigureSubplots = ConfigureSubplotsGTK3 + + class DialogLineprops: """ A GUI dialog for controlling lineprops @@ -884,6 +1125,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 3625dc5e666f..110ef93aac0b 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 @@ -534,10 +535,12 @@ def __init__(self, canvas, num, window): _, _, w, h = canvas.figure.bbox.bounds w, h = int(w), int(h) self.window.minsize(int(w*3/4),int(h*3/4)) - if matplotlib.rcParams['toolbar']=='toolbar2': - self.toolbar = NavigationToolbar2TkAgg( canvas, self.window ) - else: - self.toolbar = None + + self.toolbar = self._get_toolbar() + self.navigation = self._get_navigation() + if matplotlib.rcParams['toolbar'] == 'navigation': + self.navigation.add_tools(tools) + if self.toolbar is not None: self.toolbar.update() self.canvas._tkcanvas.pack(side=Tk.TOP, fill=Tk.BOTH, expand=1) @@ -545,9 +548,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 @@ -874,5 +897,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 bb228d7d2de3..5e6af2a788d2 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -154,7 +154,7 @@ def validate_backend(s): def validate_toolbar(s): validator = ValidateInStrings( 'toolbar', - ['None', 'toolbar2'], + ['None', 'toolbar2', 'navigation'], ignorecase=True) return validator(s)