1from matplotlib import _api, backend_tools, cbook, widgets
2
3
4class ToolEvent:
5 """Event for tool manipulation (add/remove)."""
6 def __init__(self, name, sender, tool, data=None):
7 self.name = name
8 self.sender = sender
9 self.tool = tool
10 self.data = data
11
12
13class ToolTriggerEvent(ToolEvent):
14 """Event to inform that a tool has been triggered."""
15 def __init__(self, name, sender, tool, canvasevent=None, data=None):
16 super().__init__(name, sender, tool, data)
17 self.canvasevent = canvasevent
18
19
20class ToolManagerMessageEvent:
21 """
22 Event carrying messages from toolmanager.
23
24 Messages usually get displayed to the user by the toolbar.
25 """
26 def __init__(self, name, sender, message):
27 self.name = name
28 self.sender = sender
29 self.message = message
30
31
32class ToolManager:
33 """
34 Manager for actions triggered by user interactions (key press, toolbar
35 clicks, ...) on a Figure.
36
37 Attributes
38 ----------
39 figure : `.Figure`
40 keypresslock : `~matplotlib.widgets.LockDraw`
41 `.LockDraw` object to know if the `canvas` key_press_event is locked.
42 messagelock : `~matplotlib.widgets.LockDraw`
43 `.LockDraw` object to know if the message is available to write.
44 """
45
46 def __init__(self, figure=None):
47
48 self._key_press_handler_id = None
49
50 self._tools = {}
51 self._keys = {}
52 self._toggled = {}
53 self._callbacks = cbook.CallbackRegistry()
54
55 # to process keypress event
56 self.keypresslock = widgets.LockDraw()
57 self.messagelock = widgets.LockDraw()
58
59 self._figure = None
60 self.set_figure(figure)
61
62 @property
63 def canvas(self):
64 """Canvas managed by FigureManager."""
65 if not self._figure:
66 return None
67 return self._figure.canvas
68
69 @property
70 def figure(self):
71 """Figure that holds the canvas."""
72 return self._figure
73
74 @figure.setter
75 def figure(self, figure):
76 self.set_figure(figure)
77
78 def set_figure(self, figure, update_tools=True):
79 """
80 Bind the given figure to the tools.
81
82 Parameters
83 ----------
84 figure : `.Figure`
85 update_tools : bool, default: True
86 Force tools to update figure.
87 """
88 if self._key_press_handler_id:
89 self.canvas.mpl_disconnect(self._key_press_handler_id)
90 self._figure = figure
91 if figure:
92 self._key_press_handler_id = self.canvas.mpl_connect(
93 'key_press_event', self._key_press)
94 if update_tools:
95 for tool in self._tools.values():
96 tool.figure = figure
97
98 def toolmanager_connect(self, s, func):
99 """
100 Connect event with string *s* to *func*.
101
102 Parameters
103 ----------
104 s : str
105 The name of the event. The following events are recognized:
106
107 - 'tool_message_event'
108 - 'tool_removed_event'
109 - 'tool_added_event'
110
111 For every tool added a new event is created
112
113 - 'tool_trigger_TOOLNAME', where TOOLNAME is the id of the tool.
114
115 func : callable
116 Callback function for the toolmanager event with signature::
117
118 def func(event: ToolEvent) -> Any
119
120 Returns
121 -------
122 cid
123 The callback id for the connection. This can be used in
124 `.toolmanager_disconnect`.
125 """
126 return self._callbacks.connect(s, func)
127
128 def toolmanager_disconnect(self, cid):
129 """
130 Disconnect callback id *cid*.
131
132 Example usage::
133
134 cid = toolmanager.toolmanager_connect('tool_trigger_zoom', onpress)
135 #...later
136 toolmanager.toolmanager_disconnect(cid)
137 """
138 return self._callbacks.disconnect(cid)
139
140 def message_event(self, message, sender=None):
141 """Emit a `ToolManagerMessageEvent`."""
142 if sender is None:
143 sender = self
144
145 s = 'tool_message_event'
146 event = ToolManagerMessageEvent(s, sender, message)
147 self._callbacks.process(s, event)
148
149 @property
150 def active_toggle(self):
151 """Currently toggled tools."""
152 return self._toggled
153
154 def get_tool_keymap(self, name):
155 """
156 Return the keymap associated with the specified tool.
157
158 Parameters
159 ----------
160 name : str
161 Name of the Tool.
162
163 Returns
164 -------
165 list of str
166 List of keys associated with the tool.
167 """
168
169 keys = [k for k, i in self._keys.items() if i == name]
170 return keys
171
172 def _remove_keys(self, name):
173 for k in self.get_tool_keymap(name):
174 del self._keys[k]
175
176 def update_keymap(self, name, key):
177 """
178 Set the keymap to associate with the specified tool.
179
180 Parameters
181 ----------
182 name : str
183 Name of the Tool.
184 key : str or list of str
185 Keys to associate with the tool.
186 """
187 if name not in self._tools:
188 raise KeyError(f'{name!r} not in Tools')
189 self._remove_keys(name)
190 if isinstance(key, str):
191 key = [key]
192 for k in key:
193 if k in self._keys:
194 _api.warn_external(
195 f'Key {k} changed from {self._keys[k]} to {name}')
196 self._keys[k] = name
197
198 def remove_tool(self, name):
199 """
200 Remove tool named *name*.
201
202 Parameters
203 ----------
204 name : str
205 Name of the tool.
206 """
207 tool = self.get_tool(name)
208 if getattr(tool, 'toggled', False): # If it's a toggled toggle tool, untoggle
209 self.trigger_tool(tool, 'toolmanager')
210 self._remove_keys(name)
211 event = ToolEvent('tool_removed_event', self, tool)
212 self._callbacks.process(event.name, event)
213 del self._tools[name]
214
215 def add_tool(self, name, tool, *args, **kwargs):
216 """
217 Add *tool* to `ToolManager`.
218
219 If successful, adds a new event ``tool_trigger_{name}`` where
220 ``{name}`` is the *name* of the tool; the event is fired every time the
221 tool is triggered.
222
223 Parameters
224 ----------
225 name : str
226 Name of the tool, treated as the ID, has to be unique.
227 tool : type
228 Class of the tool to be added. A subclass will be used
229 instead if one was registered for the current canvas class.
230 *args, **kwargs
231 Passed to the *tool*'s constructor.
232
233 See Also
234 --------
235 matplotlib.backend_tools.ToolBase : The base class for tools.
236 """
237
238 tool_cls = backend_tools._find_tool_class(type(self.canvas), tool)
239 if not tool_cls:
240 raise ValueError('Impossible to find class for %s' % str(tool))
241
242 if name in self._tools:
243 _api.warn_external('A "Tool class" with the same name already '
244 'exists, not added')
245 return self._tools[name]
246
247 tool_obj = tool_cls(self, name, *args, **kwargs)
248 self._tools[name] = tool_obj
249
250 if tool_obj.default_keymap is not None:
251 self.update_keymap(name, tool_obj.default_keymap)
252
253 # For toggle tools init the radio_group in self._toggled
254 if isinstance(tool_obj, backend_tools.ToolToggleBase):
255 # None group is not mutually exclusive, a set is used to keep track
256 # of all toggled tools in this group
257 if tool_obj.radio_group is None:
258 self._toggled.setdefault(None, set())
259 else:
260 self._toggled.setdefault(tool_obj.radio_group, None)
261
262 # If initially toggled
263 if tool_obj.toggled:
264 self._handle_toggle(tool_obj, None, None)
265 tool_obj.set_figure(self.figure)
266
267 event = ToolEvent('tool_added_event', self, tool_obj)
268 self._callbacks.process(event.name, event)
269
270 return tool_obj
271
272 def _handle_toggle(self, tool, canvasevent, data):
273 """
274 Toggle tools, need to untoggle prior to using other Toggle tool.
275 Called from trigger_tool.
276
277 Parameters
278 ----------
279 tool : `.ToolBase`
280 canvasevent : Event
281 Original Canvas event or None.
282 data : object
283 Extra data to pass to the tool when triggering.
284 """
285
286 radio_group = tool.radio_group
287 # radio_group None is not mutually exclusive
288 # just keep track of toggled tools in this group
289 if radio_group is None:
290 if tool.name in self._toggled[None]:
291 self._toggled[None].remove(tool.name)
292 else:
293 self._toggled[None].add(tool.name)
294 return
295
296 # If the tool already has a toggled state, untoggle it
297 if self._toggled[radio_group] == tool.name:
298 toggled = None
299 # If no tool was toggled in the radio_group
300 # toggle it
301 elif self._toggled[radio_group] is None:
302 toggled = tool.name
303 # Other tool in the radio_group is toggled
304 else:
305 # Untoggle previously toggled tool
306 self.trigger_tool(self._toggled[radio_group],
307 self,
308 canvasevent,
309 data)
310 toggled = tool.name
311
312 # Keep track of the toggled tool in the radio_group
313 self._toggled[radio_group] = toggled
314
315 def trigger_tool(self, name, sender=None, canvasevent=None, data=None):
316 """
317 Trigger a tool and emit the ``tool_trigger_{name}`` event.
318
319 Parameters
320 ----------
321 name : str
322 Name of the tool.
323 sender : object
324 Object that wishes to trigger the tool.
325 canvasevent : Event
326 Original Canvas event or None.
327 data : object
328 Extra data to pass to the tool when triggering.
329 """
330 tool = self.get_tool(name)
331 if tool is None:
332 return
333
334 if sender is None:
335 sender = self
336
337 if isinstance(tool, backend_tools.ToolToggleBase):
338 self._handle_toggle(tool, canvasevent, data)
339
340 tool.trigger(sender, canvasevent, data) # Actually trigger Tool.
341
342 s = 'tool_trigger_%s' % name
343 event = ToolTriggerEvent(s, sender, tool, canvasevent, data)
344 self._callbacks.process(s, event)
345
346 def _key_press(self, event):
347 if event.key is None or self.keypresslock.locked():
348 return
349
350 name = self._keys.get(event.key, None)
351 if name is None:
352 return
353 self.trigger_tool(name, canvasevent=event)
354
355 @property
356 def tools(self):
357 """A dict mapping tool name -> controlled tool."""
358 return self._tools
359
360 def get_tool(self, name, warn=True):
361 """
362 Return the tool object with the given name.
363
364 For convenience, this passes tool objects through.
365
366 Parameters
367 ----------
368 name : str or `.ToolBase`
369 Name of the tool, or the tool itself.
370 warn : bool, default: True
371 Whether a warning should be emitted it no tool with the given name
372 exists.
373
374 Returns
375 -------
376 `.ToolBase` or None
377 The tool or None if no tool with the given name exists.
378 """
379 if (isinstance(name, backend_tools.ToolBase)
380 and name.name in self._tools):
381 return name
382 if name not in self._tools:
383 if warn:
384 _api.warn_external(
385 f"ToolManager does not control tool {name!r}")
386 return None
387 return self._tools[name]