diff --git a/doc/release_notes/release_2.09.md b/doc/release_notes/release_2.09.md index 866a9ff..2c38734 100644 --- a/doc/release_notes/release_2.09.md +++ b/doc/release_notes/release_2.09.md @@ -1,5 +1,25 @@ # Version 2.9 # +## PlotPy Version 2.9.1 ## + +🛠️ Bug fixes: + +* Fixed the rectangular snapshot tool's "Original size" computation. This closes + [Issue #57](https://github.com/PlotPyStack/PlotPy/issues/57): + * The preview no longer displays negative dimensions when the X or Y axis is + reversed + * The "Original size" is now computed from pixel coordinates instead of axis + units, so it is correct for `XYImageItem` (and any item with non-uniform + axis scaling) regardless of axis orientation + * The `ValueError` raised by the resize dialog when the selection produced + negative dimensions on a reversed axis is gone + * Selecting a region larger than the plotted image now reports the same + native pixel resolution for both `ImageItem` and `XYImageItem` + (previously `XYImageItem` reported ``shape - 1`` while `ImageItem` + reported the full oversized resolution): exporting at "Original size" + now consistently preserves the source pixel density and avoids + upsampling, regardless of the item type + ## PlotPy Version 2.9.0 ## 💥 New features: diff --git a/plotpy/__init__.py b/plotpy/__init__.py index 089908a..9d8905d 100644 --- a/plotpy/__init__.py +++ b/plotpy/__init__.py @@ -20,7 +20,7 @@ .. _GitHub: https://github.com/PierreRaybaut/plotpy """ -__version__ = "2.9.0" +__version__ = "2.9.1" __VERSION__ = tuple([int(number) for number in __version__.split(".")]) # --- Important note: DATAPATH and LOCALEPATH are used by guidata.configtools diff --git a/plotpy/items/__init__.py b/plotpy/items/__init__.py index 26683fa..31bda93 100644 --- a/plotpy/items/__init__.py +++ b/plotpy/items/__init__.py @@ -33,6 +33,7 @@ XYImageFilterItem, XYImageItem, assemble_imageitems, + compute_image_items_original_size, compute_trimageitems_original_size, get_image_from_plot, get_image_from_qrect, diff --git a/plotpy/items/image/__init__.py b/plotpy/items/image/__init__.py index a1cb212..94f39e8 100644 --- a/plotpy/items/image/__init__.py +++ b/plotpy/items/image/__init__.py @@ -11,6 +11,7 @@ Histogram2DItem, QuadGridItem, assemble_imageitems, + compute_image_items_original_size, compute_trimageitems_original_size, get_image_from_plot, get_image_from_qrect, diff --git a/plotpy/items/image/base.py b/plotpy/items/image/base.py index a33de74..b228d58 100644 --- a/plotpy/items/image/base.py +++ b/plotpy/items/image/base.py @@ -132,6 +132,12 @@ def __init__( self._filename = None # The file this image comes from self.histogram_cache = None + + # Z-axis logarithmic scale support + self._log_data: np.ndarray | None = None + self._lin_lut_range: tuple[float, float] | None = None + self._is_zaxis_log = False + if data is not None: self.set_data(data) self.param.update_item(self) @@ -552,6 +558,34 @@ def get_lut_range_full(self) -> tuple[float, float]: """ return get_nan_range(self.data) + # ---- Z-axis logarithmic scale -------------------------------------------- + def get_zaxis_log_state(self) -> bool: + """Return True if Z-axis is in logarithmic scale""" + return self._is_zaxis_log + + def set_zaxis_log_state(self, state: bool) -> None: + """Set Z-axis logarithmic scale state + + Args: + state: True to enable logarithmic scale, False otherwise + """ + self._is_zaxis_log = state + plot = self.plot() + if state: + self._lin_lut_range = self.get_lut_range() + if self._log_data is None: + self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64) + self.set_lut_range(get_nan_range(self._log_data)) + dtype = self._log_data.dtype + else: + self._log_data = None + self.set_lut_range(self._lin_lut_range) + dtype = self.data.dtype + if self.interpolate[0] == INTERP_AA: + self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype)) + if plot is not None: + plot.update_colormap_axis(self) + def get_lut_range_max(self) -> tuple[float, float]: """Get maximum range for this dataset diff --git a/plotpy/items/image/misc.py b/plotpy/items/image/misc.py index 803adbd..1853671 100644 --- a/plotpy/items/image/misc.py +++ b/plotpy/items/image/misc.py @@ -577,19 +577,27 @@ def get_items_in_rectangle( def compute_trimageitems_original_size( items: list[TrImageItem], - src_w: list[float, float, float, float], - src_h: list[float, float, float, float], + src_w: float, + src_h: float, ) -> tuple[float, float]: """Compute `TrImageItem` original size from max dx and dy Args: items: List of image items - src_w: Source width - src_h: Source height + src_w: Source width (in plot axis units) + src_h: Source height (in plot axis units) Returns: Tuple of original size + + .. note:: + + The returned size is always positive: when the source rectangle is + defined on a reversed axis, ``src_w`` and/or ``src_h`` may be + negative. The original (pixel) size is intrinsically positive, + independent of axis orientation. """ + src_w, src_h = abs(src_w), abs(src_h) trparams = [item.get_transform() for item in items if isinstance(item, TrImageItem)] if trparams: dx_max = max([dx for _x, _y, _angle, dx, _dy, _hf, _vf in trparams]) @@ -598,6 +606,81 @@ def compute_trimageitems_original_size( return src_w, src_h +def compute_image_items_original_size( + items: list[BaseImageItem], + plot: qwt.plot.QwtPlot, + p0: QPointF, + p1: QPointF, +) -> tuple[float, float]: + """Compute the **native pixel resolution** of a rectangular selection + across the given image items. + + The "Original size" semantics is *original resolution*: the returned + size is the number of source pixels that span the selection at the + item's native resolution, *independent of axis orientation or scaling*. + When the selection is larger than the plotted image, the returned size + is consequently larger than the image (the missing area will be padded + by the export step). When the selection is smaller, it is smaller in + pixels — there is **no** clipping to the image bounding rectangle, so + that exporting at "Original size" always preserves the source pixel + density. + + Args: + plot: Plot + items: List of image items in the selection + p0: First canvas point (top-left, in canvas coordinates) + p1: Second canvas point (bottom-right, in canvas coordinates) + + Returns: + Tuple ``(width, height)`` in pixels (always positive). When no + compatible item is found, falls back to the absolute axis-units + size of the selection. + """ + p0x = plot.invTransform(X_BOTTOM, p0.x()) + p0y = plot.invTransform(Y_LEFT, p0.y()) + p1x = plot.invTransform(X_BOTTOM, p1.x() + 1) + p1y = plot.invTransform(Y_LEFT, p1.y() + 1) + sel_x0, sel_x1 = sorted([p0x, p1x]) + sel_y0, sel_y1 = sorted([p0y, p1y]) + sel_w = sel_x1 - sel_x0 + sel_h = sel_y1 - sel_y0 + widths: list[float] = [] + heights: list[float] = [] + for item in items: + data = getattr(item, "data", None) + if data is None: + continue + if isinstance(item, TrImageItem): + # Use the item's affine transform (handles rotation and shear) + get_pix = item.get_pixel_coordinates + try: + x0p, y0p = get_pix(sel_x0, sel_y0) + x1p, y1p = get_pix(sel_x1, sel_y1) + except (ValueError, TypeError, IndexError): + continue + widths.append(abs(x1p - x0p)) + heights.append(abs(y1p - y0p)) + else: + # For ImageItem / XYImageItem: convert the (possibly oversized) + # selection to pixels via the item's own pixel density. This + # avoids ``XYImageItem.get_pixel_coordinates`` clamping to + # integer indices and yields oversized values when the + # selection extends beyond the image — consistently with the + # historical behavior of ``ImageItem``. + brect = item.boundingRect() + bw = abs(brect.width()) + bh = abs(brect.height()) + if bw <= 0 or bh <= 0: + continue + widths.append(sel_w / bw * data.shape[1]) + heights.append(sel_h / bh * data.shape[0]) + if widths: + return max(widths), max(heights) + # Fallback: axis-units size (always positive) + _src_x, _src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect() + return abs(src_w), abs(src_h) + + def get_image_from_qrect( plot: BasePlot, p0: QPointF, @@ -636,12 +719,12 @@ def get_image_from_qrect( if not items: raise TypeError(_("There is no supported image item in current plot.")) if src_size is None: - _src_x, _src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect() + destw, desth = compute_image_items_original_size(items, plot, p0, p1) else: # The only benefit to pass the src_size list is to avoid any # rounding error in the transformation computed in `get_plot_qrect` src_w, src_h = src_size - destw, desth = compute_trimageitems_original_size(items, src_w, src_h) + destw, desth = compute_trimageitems_original_size(items, src_w, src_h) data = get_image_from_plot( plot, p0, diff --git a/plotpy/items/image/standard.py b/plotpy/items/image/standard.py index 6883cc3..c900e1a 100644 --- a/plotpy/items/image/standard.py +++ b/plotpy/items/image/standard.py @@ -11,7 +11,6 @@ from qtpy import QtCore as QC from plotpy import io -from plotpy._scaler import INTERP_AA from plotpy.config import _ from plotpy.constants import LUTAlpha from plotpy.coords import canvas_to_axes, pixelround @@ -29,7 +28,6 @@ ) from plotpy.items.image.base import RawImageItem from plotpy.items.image.filter import XYImageFilterItem, to_bins -from plotpy.mathutils.arrayfuncs import get_nan_range from plotpy.styles.image import ImageParam, RGBImageParam, XYImageParam if TYPE_CHECKING: @@ -84,9 +82,6 @@ def __init__( self.xmax = None self.ymin = None self.ymax = None - self._log_data = None - self._lin_lut_range = None - self._is_zaxis_log = False super().__init__(data=data, param=param) # ---- BaseImageItem API --------------------------------------------------- @@ -228,28 +223,6 @@ def update_bounds(self) -> None: (xmin, xmax), (ymin, ymax) = self.get_xdata(), self.get_ydata() self.bounds = QC.QRectF(QC.QPointF(xmin, ymin), QC.QPointF(xmax, ymax)) - def get_zaxis_log_state(self): - """Reimplement image.ImageItem method""" - return self._is_zaxis_log - - def set_zaxis_log_state(self, state): - """Reimplement image.ImageItem method""" - self._is_zaxis_log = state - plot = self.plot() - if state: - self._lin_lut_range = self.get_lut_range() - if self._log_data is None: - self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64) - self.set_lut_range(get_nan_range(self._log_data)) - dtype = self._log_data.dtype - else: - self._log_data = None - self.set_lut_range(self._lin_lut_range) - dtype = self.data.dtype - if self.interpolate[0] == INTERP_AA: - self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype)) - plot.update_colormap_axis(self) - # ---- BaseImageItem API --------------------------------------------------- def get_pixel_coordinates(self, xplot: float, yplot: float) -> tuple[float, float]: """Get pixel coordinates from plot coordinates @@ -684,8 +657,12 @@ def draw_image( return xytr = self.x, self.y, src_rect dst_rect = tuple([int(i) for i in dst_rect]) + if self.get_zaxis_log_state(): + data = self._log_data + else: + data = self.data dest = _scale_xy( - self.data, xytr, self._offscreen, dst_rect, self.lut, self.interpolate + data, xytr, self._offscreen, dst_rect, self.lut, self.interpolate ) qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3])) painter.drawImage(qrect, self._image, qrect) diff --git a/plotpy/items/image/transform.py b/plotpy/items/image/transform.py index 7aa3787..4309fe1 100644 --- a/plotpy/items/image/transform.py +++ b/plotpy/items/image/transform.py @@ -274,8 +274,12 @@ def draw_image( mat = self.tr @ tr dst_rect = tuple([int(i) for i in dst_rect]) + if self.get_zaxis_log_state(): + data = self._log_data + else: + data = self.data dest = _scale_tr( - self.data, mat, self._offscreen, dst_rect, self.lut, self.interpolate + data, mat, self._offscreen, dst_rect, self.lut, self.interpolate ) qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3])) painter.drawImage(qrect, self._image, qrect) diff --git a/plotpy/styles/base.py b/plotpy/styles/base.py index 1df2f7f..4796335 100644 --- a/plotpy/styles/base.py +++ b/plotpy/styles/base.py @@ -368,6 +368,7 @@ class SymbolParam(DataSet): marker = ImageChoiceItem(_("Style"), MARKER_CHOICES, default="NoSymbol") size = IntItem(_("Size"), default=9) edgecolor = ColorItem(_("Border"), default="gray") + edgewidth = FloatItem(_("Border width"), default=1.0, min=0.0) facecolor = ColorItem(_("Background color"), default="yellow") alpha = FloatItem(_("Background alpha"), default=1.0, min=0, max=1) @@ -386,6 +387,7 @@ def update_param(self, symb): self.marker = MARKER_NAME[symb.style()] self.size = int(symb.size().width()) self.edgecolor = str(symb.pen().color().name()) + self.edgewidth = float(symb.pen().widthF()) self.facecolor = str(symb.brush().color().name()) def build_symbol(self): @@ -396,10 +398,12 @@ def build_symbol(self): marker_type = getattr(QwtSymbol, self.marker) color = QG.QColor(self.facecolor) color.setAlphaF(self.alpha) + pen = QG.QPen(QG.QColor(self.edgecolor)) + pen.setWidthF(self.edgewidth) marker = QwtSymbol( marker_type, QG.QBrush(color), - QG.QPen(QG.QColor(self.edgecolor)), + pen, QC.QSizeF(self.size, self.size), ) return marker diff --git a/plotpy/tests/unit/test_snapshot_original_size.py b/plotpy/tests/unit/test_snapshot_original_size.py new file mode 100644 index 0000000..72066e4 --- /dev/null +++ b/plotpy/tests/unit/test_snapshot_original_size.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Unit tests for the rectangular snapshot tool's "Original size" computation. + +This test reproduces the issue reported in PlotPyStack/PlotPy#57: + + The "Original size" option in the rectangular snapshot tool does not behave + correctly under certain conditions: + + * When the Y axis is not reversed (image-style plot) + * When the X axis is reversed + * When using an XYImageItem + + In these cases, the "Original size" preview may display negative values or + incorrect dimensions. The computation appears to rely on axis scaling + rather than pixel coordinates, particularly for ``XYImageItem``. + + Additionally, a ``ValueError`` is raised when either axis leads to negative + values. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context +from qtpy import QtCore as QC + +from plotpy.builder import make +from plotpy.items import ( + compute_image_items_original_size, + compute_trimageitems_original_size, +) +from plotpy.tests import vistools as ptv + +# Image used by the tests (rows = Y, cols = X) +NB_ROWS, NB_COLS = 100, 200 + + +def _canvas_points(plot, x0_plot, y0_plot, x1_plot, y1_plot): + """Convert plot-coordinate corners into canvas QPointF, the way the + snapshot tool builds them from a rubber-band rectangle.""" + from plotpy.constants import X_BOTTOM, Y_LEFT + + x0c = plot.transform(X_BOTTOM, x0_plot) + x1c = plot.transform(X_BOTTOM, x1_plot) + y0c = plot.transform(Y_LEFT, y0_plot) + y1c = plot.transform(Y_LEFT, y1_plot) + # Mimic the tool: p0 is top-left, p1 is bottom-right (in canvas pixels) + p0 = QC.QPointF(min(x0c, x1c), min(y0c, y1c)) + p1 = QC.QPointF(max(x0c, x1c), max(y0c, y1c)) + return p0, p1 + + +def test_compute_trimageitems_original_size_handles_reversed_axes(): + """Regression: ``compute_trimageitems_original_size`` must return positive + dimensions even when the source rectangle is given with negative width or + height (which happens on reversed axes).""" + # No items: legacy fallback path + w, h = compute_trimageitems_original_size([], -50.0, -25.0) + assert w == 50.0 and h == 25.0 + + +def _expected_pixel_size(x0, y0, x1, y1): + """Original (pixel) size for a selection on a non-transformed image + spanning [0, NB_COLS] x [0, NB_ROWS] in plot units.""" + return abs(x1 - x0), abs(y1 - y0) + + +@pytest.mark.parametrize( + "xreverse,yreverse", + [(False, False), (False, True), (True, False), (True, True)], +) +def test_snapshot_original_size_with_image_item(xreverse, yreverse): + """Original size must be positive and equal to the pixel selection size, + regardless of axis orientation, for a regular ``ImageItem``.""" + data = np.arange(NB_ROWS * NB_COLS, dtype=np.float64).reshape(NB_ROWS, NB_COLS) + with qt_app_context(exec_loop=False): + image = make.image(data) + win = ptv.show_items([image], plot_type="image", auto_tools=False) + plot = win.manager.get_plot() + plot.set_axis_direction("bottom", xreverse) + plot.set_axis_direction("left", yreverse) + plot.replot() + + # Selection in plot coordinates: a 40x30 pixel rectangle + x0, y0, x1, y1 = 30.0, 20.0, 70.0, 50.0 + p0, p1 = _canvas_points(plot, x0, y0, x1, y1) + + width, height = compute_image_items_original_size([image], plot, p0, p1) + + exp_w, exp_h = _expected_pixel_size(x0, y0, x1, y1) + # Allow 1 pixel tolerance for canvas rounding + assert width > 0 and height > 0 + assert abs(width - exp_w) <= 1.5 + assert abs(height - exp_h) <= 1.5 + win.close() + + +def test_snapshot_original_size_with_xy_image_item(): + """For an ``XYImageItem``, the original size must be expressed in **pixel** + coordinates (independent of axis scaling), not in axis units.""" + data = np.arange(NB_ROWS * NB_COLS, dtype=np.float64).reshape(NB_ROWS, NB_COLS) + # Non-trivial axis scaling: 1 pixel == 5 axis units (X), 2 axis units (Y) + x = np.linspace(0.0, NB_COLS * 5.0, NB_COLS + 1) + y = np.linspace(0.0, NB_ROWS * 2.0, NB_ROWS + 1) + with qt_app_context(exec_loop=False): + image = make.xyimage(x, y, data) + win = ptv.show_items([image], plot_type="image", auto_tools=False) + plot = win.manager.get_plot() + plot.replot() + + # Selection spanning ~40 columns and ~30 rows in pixel space: + x0, x1 = 30.0 * 5.0, 70.0 * 5.0 # 40 columns + y0, y1 = 20.0 * 2.0, 50.0 * 2.0 # 30 rows + p0, p1 = _canvas_points(plot, x0, y0, x1, y1) + + width, height = compute_image_items_original_size([image], plot, p0, p1) + + # Must be in pixel units, not axis units (axis units would give + # ~200 x ~60 instead of ~40 x ~30) + assert width > 0 and height > 0 + assert abs(width - 40) <= 5 + assert abs(height - 30) <= 5 + win.close() + + +@pytest.mark.parametrize("make_factory", ["image", "xyimage"]) +def test_snapshot_original_size_selection_larger_than_image(make_factory): + """When the selection is larger than the plotted image, the "Original size" + must reflect the **native pixel resolution** of the selection (i.e. the + number of source pixels the selection would cover at the item's pixel + density), not the clipped image size — so that exporting at "Original + size" preserves the source pixel density and the image is not upsampled. + + Both ``ImageItem`` and ``XYImageItem`` must agree on this: this is the + consistency fix for the off-by-one / inconsistency reported in #57 + (99x99 for XYImageItem vs. oversized for ImageItem on a 100x100 image — + they must now both give the same oversized value). + """ + data = np.arange(NB_ROWS * NB_COLS, dtype=np.float64).reshape(NB_ROWS, NB_COLS) + with qt_app_context(exec_loop=False): + if make_factory == "image": + image = make.image(data) + else: + x = np.linspace(0.0, float(NB_COLS), NB_COLS + 1) + y = np.linspace(0.0, float(NB_ROWS), NB_ROWS + 1) + image = make.xyimage(x, y, data) + win = ptv.show_items([image], plot_type="image", auto_tools=False) + plot = win.manager.get_plot() + plot.replot() + + # Selection much larger than the image (negative lower bound, upper + # bound well beyond the image): + x0, y0, x1, y1 = -50.0, -50.0, NB_COLS + 50.0, NB_ROWS + 50.0 + p0, p1 = _canvas_points(plot, x0, y0, x1, y1) + + width, height = compute_image_items_original_size([image], plot, p0, p1) + + # Native pixel resolution of the (oversized) selection: 200 px wide + # selection on a 100 axis-unit / 100 px image -> 200 px + exp_w = (x1 - x0) * NB_COLS / float(NB_COLS) # = 200 + exp_h = (y1 - y0) * NB_ROWS / float(NB_ROWS) # = 200 + assert abs(width - exp_w) <= 2 + assert abs(height - exp_h) <= 2 + win.close() diff --git a/plotpy/tools/image.py b/plotpy/tools/image.py index 916d9b4..bb96a2f 100644 --- a/plotpy/tools/image.py +++ b/plotpy/tools/image.py @@ -25,13 +25,13 @@ from plotpy.items import ( AnnotatedRectangle, EllipseShape, - ImageItem, MaskedImageItem, MaskedXYImageItem, RectangleShape, TrImageItem, get_items_in_rectangle, ) +from plotpy.items.image.base import BaseImageItem from plotpy.mathutils.colormap import ALL_COLORMAPS, build_icon_from_cmap_name, get_cmap from plotpy.tools.base import ( CommandTool, @@ -54,7 +54,6 @@ from plotpy.events import StatefulEventFilter from plotpy.interfaces.items import IBasePlotItem - from plotpy.items.image.base import BaseImageItem from plotpy.items.shape.base import AbstractShape from plotpy.items.shape.polygon import PolygonShape from plotpy.plot import BasePlot @@ -412,7 +411,7 @@ def get_supported_items(self, plot: BasePlot) -> list[BaseImageItem]: items = [ item for item in plot.get_items() - if isinstance(item, ImageItem) + if isinstance(item, BaseImageItem) and not item.is_empty() and hasattr(item, "get_zaxis_log_state") ] diff --git a/plotpy/tools/misc.py b/plotpy/tools/misc.py index 6c1c39f..e6d322d 100644 --- a/plotpy/tools/misc.py +++ b/plotpy/tools/misc.py @@ -15,7 +15,7 @@ from plotpy.config import _ from plotpy.interfaces import IImageItemType from plotpy.items import ( - compute_trimageitems_original_size, + compute_image_items_original_size, get_image_from_plot, get_items_in_rectangle, get_plot_qrect, @@ -86,10 +86,13 @@ def save_snapshot(plot, p0, p1, new_size=None): ) return src_x, src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect() - original_size = compute_trimageitems_original_size(items, src_w, src_h) + original_size = compute_image_items_original_size(items, plot, p0, p1) if new_size is None: - new_size = (int(p1.x() - p0.x() + 1), int(p1.y() - p0.y() + 1)) # Screen size + new_size = ( + int(abs(p1.x() - p0.x()) + 1), + int(abs(p1.y() - p0.y()) + 1), + ) # Screen size dlg = ResizeDialog( plot, new_size=new_size, old_size=original_size, text=_("Destination size:") @@ -218,6 +221,34 @@ def __init__(self, manager, toolbar_id=DefaultToolbarID): manager, save_snapshot, toolbar_id=toolbar_id, fix_orientation=True ) + def end_rect(self, filter, p0, p1): + """End rect: emit ``SIG_TOOL_JOB_FINISHED`` *synchronously* so the + ``switch_to_default_tool`` listener restores the canvas cursor while + we are still inside the mouse-release event handler chain — Qt then + gets the chance to refresh the cursor on neighbouring widgets + (axes, toolbar) before any nested event loop is started by the + snapshot dialogs. The action function itself is deferred via a + zero-delay timer so the modal ``ResizeDialog`` (and following + dialogs) is not opened from inside the rubber-band ``mouseRelease`` + handler chain — otherwise Qt's implicit grab is left in an unclean + state on Windows and the cross cursor used by the canvas during + the drag remains "stuck" on neighbouring widgets until the mouse + moves over them. + """ + plot = filter.plot + if self.fix_orientation: + left, right = min(p0.x(), p1.x()), max(p0.x(), p1.x()) + top, bottom = min(p0.y(), p1.y()), max(p0.y(), p1.y()) + p0, p1 = QC.QPointF(left, top), QC.QPointF(right, bottom) + # Synchronous: cursor is restored on the canvas now, while we are + # still in the mouse-release handler chain. + self.SIG_TOOL_JOB_FINISHED.emit() + if self.switch_to_default_tool: + shape = self.get_last_final_shape() + plot.set_active_item(shape) + # Deferred: open the dialogs after Qt has cleanly released the grab. + QC.QTimer.singleShot(0, lambda: self.action_func(plot, p0, p1)) + class HelpTool(CommandTool): """ """