diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index c43bb2c2e..92f9a9a0f 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -68,9 +68,6 @@ widgets.tools.CameraTool widgets.tools.ColorTool widgets.tools.HomeTool - widgets.tools.LogNormTool - widgets.tools.LogxTool - widgets.tools.LogyTool widgets.tools.MultiToggleTool widgets.tools.OutlineTool widgets.tools.PanZoomTool diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index d97b66132..a4a29e9c2 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -2,12 +2,15 @@ # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) import warnings +from collections.abc import Callable from typing import Literal import matplotlib.pyplot as plt import numpy as np import scipp as sc from matplotlib import dates as mdates +from matplotlib.backend_bases import MouseEvent +from matplotlib.transforms import Bbox from mpl_toolkits.axes_grid1 import make_axes_locatable from ...core.utils import maybe_variable_to_number, scalar_to_string @@ -52,6 +55,70 @@ def _maybe_trim_polar_limits( return tuple(np.clip(limits, 0, 2 * np.pi)) +class CanvasToggleButton: + _value: bool + + def __init__( + self, + ax: plt.Axes, + label: str, + position: tuple[float, float], + value: bool = False, + **kwargs, + ): + default_style = {"visible": False, "fontsize": 9, "zorder": np.inf} + self._text = ax.text(*position, label, **{**default_style, **kwargs}) + self.value = value + + def contains(self, event: MouseEvent) -> bool: + return self._text.contains(event)[0] + + @property + def visible(self) -> bool: + return self._text.get_visible() + + @visible.setter + def visible(self, visible: bool): + self._text.set_visible(visible) + + def toggle(self): + self.value = not self.value + + @property + def value(self) -> bool: + return self._value + + @value.setter + def value(self, val: bool): + self._value = val + self._text.set_bbox( + { + "boxstyle": "round,pad=0.3", + "facecolor": "0.65" if val else "0.9", + "edgecolor": "0.5", + } + ) + + @property + def label(self) -> str: + return self._text.get_text() + + @label.setter + def label(self, text: str): + self._text.set_text(text) + + @property + def position(self) -> tuple[float, float]: + """ + The position of the button in display coordinates. + """ + return self._text.get_position() + + @position.setter + def position(self, pos: tuple[float, float]): + self._text.set_position(pos) + + class Canvas: """ Matplotlib-based canvas used to render 2D graphics. @@ -124,6 +191,7 @@ def __init__( xlabel: str | None = None, ylabel: str | None = None, norm: Literal['linear', 'log'] | None = None, + autoscale_axes: Callable | None = None, **ignored, ): # Note on the `**ignored`` keyword arguments: the figure which owns the canvas @@ -152,13 +220,13 @@ def __init__( self.units = {} self.dims = {} self._legend = legend + self._autoscale_axes = autoscale_axes + self._toggle_color_norm = None + self._autoscale_colors = None if self.ax is None: self.fig = make_figure(figsize=(6.0, 4.0) if figsize is None else figsize) self.ax = self.fig.add_subplot() - if self.is_widget(): - self.fig.canvas.toolbar_visible = False - self.fig.canvas.header_visible = False else: self.fig = self.ax.get_figure() if aspect is not None: @@ -179,6 +247,36 @@ def __init__( self.ax.set_title(title) self._coord_formatters = [] + self._logx_button = None + self._logy_button = None + self._logc_button = None + self._fitc_button = None + if self.is_widget(): + # Hide the native toolbar: it will be replaced by our own. Also hide the + # header in ipympl figures which takes up a lot of vertical space. + self.fig.canvas.toolbar_visible = False + self.fig.canvas.header_visible = False + + args = {"transform": self.ax.transAxes, "ha": "right", "va": "top"} + self._logx_button = CanvasToggleButton( + ax=self.ax, label="logX", position=(0.985, -0.02), **args + ) + self._logy_button = CanvasToggleButton( + ax=self.ax, label="logY", position=(-0.015, 0.98), **args + ) + + if self.cax is not None: + args = {"transform": self.cax.transAxes, "ha": "center"} + self._logc_button = CanvasToggleButton( + ax=self.cax, label="log", position=(0.5, 0.98), va="top", **args + ) + self._fitc_button = CanvasToggleButton( + ax=self.cax, label="fit", position=(0.5, 0.02), va="bottom", **args + ) + + self.fig.canvas.mpl_connect("motion_notify_event", self._on_mouse_move) + self.fig.canvas.mpl_connect("button_press_event", self._on_log_button_click) + if logx: self.xscale = 'log' if logy: @@ -188,6 +286,47 @@ def __init__( if ylabel is not None: self.ylabel = ylabel + def _on_mouse_move(self, event: MouseEvent): + """ + Show log buttons when hovering over the axes or colorbar, and hide them + otherwise. + """ + need_redraw = False + + logxy_visible = self._logx_button.visible + inside = self._axes_bbox.contains(event.x, event.y) + if inside and (not logxy_visible): + self._logx_button.visible = True + self._logy_button.visible = True + need_redraw = True + elif (not inside) and logxy_visible: + self._logx_button.visible = False + self._logy_button.visible = False + need_redraw = True + + if self._logc_button is not None: + logc_visible = self._logc_button.visible + inside_cax = self._cbar_bbox.contains(event.x, event.y) + if inside_cax and (not logc_visible): + # We may need to update the button toggle state as the canvas is created + # before the colormapper, but the latter sets the cbar state. We sync + # the button state when we hover over the axes just before making them + # visible + log_state = self.cax.get_yscale() == "log" + if self._logc_button.value != log_state: + self._logc_button.value = log_state + + self._logc_button.visible = True + self._fitc_button.visible = True + need_redraw = True + elif (not inside_cax) and logc_visible: + self._logc_button.visible = False + self._fitc_button.visible = False + need_redraw = True + + if need_redraw: + self.draw() + def is_widget(self): return hasattr(self.fig.canvas, "on_widget_constructed") @@ -215,12 +354,71 @@ def to_widget(self): return VBox([self.fig.canvas]) return self.to_image() + def _on_log_button_click(self, event: MouseEvent): + """ + Handle clicks on the log buttons. + """ + if (self.cax is not None) and (event.inaxes is self.cax): + if self._logc_button.contains(event): + self._logc_button.toggle() + self._toggle_color_norm() + elif self._fitc_button.contains(event): + self._autoscale_colors() + self.draw() + return + + need_redraw = False + if self._logx_button.contains(event): + self.toggle_logx() + need_redraw = True + elif self._logy_button.contains(event): + self.toggle_logy() + need_redraw = True + if need_redraw: + self._autoscale_axes() + self.draw() + def draw(self): """ Make a draw call to the underlying figure. + + We also update the bounding box of the canvas, which is used to determine when + to show the log buttons. """ self.fig.canvas.draw_idle() + if not self.is_widget(): + return + + dpi = self.fig.dpi + axes_bbox = self.ax.get_position().transformed(self.fig.transFigure) + # The region where the log buttons are shown is slightly larger than the axes: + # the logx button is in the lower right corner just below the x axis, and the + # logy button is in the upper left corner just to the left of the y axis. + # We add corresponding padding to the bbox in pixels that depends on the dpi. + # We need to add at least the width of the logy button to the left of the y + # axis, and the height of the logx button below the x axis. Trial and error + # shows that 0.45 inch of padding is enough for the logy button, and 0.25 inch + # is enough for the logx button. + self._axes_bbox = Bbox( + [ + [axes_bbox.x0 - 0.45 * dpi, axes_bbox.y0 - 0.25 * dpi], + [axes_bbox.x1, axes_bbox.y1], + ] + ) + if self.cax is not None: + # For the colorbar, we want to show the log button above the colorbar when + # we hover over it. We add some padding around the colorbar to make it + # easier to trigger the button display, which is for example when hovering + # over the colorbar label and not just the coloured bar itself. + cbar_bbox = self.cax.get_position().transformed(self.fig.transFigure) + self._cbar_bbox = Bbox( + [ + [cbar_bbox.x0 - 0.2 * dpi, cbar_bbox.y0 - 0.1 * dpi], + [cbar_bbox.x1 + 0.2 * dpi, cbar_bbox.y1 + 0.1 * dpi], + ] + ) + def update_legend(self): """ Update the legend on the canvas. @@ -379,6 +577,8 @@ def xscale(self) -> Literal['linear', 'log']: @xscale.setter def xscale(self, scale: Literal['linear', 'log']): self.ax.set_xscale(scale) + if self._logx_button is not None: + self._logx_button.value = scale == 'log' @property def yscale(self) -> Literal['linear', 'log']: @@ -390,6 +590,8 @@ def yscale(self) -> Literal['linear', 'log']: @yscale.setter def yscale(self, scale: Literal['linear', 'log']): self.ax.set_yscale(scale) + if self._logy_button is not None: + self._logy_button.value = scale == 'log' @property def xmin(self) -> float: diff --git a/src/plopp/backends/matplotlib/figure.py b/src/plopp/backends/matplotlib/figure.py index 5ee084dee..50aa5e9d0 100644 --- a/src/plopp/backends/matplotlib/figure.py +++ b/src/plopp/backends/matplotlib/figure.py @@ -136,6 +136,9 @@ def __init__(self, View, *args, **kwargs): self.__init_figure__(View, *args, **kwargs) self.interactive = True self.toolbar = make_toolbar_canvas2d(view=self.view) + + self._setup_linked_home_buttons() + self.left_bar = VBar([self.toolbar]) self.right_bar = VBar() self.bottom_bar = HBar() @@ -155,6 +158,32 @@ def _make_children(self): self.bottom_bar, ] + def _setup_linked_home_buttons(self): + """ + Link home buttons across all Plopp figures sharing this matplotlib figure. + This is needed when multiple Plopp figures are created from matplotlib axes + that belong to the same figure, e.g. subplots. + """ + if not hasattr(self.fig, '_plopp_home_data'): + self.fig._plopp_home_data = {'callbacks': {}, 'toolbars': {}} + data = self.fig._plopp_home_data + axes_id = id(self.ax) + # Store the original callback. + # We use a dict to make sure that for repeated use of the same axes via + # successive figure creations, we do not grow the list of callbacks + # indefinitely. + data['callbacks'][axes_id] = self.toolbar['home'].callback + data['toolbars'][axes_id] = self.toolbar + + # Create a linked callback that calls ALL original callbacks + def linked_home(): + for callback in data['callbacks'].values(): + callback() + + # Update all toolbars (including this one) to use the linked callback + for toolbar in data['toolbars'].values(): + toolbar['home'].callback = linked_home + class StaticFigure(MplBaseFig): """ diff --git a/src/plopp/backends/matplotlib/tiled.py b/src/plopp/backends/matplotlib/tiled.py index f670f5d87..eb838f155 100644 --- a/src/plopp/backends/matplotlib/tiled.py +++ b/src/plopp/backends/matplotlib/tiled.py @@ -9,8 +9,7 @@ from matplotlib import gridspec from ...core.typing import FigureLike -from .figure import get_repr_maker -from .utils import is_interactive_backend, make_figure +from .utils import make_figure class Tiled: @@ -68,8 +67,8 @@ def __init__( nrows: int, ncols: int, figsize: tuple[float, float] | None = None, - hspace: float = 0.05, - wspace: float = 0.1, + hspace: float | None = None, + wspace: float | None = None, **kwargs: Any, ) -> None: self.nrows = nrows @@ -83,6 +82,12 @@ def __init__( layout='constrained', ) + is_widget_backend = hasattr(self.fig.canvas, "on_widget_constructed") + if hspace is None: + hspace = 0.2 if is_widget_backend else 0.02 + if wspace is None: + wspace = 0.2 if is_widget_backend else 0.05 + self.gs = gridspec.GridSpec( nrows, ncols, figure=self.fig, wspace=wspace, hspace=hspace, **kwargs ) @@ -103,19 +108,11 @@ def __getitem__( ) -> FigureLike: return self.figures[inds] - def _repr_mimebundle_(self, include=None, exclude=None) -> dict: + def _repr_mimebundle_(self, *args, **kwargs) -> dict: """ Mimebundle display representation for jupyter notebooks. """ - if is_interactive_backend(): - return self.fig.canvas._repr_mimebundle_(include=include, exclude=exclude) - else: - out = {'text/plain': f'TiledFigure(nrows={self.nrows}, ncols={self.ncols})'} - npoints = sum( - len(line.get_xdata()) for ax in self.fig.get_axes() for line in ax.lines - ) - out.update(get_repr_maker(npoints=npoints)(self.fig)) - return out + return self.figures.ravel()[0]._repr_mimebundle_(*args, **kwargs) def save(self, filename: str, **kwargs: Any) -> None: """ diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index 967e41ee7..c7e295c15 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -205,6 +205,14 @@ def __init__( if self._clabel is not None: self.cax.set_ylabel(self._clabel) + # Disable zoom and pan on colorbar as this feature is not handled in the + # current implementation. + self.cax.set_navigate(False) + + if self._canvas is not None: + self._canvas._toggle_color_norm = self.toggle_norm + self._canvas._autoscale_colors = self.autoscale + def add_artist(self, key: str, artist: Any): self.artists[key] = artist @@ -215,10 +223,17 @@ def to_widget(self): """ Convert the colorbar into a widget for use with other ``ipywidgets``. """ - import ipywidgets as ipw + from ..widgets.hoverbutton import HoverButtonWidget - self.widget = ipw.HTML() + self.widget = HoverButtonWidget(log_value=self._logc) self._update_colorbar_widget() + self.widget.on_log_button_click(self.toggle_norm) + + def fit(): + self.autoscale() + self._canvas.draw() + + self.widget.on_fit_button_click(fit) return self.widget def _update_colorbar_widget(self): @@ -226,7 +241,7 @@ def _update_colorbar_widget(self): Upon an updated colorscale range, we need to update the image inside the widget. """ if self.widget is not None: - self.widget.value = fig_to_bytes(self.cax.get_figure(), form='svg').decode() + self.widget.set_svg(fig_to_bytes(self.cax.get_figure(), form='svg')) def rgba(self, data: sc.DataArray) -> np.ndarray: """ @@ -403,6 +418,8 @@ def toggle_norm(self): self._cmax["data"] = -np.inf if self.colorbar is not None: self.colorbar.mappable.norm = self.normalizer + if self.widget is not None: + self.widget.log_toggle_value = self._logc self.autoscale() if self._canvas is not None: self._canvas.draw() diff --git a/src/plopp/graphics/graphicalview.py b/src/plopp/graphics/graphicalview.py index 1fa280b6c..3dbf6d18f 100644 --- a/src/plopp/graphics/graphicalview.py +++ b/src/plopp/graphics/graphicalview.py @@ -122,6 +122,7 @@ def __init__( ylabel=ylabel, zlabel=zlabel, norm=norm if len(dims) == 1 else None, + autoscale_axes=self.autoscale, ) if colormapper: diff --git a/src/plopp/widgets/__init__.pyi b/src/plopp/widgets/__init__.pyi index 244d007ae..6f0a066f7 100644 --- a/src/plopp/widgets/__init__.pyi +++ b/src/plopp/widgets/__init__.pyi @@ -5,6 +5,7 @@ from .box import Box, HBar, VBar from .checkboxes import Checkboxes from .clip3d import Clip3dTool, ClippingManager from .drawing import DrawingTool, PointsTool, PolygonTool, RectangleTool +from .hoverbutton import HoverButtonWidget from .linesave import LineSaveTool from .slicing import CombinedSliceWidget, RangeSliceWidget, SliceWidget, slice_dims from .toolbar import Toolbar, make_toolbar_canvas2d, make_toolbar_canvas3d @@ -20,6 +21,7 @@ __all__ = [ "CombinedSliceWidget", "DrawingTool", "HBar", + "HoverButtonWidget", "LineSaveTool", "PointsTool", "PolygonTool", diff --git a/src/plopp/widgets/hoverbutton.py b/src/plopp/widgets/hoverbutton.py new file mode 100644 index 000000000..995d82f42 --- /dev/null +++ b/src/plopp/widgets/hoverbutton.py @@ -0,0 +1,150 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +import anywidget +import traitlets + + +class HoverButtonWidget(anywidget.AnyWidget): + """ + A custom widget that displays an SVG and shows buttons on hover. + - The "log" button sends a message to the backend when clicked. + - The "fit" button sends a message to the backend when clicked. + - The widget listens for changes to the SVG data and updates the display + accordingly. + - The "log" button's background color changes based on a toggle value from the + backend. + """ + + _esm = """ + function render({ model, el }) { + // Create container + const container = document.createElement('div'); + container.style.position = 'relative'; + container.style.display = 'inline-block'; + container.style.height = '98%'; + + // Create SVG container + const svgContainer = document.createElement('div'); + svgContainer.style.width = '100%'; + svgContainer.style.lineHeight = '0'; + + // Create log button + const log_button = document.createElement('button'); + log_button.textContent = 'log'; + log_button.style.position = 'absolute'; + log_button.style.top = '5%'; + log_button.style.left = '30px'; + log_button.style.transform = 'translate(-50%, -50%)'; + log_button.style.padding = '4px 4px'; + log_button.style.fontSize = '1em'; + log_button.style.cursor = 'pointer'; + log_button.style.opacity = '0'; + log_button.style.backgroundColor = 'lightgray'; + log_button.style.color = 'black'; + log_button.style.border = '1px solid gray'; + log_button.style.borderRadius = '5px'; + + // Create fit button + const fit_button = document.createElement('button'); + fit_button.textContent = 'fit'; + fit_button.style.position = 'absolute'; + fit_button.style.bottom = '5%'; + fit_button.style.left = '30px'; + fit_button.style.transform = 'translate(-50%, 50%)'; + fit_button.style.padding = '4px 4px'; + fit_button.style.fontSize = '1em'; + fit_button.style.cursor = 'pointer'; + fit_button.style.opacity = '0'; + fit_button.style.backgroundColor = 'lightgray'; + fit_button.style.color = 'black'; + fit_button.style.border = '1px solid gray'; + fit_button.style.borderRadius = '5px'; + + // Function to update SVG + function updateSVG() { + const svgData = new TextDecoder().decode(model.get('svg_data')); + svgContainer.innerHTML = svgData; + const svg = svgContainer.querySelector('svg'); + if (svg) { + svg.style.width = '100%'; + svg.style.height = 'auto'; + svg.style.display = 'block'; + } + } + + function updateLogButton() { + const toggleValue = model.get('log_toggle_value'); + log_button.style.backgroundColor = toggleValue ? 'gray' : 'lightgray'; + } + + // Initial SVG + updateSVG(); + + // Initial state + updateLogButton(); + + // Show button on hover + container.addEventListener('mouseenter', () => { + log_button.style.opacity = '1'; + fit_button.style.opacity = '1'; + }); + + container.addEventListener('mouseleave', () => { + log_button.style.opacity = '0'; + fit_button.style.opacity = '0'; + }); + + // Handle button click + log_button.addEventListener('click', () => { + model.send({ type: 'log_button_clicked' }); + }); + + // Handle fit button click + fit_button.addEventListener('click', () => { + model.send({ type: 'fit_button_clicked' }); + }); + + // Listen for SVG changes + model.on('change:svg_data', updateSVG); + + // Future changes + model.on('change:log_toggle_value', updateLogButton); + + // Assemble widget + container.appendChild(svgContainer); + container.appendChild(log_button); + container.appendChild(fit_button); + el.appendChild(container); + } + export default { render }; + """ + + # Traitlets + svg_data = traitlets.Bytes(b'').tag(sync=True) + log_toggle_value = traitlets.Bool(False).tag(sync=True) + + def __init__(self, log_value: bool = False, **kwargs): + super().__init__(**kwargs) + self.log_toggle_value = log_value + self.on_msg(self._handle_custom_msg) + + def _handle_custom_msg(self, content, buffers): + """Handle messages from JavaScript""" + if content.get('type') == 'log_button_clicked': + if hasattr(self, '_log_button_click_handler'): + self._log_button_click_handler() + elif content.get('type') == 'fit_button_clicked': + if hasattr(self, '_fit_button_click_handler'): + self._fit_button_click_handler() + + def on_log_button_click(self, handler): + """Register a callback function to be called when button is clicked""" + self._log_button_click_handler = handler + + def on_fit_button_click(self, handler): + """Register a callback function to be called when button is clicked""" + self._fit_button_click_handler = handler + + def set_svg(self, svg_string): + """Set SVG from a string""" + self.svg_data = svg_string diff --git a/src/plopp/widgets/toolbar.py b/src/plopp/widgets/toolbar.py index eb15c7779..265552720 100644 --- a/src/plopp/widgets/toolbar.py +++ b/src/plopp/widgets/toolbar.py @@ -59,42 +59,17 @@ def make_toolbar_canvas2d(view: GraphicalView) -> Toolbar: The 2D view to operate on. """ - def logx() -> None: - view.canvas.toggle_logx() - view.autoscale() - view.canvas.draw() - - def logy() -> None: - view.canvas.toggle_logy() - view.autoscale() - view.canvas.draw() - def autoscale_axes() -> None: view.autoscale() view.canvas.draw() - def autoscale_colors() -> None: - view.colormapper.autoscale() - view.canvas.draw() - - tool_list = { - "home": tools.HomeTool(autoscale_axes, tooltip="Autoscale axes range"), - "panzoom": tools.PanZoomTool(view.canvas.panzoom), - "logx": tools.LogxTool(logx, value=view.canvas.xscale == "log"), - "logy": tools.LogyTool(logy, value=view.canvas.yscale == "log"), - "save": tools.SaveTool(view.canvas.download_figure), - } - if view.colormapper is not None: - tool_list.update( - { - "lognorm": tools.LogNormTool( - view.colormapper.toggle_norm, value=view.colormapper.norm == "log" - ), - "autoscale": tools.AutoscaleTool(autoscale_colors), - } - ) - order = ["home", "autoscale", "panzoom", "logx", "logy", "lognorm", "save"] - return Toolbar(tools={key: tool_list[key] for key in order if key in tool_list}) + return Toolbar( + tools={ + "home": tools.HomeTool(autoscale_axes, tooltip="Autoscale axes range"), + "panzoom": tools.PanZoomTool(view.canvas.panzoom), + "save": tools.SaveTool(view.canvas.download_figure), + } + ) def make_toolbar_canvas3d(view: GraphicalView) -> Toolbar: @@ -108,35 +83,24 @@ def make_toolbar_canvas3d(view: GraphicalView) -> Toolbar: The 3D view to operate on. """ - def autoscale_colors() -> None: - view.colormapper.autoscale() - view.canvas.draw() - - tool_list = { - "home": tools.HomeTool(view.canvas.home, tooltip="Reset camera"), - "autoscale": tools.AutoscaleTool(autoscale_colors), - "camerax": tools.CameraTool( - view.canvas.camera_x_normal, - description="X", - tooltip="Camera to X normal. Click twice to flip the view direction.", - ), - "cameray": tools.CameraTool( - view.canvas.camera_y_normal, - description="Y", - tooltip="Camera to Y normal. Click twice to flip the view direction.", - ), - "cameraz": tools.CameraTool( - view.canvas.camera_z_normal, - description="Z", - tooltip="Camera to Z normal. Click twice to flip the view direction.", - ), - } - if view.colormapper is not None: - tool_list["lognorm"] = tools.LogNormTool( - view.colormapper.toggle_norm, value=view.colormapper.norm == "log" - ) - tool_list.update( - { + return Toolbar( + tools={ + "home": tools.HomeTool(view.canvas.home, tooltip="Reset camera"), + "camerax": tools.CameraTool( + view.canvas.camera_x_normal, + description="X", + tooltip="Camera to X normal. Click twice to flip the view direction.", + ), + "cameray": tools.CameraTool( + view.canvas.camera_y_normal, + description="Y", + tooltip="Camera to Y normal. Click twice to flip the view direction.", + ), + "cameraz": tools.CameraTool( + view.canvas.camera_z_normal, + description="Z", + tooltip="Camera to Z normal. Click twice to flip the view direction.", + ), "box": tools.OutlineTool(view.canvas.toggle_outline), "axes": tools.AxesTool(view.canvas.toggle_axes3d), "size": tools.PlusMinusTool( @@ -151,4 +115,3 @@ def autoscale_colors() -> None: ), } ) - return Toolbar(tools=tool_list) diff --git a/src/plopp/widgets/tools.py b/src/plopp/widgets/tools.py index bc67d9aea..e5058c2e7 100644 --- a/src/plopp/widgets/tools.py +++ b/src/plopp/widgets/tools.py @@ -152,20 +152,6 @@ def value(self) -> str | None: HomeTool = partial(ButtonTool, icon='home') """Return home tool.""" -AutoscaleTool = partial(ButtonTool, icon='arrows-v', tooltip='Autoscale colorbar range') -"""Autoscale the colorbar to fit the data.""" - -LogxTool = partial(ToggleTool, description='logx', tooltip='Toggle X axis scale') -"""Toggle horizontal axis scale tool.""" - -LogyTool = partial(ToggleTool, description='logy', tooltip='Toggle Y axis scale') -"""Toggle vertical axis scale tool.""" - -LogNormTool = partial( - ToggleTool, description='log', tooltip='Toggle colorscale normalization' -) -"""Toggle normalization scale tool.""" - SaveTool = partial(ButtonTool, icon='save', tooltip='Save figure') """Save figure to png tool.""" diff --git a/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py new file mode 100644 index 000000000..dc9783d30 --- /dev/null +++ b/tests/backends/matplotlib/mpl_canvas_test.py @@ -0,0 +1,171 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) + +import matplotlib.pyplot as plt +import pytest + +from plopp.data.testing import data_array + +pytestmark = pytest.mark.usefixtures("_parametrize_interactive_1d_backends") + + +class MouseEvent: + def __init__(self, x, y, inaxes): + self.x = x + self.y = y + self.inaxes = inaxes + + +@pytest.mark.parametrize("ndim", [1, 2]) +def test_logx_button_state_agrees_with_kwarg(ndim): + da = data_array(ndim=ndim) + + canvas = da.plot(logx=False).canvas + assert not canvas._logx_button.value + assert not canvas._logy_button.value + + canvas = da.plot(logx=True).canvas + assert canvas._logx_button.value + assert not canvas._logy_button.value + + +@pytest.mark.parametrize("ndim", [1, 2]) +def test_logx_button_state_agrees_with_xscale(ndim): + da = data_array(ndim=ndim) + canvas = da.plot().canvas + but = canvas._logx_button + assert not but.value + assert canvas.xscale == 'linear' + canvas.xscale = 'log' + assert but.value + + +@pytest.mark.parametrize("ndim", [1, 2]) +def test_clicking_logx_button_toggles_xscale(ndim): + da = data_array(ndim=ndim) + canvas = da.plot().canvas + assert canvas.xscale == 'linear' + + but = canvas._logx_button + # Need to make buttons visible before we click them, + # otherwise the contains() method returns False + but.visible = True + # Need to draw so that the contains() on the Text works + canvas.fig.canvas.draw() + + # Convert axes coordinates to display coordinates + x, y = canvas.ax.transAxes.transform(but.position) + canvas._on_log_button_click(MouseEvent(x - 5, y - 5, None)) + assert canvas.xscale == 'log' + assert but.value + + canvas._on_log_button_click(MouseEvent(x - 5, y - 5, None)) + assert canvas.xscale == 'linear' + assert not but.value + + +@pytest.mark.parametrize("ndim", [1, 2]) +def test_logy_button_state_agrees_with_kwarg(ndim): + da = data_array(ndim=ndim) + + canvas = da.plot(logy=False).canvas + assert not canvas._logy_button.value + assert not canvas._logx_button.value + + canvas = da.plot(logy=True).canvas + assert canvas._logy_button.value + assert not canvas._logx_button.value + + +@pytest.mark.parametrize("ndim", [1, 2]) +def test_logy_button_state_agrees_with_yscale(ndim): + da = data_array(ndim=ndim) + canvas = da.plot().canvas + but = canvas._logy_button + assert not but.value + assert canvas.yscale == 'linear' + canvas.yscale = 'log' + assert but.value + + +@pytest.mark.parametrize("ndim", [1, 2]) +def test_clicking_logy_button_toggles_yscale(ndim): + da = data_array(ndim=ndim) + canvas = da.plot().canvas + assert canvas.yscale == 'linear' + + but = canvas._logy_button + # Need to make buttons visible before we click them, + # otherwise the contains() method returns False + but.visible = True + # Need to draw so that the contains() on the Text works + canvas.fig.canvas.draw() + + # Convert axes coordinates to display coordinates + x, y = canvas.ax.transAxes.transform(but.position) + canvas._on_log_button_click(MouseEvent(x - 5, y - 5, None)) + assert canvas.yscale == 'log' + assert but.value + + canvas._on_log_button_click(MouseEvent(x - 5, y - 5, None)) + assert canvas.yscale == 'linear' + assert not but.value + + +def test_logc_button_state_agrees_with_kwarg(): + da = data_array(ndim=2) + canvas = da.plot(logc=True).canvas + # Need to make a mouse move event to trigger the button update state, as it is + # update only when mouse hovers over the cbar + canvas.fig.canvas.draw() + x, y = canvas.cax.transAxes.transform((0.5, 0.5)) + canvas._on_mouse_move(MouseEvent(x, y - 5, None)) + assert canvas._logc_button.value + + +def test_logc_button_state_agrees_with_colormapper_norm(): + da = data_array(ndim=2) + fig = da.plot() + but = fig.canvas._logc_button + assert not but.value + assert fig.view.colormapper.norm == 'linear' + fig.view.colormapper.norm = 'log' + fig.canvas.fig.canvas.draw() + x, y = fig.canvas.cax.transAxes.transform((0.5, 0.5)) + fig.canvas._on_mouse_move(MouseEvent(x, y - 5, None)) + assert but.value + + +def test_clicking_logc_button_toggles_colormapper_norm(): + da = data_array(ndim=2) + fig = da.plot() + assert fig.view.colormapper.norm == 'linear' + + but = fig.canvas._logc_button + but.visible = True + fig.canvas.fig.canvas.draw() + x, y = fig.canvas.cax.transAxes.transform(but.position) + fig.canvas._on_log_button_click(MouseEvent(x, y - 5, fig.canvas.cax)) + assert fig.view.colormapper.norm == 'log' + assert but.value + + fig.canvas._on_log_button_click(MouseEvent(x, y - 5, fig.canvas.cax)) + assert fig.view.colormapper.norm == 'linear' + assert not but.value + + +def test_home_button_rescales_all_axes_sharing_a_figure(): + da = data_array(ndim=1) + _, (ax0, ax1) = plt.subplots(2, 1) + p0 = da.plot(ax=ax0) + p1 = (da * 10.0).plot(ax=ax1) + + expected = p1.canvas.yrange + # Perturb only the second figure. + p1.canvas.yrange = (-100.0, 100.0) + + # Clicking Home on the first figure must also rescale the second one, since they + # share the same Matplotlib figure (e.g. subplots). + p0.toolbar['home'].callback() + + assert p1.canvas.yrange == pytest.approx(expected) diff --git a/tests/graphics/canvas_test.py b/tests/graphics/canvas_test.py index 17d7cd94f..d929c1c74 100644 --- a/tests/graphics/canvas_test.py +++ b/tests/graphics/canvas_test.py @@ -6,7 +6,7 @@ import pytest from plopp import Node -from plopp.data import data1d, data2d, data_array, scatter +from plopp.data import data1d, data2d, scatter from plopp.graphics import imagefigure, linefigure, scatter3dfigure, scatterfigure CASES = { @@ -164,72 +164,3 @@ def test_yrange_order_preserved(self, set_backend, backend, figure, data): fig.view.autoscale() new_range = fig.canvas.yrange assert new_range[0] > new_range[1] - - -CASES1DINTERACTIVE = { - k: c for k, c in CASES.items() if (c[1] is linefigure and c[0][1] != 'mpl-static') -} - - -@pytest.mark.parametrize( - ("backend", "figure", "data"), - CASES1DINTERACTIVE.values(), - ids=CASES1DINTERACTIVE.keys(), -) -class TestCanvasInteractive1d: - def test_logx_1d_toolbar_button_state_agrees_with_kwarg( - self, set_backend, backend, figure, data - ): - da = data() - fig = figure(Node(da), scale={'x': 'log'}) - assert fig.toolbar['logx'].value - - def test_logx_1d_toolbar_button_toggles_xscale( - self, set_backend, backend, figure, data - ): - da = data() - fig = figure(Node(da)) - assert fig.canvas.xscale == 'linear' - fig.toolbar['logx'].value = True - assert fig.canvas.xscale == 'log' - - def test_logy_1d_toolbar_button_state_agrees_with_kwarg( - self, set_backend, backend, figure, data - ): - da = data() - fig = figure(Node(da), norm='log') - assert fig.toolbar['logy'].value - - def test_logy_1d_toolbar_button_toggles_yscale( - self, set_backend, backend, figure, data - ): - da = data() - fig = figure(Node(da)) - assert fig.canvas.yscale == 'linear' - fig.toolbar['logy'].value = True - assert fig.canvas.yscale == 'log' - - -@pytest.mark.parametrize( - "backend", [('2d', 'mpl-interactive')], ids=['mpl-interactive'] -) -class TestCanvasInteractive2d: - def test_logxy_2d_toolbar_buttons_state_agrees_with_kwarg( - self, set_backend, backend - ): - da = data_array(ndim=2) - fig = da.plot(scale={'x': 'log', 'y': 'log'}) - assert fig.toolbar['logx'].value - assert fig.toolbar['logy'].value - - def test_logxy_2d_toolbar_buttons_toggles_xyscale(self, set_backend, backend): - da = data_array(ndim=2) - fig = da.plot() - assert fig.canvas.xscale == 'linear' - assert fig.canvas.yscale == 'linear' - fig.toolbar['logx'].value = True - assert fig.canvas.xscale == 'log' - assert fig.canvas.yscale == 'linear' - fig.toolbar['logy'].value = True - assert fig.canvas.xscale == 'log' - assert fig.canvas.yscale == 'log' diff --git a/tests/graphics/colormapper_test.py b/tests/graphics/colormapper_test.py index 88ce2331f..34180ce70 100644 --- a/tests/graphics/colormapper_test.py +++ b/tests/graphics/colormapper_test.py @@ -239,23 +239,22 @@ def test_colorbar_updated_on_rescale(): mapper.autoscale() _ = mapper.to_widget() - old_image = mapper.widget.value - old_image_array = old_image + old_image = mapper.widget.svg_data # Update with the same values should not make a new colorbar image artist.update(da) mapper.autoscale() - assert string_similarity(old_image_array, mapper.widget.value) > 0.9 + assert string_similarity(old_image, mapper.widget.svg_data) > 0.9 # Update with a smaller range should make a new colorbar image artist.update(da * 0.6) mapper.autoscale() - assert string_similarity(old_image_array, mapper.widget.value) < 0.9 + assert string_similarity(old_image, mapper.widget.svg_data) < 0.9 # Update with larger range should make a new colorbar image artist.update(da * 3.1) mapper.autoscale() - assert string_similarity(old_image_array, mapper.widget.value) < 0.9 + assert string_similarity(old_image, mapper.widget.svg_data) < 0.9 def test_colorbar_does_not_update_if_no_autoscale(): @@ -266,20 +265,19 @@ def test_colorbar_does_not_update_if_no_autoscale(): mapper.autoscale() _ = mapper.to_widget() - old_image = mapper.widget.value - old_image_array = old_image + old_image = mapper.widget.svg_data # Update with the same values artist.update(da) - assert old_image is mapper.widget.value + assert old_image is mapper.widget.svg_data # Update with a smaller range artist.update(da * 0.8) - assert old_image is mapper.widget.value + assert old_image is mapper.widget.svg_data # Update with larger range artist.update(da * 2.3) - assert old_image_array is mapper.widget.value + assert old_image is mapper.widget.svg_data def test_colorbar_is_not_created_if_cbar_false(): @@ -374,31 +372,48 @@ def test_colorbar_label_has_no_name_with_multiple_artists( assert fig.view.colormapper.ylabel == '[K]' -CASESINTERACTIVE = {k: c for k, c in CASES.items() if c[0][1] != 'mpl-static'} +def test_toolbar_3d_log_norm_button_state_agrees_with_kwarg(): + da = scatter() + fig = scatter3dfigure(Node(da), cbar=True, logc=False) + cmapper = fig.view.colormapper + cbar = cmapper.to_widget() + assert not cbar.log_toggle_value + assert cmapper.norm == 'linear' + fig = scatter3dfigure(Node(da), cbar=True, logc=True) + cmapper = fig.view.colormapper + cbar = cmapper.to_widget() + assert cbar.log_toggle_value + assert cmapper.norm == 'log' -@pytest.mark.parametrize( - ("backend", "figure", "data"), - CASESINTERACTIVE.values(), - ids=CASESINTERACTIVE.keys(), -) -class TestColormapperInteractiveCases: - def test_toolbar_log_norm_button_state_agrees_with_kwarg( - self, set_backend, backend, figure, data - ): - da = data() - fig = figure(Node(da)) - assert not fig.toolbar['lognorm'].value - assert fig.view.colormapper.norm == 'linear' - fig = figure(Node(da), norm='log') - assert fig.toolbar['lognorm'].value - assert fig.view.colormapper.norm == 'log' - - def test_toolbar_log_norm_button_toggles_colormapper_norm( - self, set_backend, backend, figure, data - ): - da = data() - fig = figure(Node(da)) - assert fig.view.colormapper.norm == 'linear' - fig.toolbar['lognorm'].value = True - assert fig.view.colormapper.norm == 'log' + +def test_toolbar_3d_log_norm_button_state_agrees_with_colormapper_norm(): + da = scatter() + fig = scatter3dfigure(Node(da), cbar=True, logc=False) + cmapper = fig.view.colormapper + cbar = cmapper.to_widget() + assert not cbar.log_toggle_value + assert cmapper.norm == 'linear' + + cmapper.norm = 'log' + assert cbar.log_toggle_value + + +def test_toolbar_3d_log_norm_button_toggles_colormapper_norm(): + da = scatter() + fig = scatter3dfigure(Node(da), cbar=True, logc=False) + cmapper = fig.view.colormapper + cbar = cmapper.to_widget() + assert cmapper.norm == 'linear' + + # 'click' the log norm button + cbar._log_button_click_handler() + + assert cbar.log_toggle_value + assert cmapper.norm == 'log' + + # 'click' the log norm button again to go back to linear + cbar._log_button_click_handler() + + assert not cbar.log_toggle_value + assert cmapper.norm == 'linear'