""" Event API for inter-figure communication. The event API allows figures to communicate with each other, such that a change in one figure can trigger a change in another figure. For example, moving the time cursor in one plot can update the current time in another plot. Another scenario is two drawing routines drawing into the same window, using events to stay in-sync. Authors: Marijn van Vliet """ # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. from __future__ import annotations # only needed for Python ≤ 3.9 import contextlib import re import weakref from dataclasses import dataclass from matplotlib.colors import Colormap from ..utils import _validate_type, fill_doc, logger, verbose, warn # Global dict {fig: channel} containing all currently active event channels. _event_channels = weakref.WeakKeyDictionary() # The event channels of figures can be linked together. This dict keeps track # of these links. Links are bi-directional, so if {fig1: fig2} exists, then so # must {fig2: fig1}. _event_channel_links = weakref.WeakKeyDictionary() # Event channels that are temporarily disabled by the disable_ui_events context # manager. _disabled_event_channels = weakref.WeakSet() # Regex pattern used when converting CamelCase to snake_case. # Detects all capital letters that are not at the beginning of a word. _camel_to_snake = re.compile(r"(? list] The event channel. An event channel is a list mapping string event names to a list of callback representing all subscribers to the channel. """ import matplotlib from ._brain import Brain from .evoked_field import EvokedField # Create the event channel if it doesn't exist yet if fig not in _event_channels: # The channel itself is a dict mapping string event names to a list of # subscribers. No subscribers yet for this new event channel. _event_channels[fig] = dict() weakfig = weakref.ref(fig) # When the figure is closed, its associated event channel should be # deleted. This is a good time to set this up. def delete_event_channel(event=None, *, weakfig=weakfig): """Delete the event channel (callback function).""" fig = weakfig() if fig is None: return publish(fig, event=FigureClosing()) # Notify subscribers of imminent close logger.debug(f"unlink(({fig})") unlink(fig) # Remove channel from the _event_channel_links dict if fig in _event_channels: logger.debug(f" del _event_channels[{fig}]") del _event_channels[fig] if fig in _disabled_event_channels: logger.debug(f" _disabled_event_channels.remove({fig})") _disabled_event_channels.remove(fig) # Hook up the above callback function to the close event of the figure # window. How this is done exactly depends on the various figure types # MNE-Python has. _validate_type(fig, (matplotlib.figure.Figure, Brain, EvokedField), "fig") if isinstance(fig, matplotlib.figure.Figure): fig.canvas.mpl_connect("close_event", delete_event_channel) else: assert hasattr(fig, "_renderer") # figures like Brain, EvokedField, etc. fig._renderer._window_close_connect(delete_event_channel, after=False) # Now the event channel exists for sure. return _event_channels[fig] @verbose def publish(fig, event, *, verbose=None): """Publish an event to all subscribers of the figure's channel. The figure's event channel and all linked event channels are searched for subscribers to the given event. Each subscriber had provided a callback function when subscribing, so we call that. Parameters ---------- fig : matplotlib.figure.Figure | Figure3D The figure that publishes the event. event : UIEvent Event to publish. %(verbose)s """ if fig in _disabled_event_channels: return # Compile a list of all event channels that the event should be published # on. channels = [_get_event_channel(fig)] links = _event_channel_links.get(fig, None) if links is not None: for linked_fig, (include_events, exclude_events) in links.items(): if (include_events is None or event.name in include_events) and ( exclude_events is None or event.name not in exclude_events ): channels.append(_get_event_channel(linked_fig)) # Publish the event by calling the registered callback functions. event.source = fig logger.debug(f"Publishing {event} on channel {fig}") for channel in channels: if event.name not in channel: channel[event.name] = set() for callback in channel[event.name]: callback(event=event) @verbose def subscribe(fig, event_name, callback, *, verbose=None): """Subscribe to an event on a figure's event channel. Parameters ---------- fig : matplotlib.figure.Figure | Figure3D The figure of which event channel to subscribe. event_name : str The name of the event to listen for. callback : callable The function that should be called whenever the event is published. %(verbose)s """ channel = _get_event_channel(fig) logger.debug(f"Subscribing to channel {channel}") if event_name not in channel: channel[event_name] = set() channel[event_name].add(callback) @verbose def unsubscribe(fig, event_names, callback=None, *, verbose=None): """Unsubscribe from an event on a figure's event channel. Parameters ---------- fig : matplotlib.figure.Figure | Figure3D The figure of which event channel to unsubscribe from. event_names : str | list of str Select which events to stop subscribing to. Can be a single string event name, a list of event names or ``"all"`` which will unsubscribe from all events. callback : callable | None The callback function that should be unsubscribed, leaving all other callback functions that may be subscribed untouched. By default (``None``) all callback functions are unsubscribed from the event. %(verbose)s """ channel = _get_event_channel(fig) # Determine which events to unsubscribe for. if event_names == "all": if callback is None: event_names = list(channel.keys()) else: event_names = list(k for k, v in channel.items() if callback in v) elif isinstance(event_names, str): event_names = [event_names] for event_name in event_names: if event_name not in channel: warn( f'Cannot unsubscribe from event "{event_name}" as we have never ' "subscribed to it." ) continue if callback is None: del channel[event_name] else: # Unsubscribe specific callback function. subscribers = channel[event_name] if callback in subscribers: subscribers.remove(callback) else: warn( f'Cannot unsubscribe {callback} from event "{event_name}" ' "as it was never subscribed to it." ) if len(subscribers) == 0: del channel[event_name] # keep things tidy @verbose def link(*figs, include_events=None, exclude_events=None, verbose=None): """Link the event channels of two figures together. When event channels are linked, any events that are published on one channel are simultaneously published on the other channel. Links are bi-directional. Parameters ---------- *figs : tuple of matplotlib.figure.Figure | tuple of Figure3D The figures whose event channel will be linked. include_events : list of str | None Select which events to publish across figures. By default (``None``), both figures will receive all of each other's events. Passing a list of event names will restrict the events being shared across the figures to only the given ones. exclude_events : list of str | None Select which events not to publish across figures. By default (``None``), no events are excluded. %(verbose)s """ if include_events is not None: include_events = set(include_events) if exclude_events is not None: exclude_events = set(exclude_events) # Make sure the event channels of the figures are setup properly. for fig in figs: _get_event_channel(fig) if fig not in _event_channel_links: _event_channel_links[fig] = weakref.WeakKeyDictionary() # Link the event channels for fig1 in figs: for fig2 in figs: if fig1 is not fig2: _event_channel_links[fig1][fig2] = (include_events, exclude_events) @verbose def unlink(fig, *, verbose=None): """Remove all links involving the event channel of the given figure. Parameters ---------- fig : matplotlib.figure.Figure | Figure3D The figure whose event channel should be unlinked from all other event channels. %(verbose)s """ linked_figs = _event_channel_links.get(fig) if linked_figs is not None: for linked_fig in linked_figs.keys(): del _event_channel_links[linked_fig][fig] if len(_event_channel_links[linked_fig]) == 0: del _event_channel_links[linked_fig] if fig in _event_channel_links: # need to check again because of weak refs del _event_channel_links[fig] @contextlib.contextmanager def disable_ui_events(fig): """Temporarily disable generation of UI events. Use as context manager. Parameters ---------- fig : matplotlib.figure.Figure | Figure3D The figure whose UI event generation should be temporarily disabled. """ _disabled_event_channels.add(fig) try: yield finally: _disabled_event_channels.remove(fig) def _cleanup_agg(): """Call close_event for Agg canvases to help our doc build.""" import matplotlib.backends.backend_agg import matplotlib.figure for key in list(_event_channels): # we might remove keys as we go if isinstance(key, matplotlib.figure.Figure): canvas = key.canvas if isinstance(canvas, matplotlib.backends.backend_agg.FigureCanvasAgg): for cb in key.canvas.callbacks.callbacks["close_event"].values(): cb = cb() # get the true ref if cb is not None: cb()