Source code for fastplotlib.tools._cursor

from functools import partial
from typing import Literal, Sequence, Callable

import numpy as np
import pygfx

from ..layouts import Subplot
from ..utils import RenderQueue


[docs] class Cursor: def __init__( self, mode: Literal["crosshair", "marker"] = "crosshair", size: float = 1.0, # in screen space color: str | Sequence[float] | pygfx.Color | np.ndarray = "w", marker: str = "+", edge_color: str | Sequence[float] | pygfx.Color | np.ndarray = "k", edge_width: float = 0.5, alpha: float = 0.7, size_space: Literal["screen", "world"] = "screen", ): """ A cursor that indicates the same position in world-space across subplots. Parameters ---------- mode: "crosshair" | "marker" cursor mode size: float, default 1.0 * if ``mode`` == 'crosshair', this is the crosshair line thickness * if ``mode`` == 'marker', it's the size of the marker You probably want to use ``size > 5`` if ``mode`` is 'marker' and ``size_space`` is ``screen`` color: str | Sequence[float] | pygfx.Color | np.ndarray, default "r" color of the marker marker: str, default "+" marker shape, used if mode == 'marker' edge_color: str | Sequence[float] | pygfx.Color | np.ndarray, default "k" marker edge color, used if ``mode`` == 'marker' edge_width: float, default 0.5 marker edge widget, used if ``mode`` == 'marker' alpha: float, default 0.7 alpha (transparency) of the cursor size_space: "screen" | "world", default "screen" size space of the cursor, if "screen" the ``size`` is exact screen pixels. if "world" the ``size`` is in world-space """ self._cursors: dict[Subplot, pygfx.Points | pygfx.Group[pygfx.Line]] = dict() self._transforms: dict[Subplot, Callable | None] = dict() self._mode = None self.mode = mode self.size = size self.color = color self.marker = marker self.edge_color = edge_color self.edge_width = edge_width self.alpha = alpha self.size_space = size_space self._enabled = True self._position: list[float, float] = [0.0, 0.0] @property def mode(self) -> Literal["crosshair", "marker"]: """cursor mode, one of 'crosshair' or 'marker'""" return self._mode @mode.setter def mode(self, mode: Literal["crosshair", "marker"]): if not (mode == "crosshair" or mode == "marker"): raise ValueError( f"mode must be one of: 'crosshair' | 'marker', you passed: {mode}" ) if mode == self.mode: return # mode has changed, clear and create new world objects subplots = list(self._cursors.keys()) self.clear() for subplot in subplots: self.add_subplot(subplot) self._mode = mode @property def size(self) -> float: """size of marker or crosshair line thickness""" return self._size @size.setter def size(self, new_size: float): for c in self._cursors.values(): if self.mode == "marker": c.material.size = new_size elif self.mode == "crosshair": h, v = c.children h.material.thickness = new_size v.material.thickness = new_size self._size = new_size @property def size_space(self) -> Literal["screen", "world"]: """interpret cursor size in screen or world space""" return self._size_space @size_space.setter def size_space(self, space: Literal["screen", "world"]): if space not in ["screen", "world", "model"]: raise ValueError( f"valid `size_space` is one of: 'screen' | 'world'. You passed: {space}" ) for c in self._cursors.values(): if self.mode == "marker": c.material.size_space = space elif self.mode == "crosshair": h, v = c.children h.material.thickness_space = space v.material.thickness_space = space self._size_space = space @property def color(self) -> pygfx.Color: """cursor color""" return self._color @color.setter def color(self, new_color): new_color = pygfx.Color(new_color) for c in self._cursors.values(): c.material.color = new_color self._color = new_color @property def marker(self) -> str: """cursor marker shape, if `mode` is 'marker'""" return self._marker @marker.setter def marker(self, new_marker: str): if self.mode == "marker": for c in self._cursors.values(): c.material.marker = new_marker self._marker = new_marker @property def edge_color(self) -> pygfx.Color: """cursor marker edge color, if `mode` is 'marker'""" return self._edge_color @edge_color.setter def edge_color(self, new_color: str | Sequence | np.ndarray | pygfx.Color): new_color = pygfx.Color(new_color) if self.mode == "marker": for c in self._cursors.values(): c.material.edge_color = new_color self._edge_color = new_color @property def edge_width(self) -> float: """cursor marker edge width, if `mode` is 'marker'""" return self._edge_width @edge_width.setter def edge_width(self, new_width: float): if self.mode == "marker": for c in self._cursors.values(): c.material.edge_width = new_width self._edge_width = new_width @property def alpha(self) -> float: """cursor alpha value""" return self._alpha @alpha.setter def alpha(self, value: float): for c in self._cursors.values(): c.material.opacity = value self._alpha = value @property def enabled(self) -> bool: """enable/disable the cursor, if False the cursor will not respond to mouse pointer events""" return self._enabled @enabled.setter def enabled(self, pause: bool): self._enabled = bool(pause) @property def position(self) -> tuple[float, float]: """(x, y) position in world space""" return tuple(self._position) @position.setter def position(self, pos: tuple[float, float]): for subplot, cursor in self._cursors.items(): if self._transforms[subplot] is not None: pos_transformed = self._transforms[subplot](pos) else: pos_transformed = pos if self.mode == "marker": cursor.geometry.positions.data[0, :-1] = pos_transformed cursor.geometry.positions.update_full() elif self.mode == "crosshair": line_h, line_v = cursor.children # set x vals for horizontal line line_h.geometry.positions.data[0, 0] = pos_transformed[0] - 1 line_h.geometry.positions.data[1, 0] = pos[0] + 1 # set y value line_h.geometry.positions.data[:, 1] = pos_transformed[1] line_h.geometry.positions.update_full() # set y vals for vertical line line_v.geometry.positions.data[0, 1] = pos_transformed[1] - 1 line_v.geometry.positions.data[1, 1] = pos_transformed[1] + 1 # set x value line_v.geometry.positions.data[:, 0] = pos_transformed[0] line_v.geometry.positions.update_full() # set tooltip using pick info if a graphic is at this position # for now we just set z = 1 screen_pos = subplot.map_world_to_screen((*pos_transformed, 1)) pick_info = subplot.get_pick_info(screen_pos) self._position[:] = pos_transformed if pick_info is not None: graphic = pick_info["graphic"] if ( graphic._fpl_support_tooltip ): # some graphics don't support tooltips, ex: Text if graphic.tooltip_format is not None: # custom formatter info = graphic.tooltip_format else: # default formatter for this graphic info = graphic.format_pick_info(pick_info) subplot.tooltip.display(screen_pos, info) continue # tooltip cleared if none of the above condiitionals reached the tooltip display call subplot.tooltip.clear()
[docs] def add_subplot(self, subplot: Subplot, transform: Callable | None = None): """ Add a subplot to this cursor, with an optional position transform function Parameters ---------- subplot: Subplot subplot to add transform: Callable[[tuple[float, float]], tuple[float, float]] | None a transform function that takes the cursor's position and returns a transformed position at which the cursor will visually appear. """ if subplot in self._cursors.keys(): raise KeyError(f"The given subplot has already been added to this cursor") if (not callable(transform)) and (transform is not None): raise TypeError( f"`transform` must be a callable or `None`, you passed: {transform}" ) if self.mode == "marker": cursor = self._create_marker() elif self.mode == "crosshair": cursor = self._create_crosshair() subplot.scene.add(cursor) subplot.renderer.add_event_handler( partial(self._pointer_moved, subplot), "pointer_move" ) self._cursors[subplot] = cursor self._transforms[subplot] = transform # let cursor manage tooltips subplot.renderer.remove_event_handler(subplot._fpl_set_tooltip, "pointer_move")
[docs] def remove_subplot(self, subplot: Subplot): """remove a subplot""" if subplot not in self._cursors.keys(): raise KeyError("cursor not in given supblot") subplot.scene.remove(self._cursors.pop(subplot)) # give back tooltip control to the subplot subplot.renderer.add_event_handler(subplot._fpl_set_tooltip, "pointer_move")
[docs] def clear(self): """remove all subplots""" for subplot in self._cursors.keys(): self.remove_subplot(subplot)
def _create_marker(self) -> pygfx.Points: # creates a Point object, used for "marker" mode point = pygfx.Points( pygfx.Geometry(positions=np.array([[*self.position, 0]], dtype=np.float32)), pygfx.PointsMarkerMaterial( marker=self.marker, size=self.size, size_space=self.size_space, color=self.color, edge_color=self.edge_color, edge_width=self.edge_width, opacity=self.alpha, alpha_mode="blend", render_queue=RenderQueue.selector, depth_test=False, depth_write=False, pick_write=False, ), ) return point def _create_crosshair(self) -> pygfx.Group: # Creates two infinite lines, used for "crosshair" mode x, y = self.position line_h_data = np.array( [ [x - 1, y, 0], [x + 1, y, 0], ], dtype=np.float32, ) line_v_data = np.array( [ [x, y - 1, 0], [x, y + 1, 0], ], dtype=np.float32, ) line_h = pygfx.Line( geometry=pygfx.Geometry(positions=line_h_data), material=pygfx.LineInfiniteSegmentMaterial( thickness=self.size, thickness_space=self.size_space, color=self.color, opacity=self.alpha, alpha_mode="blend", aa=True, render_queue=RenderQueue.selector, depth_test=False, depth_write=False, pick_write=False, ), ) line_v = pygfx.Line( geometry=pygfx.Geometry(positions=line_v_data), material=pygfx.LineInfiniteSegmentMaterial( thickness=self.size, thickness_space=self.size_space, color=self.color, opacity=self.alpha, alpha_mode="blend", aa=True, render_queue=RenderQueue.selector, depth_test=False, depth_write=False, pick_write=False, ), ) lines = pygfx.Group() lines.add(line_h, line_v) return lines def _pointer_moved(self, subplot, ev: pygfx.PointerEvent): if not self.enabled: return pos = subplot.map_screen_to_world(ev) if pos is None: return self.position = pos[:-1]