├── tests ├── __init__.py ├── conftest.py └── test_mplcursors.py ├── doc ├── source │ ├── changelog.rst │ ├── images │ │ └── basic.png │ ├── _static │ │ └── hide_some_gallery_elements.css │ ├── modules.rst │ ├── _local_ext.py │ ├── mplcursors.rst │ ├── conf.py │ └── index.rst ├── .gitignore ├── Makefile └── make.bat ├── .gitignore ├── examples ├── README.txt ├── hover.py ├── basic.py ├── highlight.py ├── keyboard_shortcuts.py ├── date.py ├── labeled_points.py ├── image.py ├── scatter.py ├── nondraggable.py ├── artist_labels.py ├── step.py ├── contour.py ├── paired_highlight.py ├── bar.py ├── change_popup_color.py └── dataframe.py ├── .readthedocs.yaml ├── LICENSE.txt ├── src └── mplcursors │ ├── __init__.py │ ├── _pick_info.py │ └── _mplcursors.py ├── pyproject.toml ├── README.rst ├── .github └── workflows │ └── build.yml └── CHANGELOG.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | source/examples/ 3 | source/sg_execution_times.rst 4 | -------------------------------------------------------------------------------- /doc/source/images/basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anntzer/mplcursors/HEAD/doc/source/images/basic.png -------------------------------------------------------------------------------- /doc/source/_static/hide_some_gallery_elements.css: -------------------------------------------------------------------------------- 1 | div.sphx-glr-download-link-note, div.sphx-glr-download-jupyter { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /doc/source/modules.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | mplcursors 4 | ========== 5 | 6 | .. toctree:: 7 | :maxdepth: 4 8 | 9 | mplcursors 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | .cache/ 3 | .eggs/ 4 | .ipynb_checkpoints/ 5 | .pytest_cache/ 6 | build/ 7 | dist/ 8 | htmlcov/ 9 | oprofile_data/ 10 | .*.swp 11 | *.o 12 | *.pyc 13 | *.so 14 | .coverage 15 | .gdb_history 16 | -------------------------------------------------------------------------------- /examples/README.txt: -------------------------------------------------------------------------------- 1 | .. _examples: 2 | 3 | :mod:`mplcursors` examples 4 | ========================== 5 | 6 | As `mplcursors` is fundamentally a library for interactivity, you should 7 | download the examples and try them yourself :-) 8 | 9 | .. vim: ft=rst 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-24.04" 5 | tools: 6 | python: "3.13" 7 | 8 | sphinx: 9 | configuration: doc/source/conf.py 10 | 11 | python: 12 | install: 13 | - path: . 14 | extra_requirements: 15 | - docs 16 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def pytest_make_parametrize_id(config, val): 5 | if isinstance(val, type(lambda: None)) and val.__qualname__ != "": 6 | return val.__qualname__ 7 | if isinstance(val, Path): 8 | return str(val) 9 | -------------------------------------------------------------------------------- /doc/source/_local_ext.py: -------------------------------------------------------------------------------- 1 | from sphinx_gallery.sorting import ExampleTitleSortKey 2 | 3 | 4 | class CustomSortKey(ExampleTitleSortKey): 5 | def __call__(self, filename): 6 | return ("" if filename == "basic.py" # goes first 7 | else super().__call__(filename)) 8 | -------------------------------------------------------------------------------- /examples/hover.py: -------------------------------------------------------------------------------- 1 | """ 2 | Annotate on hover 3 | ================= 4 | 5 | When *hover* is set to ``True``, annotations are displayed when the mouse 6 | hovers over the artist, without the need for clicking. 7 | """ 8 | 9 | import matplotlib.pyplot as plt 10 | import numpy as np 11 | import mplcursors 12 | np.random.seed(42) 13 | 14 | fig, ax = plt.subplots() 15 | ax.scatter(*np.random.random((2, 26))) 16 | ax.set_title("Mouse over a point") 17 | 18 | mplcursors.cursor(hover=True) 19 | 20 | plt.show() 21 | -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | """ 2 | mplcursors' core functionality 3 | ============================== 4 | 5 | ... is to add interactive data cursors to a figure. 6 | """ 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import mplcursors 11 | 12 | data = np.outer(range(10), range(1, 5)) 13 | 14 | fig, ax = plt.subplots() 15 | lines = ax.plot(data) 16 | ax.set_title("Click somewhere on a line.\nRight-click to deselect.\n" 17 | "Annotations can be dragged.") 18 | fig.tight_layout() 19 | 20 | mplcursors.cursor(lines) 21 | 22 | plt.show() 23 | -------------------------------------------------------------------------------- /examples/highlight.py: -------------------------------------------------------------------------------- 1 | """ 2 | Highlighting the artist upon selection 3 | ====================================== 4 | 5 | Just pass ``highlight=True`` to `cursor`. 6 | """ 7 | 8 | import numpy as np 9 | import matplotlib.pyplot as plt 10 | import mplcursors 11 | 12 | x = np.linspace(0, 10, 100) 13 | 14 | fig, ax = plt.subplots() 15 | 16 | # Plot a series of lines with increasing slopes. 17 | lines = [] 18 | for i in range(1, 20): 19 | line, = ax.plot(x, i * x, label=f"$y = {i}x$") 20 | lines.append(line) 21 | 22 | mplcursors.cursor(lines, highlight=True) 23 | 24 | plt.show() 25 | -------------------------------------------------------------------------------- /examples/keyboard_shortcuts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Keyboard shortcuts 3 | ================== 4 | 5 | By default, mplcursors uses "t" to toggle interactivity and "d" to hide/show 6 | annotation boxes. These shortcuts can be customized. 7 | """ 8 | 9 | import matplotlib.pyplot as plt 10 | import mplcursors 11 | 12 | fig, ax = plt.subplots() 13 | ax.plot(range(10), "o-") 14 | ax.set_title('Press "e" to enable/disable the datacursor\n' 15 | 'Press "h" to hide/show any annotation boxes') 16 | 17 | mplcursors.cursor(bindings={"toggle_visible": "h", "toggle_enabled": "e"}) 18 | 19 | plt.show() 20 | -------------------------------------------------------------------------------- /examples/date.py: -------------------------------------------------------------------------------- 1 | """ 2 | Datetime data 3 | ============= 4 | 5 | mplcursors correctly formats datetime data. 6 | """ 7 | 8 | import datetime as dt 9 | import numpy as np 10 | import matplotlib.pyplot as plt 11 | import matplotlib.dates as mdates 12 | import mplcursors 13 | 14 | t = mdates.drange(dt.datetime(2014, 1, 15), dt.datetime(2014, 2, 27), 15 | dt.timedelta(hours=2)) 16 | y = np.sin(t) 17 | fig, ax = plt.subplots() 18 | ax.plot(mdates.num2date(t), y, "-") 19 | 20 | # Note that mplcursors will automatically display the x-values as dates. 21 | mplcursors.cursor() 22 | 23 | plt.show() 24 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | -------------------------------------------------------------------------------- /examples/labeled_points.py: -------------------------------------------------------------------------------- 1 | """ 2 | Displaying a custom label for each individual point 3 | =================================================== 4 | 5 | mpldatacursor's *point_labels* functionality can be emulated with an event 6 | handler that sets the annotation text with a label selected from the target 7 | index. 8 | """ 9 | 10 | import matplotlib.pyplot as plt 11 | import mplcursors 12 | import numpy as np 13 | 14 | labels = ["a", "b", "c", "d", "e"] 15 | x = np.array([0, 1, 2, 3, 4]) 16 | 17 | fig, ax = plt.subplots() 18 | line, = ax.plot(x, x, "ro") 19 | mplcursors.cursor(ax).connect( 20 | "add", lambda sel: sel.annotation.set_text(labels[sel.index])) 21 | 22 | plt.show() 23 | -------------------------------------------------------------------------------- /examples/image.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cursors on images 3 | ================= 4 | 5 | ... display the underlying data value. 6 | """ 7 | 8 | from matplotlib.offsetbox import AnnotationBbox, OffsetImage 9 | import matplotlib.pyplot as plt 10 | import numpy as np 11 | import mplcursors 12 | 13 | data = np.arange(100).reshape((10, 10)) 14 | 15 | fig, axs = plt.subplots(ncols=2) 16 | axs[0].imshow(data, origin="lower") 17 | axs[1].imshow(data, origin="upper", extent=[2, 3, 4, 5]) 18 | 19 | axs[1].set(xlim=(2, 4), ylim=(4, 6)) 20 | axs[1].add_artist(AnnotationBbox(OffsetImage(data), (3.5, 5.5))) 21 | 22 | mplcursors.cursor() 23 | 24 | fig.suptitle("Click anywhere on the image") 25 | 26 | plt.show() 27 | -------------------------------------------------------------------------------- /examples/scatter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scatter plots are highlighted point-by-point. 3 | ============================================= 4 | 5 | ... as opposed to lines with a ``"."`` style, which have the same appearance, 6 | but are highlighted as a whole. 7 | """ 8 | 9 | import numpy as np 10 | import matplotlib.pyplot as plt 11 | import mplcursors 12 | 13 | x, y, z = np.random.random((3, 10)) 14 | fig, axs = plt.subplots(3) 15 | fig.suptitle("Highlighting affects individual points\n" 16 | "only in scatter plots (top two axes)") 17 | axs[0].scatter(x, y, c=z, s=100 * np.random.random(10)) 18 | axs[1].scatter(x, y) 19 | axs[2].plot(x, y, "o") 20 | mplcursors.cursor(highlight=True) 21 | plt.show() 22 | -------------------------------------------------------------------------------- /doc/source/mplcursors.rst: -------------------------------------------------------------------------------- 1 | The mplcursors API 2 | ================== 3 | 4 | .. module:: mplcursors 5 | 6 | | 7 | 8 | .. autosummary:: 9 | Cursor 10 | cursor 11 | HoverMode 12 | Selection 13 | compute_pick 14 | get_ann_text 15 | move 16 | make_highlight 17 | 18 | | 19 | 20 | .. autoclass:: Cursor 21 | :members: 22 | :special-members: __init__ 23 | :undoc-members: 24 | 25 | .. autofunction:: cursor 26 | 27 | .. autoclass:: HoverMode 28 | :members: 29 | :undoc-members: 30 | 31 | .. autoclass:: Selection 32 | :members: 33 | :undoc-members: 34 | 35 | .. autofunction:: compute_pick 36 | .. autofunction:: get_ann_text 37 | .. autofunction:: move 38 | .. autofunction:: make_highlight 39 | -------------------------------------------------------------------------------- /examples/nondraggable.py: -------------------------------------------------------------------------------- 1 | """ 2 | Using multiple annotations and disabling draggability via signals 3 | ================================================================= 4 | 5 | By default, each `Cursor` will ever display one annotation at a time. Pass 6 | ``multiple=True`` to display multiple annotations. 7 | 8 | Annotations can be made non-draggable by hooking their creation. 9 | """ 10 | 11 | import matplotlib.pyplot as plt 12 | import numpy as np 13 | import mplcursors 14 | 15 | data = np.outer(range(10), range(1, 5)) 16 | 17 | fig, ax = plt.subplots() 18 | ax.set_title("Multiple non-draggable annotations") 19 | ax.plot(data) 20 | 21 | mplcursors.cursor(multiple=True).connect( 22 | "add", lambda sel: sel.annotation.draggable(False)) 23 | 24 | plt.show() 25 | -------------------------------------------------------------------------------- /examples/artist_labels.py: -------------------------------------------------------------------------------- 1 | """ 2 | Display an artist's label instead of x, y coordinates 3 | ===================================================== 4 | 5 | Use an event handler to change the annotation text. 6 | """ 7 | 8 | import numpy as np 9 | import matplotlib.pyplot as plt 10 | import mplcursors 11 | 12 | x = np.linspace(0, 10, 100) 13 | 14 | fig, ax = plt.subplots() 15 | ax.set_title("Click on a line to display its label") 16 | 17 | # Plot a series of lines with increasing slopes. 18 | for i in range(1, 20): 19 | ax.plot(x, i * x, label=f"$y = {i}x$") 20 | 21 | # Use a Cursor to interactively display the label for a selected line. 22 | mplcursors.cursor().connect( 23 | "add", lambda sel: sel.annotation.set_text(sel.artist.get_label())) 24 | 25 | plt.show() 26 | -------------------------------------------------------------------------------- /examples/step.py: -------------------------------------------------------------------------------- 1 | """ 2 | Step plots 3 | ========== 4 | 5 | A selection on a step plot holds precise information on the x and y position 6 | in the ``sel.index`` attribute. 7 | """ 8 | 9 | from matplotlib import pyplot as plt 10 | import mplcursors 11 | import numpy as np 12 | 13 | 14 | fig, axs = plt.subplots(4, sharex=True, sharey=True) 15 | np.random.seed(42) 16 | xs = np.arange(5) 17 | ys = np.random.rand(5) 18 | 19 | axs[0].plot(xs, ys, "-o") 20 | axs[1].plot(xs, ys, "-o", drawstyle="steps-pre") 21 | axs[2].plot(xs, ys, "-o", drawstyle="steps-mid") 22 | axs[3].plot(xs, ys, "-o", drawstyle="steps-post") 23 | for ax in axs: 24 | ax.label_outer() 25 | 26 | mplcursors.cursor().connect( 27 | "add", lambda sel: sel.annotation.set_text(format(sel.index, ".2f"))) 28 | plt.show() 29 | -------------------------------------------------------------------------------- /examples/contour.py: -------------------------------------------------------------------------------- 1 | r""" 2 | Contour plots 3 | ============= 4 | 5 | Picking contour plots is supported on Matplotlib≥3.8. 6 | """ 7 | 8 | import numpy as np 9 | import matplotlib.pyplot as plt 10 | import mplcursors 11 | 12 | fig, axs = plt.subplots(2, 2, figsize=(10, 4), sharex=True, sharey=True) 13 | 14 | ii, jj = np.ogrid[:100, :100] 15 | img = np.cos(ii / 20) * np.sin(jj / 10) 16 | c = axs[0, 0].contour(img) 17 | fig.colorbar(c, orientation="horizontal") 18 | c = axs[0, 1].contourf(img) 19 | fig.colorbar(c, orientation="horizontal") 20 | 21 | ii, jj = np.random.rand(2, 1000) * 100 22 | img = np.cos(ii / 20) * np.sin(jj / 10) 23 | c = axs[1, 0].tricontour(jj, ii, img) 24 | fig.colorbar(c, orientation="horizontal") 25 | c = axs[1, 1].tricontourf(jj, ii, img) 26 | fig.colorbar(c, orientation="horizontal") 27 | 28 | mplcursors.cursor() 29 | 30 | plt.show() 31 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 19 | echo.then set the SPHINXBUILD environment variable to point to the full 20 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 21 | echo.Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-present Antony Lee 2 | 3 | This software is provided 'as-is', without any express or implied 4 | warranty. In no event will the authors be held liable for any damages 5 | arising from the use of this software. 6 | 7 | Permission is granted to anyone to use this software for any purpose, 8 | including commercial applications, and to alter it and redistribute it 9 | freely, subject to the following restrictions: 10 | 11 | 1. The origin of this software must not be misrepresented; you must not 12 | claim that you wrote the original software. If you use this software 13 | in a product, an acknowledgment in the product documentation would be 14 | appreciated but is not required. 15 | 2. Altered source versions must be plainly marked as such, and must not be 16 | misrepresented as being the original software. 17 | 3. This notice may not be removed or altered from any source distribution. 18 | -------------------------------------------------------------------------------- /examples/paired_highlight.py: -------------------------------------------------------------------------------- 1 | """ 2 | Linked artists 3 | ============== 4 | 5 | An example of connecting to cursor events: when an artist is selected, also 6 | highlight its "partner". 7 | """ 8 | 9 | import numpy as np 10 | import matplotlib.pyplot as plt 11 | import mplcursors 12 | 13 | 14 | def main(): 15 | fig, axes = plt.subplots(ncols=2) 16 | num = 5 17 | xy = np.random.random((num, 2)) 18 | 19 | lines = [] 20 | for i in range(num): 21 | line, = axes[0].plot((i + 1) * np.arange(10)) 22 | lines.append(line) 23 | 24 | points = [] 25 | for x, y in xy: 26 | point, = axes[1].plot([x], [y], linestyle="none", marker="o") 27 | points.append(point) 28 | 29 | cursor = mplcursors.cursor(points + lines, highlight=True) 30 | pairs = dict(zip(points, lines)) 31 | pairs.update(zip(lines, points)) 32 | 33 | @cursor.connect("add") 34 | def on_add(sel): 35 | sel.extras.append(cursor.add_highlight(pairs[sel.artist])) 36 | 37 | plt.show() 38 | 39 | 40 | if __name__ == "__main__": 41 | main() 42 | -------------------------------------------------------------------------------- /examples/bar.py: -------------------------------------------------------------------------------- 1 | """ 2 | Display a bar's height and name on top of it upon hovering 3 | ========================================================== 4 | 5 | Using an event handler to change the annotation text and position. 6 | """ 7 | 8 | import string 9 | import matplotlib.pyplot as plt 10 | import mplcursors 11 | 12 | fig, ax = plt.subplots() 13 | ax.bar(range(9), range(1, 10), align="center") 14 | labels = string.ascii_uppercase[:9] 15 | ax.set(xticks=range(9), xticklabels=labels, title="Hover over a bar") 16 | 17 | # With HoverMode.Transient, the annotation is removed as soon as the mouse 18 | # leaves the artist. Alternatively, one can use HoverMode.Persistent (or True) 19 | # which keeps the annotation until another artist gets selected. 20 | cursor = mplcursors.cursor(hover=mplcursors.HoverMode.Transient) 21 | @cursor.connect("add") 22 | def on_add(sel): 23 | x, y, width, height = sel.artist[sel.index].get_bbox().bounds 24 | sel.annotation.set(text=f"{x+width/2}: {height}", 25 | position=(0, 20), anncoords="offset points") 26 | sel.annotation.xy = (x + width / 2, y + height) 27 | 28 | plt.show() 29 | -------------------------------------------------------------------------------- /examples/change_popup_color.py: -------------------------------------------------------------------------------- 1 | """ 2 | Changing properties of the popup 3 | ================================ 4 | 5 | Use an event handler to customize the popup. 6 | """ 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import mplcursors 11 | 12 | fig, axes = plt.subplots(ncols=2) 13 | 14 | left_artist = axes[0].plot(range(11)) 15 | axes[0].set(title="No box, different position", aspect=1) 16 | 17 | right_artist = axes[1].imshow(np.arange(100).reshape(10, 10)) 18 | axes[1].set(title="Fancy white background") 19 | 20 | # Make the text pop up "underneath" the line and remove the box... 21 | c1 = mplcursors.cursor(left_artist) 22 | @c1.connect("add") 23 | def _(sel): 24 | sel.annotation.set(position=(15, -15)) 25 | # Note: Needs to be set separately due to matplotlib/matplotlib#8956. 26 | sel.annotation.set_bbox(None) 27 | 28 | # Make the box have a white background with a fancier connecting arrow 29 | c2 = mplcursors.cursor(right_artist) 30 | @c2.connect("add") 31 | def _(sel): 32 | sel.annotation.get_bbox_patch().set(fc="white") 33 | sel.annotation.arrow_patch.set(arrowstyle="simple", fc="white", alpha=.5) 34 | 35 | plt.show() 36 | -------------------------------------------------------------------------------- /src/mplcursors/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | try: 5 | import importlib.metadata as _im 6 | except ImportError: 7 | import importlib_metadata as _im 8 | try: 9 | __version__ = _im.version("mplcursors") 10 | except ImportError: 11 | __version__ = "0+unknown" 12 | 13 | from ._mplcursors import Cursor, HoverMode, cursor 14 | from ._pick_info import ( 15 | Selection, compute_pick, get_ann_text, move, make_highlight) 16 | 17 | 18 | __all__ = ["Cursor", "HoverMode", "cursor", "Selection", 19 | "compute_pick", "get_ann_text", "move", "make_highlight", "install"] 20 | 21 | 22 | def install(figure): 23 | """ 24 | A hook function that can be registered into ``rcParams["figure.hooks"]``. 25 | 26 | This hook arranges for a cursor to be registered on each figure the first 27 | time it is drawn, if the :envvar:`MPLCURSORS` environment variable is not 28 | empty (at first-draw time). That variable must contain a JSON-encoded dict 29 | of options passed to `.cursor`. 30 | """ 31 | 32 | def connect(event): 33 | figure.canvas.mpl_disconnect(cid) 34 | envopt = os.environ.get("MPLCURSORS") 35 | if not envopt: 36 | return 37 | cursor(figure, **json.loads(envopt)) 38 | 39 | cid = figure.canvas.mpl_connect("draw_event", connect) 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mplcursors" 7 | description = "Interactive data selection cursors for Matplotlib." 8 | readme = "README.rst" 9 | authors = [{name = "Antony Lee"}] 10 | urls = {Repository = "https://github.com/anntzer/mplcursors"} 11 | classifiers = [ 12 | "Framework :: Matplotlib", 13 | ] 14 | requires-python = ">=3.7" 15 | dependencies = [ 16 | # 3.7.1: matplotlib#25442; 3.10.3: matplotlib#30096. 17 | "matplotlib>=3.1,!=3.7.1,!=3.10.3", 18 | "importlib-metadata; python_version<'3.8'", 19 | ] 20 | dynamic = ["version"] 21 | 22 | [project.optional-dependencies] 23 | docs = [ 24 | "pandas", 25 | "pydata_sphinx_theme!=0.10.1", 26 | "sphinx", 27 | "sphinx-gallery>=0.16", # callables as strings. 28 | ] 29 | 30 | [tool.setuptools_scm] 31 | version_scheme = "post-release" 32 | local_scheme = "node-and-date" 33 | fallback_version = "0+unknown" 34 | 35 | [tool.coverage.run] 36 | branch = true 37 | source_pkgs = ["mplcursors"] 38 | 39 | [tool.coverage.paths] 40 | source = ["src/", "/**/python*/site-packages/"] 41 | 42 | [tool.pytest.ini_options] 43 | filterwarnings = [ 44 | "error", # Required! Some tests check that no warnings are being emitted. 45 | "ignore::DeprecationWarning", 46 | "error::DeprecationWarning:mplcursors", 47 | ] 48 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Interactive data selection cursors for Matplotlib 2 | ================================================= 3 | 4 | | |GitHub| |PyPI| 5 | | |Read the Docs| |Build| 6 | 7 | .. |GitHub| 8 | image:: https://img.shields.io/badge/github-anntzer%2Fmplcursors-brightgreen 9 | :target: https://github.com/anntzer/mplcursors 10 | .. |PyPI| 11 | image:: https://img.shields.io/pypi/v/mplcursors.svg?color=brightgreen 12 | :target: https://pypi.python.org/pypi/mplcursors 13 | .. |Read the Docs| 14 | image:: https://img.shields.io/readthedocs/mplcursors 15 | :target: https://mplcursors.readthedocs.io/en/latest/?badge=latest 16 | .. |Build| 17 | image:: https://img.shields.io/github/actions/workflow/status/anntzer/mplcursors/build.yml?branch=main 18 | :target: https://github.com/anntzer/mplcursors/actions 19 | 20 | mplcursors provides interactive data selection cursors for Matplotlib_. It is 21 | inspired from mpldatacursor_, with a much simplified API. 22 | 23 | mplcursors requires Matplotlib_\≥3.1. 24 | 25 | Read the documentation on `readthedocs.org`_. 26 | 27 | As usual, install using pip: 28 | 29 | .. code-block:: sh 30 | 31 | $ pip install mplcursors # from PyPI 32 | $ pip install git+https://github.com/anntzer/mplcursors # from Github 33 | 34 | or your favorite package manager. 35 | 36 | Run tests with pytest_. 37 | 38 | .. _Matplotlib: https://matplotlib.org 39 | .. _mpldatacursor: https://github.com/joferkington/mpldatacursor 40 | .. _pytest: https://pytest.org 41 | .. _readthedocs.org: https://mplcursors.readthedocs.org 42 | -------------------------------------------------------------------------------- /examples/dataframe.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extracting data and labels from a :class:`~pandas.DataFrame` 3 | ============================================================ 4 | 5 | :class:`~pandas.DataFrame`\\s can be used similarly to any other kind of input. 6 | Here, we generate a scatter plot using two columns and label the points using 7 | all columns. 8 | 9 | This example also applies a shadow effect to the hover panel. 10 | """ 11 | 12 | from matplotlib import pyplot as plt 13 | 14 | from matplotlib.patheffects import withSimplePatchShadow 15 | import mplcursors 16 | from pandas import DataFrame 17 | 18 | 19 | df = DataFrame( 20 | dict( 21 | Suburb=["Ames", "Somerset", "Sawyer"], 22 | Area=[1023, 2093, 723], 23 | SalePrice=[507500, 647000, 546999], 24 | ) 25 | ) 26 | 27 | df.plot.scatter(x="Area", y="SalePrice", s=100) 28 | 29 | 30 | def show_hover_panel(get_text_func=None): 31 | cursor = mplcursors.cursor( 32 | hover=2, # Transient 33 | annotation_kwargs=dict( 34 | bbox=dict( 35 | boxstyle="square,pad=0.5", 36 | facecolor="white", 37 | edgecolor="#ddd", 38 | linewidth=0.5, 39 | path_effects=[withSimplePatchShadow(offset=(1.5, -1.5))], 40 | ), 41 | linespacing=1.5, 42 | arrowprops=None, 43 | ), 44 | highlight=True, 45 | highlight_kwargs=dict(linewidth=2), 46 | ) 47 | 48 | if get_text_func: 49 | cursor.connect( 50 | event="add", 51 | func=lambda sel: sel.annotation.set_text(get_text_func(sel.index)), 52 | ) 53 | 54 | return cursor 55 | 56 | 57 | def on_add(index): 58 | item = df.iloc[index] 59 | parts = [ 60 | f"Suburb: {item.Suburb}", 61 | f"Area: {item.Area:,.0f}m²", 62 | f"Sale price: ${item.SalePrice:,.0f}", 63 | ] 64 | 65 | return "\n".join(parts) 66 | 67 | 68 | show_hover_panel(on_add) 69 | 70 | plt.show() 71 | 72 | # test: skip 73 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | strategy: 9 | matrix: 10 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 11 | flag: [""] 12 | include: 13 | - python-version: "3.7" 14 | flag: "oldest" 15 | - python-version: "3.13" 16 | flag: "pre" 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install 23 | run: | 24 | case '${{ matrix.flag }}' in 25 | oldest) 26 | NUMPY_VERSION='==1.14.*' 27 | MATPLOTLIB_VERSION='==3.1.0' 28 | ;; 29 | pre) 30 | PIP_INSTALL_PRE=true 31 | ;; 32 | esac && 33 | pip install --upgrade pip setuptools wheel pytest pytest-cov coverage[toml] && 34 | # Force install of numpy before matplotlib. 35 | pip install --upgrade --upgrade-strategy=only-if-needed --only-binary=:all: numpy"$NUMPY_VERSION" && 36 | pip install --upgrade --upgrade-strategy=only-if-needed matplotlib"$MATPLOTLIB_VERSION" && 37 | pip install . && 38 | pip list 39 | - name: Test 40 | run: | 41 | python -mpytest --cov --cov-branch 42 | - name: Upload coverage 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: coverage-${{ matrix.python-version }}-${{ matrix.flag }} 46 | include-hidden-files: true 47 | path: .coverage 48 | 49 | coverage: 50 | runs-on: ubuntu-latest 51 | needs: build 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: actions/setup-python@v5 55 | with: 56 | python-version: "3.12" 57 | - name: Run 58 | run: | 59 | shopt -s globstar && 60 | GH_TOKEN=${{ secrets.GITHUB_TOKEN }} \ 61 | gh run download ${{ github.run-id }} -p 'coverage-*' && 62 | python -mpip install --upgrade coverage && 63 | python -mcoverage combine coverage-*/.coverage && # Unifies paths across envs. 64 | python -mcoverage annotate && 65 | grep -HnTC2 '^!' **/*,cover | sed s/,cover// && 66 | python -mcoverage report --show-missing 67 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | import re 4 | import sys 5 | import mplcursors 6 | 7 | # -- General configuration ------------------------------------------------ 8 | 9 | extensions = [ 10 | 'sphinx.ext.autodoc', 11 | 'sphinx.ext.autosummary', 12 | 'sphinx.ext.coverage', 13 | 'sphinx.ext.intersphinx', 14 | 'sphinx.ext.napoleon', 15 | 'sphinx.ext.viewcode', 16 | 'sphinx_gallery.gen_gallery', 17 | ] 18 | 19 | source_suffix = '.rst' 20 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 21 | master_doc = 'index' 22 | 23 | project = 'mplcursors' 24 | copyright = '2016–present, Antony Lee' 25 | author = 'Antony Lee' 26 | 27 | # RTD modifies conf.py, making setuptools_scm mark the version as -dirty. 28 | version = release = re.sub(r'\.dirty$', '', mplcursors.__version__) 29 | 30 | language = 'en' 31 | 32 | default_role = 'any' 33 | 34 | pygments_style = 'sphinx' 35 | 36 | todo_include_todos = False 37 | 38 | python_use_unqualified_type_names = True 39 | 40 | # -- Options for HTML output ---------------------------------------------- 41 | 42 | html_theme = 'pydata_sphinx_theme' 43 | html_theme_options = { 44 | 'github_url': 'https://github.com/anntzer/mplcursors', 45 | } 46 | html_css_files = ['hide_some_gallery_elements.css'] 47 | html_static_path = ['_static'] 48 | 49 | htmlhelp_basename = 'mplcursors_doc' 50 | 51 | # -- Options for LaTeX output --------------------------------------------- 52 | 53 | latex_elements = {} 54 | latex_documents = [( 55 | master_doc, 56 | 'mplcursors.tex', 57 | 'mplcursors Documentation', 58 | 'Antony Lee', 59 | 'manual', 60 | )] 61 | 62 | # -- Options for manual page output --------------------------------------- 63 | 64 | man_pages = [( 65 | master_doc, 66 | 'mplcursors', 67 | 'mplcursors Documentation', 68 | [author], 69 | 1, 70 | )] 71 | 72 | # -- Options for Texinfo output ------------------------------------------- 73 | 74 | texinfo_documents = [( 75 | master_doc, 76 | 'mplcursors', 77 | 'mplcursors Documentation', 78 | author, 79 | 'mplcursors', 80 | 'Interactive data selection cursors for Matplotlib.', 81 | 'Miscellaneous', 82 | )] 83 | 84 | # -- Misc. configuration -------------------------------------------------- 85 | 86 | autodoc_member_order = 'bysource' 87 | 88 | intersphinx_mapping = { 89 | 'python': ('https://docs.python.org/3', None), 90 | 'matplotlib': ('https://matplotlib.org/stable', None), 91 | 'pandas': ('https://pandas.pydata.org/pandas-docs/stable', None), 92 | } 93 | 94 | # CustomSortKey cannot be defined *here* because it would be unpicklable as 95 | # this file is exec'd rather than imported. 96 | sys.path.append(os.path.dirname(__file__)) 97 | 98 | os.environ.pop("DISPLAY", None) # Don't warn about non-GUI when running s-g. 99 | 100 | sphinx_gallery_conf = { 101 | 'backreferences_dir': None, 102 | 'examples_dirs': '../../examples', 103 | 'filename_pattern': r'.*\.py', 104 | 'gallery_dirs': 'examples', 105 | 'min_reported_time': 1, 106 | 'within_subsection_order': '_local_ext.CustomSortKey', 107 | } 108 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.7 5 | --- 6 | 7 | - Mark Matplotlib 3.10.3 as incompatible. 8 | - Require registration in ``mpl.rcParams["figure.hooks"]`` for the 9 | :envvar:`MPLCURSORS` environment variable. 10 | - Added `Cursor.select_at` to trigger a selection at a given position. This 11 | API is experimental and subject to future changes. 12 | - Support new-style (Matplotlib≥3.8) contour plots. 13 | - Support :class:`~matplotlib.offsetbox.AnnotationBbox`, 14 | :class:`~matplotlib.image.BboxImage`, 15 | :class:`~matplotlib.offsetbox.OffsetBox`. 16 | - Support highlighting :class:`~matplotlib.collections.LineCollection` and 17 | non-scatter :class:`~matplotlib.collections.PathCollection`. 18 | - Modified the (semi-internal) signature of `Cursor.add_selection` to be more 19 | robust against sub-artists without a ``.axes`` attribute. 20 | 21 | 0.6 22 | --- 23 | 24 | - Fix compatibility with Matplotlib 3.9 and numpy 2. 25 | - Fix disabling multiple key bindings. 26 | - Remove the deprecated ``Selection.target.index``. 27 | 28 | 0.5.3 29 | ----- 30 | 31 | - Require Python 3.7 (due to setuptools support ranges); mark Matplotlib 3.7.1 32 | as incompatible. 33 | - Highlights can be removed by right-clicking anywhere on the highlighting 34 | artist, not only on the annotation. 35 | 36 | 0.5.2 37 | ----- 38 | 39 | - Fix compatibility with Matplotlib 3.6 and with PEP517 builds. 40 | - Non-multiple cursors can now be dragged. 41 | 42 | 0.5.1 43 | ----- 44 | 45 | No new features; minor changes to docs. 46 | 47 | 0.5 48 | --- 49 | 50 | - **Breaking change**: ``index`` is now a direct attribute of the `Selection`, 51 | rather than a sub-attribute via ``target``. (``Selection.target.index`` has 52 | been deprecated and will be removed in the future.) 53 | - Additional annotations are no longer created when dragging a ``multiple`` 54 | cursor. 55 | - Clicking on an annotation also updates the "current" selection for keyboard 56 | motion purposes. 57 | - Disabling a cursor also makes it unresponsive to motion keys. 58 | - Hovering is still active when the pan or zoom buttons are pressed (but not if 59 | there's a pan or zoom currently being selected). 60 | - Annotations are now :class:`~matplotlib.figure.Figure`-level artists, rather 61 | than Axes-level ones (so as to be drawn on top of twinned axes, if present). 62 | 63 | 0.4 64 | --- 65 | 66 | - Invisible artists are now unpickable (patch suggested by @eBardieCT). 67 | - The ``bindings`` kwarg can require modifier keys for mouse button events. 68 | - Transient hovering (suggested by @LaurenceMolloy). 69 | - Switch to supporting only "new-style" 70 | (:class:`~matplotlib.collections.LineCollection`) 71 | :meth:`~matplotlib.axes.Axes.stem` plots. 72 | - Cursors are drawn with ``zorder=np.inf``. 73 | 74 | 0.3 75 | --- 76 | 77 | - Updated dependency to Matplotlib 3.1 (``Annotation.{get,set}_anncoords``), 78 | and thus Python 3.6, numpy 1.11. 79 | - Display value in annotation for colormapped scatter plots. 80 | - Improve formatting of image values. 81 | - The add/remove callbacks no longer rely on Matplotlib's 82 | :class:`~matplotlib.cbook.CallbackRegistry`. `Cursor.connect` now returns 83 | the callback itself (simplifying its use as a decorator). 84 | `Cursor.disconnect` now takes two arguments: the event name and the callback 85 | function. Strong references are kept for the callbacks. 86 | - Overlapping annotations are now removed one at a time. 87 | - Re-clicking on an already selected point does not create a new annotation 88 | (patch suggested by @schneeammer). 89 | - :class:`~matplotlib.collections.PatchCollection`\s are now pickable (on their 90 | borders) (patch modified from a PR by @secretyv). 91 | - Support :class:`~matplotlib.collections.Collection`\s where 92 | :meth:`~matplotlib.collections.Collection.get_offset_transform()` is not 93 | ``transData`` (patch suggested by @yt87). 94 | - Support setting both ``hover`` and ``multiple``. 95 | - The ``artist`` attribute of Selections is correctly set to the 96 | :class:`~matplotlib.container.Container` when picking a 97 | :class:`~matplotlib.container.Container`, rather than to the internally used 98 | wrapper. 99 | 100 | 0.2.1 101 | ----- 102 | 103 | No new features; test suite updated for compatibility with Matplotlib 3.0. 104 | 105 | Miscellaneous bugfixes. 106 | 107 | 0.2 108 | --- 109 | 110 | - Updated dependency to Matplotlib 2.1 (2.0 gives more information about 111 | orientation of bar plots; 2.1 improves the handling of step plots). 112 | - Setting :envvar:`MPLCURSORS` hooks `Figure.draw 113 | ` (once per figure only) instead of `plt.show 114 | `, thus supporting figures created after the first 115 | call to `plt.show `. 116 | - Automatic positioning and alignment of annotation text. 117 | - Selections on images now have an index as well. 118 | - Selections created on :meth:`~matplotlib.axes.Axes.scatter` plots, 119 | :meth:`~matplotlib.axes.Axes.errorbar` plots, and 120 | :meth:`~matplotlib.axes.Axes.polar` plots can now be moved. 121 | - :class:`~matplotlib.collections.PathCollection`\s not created by 122 | :meth:`~matplotlib.axes.Axes.scatter` are now picked as paths, not as 123 | collections of points. 124 | - :class:`~matplotlib.patches.Patch`\es now pick on their borders, not their 125 | interior. 126 | - Improved picking of :class:`~matplotlib.container.Container`\s. 127 | - In hover mode, annotations can still be removed by right-clicking. 128 | 129 | Miscellaneous bugfixes. 130 | 131 | 0.1 132 | --- 133 | 134 | - First public release. 135 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | mplcursors – Interactive data selection cursors for Matplotlib 2 | ============================================================== 3 | 4 | |GitHub| |PyPI| 5 | 6 | .. |GitHub| 7 | image:: https://img.shields.io/badge/github-anntzer%2Fmplcursors-brightgreen 8 | :target: https://github.com/anntzer/mplcursors 9 | .. |PyPI| 10 | image:: https://img.shields.io/pypi/v/mplcursors.svg 11 | :target: https://pypi.python.org/pypi/mplcursors 12 | 13 | :mod:`mplcursors` provides interactive data selection cursors for Matplotlib_. 14 | It is inspired from mpldatacursor_, with a much simplified API. 15 | 16 | .. _Matplotlib: https://matplotlib.org 17 | .. _mpldatacursor: https://github.com/joferkington/mpldatacursor 18 | 19 | :mod:`mplcursors` requires Matplotlib≥3.1. 20 | 21 | .. _installation: 22 | 23 | Installation 24 | ------------ 25 | 26 | Pick one among: 27 | 28 | .. code-block:: sh 29 | 30 | $ pip install mplcursors # from PyPI 31 | $ pip install git+https://github.com/anntzer/mplcursors # from Github 32 | 33 | .. _basic-example: 34 | 35 | Basic example 36 | ------------- 37 | 38 | Basic examples work similarly to mpldatacursor_:: 39 | 40 | import matplotlib.pyplot as plt 41 | import numpy as np 42 | import mplcursors 43 | 44 | data = np.outer(range(10), range(1, 5)) 45 | 46 | fig, ax = plt.subplots() 47 | lines = ax.plot(data) 48 | ax.set_title("Click somewhere on a line.\nRight-click to deselect.\n" 49 | "Annotations can be dragged.") 50 | 51 | mplcursors.cursor(lines) # or just mplcursors.cursor() 52 | 53 | plt.show() 54 | 55 | .. image:: /images/basic.png 56 | 57 | The `cursor` convenience function makes a collection of artists selectable. 58 | Specifically, its first argument can either be a list of artists or axes (in 59 | which case all artists in each of the axes become selectable); or one can just 60 | pass no argument, in which case all artists in all figures become selectable. 61 | Other arguments (which are all keyword-only) allow for basic customization of 62 | the `Cursor`’s behavior; please refer to that class' documentation. 63 | 64 | .. _activation-by-environment-variable: 65 | 66 | Activation by environment variable 67 | ---------------------------------- 68 | 69 | To globally configure the use of :mod:`mplcursors`, set the 70 | :envvar:`MPLCURSORS` environment variable to a JSON-encoded dict, and add 71 | ``"mplcursors:install"`` to ``mpl.rcParams["figure.hooks"]`` (this requires 72 | Matplotlib≥3.7). This hook arranges for a cursor to be registered on 73 | each figure the first time it is drawn, passing the options specified in 74 | :envvar:`MPLCURSORS` to `cursor`. 75 | 76 | $ MPLCURSORS={} python foo.py 77 | 78 | and:: 79 | 80 | $ MPLCURSORS='{"hover": 1}' python foo.py 81 | 82 | Note that this will not pick up artists added to the figure after the first 83 | draw, e.g. when working interactively or when artists are added through 84 | interactive callbacks. 85 | 86 | In earlier versions, registration in ``mpl.rcParams["figure.hooks"]`` was not 87 | needed; the behavior has changed because that implementation led to complex 88 | interactions with setuptools and the import machinery. 89 | 90 | .. _default-ui: 91 | 92 | Default UI 93 | ---------- 94 | 95 | - A left click on a line (a point, for plots where the data points are not 96 | connected) creates a draggable annotation there. Only one annotation is 97 | displayed (per `Cursor` instance), except if the ``multiple`` keyword 98 | argument was set. 99 | - A right click on an existing annotation will remove it. 100 | - Clicks do not trigger annotations if the zoom or pan tool are active. It is 101 | possible to bypass this by *double*-clicking instead. 102 | - For annotations pointing to lines or images, :kbd:`Shift-Left` and 103 | :kbd:`Shift-Right` move the cursor "left" or "right" by one data point. For 104 | annotations pointing to images, :kbd:`Shift-Up` and :kbd:`Shift-Down` are 105 | likewise available. 106 | - :kbd:`v` toggles the visibility of the existing annotation(s). 107 | - :kbd:`e` toggles whether the `Cursor` is active at all (if not, no event 108 | other than re-activation is propagated). 109 | 110 | These bindings are all customizable via `Cursor`’s ``bindings`` keyword 111 | argument. Note that the keyboard bindings are only active if the canvas has 112 | the keyboard input focus. 113 | 114 | .. _customization: 115 | 116 | Customization 117 | ------------- 118 | 119 | Instead of providing a host of keyword arguments in `Cursor`’s constructor, 120 | :mod:`mplcursors` represents selections as `Selection` objects and lets you 121 | hook into their addition and removal. 122 | 123 | Specifically, a `Selection` has the following fields: 124 | 125 | - :attr:`.artist`: the selected artist, 126 | 127 | - :attr:`.target`: the ``(x, y)`` coordinates of the point picked within the 128 | artist. 129 | 130 | - :attr:`.index`: an index of the selected point, within the artist data, as 131 | detailed below. 132 | 133 | - :attr:`.dist`: the distance from the point clicked to the :attr:`.target` 134 | (mostly used to decide which artist to select). 135 | 136 | - :attr:`.annotation`: a Matplotlib :class:`~matplotlib.text.Annotation` 137 | object. 138 | 139 | - :attr:`.extras`: an additional list of artists, that will be removed whenever 140 | the main :attr:`.annotation` is deselected. 141 | 142 | The exact meaning of :attr:`.index` depends on the selected artist: 143 | 144 | - For :class:`~matplotlib.lines.Line2D`\s, the integer part of :attr:`.index` 145 | is the index of segment where the selection is, and its fractional part 146 | indicates where the selection is within that segment. 147 | 148 | For step plots (i.e., created by `plt.step ` or 149 | `plt.plot `\ ``(..., drawstyle="steps-...")``, we 150 | return a special :class:`Index` object, with attributes :attr:`int` (the 151 | segment index), :attr:`x` (how far the point has advanced in the ``x`` 152 | direction) and :attr:`y` (how far the point has advanced in the ``y`` 153 | direction). See `/examples/step` for an example. 154 | 155 | On polar plots, lines can be either drawn with a "straight" connection 156 | between two points (in screen space), or "curved" (i.e., using linear 157 | interpolation in data space). In the first case, the fractional part of the 158 | index is defined as for cartesian plots. In the second case, the index in 159 | computed first on the interpolated path, then divided by the interpolation 160 | factor (i.e., pretending that each interpolated segment advances the same 161 | index by the same amount). 162 | 163 | - For :class:`~matplotlib.image.AxesImage`\s, :attr:`.index` are the ``(y, x)`` 164 | indices of the selected point, such that ``data[y, x]`` is the value at that 165 | point (note that the indices are thus in reverse order compared to the ``(x, 166 | y)`` target coordinates!). 167 | 168 | - For :class:`~matplotlib.container.Container`\s, :attr:`.index` is the index 169 | of the selected sub-artist. 170 | 171 | - For :class:`~matplotlib.collections.LineCollection`\s and 172 | :class:`~matplotlib.collections.PathCollection`\s, :attr:`.index` is a pair: 173 | the index of the selected line, and the index within the line, as defined 174 | above. 175 | 176 | (Note that although `Selection` is implemented as a namedtuple, only the field 177 | names should be considered stable API. The number and order of fields is 178 | subject to change with no notice.) 179 | 180 | Thus, in order to customize, e.g., the annotation text, one can call:: 181 | 182 | lines = ax.plot(range(3), range(3), "o") 183 | labels = ["a", "b", "c"] 184 | cursor = mplcursors.cursor(lines) 185 | cursor.connect( 186 | "add", lambda sel: sel.annotation.set_text(labels[sel.index])) 187 | 188 | Whenever a point is selected (resp. deselected), the ``"add"`` (resp. 189 | ``"remove"``) event is triggered and the registered callbacks are executed, 190 | with the `Selection` as only argument. Here, the only callback updates the 191 | text of the annotation to a per-point label. (``cursor.connect("add")`` can 192 | also be used as a decorator to register a callback, see below for an example.) 193 | For an example using pandas' `DataFrame `\s, see 194 | `/examples/dataframe`. 195 | 196 | For additional examples of customization of the position and appearance of the 197 | annotation, see `/examples/bar` and `/examples/change_popup_color`. 198 | 199 | .. note:: 200 | When the callback is fired, the position of the annotating text is 201 | temporarily set to ``(nan, nan)``. This allows us to track whether a 202 | callback explicitly sets this position, and, if none does, automatically 203 | compute a suitable position. 204 | 205 | Likewise, if the text alignment is not explicitly set but the position is, 206 | then a suitable alignment will be automatically computed. 207 | 208 | Callbacks can also be used to make additional changes to the figure when 209 | a selection occurs. For example, the following snippet (extracted from 210 | `/examples/paired_highlight`) ensures that whenever an artist is selected, 211 | another artist that has been "paired" with it (via the ``pairs`` map) also gets 212 | selected:: 213 | 214 | @cursor.connect("add") 215 | def on_add(sel): 216 | sel.extras.append(cursor.add_highlight(pairs[sel.artist])) 217 | 218 | Note that the paired artist will also get de-highlighted when the "first" 219 | artist is deselected. 220 | 221 | In order to set the status bar text from a callback, it may be helpful to 222 | clear it during "normal" mouse motion, e.g.:: 223 | 224 | fig.canvas.mpl_connect( 225 | "motion_notify_event", 226 | lambda event: fig.canvas.toolbar.set_message("")) 227 | cursor = mplcursors.cursor(hover=True) 228 | cursor.connect( 229 | "add", 230 | lambda sel: fig.canvas.toolbar.set_message( 231 | sel.annotation.get_text().replace("\n", "; "))) 232 | 233 | .. _complex-plots: 234 | 235 | Complex plots 236 | ------------- 237 | 238 | Some complex plots, such as contour plots, may be partially supported, 239 | or not at all. Typically, it is because they do not subclass 240 | :class:`~matplotlib.artist.Artist`, and thus appear to `cursor` as a collection 241 | of independent artists (each contour level, in the case of contour plots). 242 | 243 | It is usually possible, again, to hook the ``"add"`` signal to provide 244 | additional information in the annotation text. See `/examples/contour` for an 245 | example. 246 | 247 | Animations 248 | ---------- 249 | 250 | Matplotlib's :mod:`.animation` blitting mode assumes that the animation 251 | object is entirely in charge of deciding what artists to draw and when. In 252 | particular, this means that the ``animated`` property is set on certain 253 | artists. As a result, when :mod:`mplcursors` tries to blit an animation on 254 | top of the image, the animated artists will not be drawn, and disappear. More 255 | importantly, it also means that once an annotation is added, :mod:`mplcursors` 256 | cannot remove it (as it needs to know what artists to redraw to restore the 257 | original state). 258 | 259 | As a workaround, either switch off blitting, or unset the ``animated`` 260 | property on the relevant artists before using a cursor. (The only other 261 | fix I can envision is to walk the entire tree of artists, record their 262 | visibility status, and try to later restore them; but this would fail for 263 | :class:`~matplotlib.animation.ArtistAnimation`\s which themselves fiddle with 264 | artist visibility). 265 | 266 | Indices and tables 267 | ================== 268 | 269 | * `genindex` 270 | * `modindex` 271 | * `search` 272 | 273 | .. toctree:: 274 | :hidden: 275 | 276 | Main page 277 | API 278 | Examples 279 | Changelog 280 | -------------------------------------------------------------------------------- /src/mplcursors/_pick_info.py: -------------------------------------------------------------------------------- 1 | # Unsupported Artist classes: subclasses of AxesImage, QuadMesh (upstream could 2 | # have a `format_coord`-like method); PolyCollection (picking is not well 3 | # defined). 4 | 5 | from collections import namedtuple 6 | from contextlib import suppress 7 | import copy 8 | import functools 9 | import inspect 10 | from inspect import Signature 11 | from numbers import Integral 12 | import re 13 | import warnings 14 | from weakref import WeakSet 15 | 16 | from matplotlib.axes import Axes 17 | from matplotlib.collections import ( 18 | LineCollection, PatchCollection, PathCollection) 19 | from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer 20 | from matplotlib.contour import ContourSet 21 | from matplotlib.image import AxesImage, BboxImage 22 | from matplotlib.lines import Line2D 23 | from matplotlib.offsetbox import AnnotationBbox, OffsetBox 24 | from matplotlib.patches import Patch, PathPatch, Polygon, Rectangle 25 | from matplotlib.quiver import Barbs, Quiver 26 | from matplotlib.text import Text 27 | from matplotlib.transforms import ( 28 | Affine2D, Bbox, BboxTransformFrom, BboxTransformTo) 29 | import numpy as np 30 | 31 | 32 | PATCH_PICKRADIUS = 5 # FIXME Patches do not provide `pickradius`. 33 | 34 | 35 | def _register_scatter(): 36 | """ 37 | Patch `PathCollection` and `scatter` to register their return values. 38 | 39 | This registration allows us to distinguish `PathCollection`s created by 40 | `Axes.scatter`, which should use point-like picking, from others, which 41 | should use path-like picking. The former is more common, so we store the 42 | latter instead; this also lets us guess the type better if this module is 43 | imported late. 44 | """ 45 | 46 | @functools.wraps(PathCollection.__init__) 47 | def __init__(self, *args, **kwargs): 48 | _nonscatter_pathcollections.add(self) 49 | return __init__.__wrapped__(self, *args, **kwargs) 50 | PathCollection.__init__ = __init__ 51 | 52 | @functools.wraps(Axes.scatter) 53 | def scatter(*args, **kwargs): 54 | paths = scatter.__wrapped__(*args, **kwargs) 55 | with suppress(KeyError): 56 | _nonscatter_pathcollections.remove(paths) 57 | return paths 58 | Axes.scatter = scatter 59 | 60 | 61 | _nonscatter_pathcollections = WeakSet() 62 | _register_scatter() 63 | 64 | 65 | def _is_scatter(artist): 66 | return (isinstance(artist, PathCollection) 67 | and artist not in _nonscatter_pathcollections) 68 | 69 | 70 | def _artist_in_container(container): 71 | return next(filter(None, container.get_children())) 72 | 73 | 74 | class ContainerArtist: 75 | """Workaround to make containers behave more like artists.""" 76 | 77 | def __init__(self, container): 78 | self.container = container # Guaranteed to be nonempty. 79 | # We can't weakref the Container (which subclasses tuple), so 80 | # we instead create a reference cycle between the Container and 81 | # the ContainerArtist; as no one else strongly references the 82 | # ContainerArtist, it will get GC'd whenever the Container is. 83 | vars(container).setdefault( 84 | f"_{__class__.__name__}__keep_alive", []).append(self) 85 | 86 | def __str__(self): 87 | return f"<{type(self).__name__}({self.container})>" 88 | 89 | def __repr__(self): 90 | return f"<{type(self).__name__}({self.container!r})>" 91 | 92 | figure = property(lambda self: _artist_in_container(self.container).figure) 93 | axes = property(lambda self: _artist_in_container(self.container).axes) 94 | 95 | def get_visible(self): 96 | return True # For lack of anything better. 97 | 98 | 99 | Selection = namedtuple( 100 | "Selection", "artist target index dist annotation extras") 101 | Selection.__doc__ = """ 102 | A selection. 103 | 104 | Although this class is implemented as a namedtuple (to simplify the 105 | dispatching of `compute_pick`, `get_ann_text`, and `make_highlight`), only 106 | the field names should be considered stable API. The number and order of 107 | fields are subject to change with no notice. 108 | """ 109 | # Override equality to identity: Selections should be considered immutable 110 | # (with mutable fields though) and we don't want to trigger casts of array 111 | # equality checks to booleans. We don't need to override comparisons because 112 | # artists are already non-comparable. 113 | Selection.__eq__ = lambda self, other: self is other 114 | Selection.__ne__ = lambda self, other: self is not other 115 | Selection.artist.__doc__ = ( 116 | "The selected artist.") 117 | Selection.target.__doc__ = ( 118 | "The point picked within the artist, in data coordinates.") 119 | Selection.index.__doc__ = ( 120 | "The index of the selected point, within the artist data.") 121 | Selection.dist.__doc__ = ( 122 | "The distance from the click to the target, in pixels.") 123 | Selection.annotation.__doc__ = ( 124 | "The instantiated `matplotlib.text.Annotation`.") 125 | Selection.extras.__doc__ = ( 126 | "An additional list of artists (e.g., highlighters) that will be cleared " 127 | "at the same time as the annotation.") 128 | 129 | 130 | def _gen_warning_text(kind, tp): 131 | return "{} support for {} (MRO: {}) is missing.".format( 132 | kind, tp.__name__, ", ".join(cls.__name__ for cls in tp.__mro__)) 133 | 134 | 135 | @functools.singledispatch 136 | def compute_pick(artist, event): 137 | """ 138 | Find whether *artist* has been picked by *event*. 139 | 140 | If it has, return the appropriate `Selection`; otherwise return ``None``. 141 | 142 | This is a single-dispatch function; implementations for various artist 143 | classes follow. 144 | """ 145 | warnings.warn(_gen_warning_text("Pick", type(artist))) 146 | 147 | 148 | class Index: 149 | def __init__(self, i, x, y): 150 | self.int = i 151 | self.x = x 152 | self.y = y 153 | 154 | def __floor__(self): 155 | return self.int 156 | 157 | def __ceil__(self): 158 | return self.int if max(self.x, self.y) == 0 else self.int + 1 159 | 160 | # numpy<1.17 backcompat. 161 | floor = __floor__ 162 | ceil = __ceil__ 163 | 164 | def __format__(self, fmt): 165 | return f"{self.int}.(x={self.x:{fmt}}, y={self.y:{fmt}})" 166 | 167 | def __str__(self): 168 | return format(self, "") 169 | 170 | @classmethod 171 | def pre_index(cls, n_pts, index): 172 | i, frac = divmod(index, 1) 173 | i, odd = divmod(i, 2) 174 | x, y = (0, frac) if not odd else (frac, 1) 175 | return cls(i, x, y) 176 | 177 | @classmethod 178 | def post_index(cls, n_pts, index): 179 | i, frac = divmod(index, 1) 180 | i, odd = divmod(i, 2) 181 | x, y = (frac, 0) if not odd else (1, frac) 182 | return cls(i, x, y) 183 | 184 | @classmethod 185 | def mid_index(cls, n_pts, index): 186 | i, frac = divmod(index, 1) 187 | if i == 0: 188 | frac = .5 + frac / 2 189 | elif i == 2 * n_pts - 2: # One less line than points. 190 | frac = frac / 2 191 | quot, odd = divmod(i, 2) 192 | if not odd: 193 | if frac < .5: 194 | i = quot - 1 195 | x, y = frac + .5, 1 196 | else: 197 | i = quot 198 | x, y = frac - .5, 0 199 | else: 200 | i = quot 201 | x, y = .5, frac 202 | return cls(i, x, y) 203 | 204 | 205 | def _compute_projection_pick(artist, path, xy): 206 | """ 207 | Project *xy* on *path* to obtain a `Selection` for *artist*. 208 | 209 | *path* is first transformed to screen coordinates using the artist 210 | transform, and the target of the returned `Selection` is transformed 211 | back to data coordinates using the artist *axes* inverse transform. The 212 | `Selection` `index` is returned as a float. This function returns ``None`` 213 | for degenerate inputs. 214 | 215 | The caller is responsible for converting the index to the proper class if 216 | needed. 217 | """ 218 | transform = artist.get_transform().frozen() 219 | tpath = (path.cleaned(transform) if transform.is_affine 220 | # `cleaned` only handles affine transforms. 221 | else transform.transform_path(path).cleaned()) 222 | # `cleaned` should return a path where the first element is `MOVETO`, the 223 | # following are `LINETO` or `CLOSEPOLY`, and the last one is `STOP`, i.e. 224 | # codes = path.codes 225 | # assert (codes[0], codes[-1]) == (path.MOVETO, path.STOP) 226 | # assert np.isin(codes[1:-1], [path.LINETO, path.CLOSEPOLY]).all() 227 | vertices = tpath.vertices[:-1] 228 | codes = tpath.codes[:-1] 229 | mt_idxs, = (codes == tpath.MOVETO).nonzero() 230 | cp_idxs, = (codes == tpath.CLOSEPOLY).nonzero() 231 | vertices[cp_idxs] = vertices[mt_idxs[mt_idxs.searchsorted(cp_idxs) - 1]] 232 | # Unit vectors for each segment. 233 | us = vertices[1:] - vertices[:-1] 234 | ls = np.hypot(*us.T) 235 | with np.errstate(invalid="ignore"): 236 | # Results in 0/0 for repeated consecutive points. 237 | us /= ls[:, None] 238 | # Vectors from each vertex to the event. 239 | vs = xy - vertices[:-1] 240 | # Clipped dot products -- `einsum` cannot be done in place, `clip` can. 241 | # `clip` can trigger invalid comparisons if there are nan points. 242 | with np.errstate(invalid="ignore"): 243 | dot = np.clip(np.einsum("ij,ij->i", vs, us), 0, ls) 244 | # Projections. 245 | projs = vertices[:-1] + dot[:, None] * us 246 | ds = np.hypot(*(xy - projs).T) 247 | ds[mt_idxs[1:] - 1] = np.nan 248 | try: 249 | argmin = np.nanargmin(ds) 250 | except ValueError: # Raised by nanargmin([nan]). 251 | return 252 | else: 253 | target = artist.axes.transData.inverted().transform(projs[argmin]) 254 | index = ((argmin + dot[argmin] / ls[argmin]) 255 | / (path._interpolation_steps / tpath._interpolation_steps)) 256 | return Selection(artist, target, index, ds[argmin], None, None) 257 | 258 | 259 | def _untransform(orig_xy, screen_xy, ax): 260 | """ 261 | Return data coordinates to place an annotation at screen coordinates 262 | *screen_xy* in axes *ax*. 263 | 264 | *orig_xy* are the "original" coordinates as stored by the artist; they are 265 | transformed to *screen_xy* by whatever transform the artist uses. If the 266 | artist uses ``ax.transData``, just return *orig_xy*; else, apply 267 | ``ax.transData.inverse()`` to *screen_xy*. (The first case is more 268 | accurate than always applying ``ax.transData.inverse()``.) 269 | """ 270 | tr_xy = ax.transData.transform(orig_xy) 271 | return ( 272 | orig_xy 273 | if ((tr_xy == screen_xy) | np.isnan(tr_xy) & np.isnan(screen_xy)).all() 274 | else ax.transData.inverted().transform(screen_xy)) 275 | 276 | 277 | @compute_pick.register(Line2D) 278 | def _(artist, event): 279 | # No need to call `line.contains` as we're going to redo the work anyways 280 | # (also see matplotlib/matplotlib#6645, though that's fixed in mpl2.1). 281 | 282 | # Always work in screen coordinates, as this is how we need to compute 283 | # distances. Note that the artist transform may be different from the axes 284 | # transform (e.g., for axvline). 285 | xy = event.x, event.y 286 | data_xy = artist.get_xydata() 287 | data_screen_xy = artist.get_transform().transform(data_xy) 288 | sels = [] 289 | # If markers are visible, find the closest vertex. 290 | if artist.get_marker() not in ["None", "none", " ", "", None]: 291 | ds = np.hypot(*(xy - data_screen_xy).T) 292 | try: 293 | argmin = np.nanargmin(ds) 294 | except ValueError: # Raised by nanargmin([nan]). 295 | pass 296 | else: 297 | target = _untransform( # More precise than transforming back. 298 | data_xy[argmin], data_screen_xy[argmin], artist.axes) 299 | sels.append( 300 | Selection(artist, target, argmin, ds[argmin], None, None)) 301 | # If lines are visible, find the closest projection. 302 | if (artist.get_linestyle() not in ["None", "none", " ", "", None] 303 | and len(artist.get_xydata()) > 1): 304 | sel = _compute_projection_pick(artist, artist.get_path(), xy) 305 | if sel is not None: 306 | sel = sel._replace(index={ 307 | "_draw_lines": lambda _, index: index, 308 | "_draw_steps_pre": Index.pre_index, 309 | "_draw_steps_mid": Index.mid_index, 310 | "_draw_steps_post": Index.post_index}[ 311 | Line2D.drawStyles[artist.get_drawstyle()]]( 312 | len(data_xy), sel.index)) 313 | sels.append(sel) 314 | sel = min(sels, key=lambda sel: sel.dist, default=None) 315 | return sel if sel and sel.dist < artist.get_pickradius() else None 316 | 317 | 318 | @compute_pick.register(PathPatch) 319 | @compute_pick.register(Polygon) 320 | @compute_pick.register(Rectangle) 321 | def _(artist, event): 322 | sel = _compute_projection_pick( 323 | artist, artist.get_path(), (event.x, event.y)) 324 | if sel and sel.dist < PATCH_PICKRADIUS: 325 | return sel 326 | 327 | 328 | @compute_pick.register(LineCollection) 329 | @compute_pick.register(PatchCollection) 330 | @compute_pick.register(PathCollection) 331 | def _(artist, event): 332 | offsets = artist.get_offsets() 333 | paths = artist.get_paths() 334 | if _is_scatter(artist): 335 | # Use the C implementation to prune the list of segments -- but only 336 | # for scatter plots as that implementation is inconsistent with Line2D 337 | # for segment-like collections (matplotlib/matplotlib#17279). 338 | contains, info = artist.contains(event) 339 | if not contains: 340 | return 341 | inds = info["ind"] 342 | offsets = artist.get_offsets()[inds] 343 | offsets_screen = artist.get_offset_transform().transform(offsets) 344 | ds = np.hypot(*(offsets_screen - [event.x, event.y]).T) 345 | argmin = ds.argmin() 346 | target = _untransform( 347 | offsets[argmin], offsets_screen[argmin], artist.axes) 348 | return Selection(artist, target, inds[argmin], ds[argmin], None, None) 349 | elif len(paths) and len(offsets): 350 | # Note that this won't select implicitly closed paths. 351 | sels = [ 352 | _compute_projection_pick( 353 | artist, 354 | Affine2D().translate(*offsets[ind % len(offsets)]) 355 | .transform_path(paths[ind % len(paths)]), 356 | (event.x, event.y)) 357 | for ind in range(max(len(offsets), len(paths)))] 358 | if not any(sels): 359 | return None 360 | idx = min(range(len(sels)), 361 | key=lambda idx: sels[idx].dist if sels[idx] else np.inf) 362 | sel = sels[idx] 363 | if sel.dist >= artist.get_pickradius(): 364 | return None 365 | return sel._replace(artist=artist, index=(idx, sel.index)) 366 | 367 | 368 | # This registration has no effect on mpl<3.8, where ContourSets are not artists 369 | # and thus do not appear in the draw tree. 370 | # Filled contours are picked identically to unfilled ones, in particular 371 | # because Path.contains_point does not handle holes in paths correctly; thus we 372 | # cannot determine which contour, among many nested ones, actually contains the 373 | # a point between two layers. 374 | @compute_pick.register(ContourSet) 375 | def _(artist, event): 376 | return compute_pick.dispatch(LineCollection)(artist, event) 377 | 378 | 379 | @compute_pick.register(AxesImage) 380 | @compute_pick.register(BboxImage) 381 | def _(artist, event): 382 | if type(artist) not in compute_pick.registry: 383 | # Skip and warn on subclasses (`NonUniformImage`, `PcolorImage`) as 384 | # they do not implement `contains` correctly. Even if they did, they 385 | # would not support moving as we do not know where a given index maps 386 | # back physically. 387 | return compute_pick.dispatch(object)(artist, event) 388 | contains, _ = artist.contains(event) 389 | if not contains: 390 | return 391 | ns = np.asarray(artist.get_array().shape[:2])[::-1] # (y, x) -> (x, y) 392 | xf, yf = BboxTransformFrom( 393 | artist.get_window_extent()).transform([event.x, event.y]) 394 | if artist.origin == "upper": 395 | yf = 1 - yf 396 | idxs = np.minimum(((xf, yf) * ns).astype(int), ns - 1)[::-1] 397 | return Selection(artist, (event.xdata, event.ydata), 398 | tuple(idxs), 0, None, None) 399 | 400 | 401 | @compute_pick.register(Barbs) 402 | @compute_pick.register(Quiver) 403 | def _(artist, event): 404 | offsets = artist.get_offsets() 405 | offsets_screen = artist.get_offset_transform().transform(offsets) 406 | ds = np.hypot(*(offsets_screen - [event.x, event.y]).T) 407 | argmin = np.nanargmin(ds) 408 | if ds[argmin] < artist.get_pickradius(): 409 | target = _untransform( 410 | offsets[argmin], offsets_screen[argmin], artist.axes) 411 | return Selection(artist, target, argmin, ds[argmin], None, None) 412 | else: 413 | return None 414 | 415 | 416 | @compute_pick.register(Text) 417 | def _(artist, event): 418 | return 419 | 420 | 421 | @compute_pick.register(ContainerArtist) 422 | def _(artist, event): 423 | return compute_pick(artist.container, event) 424 | 425 | 426 | @compute_pick.register(BarContainer) 427 | def _(container, event): 428 | try: 429 | (idx, patch), = { 430 | (idx, patch) for idx, patch in enumerate(container.patches) 431 | if patch.contains(event)[0]} 432 | except ValueError: 433 | return 434 | target = [event.xdata, event.ydata] 435 | if patch.sticky_edges.x: 436 | target[0], = ( 437 | x for x in [patch.get_x(), patch.get_x() + patch.get_width()] 438 | if x not in patch.sticky_edges.x) 439 | if patch.sticky_edges.y: 440 | target[1], = ( 441 | y for y in [patch.get_y(), patch.get_y() + patch.get_height()] 442 | if y not in patch.sticky_edges.y) 443 | return Selection(container, target, idx, 0, None, None) 444 | 445 | 446 | @compute_pick.register(ErrorbarContainer) 447 | def _(container, event): 448 | data_line, cap_lines, err_lcs = container 449 | sel_data = compute_pick(data_line, event) if data_line else None 450 | sel_err = min( 451 | filter(None, (compute_pick(err_lc, event) for err_lc in err_lcs)), 452 | key=lambda sel: sel.dist, default=None) 453 | if (sel_data and sel_data.dist < getattr(sel_err, "dist", np.inf)): 454 | return sel_data 455 | elif sel_err: 456 | idx, _ = sel_err.index 457 | if data_line: 458 | target = data_line.get_xydata()[idx] 459 | else: # We can't guess the original data in that case! 460 | return 461 | return Selection(container, target, idx, 0, None, None) 462 | else: 463 | return 464 | 465 | 466 | @compute_pick.register(StemContainer) 467 | def _(container, event): 468 | sel = compute_pick(container.markerline, event) 469 | if sel: 470 | return sel 471 | if not isinstance(container.stemlines, LineCollection): 472 | warnings.warn("Only stem plots created with use_line_collection=True " 473 | "are supported.") 474 | return 475 | sel = compute_pick(container.stemlines, event) 476 | if sel: 477 | idx, _ = sel.index 478 | target = container.stemlines.get_segments()[idx][-1] 479 | return Selection(container, target, sel.index, 0, None, None) 480 | 481 | 482 | @compute_pick.register(OffsetBox) 483 | def _(artist, event): 484 | # Pass-through: actually picks a child artist. 485 | return min( 486 | filter(None, [compute_pick(child, event) 487 | for child in artist.get_children()]), 488 | key=lambda sel: sel.dist, default=None) 489 | 490 | 491 | @compute_pick.register(AnnotationBbox) 492 | def _(artist, event): 493 | # Pass-through: actually picks a child artist. 494 | return compute_pick(artist.offsetbox, event) 495 | 496 | 497 | def _call_with_selection(func=None, *, argname="artist"): 498 | """Decorator that passes a `Selection` built from the non-kwonly args.""" 499 | 500 | if func is None: 501 | return functools.partial(_call_with_selection, argname=argname) 502 | 503 | wrapped_kwonly_params = [ 504 | param for param in inspect.signature(func).parameters.values() 505 | if param.kind == param.KEYWORD_ONLY] 506 | sel_sig = inspect.signature(Selection) 507 | default_sel_sig = sel_sig.replace( 508 | parameters=[param.replace(default=None) if param.default is param.empty 509 | else param 510 | for param in sel_sig.parameters.values()]) 511 | 512 | @functools.wraps(func) 513 | def wrapper(*args, **kwargs): 514 | extra_kw = {param.name: kwargs.pop(param.name) 515 | for param in wrapped_kwonly_params if param.name in kwargs} 516 | ba = default_sel_sig.bind(*args, **kwargs) 517 | ba.apply_defaults() 518 | sel = Selection(*ba.args, **ba.kwargs) 519 | return func(sel, **extra_kw) 520 | 521 | params = [*sel_sig.parameters.values(), *wrapped_kwonly_params] 522 | params[0] = params[0].replace(name=argname) 523 | wrapper.__signature__ = Signature(params) 524 | return wrapper 525 | 526 | 527 | def _format_coord_unspaced(ax, pos): 528 | # This used to directly post-process the output of format_coord(), but got 529 | # switched to handling special projections separately due to the change in 530 | # formatting for rectilinear coordinates. 531 | if ax.name == "polar": 532 | return ax.format_coord(*pos).replace(", ", "\n") 533 | elif ax.name == "3d": # Need to retrieve the actual line data coordinates. 534 | warnings.warn("3d coordinates not supported yet") 535 | return "" 536 | else: 537 | x, y = pos 538 | # In mpl<3.3 (before #16776) format_x/ydata included trailing 539 | # spaces, hence the rstrip() calls. format_xdata/format_ydata do not 540 | # actually always return strs (see test_fixed_ticks_nonstr_labels), 541 | # hence the explicit cast. 542 | return (f"x={str(ax.format_xdata(x)).rstrip()}\n" 543 | f"y={str(ax.format_ydata(y)).rstrip()}") 544 | 545 | 546 | @functools.singledispatch 547 | @_call_with_selection 548 | def get_ann_text(sel): 549 | """ 550 | Compute an annotating text for an (unpacked) `Selection`. 551 | 552 | This is a single-dispatch function; implementations for various artist 553 | classes follow. 554 | """ 555 | warnings.warn(_gen_warning_text("Annotation", type(sel.artist))) 556 | return "" 557 | 558 | 559 | @get_ann_text.register(Line2D) 560 | @get_ann_text.register(LineCollection) 561 | @get_ann_text.register(PatchCollection) 562 | @get_ann_text.register(PathCollection) 563 | @get_ann_text.register(Patch) 564 | @_call_with_selection 565 | def _(sel): 566 | artist = sel.artist 567 | label = artist.get_label() or "" 568 | text = _format_coord_unspaced(sel.annotation.axes, sel.target) 569 | if (_is_scatter(artist) 570 | # Heuristic: is the artist colormapped? 571 | # Note that this doesn't handle size-mapping (which is more likely 572 | # to involve an arbitrary scaling). 573 | and artist.get_array() is not None 574 | and len(artist.get_array()) == len(artist.get_offsets())): 575 | value = artist.format_cursor_data(artist.get_array()[sel.index]) 576 | text = f"{text}\n{value}" 577 | if re.match("[^_]", label): 578 | text = f"{label}\n{text}" 579 | return text 580 | 581 | 582 | @get_ann_text.register(ContourSet) 583 | @_call_with_selection 584 | def _(sel): 585 | artist = sel.artist 586 | return "{}\n{}".format( 587 | _format_coord_unspaced(sel.annotation.axes, sel.target), 588 | artist.levels[sel.index[0] + artist.filled]) 589 | 590 | 591 | @get_ann_text.register(AxesImage) 592 | @get_ann_text.register(BboxImage) 593 | @_call_with_selection 594 | def _(sel): 595 | artist = sel.artist 596 | text = _format_coord_unspaced(sel.annotation.axes, sel.target) 597 | cursor_text = artist.format_cursor_data(artist.get_array()[sel.index]) 598 | return f"{text}\n{cursor_text}" 599 | 600 | 601 | @get_ann_text.register(Barbs) 602 | @_call_with_selection 603 | def _(sel): 604 | artist = sel.artist 605 | text = "{}\n({!s}, {!s})".format( 606 | _format_coord_unspaced(sel.annotation.axes, sel.target), 607 | artist.u[sel.index], artist.v[sel.index]) 608 | return text 609 | 610 | 611 | @get_ann_text.register(Quiver) 612 | @_call_with_selection 613 | def _(sel): 614 | artist = sel.artist 615 | text = "{}\n({!s}, {!s})".format( 616 | _format_coord_unspaced(sel.annotation.axes, sel.target), 617 | artist.U[sel.index], artist.V[sel.index]) 618 | return text 619 | 620 | 621 | # NOTE: There is no get_ann_text(ContainerArtist) as the selection directly 622 | # refers to the Container itself. 623 | 624 | 625 | @get_ann_text.register(BarContainer) 626 | @_call_with_selection(argname="container") 627 | def _(sel): 628 | return _format_coord_unspaced(sel.annotation.axes, sel.target) 629 | 630 | 631 | @get_ann_text.register(ErrorbarContainer) 632 | @_call_with_selection(argname="container") 633 | def _(sel): 634 | data_line, cap_lines, err_lcs = sel.artist 635 | ann_text = get_ann_text(*sel._replace(artist=data_line)) 636 | if isinstance(sel.index, Integral): 637 | err_lcs = iter(err_lcs) 638 | for idx, (dir, has) in enumerate( 639 | zip("xy", [sel.artist.has_xerr, sel.artist.has_yerr])): 640 | if has: 641 | err = (next(err_lcs).get_paths()[sel.index].vertices 642 | - data_line.get_xydata()[sel.index])[:, idx] 643 | err_s = [getattr(sel.annotation.axes, f"format_{dir}data")(e) 644 | .rstrip() 645 | for e in err] 646 | # We'd normally want to check err.sum() == 0, but that can run 647 | # into fp inaccuracies. 648 | signs = "+-\N{MINUS SIGN}" 649 | if len({s.lstrip(signs) for s in err_s}) == 1: 650 | repl = rf"\1=$\2\\pm{err_s[1]}$\3" 651 | else: 652 | # Replacing unicode minus by ascii minus don't change the 653 | # rendering as the string is mathtext, but allows keeping 654 | # the same tests across Matplotlib versions that use 655 | # unicode minus and those that don't. 656 | err_s = [("+" if not s.startswith(tuple(signs)) else "") 657 | + s.replace("\N{MINUS SIGN}", "-") 658 | for s in err_s] 659 | repl = r"\1=$\2_{%s}^{%s}$\3" % tuple(err_s) 660 | ann_text = re.sub(f"({dir})=(.*)(\n?)", repl, ann_text) 661 | return ann_text 662 | 663 | 664 | @get_ann_text.register(StemContainer) 665 | @_call_with_selection(argname="container") 666 | def _(sel): 667 | return get_ann_text(*sel._replace(artist=sel.artist.markerline)) 668 | 669 | 670 | @functools.singledispatch 671 | @_call_with_selection 672 | def move(sel, *, key): 673 | """ 674 | Move an (unpacked) `Selection` following a keypress. 675 | 676 | This function is used to implement annotation displacement through the 677 | keyboard. 678 | 679 | This is a single-dispatch function; implementations for various artist 680 | classes follow. 681 | """ 682 | return sel 683 | 684 | 685 | def _move_within_points(sel, xys, *, key): 686 | # Avoid infinite loop in case everything became nan at some point. 687 | for _ in range(len(xys)): 688 | if key == "left": 689 | new_idx = int(np.ceil(sel.index) - 1) % len(xys) 690 | elif key == "right": 691 | new_idx = int(np.floor(sel.index) + 1) % len(xys) 692 | else: 693 | return sel 694 | sel = sel._replace(target=xys[new_idx], index=new_idx, dist=0) 695 | if np.isfinite(sel.target).all(): 696 | return sel 697 | 698 | 699 | @move.register(Line2D) 700 | @_call_with_selection 701 | def _(sel, *, key): 702 | data_xy = sel.artist.get_xydata() 703 | return _move_within_points( 704 | sel, 705 | _untransform(data_xy, sel.artist.get_transform().transform(data_xy), 706 | sel.annotation.axes), 707 | key=key) 708 | 709 | 710 | @move.register(PathCollection) 711 | @_call_with_selection 712 | def _(sel, *, key): 713 | if _is_scatter(sel.artist): 714 | offsets = sel.artist.get_offsets() 715 | return _move_within_points( 716 | sel, 717 | _untransform( 718 | offsets, sel.artist.get_offset_transform().transform(offsets), 719 | sel.annotation.axes), 720 | key=key) 721 | else: 722 | return sel 723 | 724 | 725 | @move.register(AxesImage) 726 | @move.register(BboxImage) 727 | @_call_with_selection 728 | def _(sel, *, key): 729 | ns = sel.artist.get_array().shape[:2] 730 | delta = np.array( 731 | {"left": [0, -1], "right": [0, +1], "down": [-1, 0], "up": [+1, 0]}[ 732 | key]) 733 | if sel.artist.origin == "upper": 734 | delta[0] *= -1 735 | idxs = (sel.index + delta) % ns 736 | yf, xf = (idxs + .5) / ns 737 | if sel.artist.origin == "upper": 738 | yf = 1 - yf 739 | if isinstance(sel.artist, AxesImage): # Same as below, but more accurate. 740 | x0, x1, y0, y1 = sel.artist.get_extent() 741 | trf = BboxTransformTo(Bbox.from_extents([x0, y0, x1, y1])) 742 | elif isinstance(sel.artist, BboxImage): 743 | trf = (BboxTransformTo(sel.artist.get_window_extent()) 744 | - sel.annotation.axes.transData) 745 | target = trf.transform([xf, yf]) 746 | return sel._replace(target=target, index=tuple(idxs)) 747 | 748 | 749 | # NOTE: There is no move(ContainerArtist) as the selection directly 750 | # refers to the Container itself. 751 | 752 | 753 | @move.register(ErrorbarContainer) 754 | @_call_with_selection(argname="container") 755 | def _(sel, *, key): 756 | data_line, cap_lines, err_lcs = sel.artist 757 | return _move_within_points(sel, data_line.get_xydata(), key=key) 758 | 759 | 760 | @functools.singledispatch 761 | @_call_with_selection 762 | def make_highlight(sel, *, highlight_kwargs): 763 | """ 764 | Create a highlight for an (unpacked) `Selection`. 765 | 766 | This is a single-dispatch function; implementations for various artist 767 | classes follow. 768 | """ 769 | warnings.warn(_gen_warning_text("Highlight", type(sel.artist))) 770 | 771 | 772 | def _set_valid_props(artist, kwargs): 773 | """Set valid properties for the artist, dropping the others.""" 774 | artist.set(**{k: kwargs[k] for k in kwargs if hasattr(artist, "set_" + k)}) 775 | return artist 776 | 777 | 778 | @make_highlight.register(Line2D) 779 | @_call_with_selection 780 | def _(sel, *, highlight_kwargs): 781 | hl = copy.copy(sel.artist) 782 | _set_valid_props(hl, highlight_kwargs) 783 | return hl 784 | 785 | 786 | @make_highlight.register(LineCollection) 787 | @make_highlight.register(PathCollection) 788 | @_call_with_selection 789 | def _(sel, *, highlight_kwargs): 790 | hl = copy.copy(sel.artist) 791 | if _is_scatter(sel.artist): 792 | offsets = hl.get_offsets() 793 | hl.set_offsets(np.where( 794 | np.arange(len(offsets))[:, None] == sel.index, offsets, np.nan)) 795 | else: 796 | hl.set_paths([ 797 | path.vertices if i == sel.index[0] else np.empty((0, 2)) 798 | for i, path in enumerate(hl.get_paths())]) 799 | _set_valid_props(hl, highlight_kwargs) 800 | return hl 801 | -------------------------------------------------------------------------------- /tests/test_mplcursors.py: -------------------------------------------------------------------------------- 1 | from contextlib import ExitStack 2 | import copy 3 | import functools 4 | import gc 5 | import os 6 | from pathlib import Path 7 | import re 8 | import subprocess 9 | import sys 10 | import weakref 11 | 12 | import matplotlib as mpl 13 | from matplotlib import pyplot as plt 14 | from matplotlib.axes import Axes 15 | from matplotlib.backend_bases import KeyEvent, MouseEvent 16 | import mplcursors 17 | from mplcursors import _pick_info, Selection, HoverMode 18 | import numpy as np 19 | import pytest 20 | 21 | 22 | # The absolute tolerance is quite large to take into account rounding of 23 | # LocationEvents to the nearest pixel by Matplotlib, which causes a relative 24 | # error of ~ 1/#pixels. 25 | approx = functools.partial(pytest.approx, abs=1e-2) 26 | 27 | 28 | @pytest.fixture 29 | def fig(): 30 | fig = plt.figure(1) 31 | fig.canvas.callbacks.exception_handler = None 32 | return fig 33 | 34 | 35 | @pytest.fixture 36 | def ax(fig): 37 | return fig.add_subplot(111) 38 | 39 | 40 | @pytest.fixture(autouse=True) 41 | def cleanup(): 42 | with mpl.rc_context({"axes.unicode_minus": False}): 43 | try: 44 | yield 45 | finally: 46 | mplcursors.__warningregistry__ = {} 47 | plt.close("all") 48 | 49 | 50 | def _process_event(name, ax, coords, *args): 51 | ax.viewLim # unstale viewLim. 52 | if name == "__mouse_click__": 53 | # So that the dragging callbacks don't go crazy. 54 | _process_event("button_press_event", ax, coords, *args) 55 | _process_event("button_release_event", ax, coords, *args) 56 | return 57 | display_coords = ax.transData.transform(coords) 58 | if name in ["button_press_event", "button_release_event", 59 | "motion_notify_event", "scroll_event"]: 60 | event = MouseEvent(name, ax.figure.canvas, *display_coords, *args) 61 | elif name in ["key_press_event", "key_release_event"]: 62 | event = KeyEvent(name, ax.figure.canvas, *args, *display_coords) 63 | else: 64 | raise ValueError(f"Unknown event name {name!r}") 65 | ax.figure.canvas.callbacks.process(name, event) 66 | 67 | 68 | def _get_remove_args(sel): 69 | ax = sel.artist.axes 70 | # Text bounds are found only upon drawing. 71 | ax.figure.canvas.draw() 72 | bbox = sel.annotation.get_window_extent() 73 | center = ax.transData.inverted().transform( 74 | ((bbox.x0 + bbox.x1) / 2, (bbox.y0 + bbox.y1) / 2)) 75 | return "__mouse_click__", ax, center, 3 76 | 77 | 78 | def _parse_annotation(sel, regex): 79 | result = re.fullmatch(regex, sel.annotation.get_text()) 80 | assert result, \ 81 | "{!r} doesn't match {!r}".format(sel.annotation.get_text(), regex) 82 | return tuple(map(float, result.groups())) 83 | 84 | 85 | def test_containerartist(ax): 86 | artist = _pick_info.ContainerArtist(ax.errorbar([], [])) 87 | str(artist) 88 | repr(artist) 89 | 90 | 91 | def test_selection_identity_comparison(): 92 | sel0, sel1 = [Selection(artist=None, 93 | target=np.array([0, 0]), 94 | index=None, 95 | dist=0, 96 | annotation=None, 97 | extras=[]) 98 | for _ in range(2)] 99 | assert sel0 != sel1 100 | 101 | 102 | def test_degenerate_inputs(ax): 103 | empty_container = ax.bar([], []) 104 | assert not mplcursors.cursor().artists 105 | assert not mplcursors.cursor(empty_container).artists 106 | pytest.raises(TypeError, mplcursors.cursor, [1]) 107 | 108 | 109 | @pytest.mark.parametrize("plotter", [Axes.plot, Axes.fill]) 110 | def test_line(ax, plotter): 111 | artist, = plotter(ax, [0, .2, 1], [0, .8, 1], label="foo") 112 | cursor = mplcursors.cursor(multiple=True) 113 | # Far, far away. 114 | _process_event("__mouse_click__", ax, (0, 1), 1) 115 | assert len(cursor.selections) == len(ax.figure.artists) == 0 116 | # On the line. 117 | _process_event("__mouse_click__", ax, (.1, .4), 1) 118 | assert len(cursor.selections) == len(ax.figure.artists) == 1 119 | assert _parse_annotation( 120 | cursor.selections[0], r"foo\nx=(.*)\ny=(.*)") == approx((.1, .4)) 121 | # Not removing it. 122 | _process_event("__mouse_click__", ax, (0, 1), 3) 123 | assert len(cursor.selections) == len(ax.figure.artists) == 1 124 | # Remove the text label; add another annotation. 125 | artist.set_label(None) 126 | _process_event("__mouse_click__", ax, (.6, .9), 1) 127 | assert len(cursor.selections) == len(ax.figure.artists) == 2 128 | assert _parse_annotation( 129 | cursor.selections[1], r"x=(.*)\ny=(.*)") == approx((.6, .9)) 130 | # Remove both of them (first removing the second one, to test 131 | # `Selection.__eq__` -- otherwise it is bypassed as `list.remove` 132 | # checks identity first). 133 | _process_event(*_get_remove_args(cursor.selections[1])) 134 | assert len(cursor.selections) == len(ax.figure.artists) == 1 135 | _process_event(*_get_remove_args(cursor.selections[0])) 136 | assert len(cursor.selections) == len(ax.figure.artists) == 0 137 | # Will project on the vertex at (.2, .8). 138 | _process_event("__mouse_click__", ax, (.2 - .001, .8 + .001), 1) 139 | assert len(cursor.selections) == len(ax.figure.artists) == 1 140 | 141 | 142 | @pytest.mark.parametrize("plotter", 143 | [lambda ax, *args: ax.plot(*args, ls="", marker="o"), 144 | Axes.scatter]) 145 | def test_scatter(ax, plotter): 146 | plotter(ax, [0, .5, 1], [0, .5, 1]) 147 | cursor = mplcursors.cursor() 148 | _process_event("__mouse_click__", ax, (.2, .2), 1) 149 | assert len(cursor.selections) == len(ax.figure.artists) == 0 150 | _process_event("__mouse_click__", ax, (.5, .5), 1) 151 | assert len(cursor.selections) == len(ax.figure.artists) == 1 152 | 153 | 154 | def test_scatter_text(ax): 155 | ax.scatter([0, 1], [0, 1], c=[2, 3]) 156 | cursor = mplcursors.cursor() 157 | _process_event("__mouse_click__", ax, (0, 0), 1) 158 | assert _parse_annotation( 159 | cursor.selections[0], r"x=(.*)\ny=(.*)\n\[(.*)\]") == (0, 0, 2) 160 | 161 | 162 | def test_steps_index(): 163 | index = _pick_info.Index(0, .5, .5) 164 | assert np.floor(index) == 0 and np.ceil(index) == 1 165 | assert str(index) == "0.(x=0.5, y=0.5)" 166 | 167 | 168 | def test_steps_pre(ax): 169 | ax.plot([0, 1], [0, 1], drawstyle="steps-pre") 170 | ax.set(xlim=(-1, 2), ylim=(-1, 2)) 171 | cursor = mplcursors.cursor() 172 | _process_event("__mouse_click__", ax, (1, 0), 1) 173 | assert len(cursor.selections) == 0 174 | _process_event("__mouse_click__", ax, (0, .5), 1) 175 | index = cursor.selections[0].index 176 | assert (index.int, index.x, index.y) == approx((0, 0, .5)) 177 | _process_event("__mouse_click__", ax, (.5, 1), 1) 178 | index = cursor.selections[0].index 179 | assert (index.int, index.x, index.y) == approx((0, .5, 1)) 180 | 181 | 182 | def test_steps_mid(ax): 183 | ax.plot([0, 1], [0, 1], drawstyle="steps-mid") 184 | ax.set(xlim=(-1, 2), ylim=(-1, 2)) 185 | cursor = mplcursors.cursor() 186 | _process_event("__mouse_click__", ax, (0, 1), 1) 187 | assert len(cursor.selections) == 0 188 | _process_event("__mouse_click__", ax, (1, 0), 1) 189 | assert len(cursor.selections) == 0 190 | _process_event("__mouse_click__", ax, (.25, 0), 1) 191 | index = cursor.selections[0].index 192 | assert (index.int, index.x, index.y) == approx((0, .25, 0)) 193 | _process_event("__mouse_click__", ax, (.5, .5), 1) 194 | index = cursor.selections[0].index 195 | assert (index.int, index.x, index.y) == approx((0, .5, .5)) 196 | _process_event("__mouse_click__", ax, (.75, 1), 1) 197 | index = cursor.selections[0].index 198 | assert (index.int, index.x, index.y) == approx((0, .75, 1)) 199 | 200 | 201 | def test_steps_post(ax): 202 | ax.plot([0, 1], [0, 1], drawstyle="steps-post") 203 | ax.set(xlim=(-1, 2), ylim=(-1, 2)) 204 | cursor = mplcursors.cursor() 205 | _process_event("__mouse_click__", ax, (0, 1), 1) 206 | assert len(cursor.selections) == 0 207 | _process_event("__mouse_click__", ax, (.5, 0), 1) 208 | index = cursor.selections[0].index 209 | assert (index.int, index.x, index.y) == approx((0, .5, 0)) 210 | _process_event("__mouse_click__", ax, (1, .5), 1) 211 | index = cursor.selections[0].index 212 | assert (index.int, index.x, index.y) == approx((0, 1, .5)) 213 | 214 | 215 | @pytest.mark.parametrize("ls", ["-", "o"]) 216 | def test_line_single_point(ax, ls): 217 | ax.plot(0, ls) 218 | ax.set(xlim=(-1, 1), ylim=(-1, 1)) 219 | cursor = mplcursors.cursor() 220 | _process_event("__mouse_click__", ax, (.001, .001), 1) 221 | assert len(cursor.selections) == len(ax.figure.artists) == (ls == "o") 222 | if cursor.selections: 223 | assert tuple(cursor.selections[0].target) == (0, 0) 224 | 225 | 226 | @pytest.mark.parametrize("plot_args,click,targets", 227 | [(([0, 1, np.nan, 3, 4],), (.5, .5), [(.5, .5)]), 228 | (([np.nan, np.nan],), (0, 0), []), 229 | (([np.nan, np.nan], "."), (0, 0), [])]) 230 | def test_nan(ax, plot_args, click, targets): 231 | ax.plot(*plot_args) 232 | cursor = mplcursors.cursor() 233 | _process_event("__mouse_click__", ax, click, 1) 234 | assert len(cursor.selections) == len(ax.figure.artists) == len(targets) 235 | for sel, target in zip(cursor.selections, targets): 236 | assert sel.target == approx(target) 237 | 238 | 239 | def test_repeated_point(ax): 240 | ax.plot([0, 1, 1, 2], [0, 1, 1, 2]) 241 | cursor = mplcursors.cursor() 242 | _process_event("__mouse_click__", ax, (.5, .5), 1) # Should not warn. 243 | 244 | 245 | @pytest.mark.parametrize("origin", ["lower", "upper"]) 246 | @pytest.mark.parametrize("kind", ["AxesImage", "BboxImage"]) 247 | def test_image(ax, origin, kind): 248 | array = np.arange(6).reshape((3, 2)) 249 | im = ax.imshow(array, origin=origin) # Always set limits & orientation. 250 | 251 | if kind == "AxesImage": 252 | xzero = 0 253 | elif kind == "BboxImage": 254 | im.remove() 255 | ax.add_artist( 256 | mpl.image.BboxImage( 257 | mpl.transforms.TransformedBbox( 258 | mpl.transforms.Bbox([[-.5, -.5], [1.5, 2.5]]), ax.transData), 259 | data=array, origin=origin)) 260 | ax.figure.canvas.draw() # Force image autonorm. 261 | xzero = approx(0) 262 | 263 | cursor = mplcursors.cursor() 264 | # Annotation text includes image value. 265 | _process_event("__mouse_click__", ax, (.25, .25), 1) 266 | sel, = cursor.selections 267 | assert _parse_annotation( # Since mpl3.5 the value repr has extra zeros. 268 | sel, r"x=(.*)\ny=(.*)\n\[0(?:\.00)?\]") == approx((.25, .25)) 269 | # Moving around. 270 | _process_event("key_press_event", ax, (.123, .456), "shift+right") 271 | sel, = cursor.selections 272 | assert _parse_annotation(sel, r"x=(.*)\ny=(.*)\n\[1(?:\.00)?\]") == (1, 0) 273 | assert array[sel.index] == 1 274 | _process_event("key_press_event", ax, (.123, .456), "shift+right") 275 | sel, = cursor.selections 276 | assert (_parse_annotation(sel, r"x=(.*)\ny=(.*)\n\[0(?:\.00)?\]") 277 | == (xzero, 0)) 278 | assert array[sel.index] == 0 279 | _process_event("key_press_event", ax, (.123, .456), "shift+up") 280 | sel, = cursor.selections 281 | assert (_parse_annotation(sel, r"x=(.*)\ny=(.*)\n\[(.*)\]") 282 | == {"upper": (xzero, 2, 4), "lower": (0, 1, 2)}[origin]) 283 | assert array[sel.index] == {"upper": 4, "lower": 2}[origin] 284 | _process_event("key_press_event", ax, (.123, .456), "shift+down") 285 | sel, = cursor.selections 286 | assert (_parse_annotation(sel, r"x=(.*)\ny=(.*)\n\[0(?:\.00)?\]") 287 | == (xzero, 0)) 288 | assert array[sel.index] == 0 289 | 290 | cursor = mplcursors.cursor() 291 | # Not picking out of axes or out of image. 292 | _process_event("__mouse_click__", ax, (-1, -1), 1) 293 | assert len(cursor.selections) == 0 294 | ax.set(xlim=(-1, None), ylim=(-1, None)) 295 | _process_event("__mouse_click__", ax, (-.75, -.75), 1) 296 | assert len(cursor.selections) == 0 297 | 298 | 299 | def test_image_rgb(ax): 300 | ax.imshow([[[.1, .2, .3], [.4, .5, .6]]]) 301 | cursor = mplcursors.cursor() 302 | _process_event("__mouse_click__", ax, (0, 0), 1) 303 | sel, = cursor.selections 304 | assert _parse_annotation( 305 | sel, r"x=(.*)\ny=(.*)\n\[0.1, 0.2, 0.3\]") == approx((0, 0)) 306 | _process_event("key_press_event", ax, (.123, .456), "shift+right") 307 | sel, = cursor.selections 308 | assert _parse_annotation( 309 | sel, r"x=(.*)\ny=(.*)\n\[0.4, 0.5, 0.6\]") == approx((1, 0)) 310 | 311 | 312 | def test_image_subclass(ax): 313 | # Cannot move around `PcolorImage`s. 314 | ax.pcolorfast(np.arange(3) ** 2, np.arange(3) ** 2, np.zeros((2, 2))) 315 | cursor = mplcursors.cursor() 316 | with pytest.warns(UserWarning): 317 | _process_event("__mouse_click__", ax, (1, 1), 1) 318 | assert len(cursor.selections) == 0 319 | 320 | 321 | def test_linecollection(ax): 322 | ax.eventplot([]) # This must not raise a division by len([]) == 0. 323 | ax.eventplot([0, 1]) 324 | cursor = mplcursors.cursor() 325 | _process_event("__mouse_click__", ax, (0, 0), 1) 326 | _process_event("__mouse_click__", ax, (.5, 1), 1) 327 | assert len(cursor.selections) == 0 328 | _process_event("__mouse_click__", ax, (0, 1), 1) 329 | assert cursor.selections[0].index == approx((0, .5)) 330 | 331 | 332 | def test_patchcollection(ax): 333 | ax.add_collection(mpl.collections.PatchCollection([ 334 | mpl.patches.Rectangle(xy, .1, .1) for xy in [(0, 0), (.5, .5)]])) 335 | cursor = mplcursors.cursor() 336 | _process_event("__mouse_click__", ax, (.05, .05), 1) 337 | assert len(cursor.selections) == 0 338 | _process_event("__mouse_click__", ax, (.6, .6), 1) 339 | # The precision is really bad :( 340 | assert cursor.selections[0].index == approx((1, 2), abs=2e-2) 341 | 342 | 343 | @pytest.mark.skipif(getattr(mpl, "__version_info__", ()) < (3, 8), 344 | reason="No support for old-style contours.") 345 | def test_contour(ax): 346 | ax.contour([[0, 1], [0, 1]], levels=[.5]) 347 | cursor = mplcursors.cursor() 348 | _process_event("__mouse_click__", ax, (.5, .5), 1) 349 | sel, = cursor.selections 350 | x, y, v = _parse_annotation(sel, r"x=(.*)\ny=(.*)\n(.*)") 351 | # The precision is really bad :( 352 | assert (x, y) == approx((.5, .5), abs=2e-2) 353 | assert v == .5 354 | 355 | 356 | @pytest.mark.parametrize("plotter", [Axes.quiver, Axes.barbs]) 357 | def test_quiver_and_barbs(ax, plotter): 358 | plotter(ax, range(3), range(3)) 359 | cursor = mplcursors.cursor() 360 | _process_event("__mouse_click__", ax, (.5, 0), 1) 361 | assert len(cursor.selections) == 0 362 | _process_event("__mouse_click__", ax, (1, 0), 1) 363 | assert _parse_annotation( 364 | cursor.selections[0], r"x=(.*)\ny=(.*)\n\(1, 1\)") == (1, 0) 365 | 366 | 367 | @pytest.mark.parametrize("plotter,order", 368 | [(Axes.bar, np.s_[:]), (Axes.barh, np.s_[::-1])]) 369 | def test_bar(ax, plotter, order): 370 | container = plotter(ax, range(3), range(1, 4)) 371 | cursor = mplcursors.cursor() 372 | assert len(cursor.artists) == 1 373 | _process_event("__mouse_click__", ax, (0, 2)[order], 1) 374 | assert len(cursor.selections) == 0 375 | _process_event("__mouse_click__", ax, (0, .5)[order], 1) 376 | assert cursor.selections[0].artist is container # not the ContainerArtist. 377 | assert cursor.selections[0].target == approx((0, 1)[order]) 378 | 379 | 380 | def test_errorbar(ax): 381 | ax.errorbar(range(2), range(2), [(1, 1), (1, 2)]) 382 | cursor = mplcursors.cursor() 383 | assert len(cursor.artists) == 1 384 | _process_event("__mouse_click__", ax, (0, 2), 1) 385 | assert len(cursor.selections) == 0 386 | _process_event("__mouse_click__", ax, (.5, .5), 1) 387 | assert cursor.selections[0].target == approx((.5, .5)) 388 | assert _parse_annotation( 389 | cursor.selections[0], r"x=(.*)\ny=(.*)") == approx((.5, .5)) 390 | _process_event("__mouse_click__", ax, (0, 1), 1) 391 | assert cursor.selections[0].target == approx((0, 0)) 392 | assert _parse_annotation( 393 | cursor.selections[0], r"x=(.*)\ny=\$(.*)\\pm(.*)\$") == (0, 0, 1) 394 | _process_event("__mouse_click__", ax, (1, 2), 1) 395 | sel, = cursor.selections 396 | assert sel.target == approx((1, 1)) 397 | assert _parse_annotation( 398 | sel, r"x=(.*)\ny=\$(.*)_\{(.*)\}\^\{(.*)\}\$") == (1, 1, -1, 2) 399 | 400 | 401 | def test_dataless_errorbar(ax): 402 | # Unfortunately, the original data cannot be recovered when fmt="none". 403 | ax.errorbar(range(2), range(2), [(1, 1), (1, 2)], fmt="none") 404 | cursor = mplcursors.cursor() 405 | assert len(cursor.artists) == 1 406 | _process_event("__mouse_click__", ax, (0, 0), 1) 407 | assert len(cursor.selections) == 0 408 | 409 | 410 | def test_stem(ax): 411 | try: # stem use_line_collection API change. 412 | ax.stem([1, 2, 3], use_line_collection=True) 413 | except TypeError: 414 | ax.stem([1, 2, 3]) 415 | cursor = mplcursors.cursor() 416 | assert len(cursor.artists) == 1 417 | _process_event("__mouse_click__", ax, (.5, .5), 1) 418 | assert len(cursor.selections) == 0 419 | _process_event("__mouse_click__", ax, (0, 1), 1) 420 | assert cursor.selections[0].target == approx((0, 1)) 421 | _process_event("__mouse_click__", ax, (0, .5), 1) 422 | assert cursor.selections[0].target == approx((0, 1)) 423 | 424 | 425 | def test_annotationbbox(ax): 426 | ax.set(xlim=(0, 1), ylim=(0, 1)) 427 | data = np.arange(9).reshape((3, 3)) 428 | ax.add_artist(mpl.offsetbox.AnnotationBbox( 429 | mpl.offsetbox.OffsetImage(data, zoom=10), (.5, .5))) 430 | ax.figure.canvas.draw() 431 | cursor = mplcursors.cursor() 432 | _process_event("__mouse_click__", ax, (.5, .5), 1) 433 | sel, = cursor.selections 434 | assert (_parse_annotation(sel, r"x=(.*)\ny=(.*)\n\[(.*)\]") 435 | == approx((.5, .5, 4))) 436 | 437 | 438 | @pytest.mark.parametrize( 439 | "plotter,warns", 440 | [(lambda ax: ax.text(.5, .5, "foo"), False), 441 | (lambda ax: ax.fill_between([0, 1], [0, 1]), True)]) 442 | def test_misc_artists(ax, plotter, warns): 443 | plotter(ax) 444 | cursor = mplcursors.cursor() 445 | with pytest.warns(UserWarning) if warns else ExitStack(): 446 | _process_event("__mouse_click__", ax, (.5, .5), 1) 447 | assert len(cursor.selections) == 0 448 | 449 | 450 | def test_indexless_projections(fig): 451 | ax = fig.subplots(subplot_kw={"projection": "polar"}) 452 | ax.plot([1, 2], [3, 4]) 453 | cursor = mplcursors.cursor() 454 | _process_event("__mouse_click__", ax, (1, 3), 1) 455 | assert len(cursor.selections) == 1 456 | _process_event("key_press_event", ax, (.123, .456), "shift+left") 457 | 458 | 459 | def test_cropped_by_axes(fig): 460 | axs = fig.subplots(2) 461 | axs[0].plot([0, 0], [0, 1]) 462 | # Pan to hide the line behind the second axes. 463 | axs[0].set(xlim=(-1, 1), ylim=(1, 2)) 464 | axs[1].set(xlim=(-1, 1), ylim=(-1, 1)) 465 | cursor = mplcursors.cursor() 466 | _process_event("__mouse_click__", axs[1], (0, 0), 1) 467 | assert len(cursor.selections) == 0 468 | 469 | 470 | @pytest.mark.parametrize("plotter", [Axes.plot, Axes.scatter, Axes.errorbar]) 471 | def test_move(ax, plotter): 472 | plotter(ax, [0, 1, 2], [0, 1, np.nan]) 473 | cursor = mplcursors.cursor() 474 | # Nothing happens with no cursor. 475 | _process_event("key_press_event", ax, (.123, .456), "shift+left") 476 | assert len(cursor.selections) == 0 477 | # Now we move the cursor left or right. 478 | if plotter in [Axes.plot, Axes.errorbar]: 479 | _process_event("__mouse_click__", ax, (.5, .5), 1) 480 | assert tuple(cursor.selections[0].target) == approx((.5, .5)) 481 | _process_event("key_press_event", ax, (.123, .456), "shift+up") 482 | _process_event("key_press_event", ax, (.123, .456), "shift+left") 483 | elif plotter is Axes.scatter: 484 | _process_event("__mouse_click__", ax, (0, 0), 1) 485 | _process_event("key_press_event", ax, (.123, .456), "shift+up") 486 | assert tuple(cursor.selections[0].target) == (0, 0) 487 | assert cursor.selections[0].index == 0 488 | _process_event("key_press_event", ax, (.123, .456), "shift+right") 489 | assert tuple(cursor.selections[0].target) == (1, 1) 490 | assert cursor.selections[0].index == 1 491 | # Skip through nan. 492 | _process_event("key_press_event", ax, (.123, .456), "shift+right") 493 | assert tuple(cursor.selections[0].target) == (0, 0) 494 | assert cursor.selections[0].index == 0 495 | 496 | 497 | @pytest.mark.parametrize( 498 | "hover", [True, HoverMode.Persistent, 2, HoverMode.Transient]) 499 | def test_hover(ax, hover): 500 | l1, = ax.plot([0, 1]) 501 | l2, = ax.plot([1, 2]) 502 | cursor = mplcursors.cursor(hover=hover) 503 | _process_event("motion_notify_event", ax, (.5, .5), 1) 504 | assert len(cursor.selections) == 0 # No trigger if mouse button pressed. 505 | _process_event("motion_notify_event", ax, (.5, .5)) 506 | assert cursor.selections[0].artist == l1 507 | _process_event("motion_notify_event", ax, (.5, 1)) 508 | assert bool(cursor.selections) == (hover == HoverMode.Persistent) 509 | _process_event("motion_notify_event", ax, (.5, 1.5)) 510 | assert cursor.selections[0].artist == l2 511 | 512 | 513 | @pytest.mark.parametrize("plotter", [Axes.plot, Axes.scatter]) 514 | @pytest.mark.parametrize("remove_click_on_annotation", [True, False]) 515 | def test_highlight(ax, plotter, remove_click_on_annotation): 516 | plotter(ax, [0, 1], [0, 1]) 517 | ax.set(xlim=(-1, 2), ylim=(-1, 2)) 518 | base_children = {*ax.artists, *ax.lines, *ax.collections} 519 | cursor = mplcursors.cursor(highlight=True) 520 | _process_event("__mouse_click__", ax, (0, 0), 1) 521 | # On Matplotlib<=3.4, the highlight went to ax.artists. On >=3.5, it goes 522 | # to its type-specific container. The construct below handles both cases. 523 | assert [*{*ax.artists, *ax.lines, *ax.collections} - base_children] \ 524 | == cursor.selections[0].extras != [] 525 | if remove_click_on_annotation: 526 | _process_event(*_get_remove_args(cursor.selections[0])) 527 | else: 528 | _process_event("__mouse_click__", ax, (1, 1), 3) 529 | assert len({*ax.artists, *ax.lines, *ax.collections} - base_children) == ( 530 | 1 if plotter is Axes.scatter and not remove_click_on_annotation else 0) 531 | 532 | 533 | def test_highlight_linecollection(ax): 534 | ax.eventplot([0, 1]) 535 | cursor = mplcursors.cursor(highlight=True) 536 | _process_event("__mouse_click__", ax, (0, 1), 1) 537 | sel, = cursor.selections 538 | assert sel.extras 539 | 540 | 541 | def test_misc_artists_highlight(ax): 542 | # Unsupported artists trigger a warning upon a highlighting attempt. 543 | ax.imshow([[0, 1], [2, 3]]) 544 | cursor = mplcursors.cursor(highlight=True) 545 | with pytest.warns(UserWarning): 546 | _process_event("__mouse_click__", ax, (.5, .5), 1) 547 | 548 | 549 | def test_callback(ax): 550 | ax.plot([0, 1]) 551 | add_calls = [] 552 | remove_calls = [] 553 | cursor = mplcursors.cursor() 554 | on_add = cursor.connect("add")(lambda sel: add_calls.append(sel)) 555 | on_remove = cursor.connect("remove")(lambda sel: remove_calls.append(sel)) 556 | _process_event("__mouse_click__", ax, (.3, .3), 1) 557 | assert len(add_calls) == 1 558 | assert len(remove_calls) == 0 559 | _process_event("__mouse_click__", ax, (.7, .7), 1) 560 | assert len(add_calls) == 2 561 | assert len(remove_calls) == 1 562 | cursor.disconnect("add", on_add) 563 | _process_event("__mouse_click__", ax, (.5, .5), 1) 564 | assert len(add_calls) == 2 565 | with pytest.raises(ValueError): 566 | cursor.disconnect("add", lambda sel: None) 567 | with pytest.raises(ValueError): 568 | cursor.connect("foo", lambda sel: None) 569 | 570 | 571 | def test_remove_while_adding(ax): 572 | ax.plot([0, 1]) 573 | cursor = mplcursors.cursor() 574 | cursor.connect("add", cursor.remove_selection) 575 | _process_event("__mouse_click__", ax, (.5, .5), 1) 576 | 577 | 578 | def test_no_duplicate(ax): 579 | ax.plot([0, 1]) 580 | cursor = mplcursors.cursor(multiple=True) 581 | _process_event("__mouse_click__", ax, (.5, .5), 1) 582 | _process_event("__mouse_click__", ax, (.5, .5), 1) 583 | assert len(cursor.selections) == 1 584 | 585 | 586 | def test_remove_multiple_overlapping(ax): 587 | ax.plot([0, 1]) 588 | cursor = mplcursors.cursor(multiple=True) 589 | _process_event("__mouse_click__", ax, (.5, .5), 1) 590 | sel, = cursor.selections 591 | cursor.add_selection( 592 | copy.copy(sel), sel.annotation.figure, sel.annotation.axes) 593 | assert len(cursor.selections) == 2 594 | _process_event(*_get_remove_args(sel)) 595 | assert [*map(id, cursor.selections)] == [id(sel)] # To check LIFOness. 596 | _process_event(*_get_remove_args(sel)) 597 | assert len(cursor.selections) == 0 598 | 599 | 600 | def test_autoalign(ax): 601 | ax.plot([0, 1]) 602 | cursor = mplcursors.cursor() 603 | cb = cursor.connect( 604 | "add", lambda sel: sel.annotation.set(position=(-10, 0))) 605 | _process_event("__mouse_click__", ax, (.4, .4), 1) 606 | sel, = cursor.selections 607 | assert (sel.annotation.get_ha() == "right" 608 | and sel.annotation.get_va() == "center") 609 | cursor.disconnect("add", cb) 610 | cursor.connect( 611 | "add", lambda sel: sel.annotation.set(ha="center", va="bottom")) 612 | _process_event("__mouse_click__", ax, (.6, .6), 1) 613 | sel, = cursor.selections 614 | assert (sel.annotation.get_ha() == "center" 615 | and sel.annotation.get_va() == "bottom") 616 | 617 | 618 | def test_drag(ax, capsys): 619 | l, = ax.plot([0, 1]) 620 | cursor = mplcursors.cursor() 621 | cursor.connect( 622 | "add", lambda sel: sel.annotation.set(position=(0, 0))) 623 | _process_event("__mouse_click__", ax, (.5, .5), 1) 624 | _process_event("button_press_event", ax, (.5, .5), 1) 625 | _process_event("motion_notify_event", ax, (.4, .6), 1) 626 | assert not capsys.readouterr().err 627 | 628 | 629 | def test_removed_artist(ax): 630 | l, = ax.plot([0, 1]) 631 | cursor = mplcursors.cursor() 632 | l.remove() 633 | _process_event("__mouse_click__", ax, (.5, .5), 1) 634 | assert len(cursor.selections) == len(ax.figure.artists) == 0 635 | 636 | 637 | def test_remove_cursor(ax): 638 | ax.plot([0, 1]) 639 | cursor = mplcursors.cursor() 640 | _process_event("__mouse_click__", ax, (.5, .5), 1) 641 | assert len(cursor.selections) == len(ax.figure.artists) == 1 642 | cursor.remove() 643 | assert len(cursor.selections) == len(ax.figure.artists) == 0 644 | _process_event("__mouse_click__", ax, (.5, .5), 1) 645 | assert len(cursor.selections) == len(ax.figure.artists) == 0 646 | 647 | 648 | def test_keys(ax): 649 | ax.plot([0, 1]) 650 | cursor = mplcursors.cursor(multiple=True) 651 | _process_event("__mouse_click__", ax, (.3, .3), 1) 652 | # Toggle visibility. 653 | _process_event("key_press_event", ax, (.123, .456), "v") 654 | assert not cursor.selections[0].annotation.get_visible() 655 | _process_event("key_press_event", ax, (.123, .456), "v") 656 | assert cursor.selections[0].annotation.get_visible() 657 | # Disable the cursor. 658 | _process_event("key_press_event", ax, (.123, .456), "e") 659 | assert not cursor.enabled 660 | # (Adding becomes inactive.) 661 | _process_event("__mouse_click__", ax, (.6, .6), 1) 662 | assert len(cursor.selections) == 1 663 | # (Removing becomes inactive.) 664 | ax.figure.canvas.draw() 665 | _process_event(*_get_remove_args(cursor.selections[0])) 666 | assert len(cursor.selections) == 1 667 | # (Moving becomes inactive.) 668 | old_target = cursor.selections[0].target 669 | _process_event("key_press_event", ax, (.123, .456), "shift+left") 670 | new_target = cursor.selections[0].target 671 | assert (old_target == new_target).all() 672 | # Reenable it. 673 | _process_event("key_press_event", ax, (.123, .456), "e") 674 | assert cursor.enabled 675 | _process_event(*_get_remove_args(cursor.selections[0])) 676 | assert len(cursor.selections) == 0 677 | 678 | 679 | def test_select_at(ax): 680 | l1, = ax.plot([0, 1]) 681 | l2, = ax.plot([1, 0]) 682 | cursor = mplcursors.cursor([l1]) 683 | assert cursor.select_at(l1, (.5, .5)) is not None 684 | assert len(cursor.selections) == 1 685 | cursor.remove_selection(cursor.selections[0]) 686 | assert len(cursor.selections) == 0 687 | assert cursor.select_at(ax, (.2, .2)) is not None 688 | assert len(cursor.selections) == 1 689 | with pytest.raises(ValueError): 690 | cursor.select_at(l2, (.5, .5)) 691 | 692 | 693 | def test_convenience(ax): 694 | l, = ax.plot([1, 2]) 695 | assert len(mplcursors.cursor().artists) == 1 696 | assert len(mplcursors.cursor(ax).artists) == 1 697 | assert len(mplcursors.cursor(l).artists) == 1 698 | assert len(mplcursors.cursor([l]).artists) == 1 699 | bc = ax.bar(range(3), range(3)) 700 | assert len(mplcursors.cursor(bc).artists) == 1 701 | 702 | 703 | @pytest.mark.skipif("figure.hooks" not in mpl.rcParams, 704 | reason="Matplotlib version without figure.hooks.") 705 | @pytest.mark.parametrize("envopt", ["", '{"hover": 1}']) 706 | def test_figurehook(monkeypatch, envopt): 707 | monkeypatch.setenv("MPLCURSORS", envopt) 708 | with mpl.rc_context({"figure.hooks": ["mplcursors:install"]}): 709 | fig = plt.figure() 710 | try: 711 | ax = fig.add_subplot() 712 | ax.plot([0, 1]) 713 | fig.canvas.draw() 714 | _process_event("motion_notify_event", ax, (.5, .5)) 715 | assert len(fig.artists) == bool(envopt) # The annotation. 716 | finally: 717 | plt.close(fig) 718 | 719 | 720 | def test_invalid_args(): 721 | pytest.raises(ValueError, mplcursors.cursor, 722 | bindings={"foo": 42}) 723 | pytest.raises(ValueError, mplcursors.cursor, 724 | bindings={"select": 1, "deselect": 1}) 725 | mplcursors.cursor(bindings={"select": None, "deselect": None}) 726 | pytest.raises(ValueError, mplcursors.cursor().connect, 727 | "foo") 728 | 729 | 730 | def test_multiple_figures(ax): 731 | ax1 = ax 732 | _, ax2 = plt.subplots() 733 | ax1.plot([0, 1]) 734 | ax2.plot([0, 1]) 735 | cursor = mplcursors.cursor([ax1, ax2], multiple=True) 736 | # Add something on the first axes. 737 | _process_event("__mouse_click__", ax1, (.5, .5), 1) 738 | assert len(cursor.selections) == 1 739 | assert len(ax1.figure.artists) == 1 740 | assert len(ax2.figure.artists) == 0 741 | # Right-clicking on the second axis doesn't remove it. 742 | remove_args = [*_get_remove_args(cursor.selections[0])] 743 | remove_args[remove_args.index(ax1)] = ax2 744 | _process_event(*remove_args) 745 | assert len(cursor.selections) == 1 746 | assert len(ax1.figure.artists) == 1 747 | assert len(ax2.figure.artists) == 0 748 | # Remove it, add something on the second. 749 | _process_event(*_get_remove_args(cursor.selections[0])) 750 | _process_event("__mouse_click__", ax2, (.5, .5), 1) 751 | assert len(cursor.selections) == 1 752 | assert len(ax1.figure.artists) == 0 753 | assert len(ax2.figure.artists) == 1 754 | 755 | 756 | def test_gc(ax): 757 | def inner(): 758 | img = ax.imshow([[0, 1], [2, 3]]) 759 | cursor = mplcursors.cursor(img) 760 | f_img = weakref.finalize(img, lambda: None) 761 | f_cursor = weakref.finalize(cursor, lambda: None) 762 | img.remove() 763 | return f_img, f_cursor 764 | f_img, f_cursor = inner() 765 | gc.collect() 766 | assert not f_img.alive 767 | assert not f_cursor.alive 768 | 769 | 770 | def test_fixed_ticks_nonstr_labels(ax): 771 | ax.set_xticks([0]) 772 | ax.set_xticklabels([0]) # The formatter will return the label as an int. 773 | ax.plot(0, 0, ".") 774 | cursor = mplcursors.cursor(ax) 775 | _process_event("__mouse_click__", ax, (0, 0), 1) 776 | # Just check that this does not error out, but don't check the annotation 777 | # text (it starts with "x=0" since #17266 but with "x=" before that). 778 | 779 | 780 | @pytest.mark.parametrize( 781 | "example", 782 | [path for path in Path("examples").glob("*.py") 783 | if "test: skip" not in path.read_text()]) 784 | def test_example(example): 785 | subprocess.check_call( 786 | [sys.executable, "-mexamples.{}".format(example.with_suffix("").name)], 787 | # Unset $DISPLAY to avoid the non-GUI backend warning. 788 | env={**os.environ, "DISPLAY": "", "MPLBACKEND": "Agg"}) 789 | -------------------------------------------------------------------------------- /src/mplcursors/_mplcursors.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from contextlib import suppress 3 | import copy 4 | from enum import IntEnum 5 | import functools 6 | from functools import partial 7 | import sys 8 | import weakref 9 | from weakref import WeakKeyDictionary, WeakSet 10 | 11 | import matplotlib as mpl 12 | from matplotlib.axes import Axes 13 | from matplotlib.backend_bases import MouseEvent 14 | from matplotlib.container import Container 15 | from matplotlib.figure import Figure 16 | import numpy as np 17 | 18 | from . import _pick_info 19 | 20 | 21 | _default_bindings = dict( 22 | select=1, 23 | deselect=3, 24 | left="shift+left", 25 | right="shift+right", 26 | up="shift+up", 27 | down="shift+down", 28 | toggle_enabled="e", 29 | toggle_visible="v", 30 | ) 31 | _default_annotation_kwargs = dict( 32 | bbox=dict( 33 | boxstyle="round,pad=.5", 34 | fc="yellow", 35 | alpha=.5, 36 | ec="k", 37 | ), 38 | arrowprops=dict( 39 | arrowstyle="->", 40 | connectionstyle="arc3", 41 | shrinkB=0, 42 | ec="k", 43 | ), 44 | ) 45 | _default_annotation_positions = [ 46 | dict(position=(-15, 15), anncoords="offset points", 47 | horizontalalignment="right", verticalalignment="bottom"), 48 | dict(position=(15, 15), anncoords="offset points", 49 | horizontalalignment="left", verticalalignment="bottom"), 50 | dict(position=(15, -15), anncoords="offset points", 51 | horizontalalignment="left", verticalalignment="top"), 52 | dict(position=(-15, -15), anncoords="offset points", 53 | horizontalalignment="right", verticalalignment="top"), 54 | ] 55 | _default_highlight_kwargs = dict( 56 | # Only the kwargs corresponding to properties of the artist will be passed. 57 | # Line2D. 58 | color="yellow", 59 | markeredgecolor="yellow", 60 | linewidth=3, 61 | markeredgewidth=3, 62 | # PathCollection. 63 | facecolor="yellow", 64 | edgecolor="yellow", 65 | ) 66 | 67 | 68 | class _MarkedStr(str): 69 | """A string subclass solely for marking purposes.""" 70 | 71 | 72 | def _mouse_event_matches(event, spec): 73 | """ 74 | Return whether a mouse event "matches" an event spec, which is either a 75 | single mouse button, or a mapping matched against ``vars(event)``, e.g. 76 | ``{"button": 1, "key": "control"}``. 77 | """ 78 | if isinstance(spec, int): 79 | spec = {"button": spec} 80 | return all(getattr(event, k) == v for k, v in spec.items()) 81 | 82 | 83 | def _get_rounded_intersection_area(bbox_1, bbox_2): 84 | """Compute the intersection area between two bboxes rounded to 8 digits.""" 85 | # The rounding allows sorting areas without floating point issues. 86 | bbox = bbox_1.intersection(bbox_1, bbox_2) 87 | return round(bbox.width * bbox.height, 8) if bbox else 0 88 | 89 | 90 | def _iter_axes_subartists(ax): 91 | r"""Yield all child `Artist`\s (*not* `Container`\s) of *ax*.""" 92 | yield from ax.collections 93 | yield from ax.images 94 | yield from ax.lines 95 | yield from ax.patches 96 | yield from ax.texts 97 | yield from ax.artists 98 | 99 | 100 | def _is_alive(artist): 101 | """Check whether *artist* is still present on its parent axes.""" 102 | return bool( 103 | artist 104 | and artist.axes 105 | # `cla()` clears `.axes` since matplotlib/matplotlib#24627 (3.7); 106 | # iterating over subartists can be very slow. 107 | and (getattr(mpl, "__version_info__", ()) >= (3, 7) 108 | or (artist.container in artist.axes.containers 109 | if isinstance(artist, _pick_info.ContainerArtist) else 110 | artist in _iter_axes_subartists(artist.axes)))) 111 | 112 | 113 | def _reassigned_axes_event(event, ax): 114 | """Reassign *event* to *ax*.""" 115 | event = copy.copy(event) 116 | event.xdata, event.ydata = ( 117 | ax.transData.inverted().transform((event.x, event.y))) 118 | return event 119 | 120 | 121 | class HoverMode(IntEnum): 122 | NoHover, Persistent, Transient = range(3) 123 | 124 | 125 | class Cursor: 126 | """ 127 | A cursor for selecting Matplotlib artists. 128 | 129 | Attributes 130 | ---------- 131 | bindings : dict 132 | See the *bindings* keyword argument to the constructor. 133 | annotation_kwargs : dict 134 | See the *annotation_kwargs* keyword argument to the constructor. 135 | annotation_positions : dict 136 | See the *annotation_positions* keyword argument to the constructor. 137 | highlight_kwargs : dict 138 | See the *highlight_kwargs* keyword argument to the constructor. 139 | """ 140 | 141 | _keep_alive = WeakKeyDictionary() 142 | 143 | def __init__(self, 144 | artists, 145 | *, 146 | multiple=False, 147 | highlight=False, 148 | hover=False, 149 | bindings=None, 150 | annotation_kwargs=None, 151 | annotation_positions=None, 152 | highlight_kwargs=None): 153 | """ 154 | Construct a cursor. 155 | 156 | Parameters 157 | ---------- 158 | 159 | artists : List[Artist] 160 | A list of artists that can be selected by this cursor. 161 | 162 | multiple : bool, default: False 163 | Whether multiple artists can be "on" at the same time. If on, 164 | cursor dragging is disabled (so that one does not end up with many 165 | cursors on top of one another). 166 | 167 | highlight : bool, default: False 168 | Whether to also highlight the selected artist. If so, 169 | "highlighter" artists will be placed as the first item in the 170 | :attr:`extras` attribute of the `Selection`. 171 | 172 | hover : `HoverMode`, default: False 173 | Whether to select artists upon hovering instead of by clicking. 174 | (Hovering over an artist while a button is pressed will not trigger 175 | a selection; right clicking on an annotation will still remove it.) 176 | Possible values are 177 | 178 | - False, alias `HoverMode.NoHover`: hovering is inactive. 179 | - True, alias `HoverMode.Persistent`: hovering is active; 180 | annotations remain in place even after the mouse moves away from 181 | the artist (until another artist is selected, if *multiple* is 182 | False). 183 | - 2, alias `HoverMode.Transient`: hovering is active; annotations 184 | are removed as soon as the mouse moves away from the artist. 185 | 186 | bindings : dict, optional 187 | A mapping of actions to button and keybindings. Valid keys are: 188 | 189 | ================ ================================================== 190 | 'select' mouse button to select an artist 191 | (default: :data:`.MouseButton.LEFT`) 192 | 'deselect' mouse button to deselect an artist 193 | (default: :data:`.MouseButton.RIGHT`) 194 | 'left' move to the previous point in the selected path, 195 | or to the left in the selected image 196 | (default: shift+left) 197 | 'right' move to the next point in the selected path, or to 198 | the right in the selected image 199 | (default: shift+right) 200 | 'up' move up in the selected image 201 | (default: shift+up) 202 | 'down' move down in the selected image 203 | (default: shift+down) 204 | 'toggle_enabled' toggle whether the cursor is active 205 | (default: e) 206 | 'toggle_visible' toggle default cursor visibility and apply it to 207 | all cursors (default: v) 208 | ================ ================================================== 209 | 210 | Missing entries will be set to the defaults. In order to not 211 | assign any binding to an action, set it to ``None``. Modifier keys 212 | (or other event properties) can be set for mouse button bindings by 213 | passing them as e.g. ``{"button": 1, "key": "control"}``. 214 | 215 | annotation_kwargs : dict, default: {} 216 | Keyword argments passed to the `annotate 217 | ` call. 218 | 219 | annotation_positions : List[dict], optional 220 | List of positions tried by the annotation positioning algorithm. 221 | The default is to try four positions, 15 points to the NW, NE, SE, 222 | and SW from the selected point; annotations that stay within the 223 | axes are preferred. 224 | 225 | highlight_kwargs : dict, default: {} 226 | Keyword arguments used to create a highlighted artist. 227 | """ 228 | 229 | artists = [*artists] 230 | # Be careful with GC. 231 | self._artists = [weakref.ref(artist) for artist in artists] 232 | 233 | for artist in artists: 234 | type(self)._keep_alive.setdefault(artist, set()).add(self) 235 | 236 | self._multiple = multiple 237 | self._highlight = highlight 238 | 239 | self._visible = True 240 | self._enabled = True 241 | self._selections = [] 242 | self._selection_stack = [] 243 | self._last_auto_position = None 244 | self._callbacks = {"add": [], "remove": []} 245 | self._hover = hover 246 | 247 | self._suppressed_events = WeakSet() 248 | connect_pairs = [ 249 | ("pick_event", self._on_pick), 250 | ("key_press_event", self._on_key_press), 251 | ] 252 | if hover: 253 | connect_pairs += [ 254 | ("motion_notify_event", self._on_hover_motion_notify), 255 | ("button_press_event", self._on_hover_button_press), 256 | ] 257 | else: 258 | connect_pairs += [ 259 | ("button_press_event", self._on_nonhover_button_press), 260 | ] 261 | if not self._multiple: 262 | connect_pairs.append( 263 | ("motion_notify_event", self._on_nonhover_button_press)) 264 | self._disconnectors = [ 265 | partial(canvas.mpl_disconnect, canvas.mpl_connect(*pair)) 266 | for pair in connect_pairs 267 | for canvas in {artist.figure.canvas for artist in artists}] 268 | 269 | bindings = {**_default_bindings, 270 | **(bindings if bindings is not None else {})} 271 | unknown_bindings = {*bindings} - {*_default_bindings} 272 | if unknown_bindings: 273 | raise ValueError("Unknown binding(s): {}".format( 274 | ", ".join(sorted(unknown_bindings)))) 275 | bindings_items = list(bindings.items()) 276 | for i in range(len(bindings)): 277 | action, key = bindings_items[i] 278 | for j in range(i): 279 | other_action, other_key = bindings_items[j] 280 | if key == other_key and key is not None: 281 | raise ValueError( 282 | f"Duplicate bindings: {key} is used for " 283 | f"{other_action} and for {action}") 284 | self.bindings = bindings 285 | 286 | self.annotation_kwargs = ( 287 | annotation_kwargs if annotation_kwargs is not None 288 | else copy.deepcopy(_default_annotation_kwargs)) 289 | self.annotation_positions = ( 290 | annotation_positions if annotation_positions is not None 291 | else copy.deepcopy(_default_annotation_positions)) 292 | self.highlight_kwargs = ( 293 | highlight_kwargs if highlight_kwargs is not None 294 | else copy.deepcopy(_default_highlight_kwargs)) 295 | 296 | @property 297 | def artists(self): 298 | """The tuple of selectable artists.""" 299 | return tuple(filter(_is_alive, (ref() for ref in self._artists))) 300 | 301 | @property 302 | def enabled(self): 303 | """Whether clicks are registered for picking and unpicking events.""" 304 | return self._enabled 305 | 306 | @enabled.setter 307 | def enabled(self, value): 308 | self._enabled = value 309 | 310 | @property 311 | def selections(self): 312 | r"""The tuple of current `Selection`\s.""" 313 | for sel in self._selections: 314 | if sel.annotation.axes is None: 315 | raise RuntimeError("Annotation unexpectedly removed; " 316 | "use 'cursor.remove_selection' instead") 317 | return tuple(self._selections) 318 | 319 | @property 320 | def visible(self): 321 | """ 322 | Whether selections are visible by default. 323 | 324 | Setting this property also updates the visibility status of current 325 | selections. 326 | """ 327 | return self._visible 328 | 329 | @visible.setter 330 | def visible(self, value): 331 | self._visible = value 332 | for sel in self.selections: 333 | sel.annotation.set_visible(value) 334 | sel.annotation.figure.canvas.draw_idle() 335 | 336 | def add_selection(self, pi, fig, ax): 337 | """ 338 | Create an annotation for a `Selection` and register it. 339 | 340 | Returns a new `Selection`, that has been registered by the `Cursor`, 341 | with the added annotation set in the :attr:`annotation` field and, if 342 | applicable, the highlighting artist in the :attr:`extras` field. 343 | 344 | Emits the ``"add"`` event with the new `Selection` as argument. When 345 | the event is emitted, the position of the annotation is temporarily 346 | set to ``(nan, nan)``; if this position is not explicitly set by a 347 | callback, then a suitable position will be automatically computed. 348 | 349 | Likewise, if the text alignment is not explicitly set but the position 350 | is, then a suitable alignment will be automatically computed. 351 | """ 352 | # This method takes fig & ax as additional parameters to be robust when 353 | # picking a Container/sub-artist which may be missing .figure/.axes. 354 | # pi: "pick_info", i.e. an incomplete selection. 355 | get_cached_renderer = ( 356 | fig.canvas.get_renderer 357 | if hasattr(fig.canvas, "get_renderer") 358 | else ax.get_renderer_cache) # mpl<3.6. 359 | renderer = get_cached_renderer() 360 | if renderer is None: 361 | fig.canvas.draw() # Needed below anyways. 362 | renderer = get_cached_renderer() 363 | ann = ax.annotate( 364 | "", xy=pi.target, xytext=(np.nan, np.nan), 365 | horizontalalignment=_MarkedStr("center"), 366 | verticalalignment=_MarkedStr("center"), 367 | visible=self.visible, 368 | zorder=np.inf, 369 | **self.annotation_kwargs) 370 | # Move the Annotation's ownership from the Axes to the Figure, so that 371 | # it gets drawn even above twinned axes. But ann.axes must stay set, 372 | # so that e.g. unit converters get correctly applied. 373 | ann.remove() 374 | ann.axes = ax 375 | fig.add_artist(ann) 376 | ann.draggable(use_blit=not self._multiple) 377 | extras = [] 378 | if self._highlight: 379 | hl = self.add_highlight(*pi) 380 | if hl: 381 | extras.append(hl) 382 | sel = pi._replace(annotation=ann, extras=extras) 383 | # Update the text after setting the annotation on the Selection, so 384 | # that get_ann_text can safely access sel.annotation.axes even if 385 | # artist.axes itself is unset (Containers/sub-artist picks). 386 | ann.set_text(_pick_info.get_ann_text(*sel)) 387 | self._selections.append(sel) 388 | self._selection_stack.append(sel) 389 | for cb in self._callbacks["add"]: 390 | cb(sel) 391 | 392 | user_set_ha = not isinstance(ann.get_horizontalalignment(), _MarkedStr) 393 | user_set_va = not isinstance(ann.get_verticalalignment(), _MarkedStr) 394 | 395 | # Check that `ann.axes` is still set, as callbacks may have removed the 396 | # annotation. 397 | if ann.axes and ann.xyann == (np.nan, np.nan): 398 | fig_bbox = fig.get_window_extent() 399 | ax_bbox = ax.get_window_extent() 400 | overlaps = [] 401 | for idx, annotation_position in enumerate( 402 | self.annotation_positions): 403 | if user_set_ha: 404 | annotation_position = { 405 | k: v for k, v in annotation_position.items() 406 | if k not in ["ha", "horizontalalignment"]} 407 | if user_set_va: 408 | annotation_position = { 409 | k: v for k, v in annotation_position.items() 410 | if k not in ["va", "verticalalignment"]} 411 | ann.set(**annotation_position) 412 | # Work around matplotlib/matplotlib#7614: position update is 413 | # missing. 414 | ann.update_positions(renderer) 415 | bbox = ann.get_window_extent(renderer) 416 | overlaps.append( 417 | (_get_rounded_intersection_area(fig_bbox, bbox), 418 | _get_rounded_intersection_area(ax_bbox, bbox), 419 | # Avoid needlessly jumping around by breaking ties using 420 | # the last used position as default. 421 | idx == self._last_auto_position)) 422 | auto_position = max(range(len(overlaps)), key=overlaps.__getitem__) 423 | annotation_position = self.annotation_positions[auto_position] 424 | if user_set_ha: 425 | annotation_position = { 426 | k: v for k, v in annotation_position.items() 427 | if k not in ["ha", "horizontalalignment"]} 428 | if user_set_va: 429 | annotation_position = { 430 | k: v for k, v in annotation_position.items() 431 | if k not in ["va", "verticalalignment"]} 432 | ann.set(**annotation_position) 433 | self._last_auto_position = auto_position 434 | else: 435 | if not user_set_ha: 436 | ann.set_horizontalalignment( 437 | {-1: "right", 0: "center", 1: "left"}[ 438 | np.sign(np.nan_to_num(ann.xyann[0]))]) 439 | if not user_set_ha: 440 | ann.set_verticalalignment( 441 | {-1: "top", 0: "center", 1: "bottom"}[ 442 | np.sign(np.nan_to_num(ann.xyann[1]))]) 443 | 444 | if (extras 445 | or len(self.selections) > 1 and not self._multiple 446 | or not fig.canvas.supports_blit): 447 | # Either: 448 | # - there may be more things to draw, or 449 | # - annotation removal will make a full redraw necessary, or 450 | # - blitting is not (yet) supported. 451 | fig.canvas.draw_idle() 452 | elif ann.axes: 453 | # Fast path, only needed if the annotation has not been immediately 454 | # removed. 455 | ann.draw(renderer) 456 | fig.canvas.blit() 457 | # Removal comes after addition so that the fast blitting path works. 458 | if not self._multiple: 459 | for other in self.selections[:-1]: 460 | self.remove_selection(other) 461 | return sel 462 | 463 | def add_highlight(self, artist, *args, **kwargs): 464 | """ 465 | Create, add, and return a highlighting artist. 466 | 467 | This method is should be called with an "unpacked" `Selection`, 468 | possibly with some fields set to None. 469 | 470 | It is up to the caller to register the artist with the proper 471 | `Selection` (by calling ``sel.extras.append`` on the result of this 472 | method) in order to ensure cleanup upon deselection. 473 | """ 474 | hl = _pick_info.make_highlight( 475 | artist, *args, 476 | **{"highlight_kwargs": self.highlight_kwargs, **kwargs}) 477 | if hl: 478 | artist.axes.add_artist(hl) 479 | return hl 480 | 481 | def connect(self, event, func=None): 482 | """ 483 | Connect a callback to a `Cursor` event; return the callback. 484 | 485 | Two events can be connected to: 486 | 487 | - callbacks connected to the ``"add"`` event are called when a 488 | `Selection` is added, with that selection as only argument; 489 | - callbacks connected to the ``"remove"`` event are called when a 490 | `Selection` is removed, with that selection as only argument. 491 | 492 | This method can also be used as a decorator:: 493 | 494 | @cursor.connect("add") 495 | def on_add(sel): 496 | ... 497 | 498 | Examples of callbacks:: 499 | 500 | # Change the annotation text and alignment: 501 | lambda sel: sel.annotation.set( 502 | text=sel.artist.get_label(), # or use e.g. sel.index 503 | ha="center", va="bottom") 504 | 505 | # Make label non-draggable: 506 | lambda sel: sel.draggable(False) 507 | 508 | Note that when a single event causes both the removal of an "old" 509 | selection and the addition of a "new" one (typically, clicking on an 510 | artist when another one is selected, or hovering -- both assuming that 511 | ``multiple=False``), the "add" callback is called *first*. This allows 512 | it, in particular, to "cancel" the addition (by immediately removing 513 | the "new" selection) and thus avoid removing the "old" selection. 514 | However, this call order may change in a future release. 515 | """ 516 | if event not in self._callbacks: 517 | raise ValueError(f"{event!r} is not a valid cursor event") 518 | if func is None: 519 | return partial(self.connect, event) 520 | self._callbacks[event].append(func) 521 | return func 522 | 523 | def disconnect(self, event, cb): 524 | """ 525 | Disconnect a previously connected callback. 526 | 527 | If a callback is connected multiple times, only one connection is 528 | removed. 529 | """ 530 | try: 531 | self._callbacks[event].remove(cb) 532 | except KeyError: 533 | raise ValueError(f"{event!r} is not a valid cursor event") 534 | except ValueError: 535 | raise ValueError(f"Callback {cb} is not registered to {event}") 536 | 537 | def remove(self): 538 | """ 539 | Remove a cursor. 540 | 541 | Remove all `Selection`\\s, disconnect all callbacks, and allow the 542 | cursor to be garbage collected. 543 | """ 544 | for disconnector in self._disconnectors: 545 | disconnector() 546 | for sel in self.selections: 547 | self.remove_selection(sel) 548 | for s in type(self)._keep_alive.values(): 549 | with suppress(KeyError): 550 | s.remove(self) 551 | 552 | def _on_pick(self, event): 553 | # Avoid creating a new annotation when dragging a preexisting 554 | # annotation (if multiple = True). To do so, rely on the fact that 555 | # pick_events (which are used to implement dragging) trigger first (via 556 | # Figure's button_press_event, which is registered first); when one of 557 | # our annotations is picked, registed the corresponding mouse event as 558 | # "suppressed". This can be done via a WeakSet as Matplotlib will keep 559 | # the event alive while being propagated through the callbacks. 560 | # Additionally, also rely on this mechanism to update the "current" 561 | # selection. 562 | for sel in self._selections: 563 | if event.artist is sel.annotation: 564 | self._suppressed_events.add(event.mouseevent) 565 | self._selection_stack.remove(sel) 566 | self._selection_stack.append(sel) 567 | break 568 | 569 | def _on_nonhover_button_press(self, event): 570 | if _mouse_event_matches(event, self.bindings["select"]): 571 | self._on_select_event(event) 572 | if _mouse_event_matches(event, self.bindings["deselect"]): 573 | self._on_deselect_event(event) 574 | 575 | def _on_hover_motion_notify(self, event): 576 | if event.button is None: 577 | # Filter away events where the mouse is pressed, in particular to 578 | # avoid conflicts between hover and draggable. 579 | self._on_select_event(event) 580 | 581 | def _on_hover_button_press(self, event): 582 | if _mouse_event_matches(event, self.bindings["deselect"]): 583 | # Still allow removing the annotation by right clicking. 584 | self._on_deselect_event(event) 585 | 586 | def _filter_mouse_event(self, event): 587 | # Accept the event iff we are enabled, and either 588 | # - no other widget is active, and this is not the second click of a 589 | # double click (to prevent double selection), or 590 | # - another widget is active, and this is a double click (to bypass 591 | # the widget lock), or 592 | # - hovering is active (in which case this is a motion_notify_event 593 | # anyways). 594 | return (self.enabled 595 | and (event.canvas.widgetlock.locked() == event.dblclick 596 | or self._hover)) 597 | 598 | def _gen_sorted_candidates(self, event): 599 | per_axes_event = functools.lru_cache(None)( 600 | partial(_reassigned_axes_event, event)) # Fix twin axes support. 601 | pifas = [] 602 | for artist in self.artists: 603 | if (artist.axes is None # Removed or figure-level artist. 604 | or event.canvas is not artist.figure.canvas 605 | or not artist.get_visible() 606 | or not artist.axes.contains(event)[0]): # Cropped by axes. 607 | continue 608 | pi = _pick_info.compute_pick(artist, per_axes_event(artist.axes)) 609 | if pi: 610 | pifas.append((pi, artist.figure, artist.axes)) 611 | return sorted(pifas, key=lambda pifa: pifa[0].dist) 612 | 613 | def _on_select_event(self, event): 614 | if (not self._filter_mouse_event(event) 615 | # See _on_pick. (We only suppress selects, not deselects.) 616 | or event in self._suppressed_events): 617 | return 618 | candidates = self._gen_sorted_candidates(event) 619 | # If the pick corresponds to an already selected artist at the same 620 | # point, the user is likely just dragging it. 621 | duplicates = {(other.artist, tuple(other.target)) 622 | for other in self._selections} 623 | candidates_nodupe = [ 624 | (pi, fig, ax) for pi, fig, ax in candidates 625 | if (pi.artist, tuple(pi.target)) not in duplicates] 626 | if candidates_nodupe: 627 | return self.add_selection(*candidates_nodupe[0]) 628 | elif not candidates and self._hover == HoverMode.Transient: 629 | # In transient hover mode, selections should be cleared if no 630 | # candidate picks *including duplicates* have been seen. 631 | for sel in self.selections: 632 | if event.canvas is sel.annotation.figure.canvas: 633 | self.remove_selection(sel) 634 | 635 | def _on_deselect_event(self, event): 636 | if not self._filter_mouse_event(event): 637 | return 638 | for sel in self.selections[::-1]: # LIFO. 639 | ann = sel.annotation 640 | if event.canvas is not ann.figure.canvas: 641 | continue 642 | if ann.contains(event)[0]: 643 | self.remove_selection(sel) 644 | break 645 | else: 646 | if self._highlight: 647 | for sel in self.selections[::-1]: 648 | if any(extra.contains(event)[0] for extra in sel.extras): 649 | self.remove_selection(sel) 650 | break 651 | 652 | def _on_key_press(self, event): 653 | if event.key == self.bindings["toggle_enabled"]: 654 | self.enabled = not self.enabled 655 | elif event.key == self.bindings["toggle_visible"]: 656 | self.visible = not self.visible 657 | if not self._selections or not self.enabled: 658 | return 659 | sel = self._selection_stack[-1] 660 | for key in ["left", "right", "up", "down"]: 661 | if event.key == self.bindings[key]: 662 | self.add_selection( 663 | _pick_info.move(*sel, key=key), 664 | sel.annotation.figure, sel.annotation.axes) 665 | if self._multiple: # Else, already removed. 666 | self.remove_selection(sel) # Also unsets .figure, .axes. 667 | break 668 | 669 | def select_at(self, target, xy): 670 | """ 671 | Trigger and return a selection on *target* at data coordinates *xy*. 672 | 673 | *target* can be an axes or an artist. The selection is guaranteed to 674 | be, in the first case, on a child artist; in the second case, on that 675 | specific artist. If a click at *xy* does not result in a valid 676 | selection, then None is returned. 677 | 678 | Note 679 | ---- 680 | This API is experimental and subject to future adjustments. 681 | """ 682 | if isinstance(target, Axes): 683 | ax = target 684 | elif target in self.artists: 685 | ax = target.axes 686 | else: 687 | raise ValueError(f"Not a valid target: {target}") 688 | event = MouseEvent("button_press_event", ax.figure.canvas, 689 | *ax.transData.transform(xy)) 690 | candidates = [ 691 | (pi, fig, ax) for pi, fig, ax in self._gen_sorted_candidates(event) 692 | if target in [pi.artist, ax]] 693 | if candidates: 694 | return self.add_selection(*candidates[0]) 695 | 696 | def remove_selection(self, sel): 697 | """Remove a `Selection`.""" 698 | self._selections.remove(sel) 699 | self._selection_stack.remove(sel) 700 | # .figure will be unset so we save them first. 701 | figs = {artist.figure for artist in [sel.annotation] + sel.extras} 702 | # ValueError is raised if the artist has already been removed. 703 | with suppress(ValueError): 704 | sel.annotation.remove() 705 | for artist in sel.extras: 706 | with suppress(ValueError): 707 | artist.remove() 708 | for cb in self._callbacks["remove"]: 709 | cb(sel) 710 | for fig in figs: 711 | fig.canvas.draw_idle() 712 | 713 | 714 | def cursor(pickables=None, **kwargs): 715 | """ 716 | Create a `Cursor` for a list of artists, containers, and axes. 717 | 718 | Parameters 719 | ---------- 720 | 721 | pickables : Optional[List[Union[Artist, Container, Axes, Figure]]] 722 | All artists and containers in the list or on any of the axes or 723 | figures passed in the list are selectable by the constructed `Cursor`. 724 | Defaults to all artists and containers on any of the figures that 725 | :mod:`~matplotlib.pyplot` is tracking. Note that the latter will only 726 | work when relying on pyplot, not when figures are directly instantiated 727 | (e.g., when manually embedding Matplotlib in a GUI toolkit). 728 | 729 | **kwargs 730 | Keyword arguments are passed to the `Cursor` constructor. 731 | """ 732 | 733 | # Explicit check to avoid a confusing 734 | # "TypeError: Cursor.__init__() got multiple values for argument 'artists'" 735 | if "artists" in kwargs: 736 | raise TypeError( 737 | "cursor() got an unexpected keyword argument 'artists'") 738 | 739 | if pickables is None: 740 | # Do not import pyplot ourselves to avoid forcing the backend. 741 | plt = sys.modules.get("matplotlib.pyplot") 742 | pickables = [ 743 | plt.figure(num) for num in plt.get_fignums()] if plt else [] 744 | elif (isinstance(pickables, Container) 745 | or not isinstance(pickables, Iterable)): 746 | pickables = [pickables] 747 | 748 | def iter_unpack_figures(pickables): 749 | for entry in pickables: 750 | if isinstance(entry, Figure): 751 | yield from entry.axes 752 | else: 753 | yield entry 754 | 755 | def iter_unpack_axes(pickables): 756 | for entry in pickables: 757 | if isinstance(entry, Axes): 758 | yield from _iter_axes_subartists(entry) 759 | containers.extend(entry.containers) 760 | elif isinstance(entry, Container): 761 | containers.append(entry) 762 | else: 763 | yield entry 764 | 765 | containers = [] 766 | artists = [*iter_unpack_axes(iter_unpack_figures(pickables))] 767 | for container in containers: 768 | contained = [*filter(None, container.get_children())] 769 | for artist in contained: 770 | with suppress(ValueError): 771 | artists.remove(artist) 772 | if contained: 773 | artists.append(_pick_info.ContainerArtist(container)) 774 | 775 | return Cursor(artists, **kwargs) 776 | --------------------------------------------------------------------------------