diff --git a/doc/release_notes/release_2.10.md b/doc/release_notes/release_2.10.md new file mode 100644 index 0000000..c351019 --- /dev/null +++ b/doc/release_notes/release_2.10.md @@ -0,0 +1,34 @@ +# Version 2.10 # + +## PlotPy Version 2.10.0 ## + +✨ New features: + +* **Per-axis autoscale strategy**: Added configurable autoscale behavior for each axis via the axis parameters dialog. Three strategies are available: *Auto* (default — compute bounds from items), *Fixed range* (apply user-defined Min/Max values) and *Disabled* (leave the axis untouched on autoscale). New API: `BasePlot.set_axis_autoscale_strategy()` / `BasePlot.get_axis_autoscale_strategy()` (closes [Issue #63](https://github.com/PlotPyStack/PlotPy/issues/63), partial) +* **Symbol border width**: Added an `edgewidth` parameter to `SymbolParam` for customizable marker border thickness — previously the border was always 1 pixel wide + +🛠️ Bug fixes: + +* **Rectangular snapshot tool** — Fixed the "Original size" computation (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 +* **Snapshot tool cursor** — Fixed the mouse cursor remaining stuck as a cross (`+`) outside the plot canvas (axes, toolbar) after using the snapshot tool. The modal dialogs are now opened after Qt has released the implicit pointer grab, so the cursor is correctly restored (closes [Issue #58](https://github.com/PlotPyStack/PlotPy/issues/58)) +* **Z-axis log tool** — Fixed the `ZAxisLogTool` being always disabled for non-`ImageItem` image types (`XYImageItem`, `MaskedImageItem`, `MaskedXYImageItem`, `TrImageItem`, `RGBImageItem`). The Z-axis log API (`get_zaxis_log_state` / `set_zaxis_log_state`) was moved from `ImageItem` up to `BaseImageItem` so all image item types support it. This notably fixes the tool being permanently greyed out in DataLab's image panel (closes [Issue #59](https://github.com/PlotPyStack/PlotPy/issues/59)) +* **Z-axis log data update** — Fixed image data not being recomputed when calling `set_data()` while Z-axis log scale is active — the log-transformed data is now refreshed and the LUT range preserved in log mode +* **`YRangeCursorTool`** — Fixed incorrect inequality display and negative ∆y when the Y-range cursors are inverted (dragging the top cursor below the bottom one). Values are now sorted and ∆y is always positive (closes [Issue #55](https://github.com/PlotPyStack/PlotPy/issues/55)) +* **`CurveStatsTool`** — Replaced `min`/`max`/`mean`/`std`/`sum` with their NaN-safe equivalents (`nanmin`, `nanmax`, `nanmean`, `nanstd`, `nansum`) so that signal statistics are computed correctly when the data contains NaN values + +⚙️ Dependencies: + +* Bumped minimum PythonQwt version from 0.15 to **0.16** to benefit from the Qt6 performance optimizations (closes [Issue #22](https://github.com/PlotPyStack/PlotPy/issues/22) — see [PythonQwt#93](https://github.com/PlotPyStack/PythonQwt/issues/93) for the full optimization log) \ No newline at end of file diff --git a/doc/requirements.rst b/doc/requirements.rst index 79c61bb..4026339 100644 --- a/doc/requirements.rst +++ b/doc/requirements.rst @@ -14,7 +14,7 @@ The `PlotPy` package requires the following Python modules: - >= 3.14.1 - Automatic GUI generation for easy dataset editing and display * - PythonQwt - - >= 0.15 + - >= 0.16 - Qt plotting widgets for Python * - numpy - >= 1.22 @@ -26,10 +26,10 @@ The `PlotPy` package requires the following Python modules: - >= 0.19 - Image processing in Python * - Pillow - - + - - Python Imaging Library (fork) * - tifffile - - + - - Read and write TIFF files Optional modules for GUI support (Qt): @@ -55,26 +55,32 @@ Optional modules for development: - Version - Summary * - build - - + - - A simple, correct Python build frontend * - babel - - + - - Internationalization utilities * - Coverage - - + - - Code coverage measurement for Python * - Cython - >=3.0 - The Cython compiler for writing C extensions in the Python language. * - pylint - - + - - python code static checker * - ruff - - + - - An extremely fast Python linter and code formatter, written in Rust. * - pre-commit - - + - - A framework for managing and maintaining multi-language pre-commit hooks. + * - setuptools + - + - Most extensible Python build backend with support for C/C++ extension modules + * - wheel + - + - Command line tool for manipulating wheel files Optional modules for building the documentation: @@ -86,19 +92,19 @@ Optional modules for building the documentation: - Version - Summary * - sphinx - - + - - Python documentation generator * - myst_parser - - + - - An extended [CommonMark](https://spec.commonmark.org/) compliant parser, * - sphinx-copybutton - - + - - Add a copy button to each of your code cells. * - sphinx_qt_documentation - - + - - Plugin for proper resolve intersphinx references for Qt elements * - python-docs-theme - - + - - The Sphinx theme for the CPython docs and related projects Optional modules for running test suite: @@ -111,8 +117,8 @@ Optional modules for running test suite: - Version - Summary * - pytest - - + - - pytest: simple powerful testing with Python * - pytest-xvfb - - + - - A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests. \ No newline at end of file diff --git a/plotpy/__init__.py b/plotpy/__init__.py index 089908a..50d749b 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.10.0" __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..0a251d8 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) @@ -334,6 +340,15 @@ def get_r_values(self, i0, i1, j0, j1, flag_circle=False): """ return self.get_x_values(i0, i1) + def _recompute_log_data(self) -> None: + """Refresh the cached log10 data from the current ``self.data``. + + Used both when toggling the Z-axis log scale on and when the underlying + data is replaced (e.g. via :meth:`set_data`) while the log scale is + already active. + """ + self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64) + def set_data( self, data: np.ndarray, lut_range: tuple[float, float] | None = None ) -> None: @@ -347,9 +362,15 @@ def set_data( self.histogram_cache = None self.update_bounds() self.update_border() + # Refresh the cached log10 data when log scale is active, otherwise the + # display would keep using the previous (now stale) log data. + if self.get_zaxis_log_state(): + self._recompute_log_data() if not self.param.keep_lut_range: if lut_range is not None: _min, _max = lut_range + elif self.get_zaxis_log_state(): + _min, _max = get_nan_range(self._log_data) else: _min, _max = get_nan_range(data) self.set_lut_range((_min, _max)) @@ -552,6 +573,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._recompute_log_data() + 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/locale/fr/LC_MESSAGES/plotpy.po b/plotpy/locale/fr/LC_MESSAGES/plotpy.po index 97dd6c4..773f0a7 100644 --- a/plotpy/locale/fr/LC_MESSAGES/plotpy.po +++ b/plotpy/locale/fr/LC_MESSAGES/plotpy.po @@ -355,15 +355,15 @@ msgstr "Police des valeurs" msgid "Scale" msgstr "Échelle" +msgid "logarithmic" +msgstr "logarithmique" + msgid "linear" msgstr "linéaire" msgid "date/time" msgstr "date/heure" -msgid "logarithmic" -msgstr "logarithmique" - msgid "Lower axis limit" msgstr "Borne inférieure de l'axe" @@ -630,12 +630,12 @@ msgstr "Classes" msgid "Number of bins" msgstr "Nombre de classes" -msgid "Minimum value" -msgstr "Valeur minimale" - msgid "Min" msgstr "Min" +msgid "Minimum value" +msgstr "Valeur minimale" + msgid "Maximum value" msgstr "Valeur maximale" @@ -1422,12 +1422,11 @@ msgid "" "Keyboard/mouse shortcuts:

