481 lines
15 KiB
Python
481 lines
15 KiB
Python
"""
|
|
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 <w.m.vanvliet@gmail.com>
|
|
"""
|
|
|
|
# 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"(?<!^)(?=[A-Z])")
|
|
|
|
|
|
# List of events
|
|
@fill_doc
|
|
class UIEvent:
|
|
"""Abstract base class for all events.
|
|
|
|
Attributes
|
|
----------
|
|
%(ui_event_name_source)s
|
|
"""
|
|
|
|
source = None
|
|
|
|
@property
|
|
def name(self):
|
|
"""The name of the event, which is the class name in snake case."""
|
|
return _camel_to_snake.sub("_", self.__class__.__name__).lower()
|
|
|
|
|
|
@fill_doc
|
|
class FigureClosing(UIEvent):
|
|
"""Indicates that the user has requested to close a figure.
|
|
|
|
Attributes
|
|
----------
|
|
%(ui_event_name_source)s
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
@fill_doc
|
|
class TimeChange(UIEvent):
|
|
"""Indicates that the user has selected a time.
|
|
|
|
Parameters
|
|
----------
|
|
time : float
|
|
The new time in seconds.
|
|
|
|
Attributes
|
|
----------
|
|
%(ui_event_name_source)s
|
|
time : float
|
|
The new time in seconds.
|
|
"""
|
|
|
|
time: float
|
|
|
|
|
|
@dataclass
|
|
@fill_doc
|
|
class PlaybackSpeed(UIEvent):
|
|
"""Indicates that the user has selected a different playback speed for videos.
|
|
|
|
Parameters
|
|
----------
|
|
speed : float
|
|
The new speed in seconds per frame.
|
|
|
|
Attributes
|
|
----------
|
|
%(ui_event_name_source)s
|
|
speed : float
|
|
The new speed in seconds per frame.
|
|
"""
|
|
|
|
speed: float
|
|
|
|
|
|
@dataclass
|
|
@fill_doc
|
|
class ColormapRange(UIEvent):
|
|
"""Indicates that the user has updated the bounds of the colormap.
|
|
|
|
Parameters
|
|
----------
|
|
kind : str
|
|
Kind of colormap being updated. The Notes section of the drawing
|
|
routine publishing this event should mention the possible kinds.
|
|
ch_type : str
|
|
Type of sensor the data originates from.
|
|
%(fmin_fmid_fmax)s
|
|
%(alpha)s
|
|
cmap : str
|
|
The colormap to use. Either string or matplotlib.colors.Colormap
|
|
instance.
|
|
|
|
Attributes
|
|
----------
|
|
kind : str
|
|
Kind of colormap being updated. The Notes section of the drawing
|
|
routine publishing this event should mention the possible kinds.
|
|
ch_type : str
|
|
Type of sensor the data originates from.
|
|
unit : str
|
|
The unit of the values.
|
|
%(ui_event_name_source)s
|
|
%(fmin_fmid_fmax)s
|
|
%(alpha)s
|
|
cmap : str
|
|
The colormap to use. Either string or matplotlib.colors.Colormap
|
|
instance.
|
|
"""
|
|
|
|
kind: str
|
|
ch_type: str | None = None
|
|
fmin: float | None = None
|
|
fmid: float | None = None
|
|
fmax: float | None = None
|
|
alpha: bool | None = None
|
|
cmap: Colormap | str | None = None
|
|
|
|
|
|
@dataclass
|
|
@fill_doc
|
|
class VertexSelect(UIEvent):
|
|
"""Indicates that the user has selected a vertex.
|
|
|
|
Parameters
|
|
----------
|
|
hemi : str
|
|
The hemisphere the vertex was selected on.
|
|
Can be ``"lh"``, ``"rh"``, or ``"vol"``.
|
|
vertex_id : int
|
|
The vertex number (in the high resolution mesh) that was selected.
|
|
|
|
Attributes
|
|
----------
|
|
%(ui_event_name_source)s
|
|
hemi : str
|
|
The hemisphere the vertex was selected on.
|
|
Can be ``"lh"``, ``"rh"``, or ``"vol"``.
|
|
vertex_id : int
|
|
The vertex number (in the high resolution mesh) that was selected.
|
|
"""
|
|
|
|
hemi: str
|
|
vertex_id: int
|
|
|
|
|
|
@dataclass
|
|
@fill_doc
|
|
class Contours(UIEvent):
|
|
"""Indicates that the user has changed the contour lines.
|
|
|
|
Parameters
|
|
----------
|
|
kind : str
|
|
The kind of contours lines being changed. The Notes section of the
|
|
drawing routine publishing this event should mention the possible
|
|
kinds.
|
|
contours : list of float
|
|
The new values at which contour lines need to be drawn.
|
|
|
|
Attributes
|
|
----------
|
|
%(ui_event_name_source)s
|
|
kind : str
|
|
The kind of contours lines being changed. The Notes section of the
|
|
drawing routine publishing this event should mention the possible
|
|
kinds.
|
|
contours : list of float
|
|
The new values at which contour lines need to be drawn.
|
|
"""
|
|
|
|
kind: str
|
|
contours: list[str]
|
|
|
|
|
|
def _get_event_channel(fig):
|
|
"""Get the event channel associated with a figure.
|
|
|
|
If the event channel doesn't exist yet, it gets created and added to the
|
|
global ``_event_channels`` dict.
|
|
|
|
Parameters
|
|
----------
|
|
fig : matplotlib.figure.Figure | Figure3D
|
|
The figure to get the event channel for.
|
|
|
|
Returns
|
|
-------
|
|
channel : dict[event -> 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()
|