Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 65 additions & 9 deletions src/mpltoolbox/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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):
"""
Expand Down
56 changes: 56 additions & 0 deletions tests/points_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
117 changes: 117 additions & 0 deletions tests/rectangles_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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


Expand All @@ -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)
Expand Down
Loading