\n" " - single left-click: item (curve, image, ...) selection
\n" " - single right-click: context-menu relative to selected item
\n" -" - shift: on-active-curve (or image) cursor (+ control to maintain\n" -"cursor visible)
\n" -" - shift + control: on-active-curve cursor (+ control to maintain\n" -"cursor visible)
\n" +" - shift: on-active-curve (or image) cursor
\n" +" - shift + control: on-active-curve cursor (maintained visible)
\n" " - alt: free cursor
\n" " - left-click + mouse move: move item (when available)
\n" +" - control + left-click + mouse move: move label on markers and range selections
\n" " - middle-click + mouse move: pan
\n" " - right-click + mouse move: zoom" msgstr "" @@ -1687,3 +1686,44 @@ msgstr "Rotation et rognage" msgid "Show cropping rectangle" msgstr "Afficher le rectangle de rognage" +#, fuzzy +msgid "Border width" +msgstr "Bordure" + +msgid "" +"Keyboard/mouse shortcuts:

\n" +" - single left-click: item (curve, image, ...) selection
\n" +" - single right-click: context-menu relative to selected item
\n" +" - shift: on-active-curve (or image) cursor (+ control to maintain\n" +"cursor visible)
\n" +" - shift + control: on-active-curve cursor (+ control to maintain\n" +"cursor visible)
\n" +" - alt: free cursor
\n" +" - left-click + mouse move: move item (when available)
\n" +" - middle-click + mouse move: pan
\n" +" - right-click + mouse move: zoom" +msgstr "" +"Raccourcis clavier et souris :

