backend_tools.py 35 KB

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