From 76c6d77ce61827cf2b31d40b16616b9b68a1b5fb Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Mon, 10 Aug 2026 14:05:11 +0200 Subject: [PATCH] fix: support other than 'left' click --- src/mpltoolbox/tool.py | 74 ++++++++++++++++++++++--- tests/points_test.py | 56 +++++++++++++++++++ tests/rectangles_test.py | 117 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 9 deletions(-) diff --git a/src/mpltoolbox/tool.py b/src/mpltoolbox/tool.py index 729a706..b7c6725 100644 --- a/src/mpltoolbox/tool.py +++ b/src/mpltoolbox/tool.py @@ -5,11 +5,9 @@ from functools import partial from typing import Any -from matplotlib.backend_bases import Event +from matplotlib.backend_bases import Event, MouseEvent, PickEvent from matplotlib.pyplot import Axes -from .event import DummyEvent - class Tool: """ @@ -267,6 +265,30 @@ def _connect(self, connections: dict): for key, func in connections.items(): self._connections[key] = self._fig.canvas.mpl_connect(key, func) + def _make_click_event( + self, + x: float, + y: float, + button: int, + modifiers: list[str] | None, + ) -> MouseEvent: + display_x, display_y = self._ax.transData.transform((x, y)) + event = MouseEvent( + name="button_press_event", + canvas=self._fig.canvas, + x=display_x, + y=display_y, + button=button, + modifiers=modifiers, + ) + # ``click`` has always accepted data coordinates outside the current axes + # limits. Preserve that behavior while providing the display coordinates + # needed by Matplotlib's picking machinery. + event.inaxes = self._ax + event.xdata = x + event.ydata = y + return event + def _on_button_press(self, event: Event): if ( event.button != 1 @@ -322,7 +344,7 @@ def _finalize_owner(self): if self.on_create is not None: self.call_on_create(child) - def _on_pick(self, event: Event): + def _on_pick(self, event: PickEvent) -> str | None: mev = event.mouseevent if ( self._motion_connected() @@ -338,16 +360,44 @@ def _on_pick(self, event: Event): self._pick_lock = True self._ax._mpltoolbox_lock = True self._grab_vertex(event) + return "vertex" if mev.button == 3: if (not art.parent.is_draggable(art)) or (not self._enable_drag): return self._pick_lock = True self._grab_owner(event) + return "drag" if (mev.button == 2) or ((mev.button == 1) and ("ctrl" in mev.modifiers)): if (not art.parent.is_removable(art)) or (not self._enable_remove): return self._remove_owner(art.parent) + def _pick(self, mouse_event: MouseEvent) -> str | None: + for artist in self._ax.get_children(): + owner = getattr(artist, "parent", None) + if not artist.pickable() or not any( + owner is child for child in self.children + ): + continue + picker = artist.get_picker() + if callable(picker): + inside, properties = picker(artist, mouse_event) + else: + inside, properties = artist.contains(mouse_event) + if inside: + kind = self._on_pick( + PickEvent( + "pick_event", + self._fig.canvas, + mouse_event, + artist, + **properties, + ) + ) + if kind is not None: + return kind + return None + def _remove_owner(self, owner): owner.remove() self.children.remove(owner) @@ -432,7 +482,7 @@ def click( modifiers: list[str] | None = None, ): """ - Simulate a click on the figure. + Apply a click to this tool using data coordinates. :param x: If only a float is given: the x coordinate for the click event. If a tuple of length 2 is given, it contains both the x and y coordinates for @@ -448,12 +498,18 @@ def click( if y is None: y = x[1] x = x[0] - ev = DummyEvent( - xdata=x, ydata=y, inaxes=self._ax, button=button, modifiers=modifiers + click_event = self._make_click_event( + x=x, + y=y, + button=button, + modifiers=modifiers, ) if self._motion_connected(): - self._on_motion_notify(ev) - self._on_button_press(ev) + self._on_motion_notify(click_event) + if button == 1 and not modifiers: + self._on_button_press(click_event) + elif kind := self._pick(click_event): + self._release_owner(click_event, kind=kind) def remove(self, child): """ diff --git a/tests/points_test.py b/tests/points_test.py index db6c63a..a74adbe 100644 --- a/tests/points_test.py +++ b/tests/points_test.py @@ -2,6 +2,7 @@ # Copyright (c) Scipp contributors (https://github.com/scipp) import matplotlib.pyplot as plt +from matplotlib.backend_bases import MouseEvent from matplotlib.colors import to_hex import mpltoolbox as tbx @@ -81,6 +82,61 @@ def on_remove(artist): assert len(my_event_list) == 1 +def test_points_middle_click_removes_point(): + _, ax = plt.subplots() + points = tbx.Points(ax=ax) + points.click(x=20, y=50) + + points.click(x=20, y=50, button=2) + + assert len(points.children) == 0 + assert len(ax.lines) == 0 + + +def test_click_does_not_process_canvas_input_events(): + _, ax = plt.subplots() + points = tbx.Points(ax=ax) + events = [] + ax.figure.canvas.mpl_connect( + 'button_press_event', lambda event: events.append(event.name) + ) + ax.figure.canvas.mpl_connect( + 'button_release_event', lambda event: events.append(event.name) + ) + ax.figure.canvas.mpl_connect('pick_event', lambda event: events.append(event.name)) + + points.click(x=20, y=50) + points.click(x=20, y=50, button=2) + + assert events == [] + + +def test_canvas_middle_click_removes_point(): + _, ax = plt.subplots() + points = tbx.Points(ax=ax) + points.click(x=20, y=50) + ax.figure.canvas.draw() + x, y = ax.transData.transform((20, 50)) + event = MouseEvent( + name='button_press_event', canvas=ax.figure.canvas, x=x, y=y, button=2 + ) + + ax.figure.canvas.callbacks.process(event.name, event) + + assert len(points.children) == 0 + + +def test_points_middle_click_with_log_scale(): + _, ax = plt.subplots() + ax.set_xscale('log') + points = tbx.Points(ax=ax) + points.click(x=10, y=1) + + points.click(x=10, y=1, button=2) + + assert len(points.children) == 0 + + def test_points_stop(): _, ax = plt.subplots() points = tbx.Points(ax=ax) diff --git a/tests/rectangles_test.py b/tests/rectangles_test.py index 616a07d..7745891 100644 --- a/tests/rectangles_test.py +++ b/tests/rectangles_test.py @@ -2,6 +2,7 @@ # Copyright (c) Scipp contributors (https://github.com/scipp) import matplotlib.pyplot as plt +import pytest from matplotlib.colors import to_hex import mpltoolbox as tbx @@ -103,6 +104,120 @@ def on_remove(artist): assert len(my_event_list) == 1 +def test_rectangles_middle_click_removes_rectangle_and_calls_on_remove(): + _, ax = plt.subplots() + removed = [] + rects = tbx.Rectangles(ax=ax, on_remove=removed.append) + rects.click(x=1, y=1) + rects.click(x=5, y=5) + rectangle = rects.children[0] + + rects.click(x=3, y=3, button=2) + + assert len(rects.children) == 0 + assert len(ax.patches) == 0 + assert removed == [rectangle] + + +def test_rectangles_middle_click_outside_rectangle_does_not_remove(): + _, ax = plt.subplots() + removed = [] + rects = tbx.Rectangles(ax=ax, on_remove=removed.append) + rects.click(x=1, y=1) + rects.click(x=5, y=5) + + rects.click(x=3, y=-3, button=2) + + assert len(rects.children) == 1 + assert len(ax.patches) == 1 + assert removed == [] + + +def test_rectangles_middle_click_respects_enable_remove(): + _, ax = plt.subplots() + rects = tbx.Rectangles(ax=ax, enable_remove=False) + rects.click(x=1, y=1) + rects.click(x=5, y=5) + + rects.click(x=3, y=3, button=2) + + assert len(rects.children) == 1 + assert len(ax.patches) == 1 + + +def test_rectangles_ctrl_left_click_removes_rectangle(): + _, ax = plt.subplots() + rects = tbx.Rectangles(ax=ax) + rects.click(x=1, y=1) + rects.click(x=5, y=5) + + rects.click(x=3, y=3, modifiers=['ctrl']) + + assert len(rects.children) == 0 + assert len(ax.patches) == 0 + + +def test_rectangles_right_click_releases_rectangle(): + _, ax = plt.subplots() + pressed = [] + released = [] + rects = tbx.Rectangles( + ax=ax, on_drag_press=pressed.append, on_drag_release=released.append + ) + rects.click(x=1, y=1) + rects.click(x=5, y=5) + rectangle = rects.children[0] + + rects.click(x=3, y=3, button=3) + + assert pressed == [rectangle] + assert released == [rectangle] + assert not rects._pick_lock + assert not ax._mpltoolbox_lock + + +def test_rectangles_shift_left_click_releases_vertex(): + _, ax = plt.subplots() + pressed = [] + released = [] + rects = tbx.Rectangles( + ax=ax, on_vertex_press=pressed.append, on_vertex_release=released.append + ) + rects.click(x=1, y=1) + rects.click(x=5, y=5) + rectangle = rects.children[0] + + rects.click(x=1, y=1, modifiers=['shift']) + + assert pressed == [rectangle] + assert released == [rectangle] + assert not rects._pick_lock + assert not ax._mpltoolbox_lock + + +@pytest.mark.parametrize('button', [2, 3]) +def test_click_only_affects_called_tool(button): + _, ax = plt.subplots() + removed = [] + released = [] + points = tbx.Points( + ax=ax, on_remove=removed.append, on_drag_release=released.append + ) + points.click(x=3, y=3) + point = points.children[0] + rects = tbx.Rectangles(ax=ax) + rects.click(x=1, y=1) + rects.click(x=5, y=5) + + rects.click(x=3, y=3, button=button) + + assert points.children == [point] + assert removed == [] + assert released == [] + assert not points._pick_lock + assert not getattr(ax, '_mpltoolbox_lock', False) + + def test_rectangles_stop(): _, ax = plt.subplots() rects = tbx.Rectangles(ax=ax) @@ -112,6 +227,7 @@ def test_rectangles_stop(): rects.stop() rects.click(x=30, y=60) rects.click(x=40, y=80) + rects.click(x=50, y=60, button=2) assert len(ax.patches) == 1 @@ -137,6 +253,7 @@ def test_rectangles_freeze(): rects.freeze() rects.click(x=30, y=60) rects.click(x=40, y=80) + rects.click(x=50, y=60, button=2) assert len(ax.patches) == 1 rects.start() rects.click(x=30, y=60)