\n" +" - clique gauche : sélection d'un objet (courbe, image, ...)
\n" +" - clique droit : menu contextuel relatif à l'objet sélectionné
\n" +" - shift : curseur sur la courbe (ou l'image) active (+ control pour maintenir le curseur visible)
\n" +" - shift + control : curseur sur la courbe (ou l'image) active (+ control pour maintenir le curseur visible)
\n" +" - alt : curseur libre
\n" +" - clique gauche + déplacement souris : déplacement de l'objet actif (si possible)
\n" +" - clique du milieu + déplacement souris : translation dans le plan ('pan')
\n" +" - clique droit + déplacement souris : agrandissement ('zoom')" + +msgid "Autoscale strategy" +msgstr "Stratégie d'autoscale" + +msgid "Auto" +msgstr "Automatique" + +msgid "Fixed range" +msgstr "Plage fixe" + +msgid "Disabled" +msgstr "Désactivée" + +msgid "Strategy used by the AutoScale action for this axis: 'Auto' computes bounds from items, 'Fixed range' applies the Min/Max values defined above, 'Disabled' leaves the axis untouched." +msgstr "Stratégie utilisée par l'action AutoScale pour cet axe : « Automatique » calcule les bornes à partir des éléments, « Plage fixe » applique les valeurs Min/Max définies ci-dessus, « Désactivée » laisse l'axe inchangé." diff --git a/plotpy/plot/base.py b/plotpy/plot/base.py index de34684..7e978fa 100644 --- a/plotpy/plot/base.py +++ b/plotpy/plot/base.py @@ -325,6 +325,9 @@ def __init__( self.__autoscale_excluded_items: list[itf.IBasePlotItem] = [] self.autoscale_margin_percent = options.autoscale_margin_percent + self._axis_autoscale_strategy: dict[ + int, tuple[str, float | None, float | None] + ] = {axis_id: ("auto", None, None) for axis_id in self.AXIS_IDS} self.lock_aspect_ratio = options.lock_aspect_ratio self.__autoLockAspectRatio = False if self.lock_aspect_ratio is None: @@ -2177,9 +2180,54 @@ def get_auto_scale_excludes(self) -> list[itf.IBasePlotItem]: ] return [item_ref() for item_ref in self.__autoscale_excluded_items] + def get_axis_autoscale_strategy( + self, axis_id: int + ) -> tuple[str, float | None, float | None]: + """Return the autoscale strategy configured for a given axis. + + Args: + axis_id: the axis ID + + Returns: + A 3-tuple ``(strategy, vmin, vmax)`` where ``strategy`` is one of + ``"auto"``, ``"fixed"`` or ``"none"``. ``vmin``/``vmax`` are the + user-defined bounds applied when ``strategy == "fixed"`` + (``None`` otherwise). + """ + return self._axis_autoscale_strategy.get(axis_id, ("auto", None, None)) + + def set_axis_autoscale_strategy( + self, + axis_id: int, + strategy: str, + vmin: float | None = None, + vmax: float | None = None, + ) -> None: + """Set the autoscale strategy for a given axis. + + Args: + axis_id: the axis ID + strategy: one of ``"auto"`` (compute bounds from items, current + behavior), ``"fixed"`` (apply ``vmin``/``vmax``) or ``"none"`` + (leave the axis untouched on autoscale) + vmin: lower bound applied when ``strategy == "fixed"`` + vmax: upper bound applied when ``strategy == "fixed"`` + """ + if strategy not in ("auto", "fixed", "none"): + raise ValueError( + f"Invalid autoscale strategy {strategy!r}: " + "expected one of 'auto', 'fixed', 'none'" + ) + self._axis_autoscale_strategy[axis_id] = (strategy, vmin, vmax) + def do_autoscale(self, replot: bool = True, axis_id: int | None = None) -> None: """Do autoscale on all axes + The behavior of each axis depends on its autoscale strategy + (see :py:meth:`set_axis_autoscale_strategy`): ``"auto"`` computes + bounds from items (default), ``"fixed"`` applies the configured + ``vmin``/``vmax`` and ``"none"`` leaves the axis untouched. + Args: replot (bool): replot the widget (optional, default=True) axis_id (int | None): the axis ID (optional, default=None) @@ -2191,6 +2239,14 @@ def do_autoscale(self, replot: bool = True, axis_id: int | None = None) -> None: vmin, vmax = None, None if not self.axisEnabled(axis_id): continue + strategy, fixed_vmin, fixed_vmax = self.get_axis_autoscale_strategy(axis_id) + if strategy == "none": + continue + if strategy == "fixed": + if fixed_vmin is None or fixed_vmax is None: + continue + self.set_axis_limits(axis_id, fixed_vmin, fixed_vmax) + continue for item in self.get_items(): if ( isinstance(item, self.AUTOSCALE_TYPES) diff --git a/plotpy/styles/axes.py b/plotpy/styles/axes.py index a61289c..c474964 100644 --- a/plotpy/styles/axes.py +++ b/plotpy/styles/axes.py @@ -47,6 +47,20 @@ class AxisParam(DataSet): [("lin", _("linear")), ("log", _("logarithmic")), ("datetime", _("date/time"))], default="lin", ) + autoscale = ChoiceItem( + _("Autoscale strategy"), + [ + ("auto", _("Auto")), + ("fixed", _("Fixed range")), + ("none", _("Disabled")), + ], + default="auto", + help=_( + "Strategy used by the AutoScale action for this axis: " + "'Auto' computes bounds from items, 'Fixed range' applies the " + "Min/Max values defined above, 'Disabled' leaves the axis untouched." + ), + ) vmin = FloatItem("Min", help=_("Lower axis limit"), default=0.0) vmax = FloatItem("Max", help=_("Upper axis limit"), default=1.0) @@ -62,6 +76,13 @@ def update_param(self, plot: BasePlot, axis_id: int) -> None: axis: QwtScaleDiv = plot.axisScaleDiv(axis_id) self.vmin = axis.lowerBound() self.vmax = axis.upperBound() + strategy, fixed_vmin, fixed_vmax = plot.get_axis_autoscale_strategy(axis_id) + self.autoscale = strategy + if strategy == "fixed": + if fixed_vmin is not None: + self.vmin = fixed_vmin + if fixed_vmax is not None: + self.vmax = fixed_vmax def update_axis(self, plot: BasePlot, axis_id: int) -> None: """ @@ -74,6 +95,9 @@ def update_axis(self, plot: BasePlot, axis_id: int) -> None: plot.enableAxis(axis_id, True) plot.set_axis_scale(axis_id, self.scale, autoscale=False) plot.setAxisScale(axis_id, self.vmin, self.vmax) + plot.set_axis_autoscale_strategy( + axis_id, self.autoscale, vmin=self.vmin, vmax=self.vmax + ) plot.disable_unused_axes() plot.SIG_AXIS_PARAMETERS_CHANGED.emit(axis_id) 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_autoscale_strategy.py b/plotpy/tests/unit/test_autoscale_strategy.py new file mode 100644 index 0000000..4162d19 --- /dev/null +++ b/plotpy/tests/unit/test_autoscale_strategy.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Testing per-axis autoscale strategy.""" + +# guitest: skip + +from __future__ import annotations + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context + +from plotpy.builder import make +from plotpy.constants import AXIS_IDS, X_BOTTOM, Y_LEFT +from plotpy.tests import vistools as ptv + + +def _make_plot(): + """Create a plot widget with a single curve item.""" + x = np.linspace(0.0, 10.0, 11) + y = np.linspace(-5.0, 5.0, 11) + items = [make.curve(x, y, color="b")] + win = ptv.show_items(items, wintitle="autoscale-strategy-test", auto_tools=False) + return win, win.get_plot() + + +def test_default_strategy_is_auto(): + """All axes default to the 'auto' strategy.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + for axis_id in AXIS_IDS: + assert plot.get_axis_autoscale_strategy(axis_id) == ("auto", None, None) + + +def test_set_get_strategy_round_trip(): + """`set_axis_autoscale_strategy` round-trips through the getter.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.set_axis_autoscale_strategy(X_BOTTOM, "fixed", vmin=1.5, vmax=8.5) + assert plot.get_axis_autoscale_strategy(X_BOTTOM) == ("fixed", 1.5, 8.5) + plot.set_axis_autoscale_strategy(Y_LEFT, "none") + assert plot.get_axis_autoscale_strategy(Y_LEFT) == ("none", None, None) + + +def test_invalid_strategy_raises(): + """Unknown strategies are rejected.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + with pytest.raises(ValueError): + plot.set_axis_autoscale_strategy(X_BOTTOM, "bogus") + + +def test_strategy_none_keeps_limits(): + """An axis with strategy 'none' is left untouched by `do_autoscale`.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.set_axis_limits(X_BOTTOM, -42.0, 42.0) + plot.set_axis_autoscale_strategy(X_BOTTOM, "none") + plot.do_autoscale(replot=False) + vmin, vmax = plot.get_axis_limits(X_BOTTOM) + assert vmin == -42.0 + assert vmax == 42.0 + + +def test_strategy_fixed_applies_bounds(): + """An axis with strategy 'fixed' is set to the configured vmin/vmax.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.set_axis_autoscale_strategy(Y_LEFT, "fixed", vmin=-100.0, vmax=100.0) + plot.do_autoscale(replot=False) + vmin, vmax = plot.get_axis_limits(Y_LEFT) + assert vmin == -100.0 + assert vmax == 100.0 + + +def test_strategy_auto_uses_item_bounds(): + """An axis with strategy 'auto' covers the items' bounding rect.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.do_autoscale(replot=False) + vmin, vmax = plot.get_axis_limits(X_BOTTOM) + # Curve x-range is [0, 10]; auto strategy adds a margin so bounds are wider. + assert vmin <= 0.0 + assert vmax >= 10.0 + + +def test_explicit_axis_id_honors_none(): + """`do_autoscale(axis_id=...)` honors the 'none' strategy.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.set_axis_limits(X_BOTTOM, -7.0, 7.0) + plot.set_axis_autoscale_strategy(X_BOTTOM, "none") + plot.do_autoscale(replot=False, axis_id=X_BOTTOM) + vmin, vmax = plot.get_axis_limits(X_BOTTOM) + assert vmin == -7.0 + assert vmax == 7.0 + + +def test_disabled_axis_is_inert(): + """A disabled axis is ignored even when its strategy is 'fixed'.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + from plotpy.constants import X_TOP + + assert not plot.axisEnabled(X_TOP) + plot.set_axis_autoscale_strategy(X_TOP, "fixed", vmin=-1.0, vmax=1.0) + # Should not raise nor mutate the disabled axis state. + plot.do_autoscale(replot=False) + plot.do_autoscale(replot=False, axis_id=X_TOP) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/plotpy/tests/unit/test_image_log_set_data.py b/plotpy/tests/unit/test_image_log_set_data.py new file mode 100644 index 0000000..cc26cb1 --- /dev/null +++ b/plotpy/tests/unit/test_image_log_set_data.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Regression tests for cached log10 data refresh in ``ImageItem.set_data``. + +When the Z-axis is in logarithmic scale, ``ImageItem`` keeps a cached +``_log_data`` array. Prior to the fix, calling ``set_data`` did not refresh +that cache, so the displayed image kept reflecting the previous values until +the user toggled the log scale off and on again. +""" + +from __future__ import annotations + +import numpy as np +from guidata.qthelpers import qt_app_context + +from plotpy.builder import make + + +def _make_item(data: np.ndarray): + """Return an ``ImageItem`` ready for log-scale tests.""" + return make.image(data, interpolation="nearest") + + +def test_set_data_refreshes_log_data_when_log_scale_enabled() -> None: + """``set_data`` must recompute ``_log_data`` when log scale is active.""" + with qt_app_context(exec_loop=False): + first = np.array([[1.0, 10.0], [100.0, 1000.0]]) + item = _make_item(first) + item.set_zaxis_log_state(True) + np.testing.assert_array_almost_equal(item._log_data, np.log10(first.clip(1))) + + second = np.array([[10.0, 100.0], [1000.0, 10000.0]]) + item.set_data(second) + + # The cache must reflect the new data, not the previous one. + np.testing.assert_array_almost_equal(item._log_data, np.log10(second.clip(1))) + # And the LUT range must be derived from the refreshed log data. + lut_min, lut_max = item.get_lut_range() + assert lut_min == np.log10(second.clip(1)).min() + assert lut_max == np.log10(second.clip(1)).max() + + +def test_set_data_keeps_lut_range_in_log_mode() -> None: + """``keep_lut_range`` must be honored even when log scale is active.""" + with qt_app_context(exec_loop=False): + first = np.array([[1.0, 10.0], [100.0, 1000.0]]) + item = _make_item(first) + item.set_zaxis_log_state(True) + item.set_lut_range((0.5, 2.5)) + item.param.keep_lut_range = True + + second = np.array([[10.0, 100.0], [1000.0, 10000.0]]) + item.set_data(second) + + # Cache must still be refreshed (display correctness)… + np.testing.assert_array_almost_equal(item._log_data, np.log10(second.clip(1))) + # …but the LUT range must remain frozen as requested by the user. + assert item.get_lut_range() == (0.5, 2.5) + + +def test_set_data_does_not_create_log_data_when_log_scale_disabled() -> None: + """When log scale is off, ``set_data`` must not create ``_log_data``.""" + with qt_app_context(exec_loop=False): + item = _make_item(np.array([[1.0, 2.0], [3.0, 4.0]])) + assert item._log_data is None + item.set_data(np.array([[5.0, 6.0], [7.0, 8.0]])) + assert item._log_data is None 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/curve.py b/plotpy/tools/curve.py index 3285dcb..9ea68f8 100644 --- a/plotpy/tools/curve.py +++ b/plotpy/tools/curve.py @@ -142,13 +142,13 @@ class CurveStatsTool(BaseRangeCursorTool): TITLE = _("Signal statistics") ICON = "xrange.png" LABELFUNCS: tuple[tuple[str, Callable[..., Any]], ...] = ( - ("%g < x < %g", lambda *args: (args[0].min(), args[0].max())), - ("%g < y < %g", lambda *args: (args[1].min(), args[1].max())), - ("∆x=%g", lambda *args: args[0].max() - args[0].min()), - ("∆y=%g", lambda *args: args[1].max() - args[1].min()), - ("<y>=%g", lambda *args: args[1].mean()), - ("σ(y)=%g", lambda *args: args[1].std()), - ("∑(y)=%g", lambda *args: np.sum(args[1])), + ("%g < x < %g", lambda *args: (np.nanmin(args[0]), np.nanmax(args[0]))), + ("%g < y < %g", lambda *args: (np.nanmin(args[1]), np.nanmax(args[1]))), + ("∆x=%g", lambda *args: np.nanmax(args[0]) - np.nanmin(args[0])), + ("∆y=%g", lambda *args: np.nanmax(args[1]) - np.nanmin(args[1])), + ("<y>=%g", lambda *args: np.nanmean(args[1])), + ("σ(y)=%g", lambda *args: np.nanstd(args[1])), + ("∑(y)=%g", lambda *args: np.nansum(args[1])), ("∫ydx=%g", lambda *args: spt.trapezoid(args[1], args[0])), ) SHAPECLASS = XRangeSelection @@ -212,8 +212,8 @@ class YRangeCursorTool(BaseRangeCursorTool): TITLE = _("Y-range") ICON = "yrange.png" LABELFUNCS: tuple[tuple[str, Callable[..., Any]], ...] = ( - ("%g < y < %g", lambda ymin, ymax: (ymin, ymax)), - ("∆y=%g", lambda ymin, ymax: ymax - ymin), + ("%g < y < %g", lambda ymin, ymax: (min(ymin, ymax), max(ymin, ymax))), + ("∆y=%g", lambda ymin, ymax: abs(ymax - ymin)), ) SHAPECLASS = YRangeSelection 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): """ """ diff --git a/pyproject.toml b/pyproject.toml index 45d3f15..cc5bb73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ requires-python = ">=3.9, <4" dependencies = [ "guidata >= 3.14.1", - "PythonQwt >= 0.15", + "PythonQwt >= 0.16", "numpy >= 1.22", "SciPy >= 1.7.3", "scikit-image >= 0.19", diff --git a/requirements.txt b/requirements.txt index 2437ecc..9b23aa0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,10 @@ Coverage Cython>=3.0 Pillow PyQt5>5.15.5 -PythonQwt >= 0.15 +PythonQwt >= 0.16 SciPy >= 1.7.3 babel build -setuptools guidata >= 3.14.1 myst_parser numpy >= 1.22 @@ -17,7 +16,9 @@ pytest-xvfb python-docs-theme ruff scikit-image >= 0.19 +setuptools sphinx sphinx-copybutton sphinx_qt_documentation tifffile +wheel diff --git a/scripts/reinstall_dev.py b/scripts/reinstall_dev.py index 532477d..1ea0e6c 100644 --- a/scripts/reinstall_dev.py +++ b/scripts/reinstall_dev.py @@ -3,7 +3,8 @@ Reinstall multiple local libraries in editable mode for development. Workflow: - 1) Try to uninstall all target libraries in one command (ignore errors if some are not installed). + 1) Try to uninstall all target libraries in one command (ignore errors if some are + not installed). 2) Reinstall each library in editable mode from a sibling folder: ../. This script uses the same Python interpreter that runs it (sys.executable),