From 6f00e259ec3f27cf751ec91ebe0cc688f153f536 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 10 Jun 2026 16:37:23 +0200 Subject: [PATCH 01/37] logXY buttons in canvas instead of toolbar --- src/plopp/backends/matplotlib/canvas.py | 70 +++++++++++++++++++++++++ src/plopp/graphics/graphicalview.py | 1 + src/plopp/widgets/toolbar.py | 23 ++++---- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index d97b66132..2452aa9c1 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -2,6 +2,7 @@ # 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 @@ -124,6 +125,7 @@ def __init__( xlabel: str | None = None, ylabel: str | None = None, norm: Literal['linear', 'log'] | None = None, + autoscale_callback: Callable | None = None, **ignored, ): # Note on the `**ignored`` keyword arguments: the figure which owns the canvas @@ -152,6 +154,7 @@ def __init__( self.units = {} self.dims = {} self._legend = legend + self._call_autoscale = autoscale_callback if self.ax is None: self.fig = make_figure(figsize=(6.0, 4.0) if figsize is None else figsize) @@ -179,6 +182,28 @@ def __init__( self.ax.set_title(title) self._coord_formatters = [] + self._logx_button = self.ax.text( + 0.98, + 0.02, + "LogX", + transform=self.ax.transAxes, + ha="right", + va="bottom", + visible=False, + fontsize=8, + ) + + self._logy_button = self.ax.text( + 0.02, + 0.98, + "LogY", + transform=self.ax.transAxes, + ha="left", + va="top", + visible=False, + fontsize=8, + ) + if logx: self.xscale = 'log' if logy: @@ -188,6 +213,21 @@ def __init__( if ylabel is not None: self.ylabel = ylabel + self._update_log_buttons() + self.fig.canvas.mpl_connect("axes_enter_event", self.on_axes_enter) + self.fig.canvas.mpl_connect("axes_leave_event", self.on_axes_leave) + self.fig.canvas.mpl_connect("button_press_event", self._on_log_button_click) + + def on_axes_enter(self, _): + self._logx_button.set_visible(True) + self._logy_button.set_visible(True) + self.draw() + + def on_axes_leave(self, _): + self._logx_button.set_visible(False) + self._logy_button.set_visible(False) + self.draw() + def is_widget(self): return hasattr(self.fig.canvas, "on_widget_constructed") @@ -215,6 +255,36 @@ def to_widget(self): return VBox([self.fig.canvas]) return self.to_image() + def _update_log_buttons(self): + self._logx_button.set_bbox( + { + "boxstyle": "round,pad=0.3", + "facecolor": "0.65" if self.xscale == "log" else "0.9", + "edgecolor": "0.5", + } + ) + + self._logy_button.set_bbox( + { + "boxstyle": "round,pad=0.3", + "facecolor": "0.65" if self.yscale == "log" else "0.9", + "edgecolor": "0.5", + } + ) + + def _on_log_button_click(self, event): + if event.inaxes is not self.ax: + return + + if self._logx_button.contains(event)[0]: + self.toggle_logx() + elif self._logy_button.contains(event)[0]: + self.toggle_logy() + + self._call_autoscale() + self._update_log_buttons() + self.draw() + def draw(self): """ Make a draw call to the underlying figure. diff --git a/src/plopp/graphics/graphicalview.py b/src/plopp/graphics/graphicalview.py index 1fa280b6c..3d615c0ef 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_callback=self.autoscale, ) if colormapper: diff --git a/src/plopp/widgets/toolbar.py b/src/plopp/widgets/toolbar.py index eb15c7779..e026501db 100644 --- a/src/plopp/widgets/toolbar.py +++ b/src/plopp/widgets/toolbar.py @@ -59,15 +59,15 @@ 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 logx() -> None: + # view.canvas.toggle_logx() + # view.autoscale() + # view.canvas.draw() - def logy() -> None: - view.canvas.toggle_logy() - view.autoscale() - view.canvas.draw() + # def logy() -> None: + # view.canvas.toggle_logy() + # view.autoscale() + # view.canvas.draw() def autoscale_axes() -> None: view.autoscale() @@ -80,8 +80,8 @@ def autoscale_colors() -> None: 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"), + # "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: @@ -93,7 +93,8 @@ def autoscale_colors() -> None: "autoscale": tools.AutoscaleTool(autoscale_colors), } ) - order = ["home", "autoscale", "panzoom", "logx", "logy", "lognorm", "save"] + # order = ["home", "autoscale", "panzoom", "logx", "logy", "lognorm", "save"] + order = ["home", "autoscale", "panzoom", "lognorm", "save"] return Toolbar(tools={key: tool_list[key] for key in order if key in tool_list}) From 68cd95bbbfd4a67e043c950c8d97944793f67a8a Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 10 Jun 2026 16:47:04 +0200 Subject: [PATCH 02/37] update buttons when scale property is set instead --- src/plopp/backends/matplotlib/canvas.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 2452aa9c1..075bbb7e1 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -9,6 +9,7 @@ import numpy as np import scipp as sc from matplotlib import dates as mdates +from matplotlib.backend_bases import MouseEvent from mpl_toolkits.axes_grid1 import make_axes_locatable from ...core.utils import maybe_variable_to_number, scalar_to_string @@ -272,7 +273,7 @@ def _update_log_buttons(self): } ) - def _on_log_button_click(self, event): + def _on_log_button_click(self, event: MouseEvent): if event.inaxes is not self.ax: return @@ -282,7 +283,6 @@ def _on_log_button_click(self, event): self.toggle_logy() self._call_autoscale() - self._update_log_buttons() self.draw() def draw(self): @@ -449,6 +449,7 @@ def xscale(self) -> Literal['linear', 'log']: @xscale.setter def xscale(self, scale: Literal['linear', 'log']): self.ax.set_xscale(scale) + self._update_log_buttons() @property def yscale(self) -> Literal['linear', 'log']: @@ -460,6 +461,7 @@ def yscale(self) -> Literal['linear', 'log']: @yscale.setter def yscale(self, scale: Literal['linear', 'log']): self.ax.set_yscale(scale) + self._update_log_buttons() @property def xmin(self) -> float: From c5a91781fdb863d39714e90e1811df4466d57644 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 10 Jun 2026 23:17:22 +0200 Subject: [PATCH 03/37] add buttons in colorbar --- src/plopp/backends/matplotlib/canvas.py | 100 +++++++++++++++++++----- src/plopp/graphics/colormapper.py | 15 ++++ src/plopp/graphics/graphicalview.py | 2 +- 3 files changed, 95 insertions(+), 22 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 075bbb7e1..ff20c9d40 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -126,7 +126,7 @@ def __init__( xlabel: str | None = None, ylabel: str | None = None, norm: Literal['linear', 'log'] | None = None, - autoscale_callback: Callable | None = None, + autoscale_axes: Callable | None = None, **ignored, ): # Note on the `**ignored`` keyword arguments: the figure which owns the canvas @@ -155,7 +155,7 @@ def __init__( self.units = {} self.dims = {} self._legend = legend - self._call_autoscale = autoscale_callback + self._autoscale_axes = autoscale_axes if self.ax is None: self.fig = make_figure(figsize=(6.0, 4.0) if figsize is None else figsize) @@ -183,28 +183,55 @@ def __init__( self.ax.set_title(title) self._coord_formatters = [] + fontsize = 9 + self._logx_button = self.ax.text( - 0.98, + 0.985, 0.02, - "LogX", + "logX", transform=self.ax.transAxes, ha="right", va="bottom", visible=False, - fontsize=8, + fontsize=fontsize, ) self._logy_button = self.ax.text( - 0.02, + 0.015, 0.98, - "LogY", + "logY", transform=self.ax.transAxes, ha="left", va="top", visible=False, - fontsize=8, + fontsize=fontsize, ) + if self.cax is not None: + self._logc_button = self.cax.text( + 0.5, + 0.98, + "log", + transform=self.cax.transAxes, + ha="center", + va="top", + visible=False, + fontsize=fontsize, + ) + self._fitc_button = self.cax.text( + 0.5, + 0.02, + "fit", + transform=self.cax.transAxes, + ha="center", + va="bottom", + visible=False, + fontsize=fontsize, + ) + else: + self._logc_button = None + self._fitc_button = None + if logx: self.xscale = 'log' if logy: @@ -219,14 +246,20 @@ def __init__( self.fig.canvas.mpl_connect("axes_leave_event", self.on_axes_leave) self.fig.canvas.mpl_connect("button_press_event", self._on_log_button_click) - def on_axes_enter(self, _): - self._logx_button.set_visible(True) - self._logy_button.set_visible(True) + def on_axes_enter(self, event): + if event.inaxes is self.ax: + self._logx_button.set_visible(True) + self._logy_button.set_visible(True) + elif event.inaxes is self.cax: + self._logc_button.set_visible(True) + self._fitc_button.set_visible(True) self.draw() def on_axes_leave(self, _): self._logx_button.set_visible(False) self._logy_button.set_visible(False) + self._logc_button.set_visible(False) + self._fitc_button.set_visible(False) self.draw() def is_widget(self): @@ -273,17 +306,42 @@ def _update_log_buttons(self): } ) - def _on_log_button_click(self, event: MouseEvent): - if event.inaxes is not self.ax: - return - - if self._logx_button.contains(event)[0]: - self.toggle_logx() - elif self._logy_button.contains(event)[0]: - self.toggle_logy() + if self._logc_button is not None: + self._logc_button.set_bbox( + { + "boxstyle": "round,pad=0.3", + "facecolor": "0.65", # if self.xscale == "log" else "0.9", + "edgecolor": "0.5", + } + ) + if self._fitc_button is not None: + self._fitc_button.set_bbox( + { + "boxstyle": "round,pad=0.3", + "facecolor": "0.65", # if self.xscale == "log" else "0.9", + "edgecolor": "0.5", + } + ) - self._call_autoscale() - self.draw() + def _on_log_button_click(self, event: MouseEvent): + if event.inaxes is self.ax: + need_redraw = False + if self._logx_button.contains(event)[0]: + self.toggle_logx() + need_redraw = True + elif self._logy_button.contains(event)[0]: + self.toggle_logy() + need_redraw = True + if need_redraw: + self._autoscale_axes() + self.draw() + + elif event.inaxes is self.cax: + if self._logc_button.contains(event)[0]: + self._toggle_color_norm() + elif self._fitc_button.contains(event)[0]: + self._autoscale_colors() + self.draw() def draw(self): """ diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index 967e41ee7..f5de31f28 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -205,6 +205,21 @@ def __init__( if self._clabel is not None: self.cax.set_ylabel(self._clabel) + self._canvas._toggle_color_norm = self.toggle_norm + self._canvas._autoscale_colors = self.autoscale + + # # For matplotlib backend + # self._logc_button = self.cax.text( + # 0.5, + # 1.02, + # "LogC", + # transform=self.cax.transAxes, + # ha="center", + # va="bottom", + # visible=False, + # fontsize=8, + # ) + def add_artist(self, key: str, artist: Any): self.artists[key] = artist diff --git a/src/plopp/graphics/graphicalview.py b/src/plopp/graphics/graphicalview.py index 3d615c0ef..3dbf6d18f 100644 --- a/src/plopp/graphics/graphicalview.py +++ b/src/plopp/graphics/graphicalview.py @@ -122,7 +122,7 @@ def __init__( ylabel=ylabel, zlabel=zlabel, norm=norm if len(dims) == 1 else None, - autoscale_callback=self.autoscale, + autoscale_axes=self.autoscale, ) if colormapper: From dc6a2d3fe0ebb1a7f70aecbef7bb16888310a243 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 10 Jun 2026 23:26:52 +0200 Subject: [PATCH 04/37] update cbar button colors on click --- src/plopp/backends/matplotlib/canvas.py | 18 ++++++++---------- src/plopp/graphics/colormapper.py | 12 ------------ src/plopp/graphics/graphicalview.py | 2 ++ 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index ff20c9d40..73c447bc2 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -227,6 +227,11 @@ def __init__( va="bottom", visible=False, fontsize=fontsize, + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": "0.9", + "edgecolor": "0.5", + }, ) else: self._logc_button = None @@ -241,7 +246,7 @@ def __init__( if ylabel is not None: self.ylabel = ylabel - self._update_log_buttons() + # self._update_log_buttons() self.fig.canvas.mpl_connect("axes_enter_event", self.on_axes_enter) self.fig.canvas.mpl_connect("axes_leave_event", self.on_axes_leave) self.fig.canvas.mpl_connect("button_press_event", self._on_log_button_click) @@ -310,15 +315,7 @@ def _update_log_buttons(self): self._logc_button.set_bbox( { "boxstyle": "round,pad=0.3", - "facecolor": "0.65", # if self.xscale == "log" else "0.9", - "edgecolor": "0.5", - } - ) - if self._fitc_button is not None: - self._fitc_button.set_bbox( - { - "boxstyle": "round,pad=0.3", - "facecolor": "0.65", # if self.xscale == "log" else "0.9", + "facecolor": "0.65" if self.cax.get_yscale() == "log" else "0.9", "edgecolor": "0.5", } ) @@ -339,6 +336,7 @@ def _on_log_button_click(self, event: MouseEvent): elif event.inaxes is self.cax: if self._logc_button.contains(event)[0]: self._toggle_color_norm() + self._update_log_buttons() elif self._fitc_button.contains(event)[0]: self._autoscale_colors() self.draw() diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index f5de31f28..0a12d2509 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -208,18 +208,6 @@ def __init__( self._canvas._toggle_color_norm = self.toggle_norm self._canvas._autoscale_colors = self.autoscale - # # For matplotlib backend - # self._logc_button = self.cax.text( - # 0.5, - # 1.02, - # "LogC", - # transform=self.cax.transAxes, - # ha="center", - # va="bottom", - # visible=False, - # fontsize=8, - # ) - def add_artist(self, key: str, artist: Any): self.artists[key] = artist diff --git a/src/plopp/graphics/graphicalview.py b/src/plopp/graphics/graphicalview.py index 3dbf6d18f..b642e19d2 100644 --- a/src/plopp/graphics/graphicalview.py +++ b/src/plopp/graphics/graphicalview.py @@ -153,6 +153,8 @@ def __init__( # artist maker. self._kwargs['mask_color'] = mask_color + self.canvas._update_log_buttons() + self.render() def autoscale(self): From be6f603da5941d281c2facab09e2f3a096993ea9 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 00:12:04 +0200 Subject: [PATCH 05/37] also make clickable buttons on 3d plots colorbar --- src/plopp/graphics/colormapper.py | 146 +++++++++++++++++++++++++++- src/plopp/graphics/graphicalview.py | 2 +- 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index 0a12d2509..8347a553c 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -1,14 +1,18 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) +import base64 +import io from copy import copy from functools import reduce from typing import Any, Literal +import anywidget import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np import scipp as sc +import traitlets from matplotlib.colorbar import ColorbarBase from matplotlib.colors import Colormap, LinearSegmentedColormap, LogNorm, Normalize @@ -218,10 +222,22 @@ def to_widget(self): """ Convert the colorbar into a widget for use with other ``ipywidgets``. """ - import ipywidgets as ipw + # import ipywidgets as ipw - self.widget = ipw.HTML() + # self.widget = ipw.HTML() + # self._update_colorbar_widget() + # return self.widget + + self.widget = HoverButtonWidget(button_text='log') self._update_colorbar_widget() + self.widget.on_log_button_click(self.toggle_norm) + + def fit(): + self.autoscale() + # if self._canvas is not None: + self._canvas.draw() + + self.widget.on_fit_button_click(fit) return self.widget def _update_colorbar_widget(self): @@ -229,7 +245,8 @@ 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.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: """ @@ -429,3 +446,126 @@ def has_user_clabel(self) -> bool: Return ``True`` if the user has set a colorbar label. """ return self._clabel is not None + + +class HoverButtonWidget(anywidget.AnyWidget): + _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 = '20px'; + log_button.style.transform = 'translate(-50%, -50%)'; + log_button.style.padding = '4px 4px'; + log_button.style.fontSize = '0.8em'; + log_button.style.cursor = 'pointer'; + log_button.style.opacity = '0'; + log_button.style.backgroundColor = 'gray'; + log_button.style.color = 'black'; + log_button.style.border = 'none'; + 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 = '20px'; + fit_button.style.transform = 'translate(-50%, 50%)'; + fit_button.style.padding = '4px 4px'; + fit_button.style.fontSize = '0.8em'; + fit_button.style.cursor = 'pointer'; + fit_button.style.opacity = '0'; + fit_button.style.backgroundColor = 'gray'; + fit_button.style.color = 'black'; + fit_button.style.border = 'none'; + log_button.style.borderRadius = '5px'; + + // Function to update SVG + function updateSVG() { + const svgData = 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'; + } + } + + // Initial SVG + updateSVG(); + + // 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); + + // Assemble widget + container.appendChild(svgContainer); + container.appendChild(log_button); + container.appendChild(fit_button); + el.appendChild(container); + } + export default { render }; + """ + + # Traitlets + svg_data = traitlets.Unicode('').tag(sync=True) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + 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/graphics/graphicalview.py b/src/plopp/graphics/graphicalview.py index b642e19d2..223c89b18 100644 --- a/src/plopp/graphics/graphicalview.py +++ b/src/plopp/graphics/graphicalview.py @@ -153,7 +153,7 @@ def __init__( # artist maker. self._kwargs['mask_color'] = mask_color - self.canvas._update_log_buttons() + # self.canvas._update_log_buttons() self.render() From 3b027ce1aaf4083ef07f73c487b65ff0ab11613c Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 00:27:43 +0200 Subject: [PATCH 06/37] toggle log button color in anywidget --- src/plopp/backends/matplotlib/canvas.py | 13 +++++++---- src/plopp/backends/pythreejs/canvas.py | 3 +++ src/plopp/graphics/colormapper.py | 31 +++++++++++++++---------- src/plopp/graphics/graphicalview.py | 3 +-- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 73c447bc2..82566f696 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -246,11 +246,14 @@ def __init__( if ylabel is not None: self.ylabel = ylabel - # self._update_log_buttons() + # self.update_log_buttons() self.fig.canvas.mpl_connect("axes_enter_event", self.on_axes_enter) self.fig.canvas.mpl_connect("axes_leave_event", self.on_axes_leave) self.fig.canvas.mpl_connect("button_press_event", self._on_log_button_click) + def before_render(self): + self.update_log_buttons() + def on_axes_enter(self, event): if event.inaxes is self.ax: self._logx_button.set_visible(True) @@ -294,7 +297,7 @@ def to_widget(self): return VBox([self.fig.canvas]) return self.to_image() - def _update_log_buttons(self): + def update_log_buttons(self): self._logx_button.set_bbox( { "boxstyle": "round,pad=0.3", @@ -336,7 +339,7 @@ def _on_log_button_click(self, event: MouseEvent): elif event.inaxes is self.cax: if self._logc_button.contains(event)[0]: self._toggle_color_norm() - self._update_log_buttons() + self.update_log_buttons() elif self._fitc_button.contains(event)[0]: self._autoscale_colors() self.draw() @@ -505,7 +508,7 @@ def xscale(self) -> Literal['linear', 'log']: @xscale.setter def xscale(self, scale: Literal['linear', 'log']): self.ax.set_xscale(scale) - self._update_log_buttons() + self.update_log_buttons() @property def yscale(self) -> Literal['linear', 'log']: @@ -517,7 +520,7 @@ def yscale(self) -> Literal['linear', 'log']: @yscale.setter def yscale(self, scale: Literal['linear', 'log']): self.ax.set_yscale(scale) - self._update_log_buttons() + self.update_log_buttons() @property def xmin(self) -> float: diff --git a/src/plopp/backends/pythreejs/canvas.py b/src/plopp/backends/pythreejs/canvas.py index f1887cf3e..a219ad703 100644 --- a/src/plopp/backends/pythreejs/canvas.py +++ b/src/plopp/backends/pythreejs/canvas.py @@ -80,6 +80,9 @@ def __init__( height=height, ) + def before_render(self): + return + def to_widget(self): # The max_width is set to prevent overflow, see # https://github.com/scipp/plopp/issues/169. diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index 8347a553c..fce66be98 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -222,19 +222,20 @@ def to_widget(self): """ Convert the colorbar into a widget for use with other ``ipywidgets``. """ - # import ipywidgets as ipw - # self.widget = ipw.HTML() - # self._update_colorbar_widget() - # return self.widget - - self.widget = HoverButtonWidget(button_text='log') + self.widget = HoverButtonWidget() self._update_colorbar_widget() - self.widget.on_log_button_click(self.toggle_norm) + # TODO: this doesn't seem to work on widget creation + self.widget.log_toggle_value = self._logc + + def toggle_log_button(): + self.toggle_norm() + self.widget.log_toggle_value = self._logc + + self.widget.on_log_button_click(toggle_log_button) def fit(): self.autoscale() - # if self._canvas is not None: self._canvas.draw() self.widget.on_fit_button_click(fit) @@ -245,7 +246,6 @@ 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: @@ -473,7 +473,7 @@ class HoverButtonWidget(anywidget.AnyWidget): log_button.style.fontSize = '0.8em'; log_button.style.cursor = 'pointer'; log_button.style.opacity = '0'; - log_button.style.backgroundColor = 'gray'; + log_button.style.backgroundColor = 'lightgray'; log_button.style.color = 'black'; log_button.style.border = 'none'; log_button.style.borderRadius = '5px'; @@ -489,10 +489,10 @@ class HoverButtonWidget(anywidget.AnyWidget): fit_button.style.fontSize = '0.8em'; fit_button.style.cursor = 'pointer'; fit_button.style.opacity = '0'; - fit_button.style.backgroundColor = 'gray'; + fit_button.style.backgroundColor = 'lightgray'; fit_button.style.color = 'black'; fit_button.style.border = 'none'; - log_button.style.borderRadius = '5px'; + fit_button.style.borderRadius = '5px'; // Function to update SVG function updateSVG() { @@ -533,6 +533,12 @@ class HoverButtonWidget(anywidget.AnyWidget): // Listen for SVG changes model.on('change:svg_data', updateSVG); + // Listen for toggle state changes + model.on('change:log_toggle_value', () => { + const toggleValue = model.get('log_toggle_value'); + log_button.style.backgroundColor = toggleValue ? 'gray' : 'lightgray'; + }); + // Assemble widget container.appendChild(svgContainer); container.appendChild(log_button); @@ -544,6 +550,7 @@ class HoverButtonWidget(anywidget.AnyWidget): # Traitlets svg_data = traitlets.Unicode('').tag(sync=True) + log_toggle_value = traitlets.Bool(False).tag(sync=True) def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/src/plopp/graphics/graphicalview.py b/src/plopp/graphics/graphicalview.py index 223c89b18..55776a4c1 100644 --- a/src/plopp/graphics/graphicalview.py +++ b/src/plopp/graphics/graphicalview.py @@ -153,8 +153,7 @@ def __init__( # artist maker. self._kwargs['mask_color'] = mask_color - # self.canvas._update_log_buttons() - + self.canvas.before_render() self.render() def autoscale(self): From 337cc39525b45f4d4f8fa64ea94960d10c3884e5 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 08:49:14 +0200 Subject: [PATCH 07/37] move hover button widget into its own file --- src/plopp/backends/matplotlib/canvas.py | 5 +- src/plopp/graphics/colormapper.py | 135 +----------------------- src/plopp/widgets/__init__.pyi | 2 + src/plopp/widgets/hoverbutton.py | 134 +++++++++++++++++++++++ src/plopp/widgets/toolbar.py | 40 +++---- 5 files changed, 160 insertions(+), 156 deletions(-) create mode 100644 src/plopp/widgets/hoverbutton.py diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 82566f696..99fa6c9ee 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -266,8 +266,9 @@ def on_axes_enter(self, event): def on_axes_leave(self, _): self._logx_button.set_visible(False) self._logy_button.set_visible(False) - self._logc_button.set_visible(False) - self._fitc_button.set_visible(False) + if self._logc_button is not None: + self._logc_button.set_visible(False) + self._fitc_button.set_visible(False) self.draw() def is_widget(self): diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index fce66be98..3325642df 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -1,18 +1,14 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) -import base64 -import io from copy import copy from functools import reduce from typing import Any, Literal -import anywidget import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np import scipp as sc -import traitlets from matplotlib.colorbar import ColorbarBase from matplotlib.colors import Colormap, LinearSegmentedColormap, LogNorm, Normalize @@ -222,6 +218,7 @@ def to_widget(self): """ Convert the colorbar into a widget for use with other ``ipywidgets``. """ + from ..widgets.hoverbutton import HoverButtonWidget self.widget = HoverButtonWidget() self._update_colorbar_widget() @@ -446,133 +443,3 @@ def has_user_clabel(self) -> bool: Return ``True`` if the user has set a colorbar label. """ return self._clabel is not None - - -class HoverButtonWidget(anywidget.AnyWidget): - _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 = '20px'; - log_button.style.transform = 'translate(-50%, -50%)'; - log_button.style.padding = '4px 4px'; - log_button.style.fontSize = '0.8em'; - log_button.style.cursor = 'pointer'; - log_button.style.opacity = '0'; - log_button.style.backgroundColor = 'lightgray'; - log_button.style.color = 'black'; - log_button.style.border = 'none'; - 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 = '20px'; - fit_button.style.transform = 'translate(-50%, 50%)'; - fit_button.style.padding = '4px 4px'; - fit_button.style.fontSize = '0.8em'; - fit_button.style.cursor = 'pointer'; - fit_button.style.opacity = '0'; - fit_button.style.backgroundColor = 'lightgray'; - fit_button.style.color = 'black'; - fit_button.style.border = 'none'; - fit_button.style.borderRadius = '5px'; - - // Function to update SVG - function updateSVG() { - const svgData = 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'; - } - } - - // Initial SVG - updateSVG(); - - // 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); - - // Listen for toggle state changes - model.on('change:log_toggle_value', () => { - const toggleValue = model.get('log_toggle_value'); - log_button.style.backgroundColor = toggleValue ? 'gray' : 'lightgray'; - }); - - // Assemble widget - container.appendChild(svgContainer); - container.appendChild(log_button); - container.appendChild(fit_button); - el.appendChild(container); - } - export default { render }; - """ - - # Traitlets - svg_data = traitlets.Unicode('').tag(sync=True) - log_toggle_value = traitlets.Bool(False).tag(sync=True) - - def __init__(self, **kwargs): - super().__init__(**kwargs) - 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/__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..76028924f --- /dev/null +++ b/src/plopp/widgets/hoverbutton.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) +import anywidget +import traitlets + + +class HoverButtonWidget(anywidget.AnyWidget): + _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 = '20px'; + 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 = '20px'; + 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 = 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'; + } + } + + // Initial SVG + updateSVG(); + + // 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); + + // Listen for toggle state changes + model.on('change:log_toggle_value', () => { + const toggleValue = model.get('log_toggle_value'); + log_button.style.backgroundColor = toggleValue ? 'gray' : 'lightgray'; + }); + + // Assemble widget + container.appendChild(svgContainer); + container.appendChild(log_button); + container.appendChild(fit_button); + el.appendChild(container); + } + export default { render }; + """ + + # Traitlets + svg_data = traitlets.Unicode('').tag(sync=True) + log_toggle_value = traitlets.Bool(False).tag(sync=True) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + 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 e026501db..e2dedeb7d 100644 --- a/src/plopp/widgets/toolbar.py +++ b/src/plopp/widgets/toolbar.py @@ -73,9 +73,9 @@ def autoscale_axes() -> None: view.autoscale() view.canvas.draw() - def autoscale_colors() -> None: - view.colormapper.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"), @@ -84,15 +84,15 @@ def autoscale_colors() -> None: # "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), - } - ) + # 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"] order = ["home", "autoscale", "panzoom", "lognorm", "save"] return Toolbar(tools={key: tool_list[key] for key in order if key in tool_list}) @@ -109,13 +109,13 @@ def make_toolbar_canvas3d(view: GraphicalView) -> Toolbar: The 3D view to operate on. """ - def autoscale_colors() -> None: - view.colormapper.autoscale() - view.canvas.draw() + # 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), + # "autoscale": tools.AutoscaleTool(autoscale_colors), "camerax": tools.CameraTool( view.canvas.camera_x_normal, description="X", @@ -132,10 +132,10 @@ def autoscale_colors() -> None: 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" - ) + # if view.colormapper is not None: + # tool_list["lognorm"] = tools.LogNormTool( + # view.colormapper.toggle_norm, value=view.colormapper.norm == "log" + # ) tool_list.update( { "box": tools.OutlineTool(view.canvas.toggle_outline), From b4e2eb0df2e00069df0f582d3c40e022419809ea Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 08:57:46 +0200 Subject: [PATCH 08/37] disable zoom and pan on colorbar axes --- src/plopp/graphics/colormapper.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index 3325642df..428bddc60 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -205,6 +205,10 @@ 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) + self._canvas._toggle_color_norm = self.toggle_norm self._canvas._autoscale_colors = self.autoscale From d862a064875793e696eca6e84ab5a6866643adad Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 12:48:20 +0200 Subject: [PATCH 09/37] place buttons outside of plotting area --- src/plopp/backends/matplotlib/canvas.py | 246 +++++++++++++++++------- 1 file changed, 174 insertions(+), 72 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 99fa6c9ee..a67b587d8 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -10,6 +10,7 @@ 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 @@ -183,59 +184,75 @@ def __init__( self.ax.set_title(title) self._coord_formatters = [] - fontsize = 9 - - self._logx_button = self.ax.text( - 0.985, - 0.02, - "logX", - transform=self.ax.transAxes, - ha="right", - va="bottom", - visible=False, - fontsize=fontsize, - ) + if self.is_widget(): + fontsize = 9 - self._logy_button = self.ax.text( - 0.015, - 0.98, - "logY", - transform=self.ax.transAxes, - ha="left", - va="top", - visible=False, - fontsize=fontsize, - ) + self._logx_button = self.ax.text( + 0.985, + -0.02, + "logX", + transform=self.ax.transAxes, + ha="right", + va="top", + # visible=False, + fontsize=fontsize, + ) - if self.cax is not None: - self._logc_button = self.cax.text( - 0.5, + self._logy_button = self.ax.text( + -0.015, 0.98, - "log", - transform=self.cax.transAxes, - ha="center", + "logY", + transform=self.ax.transAxes, + ha="right", va="top", - visible=False, + # visible=False, fontsize=fontsize, ) - self._fitc_button = self.cax.text( - 0.5, - 0.02, - "fit", - transform=self.cax.transAxes, - ha="center", - va="bottom", - visible=False, - fontsize=fontsize, - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": "0.9", - "edgecolor": "0.5", - }, + + if self.cax is not None: + self._logc_button = self.cax.text( + 0.5, + 0.98, + "log", + transform=self.cax.transAxes, + ha="center", + va="top", + visible=False, + fontsize=fontsize, + ) + self._fitc_button = self.cax.text( + 0.5, + 0.02, + "fit", + transform=self.cax.transAxes, + ha="center", + va="bottom", + visible=False, + fontsize=fontsize, + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": "0.9", + "edgecolor": "0.5", + }, + ) + else: + self._logc_button = None + self._fitc_button = None + + lx_bbox = self._logx_button.get_tightbbox() + ly_bbox = self._logy_button.get_tightbbox() + self._canvas_bbox = Bbox( + [[ly_bbox.x0, lx_bbox.y0], [lx_bbox.x1, ly_bbox.y1]] ) - else: - self._logc_button = None - self._fitc_button = None + # Need to set to visible after getting the bbox, otherwise the bbox is + # empty and the buttons never show up. + self._logx_button.set_visible(False) + self._logy_button.set_visible(False) + + self.fig.canvas.mpl_connect("axes_enter_event", self._on_cax_enter) + self.fig.canvas.mpl_connect("axes_leave_event", self._on_cax_leave) + 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' @@ -246,29 +263,106 @@ def __init__( if ylabel is not None: self.ylabel = ylabel - # self.update_log_buttons() - self.fig.canvas.mpl_connect("axes_enter_event", self.on_axes_enter) - self.fig.canvas.mpl_connect("axes_leave_event", self.on_axes_leave) - self.fig.canvas.mpl_connect("button_press_event", self._on_log_button_click) + # # self.update_log_buttons() + # if self.is_widget(): + # # lx_bbox = self._logx_button.get_tightbbox() + # # ly_bbox = self._logy_button.get_tightbbox() + # # print("Log button bboxes:", lx_bbox, ly_bbox) + # # # inv = self.ax.transAxes.inverted() + # # # lx_disp = self.ax.transAxes.transform(lx_bbox) + # # # ly_disp = self.ax.transAxes.transform(ly_bbox) + # # x0, y0 = self.ax.transAxes.transform((ly_bbox.x0, lx_bbox.y0)) + # # x1, y1 = self.ax.transAxes.transform((lx_bbox.x1, ly_bbox.y1)) + # # dpi = self.fig.dpi + # # axes_bbox = self.ax.get_position().transformed(self.fig.transFigure) + # # self._canvas_bbox = Bbox( + # # [ + # # [axes_bbox.x0 - 0.4 * dpi, axes_bbox.y0 - 0.2 * dpi], + # # [axes_bbox.x1, axes_bbox.y1], + # # ] + # # ) + # # self. + # # print("Canvas bbox:", self._canvas_bbox) + + # self.fig.canvas.mpl_connect("axes_enter_event", self.on_cax_enter) + # self.fig.canvas.mpl_connect("axes_leave_event", self.on_cax_leave) + # 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) def before_render(self): self.update_log_buttons() - def on_axes_enter(self, event): - if event.inaxes is self.ax: + def _on_mouse_move(self, event): + need_redraw = False + + # if self._logc_button is not None: + # logc_visible = self._logc_button.get_visible() + # else: + # logc_visible = False + # if event.inaxes in ({self.cax} - {None}): + # if not logc_visible: + # # need_redraw = True + # self._logc_button.set_visible(True) + # self._fitc_button.set_visible(True) + # self.draw() + # return + # elif logc_visible: + # need_redraw = True + # self._logc_button.set_visible(False) + # self._fitc_button.set_visible(False) + + logxy_visible = self._logx_button.get_visible() + + if ((event.x is None) or (event.y is None)) and logxy_visible: + need_redraw = True + self._logx_button.set_visible(False) + self._logy_button.set_visible(False) + + # # inv = self.ax.transAxes.inverted() + # ax_x, ax_y = self._ax_inv_transform.transform((event.x, event.y)) + # pad = 0.08 + dpi = self.fig.dpi + axes_bbox = self.ax.get_position().transformed(self.fig.transFigure) + canvas_bbox = Bbox( + [ + [axes_bbox.x0 - 0.45 * dpi, axes_bbox.y0 - 0.25 * dpi], + [axes_bbox.x1, axes_bbox.y1], + ] + ) + inside = canvas_bbox.contains(event.x, event.y) + # print(event.x, event.y, inside, self._canvas_bbox) + if inside and not logxy_visible: + need_redraw = True self._logx_button.set_visible(True) self._logy_button.set_visible(True) - elif event.inaxes is self.cax: + elif not inside and logxy_visible: + need_redraw = True + self._logx_button.set_visible(False) + self._logy_button.set_visible(False) + + if need_redraw: + self.draw() + + # return + # new_visible = self._canvas_bbox.contains(event.x, event.y) + + # if new_visible != self._logx_button.get_visible(): + # self._logx_button.set_visible(new_visible) + # self._logy_button.set_visible(new_visible) + # if self._logc_button is not None: + # self._logc_button.set_visible(new_visible) + # self._fitc_button.set_visible(new_visible) + # self.draw() + + def _on_cax_enter(self, event): + if event.inaxes is self.cax: self._logc_button.set_visible(True) self._fitc_button.set_visible(True) self.draw() - def on_axes_leave(self, _): - self._logx_button.set_visible(False) - self._logy_button.set_visible(False) - if self._logc_button is not None: - self._logc_button.set_visible(False) - self._fitc_button.set_visible(False) + def _on_cax_leave(self, _): + self._logc_button.set_visible(False) + self._fitc_button.set_visible(False) self.draw() def is_widget(self): @@ -325,25 +419,33 @@ def update_log_buttons(self): ) def _on_log_button_click(self, event: MouseEvent): - if event.inaxes is self.ax: - need_redraw = False - if self._logx_button.contains(event)[0]: - self.toggle_logx() - need_redraw = True - elif self._logy_button.contains(event)[0]: - self.toggle_logy() - need_redraw = True - if need_redraw: - self._autoscale_axes() - self.draw() - - elif event.inaxes is self.cax: + if event.inaxes in ({self.cax} - {None}): if self._logc_button.contains(event)[0]: self._toggle_color_norm() self.update_log_buttons() elif self._fitc_button.contains(event)[0]: self._autoscale_colors() self.draw() + return + + need_redraw = False + if self._logx_button.contains(event)[0]: + self.toggle_logx() + need_redraw = True + elif self._logy_button.contains(event)[0]: + self.toggle_logy() + need_redraw = True + if need_redraw: + self._autoscale_axes() + self.draw() + + # elif event.inaxes in ({self.cax} - {None}): + # if self._logc_button.contains(event)[0]: + # self._toggle_color_norm() + # self.update_log_buttons() + # elif self._fitc_button.contains(event)[0]: + # self._autoscale_colors() + # self.draw() def draw(self): """ From 723832a3c8a138686200d4d5ba6f2c4370a2f9cd Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 13:23:03 +0200 Subject: [PATCH 10/37] make hover area larger for colorbar --- src/plopp/backends/matplotlib/canvas.py | 146 ++++++------------------ src/plopp/widgets/hoverbutton.py | 10 ++ 2 files changed, 48 insertions(+), 108 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index a67b587d8..88b70063c 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -194,7 +194,7 @@ def __init__( transform=self.ax.transAxes, ha="right", va="top", - # visible=False, + visible=False, fontsize=fontsize, ) @@ -205,7 +205,7 @@ def __init__( transform=self.ax.transAxes, ha="right", va="top", - # visible=False, + visible=False, fontsize=fontsize, ) @@ -239,18 +239,6 @@ def __init__( self._logc_button = None self._fitc_button = None - lx_bbox = self._logx_button.get_tightbbox() - ly_bbox = self._logy_button.get_tightbbox() - self._canvas_bbox = Bbox( - [[ly_bbox.x0, lx_bbox.y0], [lx_bbox.x1, ly_bbox.y1]] - ) - # Need to set to visible after getting the bbox, otherwise the bbox is - # empty and the buttons never show up. - self._logx_button.set_visible(False) - self._logy_button.set_visible(False) - - self.fig.canvas.mpl_connect("axes_enter_event", self._on_cax_enter) - self.fig.canvas.mpl_connect("axes_leave_event", self._on_cax_leave) 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) @@ -263,108 +251,39 @@ def __init__( if ylabel is not None: self.ylabel = ylabel - # # self.update_log_buttons() - # if self.is_widget(): - # # lx_bbox = self._logx_button.get_tightbbox() - # # ly_bbox = self._logy_button.get_tightbbox() - # # print("Log button bboxes:", lx_bbox, ly_bbox) - # # # inv = self.ax.transAxes.inverted() - # # # lx_disp = self.ax.transAxes.transform(lx_bbox) - # # # ly_disp = self.ax.transAxes.transform(ly_bbox) - # # x0, y0 = self.ax.transAxes.transform((ly_bbox.x0, lx_bbox.y0)) - # # x1, y1 = self.ax.transAxes.transform((lx_bbox.x1, ly_bbox.y1)) - # # dpi = self.fig.dpi - # # axes_bbox = self.ax.get_position().transformed(self.fig.transFigure) - # # self._canvas_bbox = Bbox( - # # [ - # # [axes_bbox.x0 - 0.4 * dpi, axes_bbox.y0 - 0.2 * dpi], - # # [axes_bbox.x1, axes_bbox.y1], - # # ] - # # ) - # # self. - # # print("Canvas bbox:", self._canvas_bbox) - - # self.fig.canvas.mpl_connect("axes_enter_event", self.on_cax_enter) - # self.fig.canvas.mpl_connect("axes_leave_event", self.on_cax_leave) - # 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) - def before_render(self): self.update_log_buttons() def _on_mouse_move(self, event): - need_redraw = False - # if self._logc_button is not None: - # logc_visible = self._logc_button.get_visible() - # else: - # logc_visible = False - # if event.inaxes in ({self.cax} - {None}): - # if not logc_visible: - # # need_redraw = True - # self._logc_button.set_visible(True) - # self._fitc_button.set_visible(True) - # self.draw() - # return - # elif logc_visible: - # need_redraw = True - # self._logc_button.set_visible(False) - # self._fitc_button.set_visible(False) + need_redraw = False logxy_visible = self._logx_button.get_visible() - - if ((event.x is None) or (event.y is None)) and logxy_visible: - need_redraw = True - self._logx_button.set_visible(False) - self._logy_button.set_visible(False) - - # # inv = self.ax.transAxes.inverted() - # ax_x, ax_y = self._ax_inv_transform.transform((event.x, event.y)) - # pad = 0.08 - dpi = self.fig.dpi - axes_bbox = self.ax.get_position().transformed(self.fig.transFigure) - canvas_bbox = Bbox( - [ - [axes_bbox.x0 - 0.45 * dpi, axes_bbox.y0 - 0.25 * dpi], - [axes_bbox.x1, axes_bbox.y1], - ] - ) - inside = canvas_bbox.contains(event.x, event.y) - # print(event.x, event.y, inside, self._canvas_bbox) - if inside and not logxy_visible: - need_redraw = True + inside = self._axes_bbox.contains(event.x, event.y) + if inside and (not logxy_visible): self._logx_button.set_visible(True) self._logy_button.set_visible(True) - elif not inside and logxy_visible: need_redraw = True + elif (not inside) and logxy_visible: self._logx_button.set_visible(False) self._logy_button.set_visible(False) + need_redraw = True + + if self._logc_button is not None: + logc_visible = self._logc_button.get_visible() + inside_cax = self._cbar_bbox.contains(event.x, event.y) + if inside_cax and (not logc_visible): + self._logc_button.set_visible(True) + self._fitc_button.set_visible(True) + need_redraw = True + elif (not inside_cax) and logc_visible: + self._logc_button.set_visible(False) + self._fitc_button.set_visible(False) + need_redraw = True if need_redraw: self.draw() - # return - # new_visible = self._canvas_bbox.contains(event.x, event.y) - - # if new_visible != self._logx_button.get_visible(): - # self._logx_button.set_visible(new_visible) - # self._logy_button.set_visible(new_visible) - # if self._logc_button is not None: - # self._logc_button.set_visible(new_visible) - # self._fitc_button.set_visible(new_visible) - # self.draw() - - def _on_cax_enter(self, event): - if event.inaxes is self.cax: - self._logc_button.set_visible(True) - self._fitc_button.set_visible(True) - self.draw() - - def _on_cax_leave(self, _): - self._logc_button.set_visible(False) - self._fitc_button.set_visible(False) - self.draw() - def is_widget(self): return hasattr(self.fig.canvas, "on_widget_constructed") @@ -439,19 +358,30 @@ def _on_log_button_click(self, event: MouseEvent): self._autoscale_axes() self.draw() - # elif event.inaxes in ({self.cax} - {None}): - # if self._logc_button.contains(event)[0]: - # self._toggle_color_norm() - # self.update_log_buttons() - # elif self._fitc_button.contains(event)[0]: - # self._autoscale_colors() - # 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() + dpi = self.fig.dpi + axes_bbox = self.ax.get_position().transformed(self.fig.transFigure) + 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: + 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): """ diff --git a/src/plopp/widgets/hoverbutton.py b/src/plopp/widgets/hoverbutton.py index 76028924f..f312b0aa9 100644 --- a/src/plopp/widgets/hoverbutton.py +++ b/src/plopp/widgets/hoverbutton.py @@ -5,6 +5,16 @@ 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 From 96a0ef1b76055950195f86bb33249c382bf5fcd9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:30:11 +0000 Subject: [PATCH 11/37] Apply automatic formatting --- src/plopp/backends/matplotlib/canvas.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 88b70063c..4b7079d9b 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -255,7 +255,6 @@ def before_render(self): self.update_log_buttons() def _on_mouse_move(self, event): - need_redraw = False logxy_visible = self._logx_button.get_visible() From c2afe9745aa1aec47bff1373ea332cd3b854503e Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 18:05:22 +0200 Subject: [PATCH 12/37] make home button in toolbar rescale all axes --- src/plopp/backends/matplotlib/canvas.py | 12 +++- src/plopp/backends/matplotlib/figure.py | 25 +++++++ src/plopp/widgets/toolbar.py | 88 +++++++------------------ 3 files changed, 59 insertions(+), 66 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 88b70063c..189823f96 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -161,9 +161,6 @@ def __init__( 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: @@ -185,6 +182,11 @@ def __init__( self._coord_formatters = [] if self.is_widget(): + # Hide the native toolbar: it will be replaced by our own. Also hide the + # (ugly) header in ipympl figures which takes up a lot of vertical space. + self.fig.canvas.toolbar_visible = False + self.fig.canvas.header_visible = False + fontsize = 9 self._logx_button = self.ax.text( @@ -196,6 +198,7 @@ def __init__( va="top", visible=False, fontsize=fontsize, + zorder=np.inf, ) self._logy_button = self.ax.text( @@ -207,6 +210,7 @@ def __init__( va="top", visible=False, fontsize=fontsize, + zorder=np.inf, ) if self.cax is not None: @@ -219,6 +223,7 @@ def __init__( va="top", visible=False, fontsize=fontsize, + zorder=np.inf, ) self._fitc_button = self.cax.text( 0.5, @@ -234,6 +239,7 @@ def __init__( "facecolor": "0.9", "edgecolor": "0.5", }, + zorder=np.inf, ) else: self._logc_button = None diff --git a/src/plopp/backends/matplotlib/figure.py b/src/plopp/backends/matplotlib/figure.py index 5ee084dee..c96d492c5 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,28 @@ 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 + # Store the original callback + data['callbacks'].append(self.toolbar['home'].callback) + data['toolbars'].append(self.toolbar) + + # Create a linked callback that calls ALL original callbacks + def linked_home(): + for callback in data['callbacks']: + callback() + + # Update all toolbars (including this one) to use the linked callback + for toolbar in data['toolbars']: + toolbar['home'].callback = linked_home + class StaticFigure(MplBaseFig): """ diff --git a/src/plopp/widgets/toolbar.py b/src/plopp/widgets/toolbar.py index e2dedeb7d..265552720 100644 --- a/src/plopp/widgets/toolbar.py +++ b/src/plopp/widgets/toolbar.py @@ -59,43 +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"] - order = ["home", "autoscale", "panzoom", "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: @@ -109,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( @@ -152,4 +115,3 @@ def make_toolbar_canvas3d(view: GraphicalView) -> Toolbar: ), } ) - return Toolbar(tools=tool_list) From 67cf5cb73a3dea2cc92bbb2eec9a4708466a860d Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 18:27:05 +0200 Subject: [PATCH 13/37] improve tiled --- src/plopp/backends/matplotlib/canvas.py | 2 ++ src/plopp/backends/matplotlib/tiled.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index a4a345b75..ab0f5e80a 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -317,6 +317,8 @@ def to_widget(self): return self.to_image() def update_log_buttons(self): + if not self.is_widget(): + return self._logx_button.set_bbox( { "boxstyle": "round,pad=0.3", diff --git a/src/plopp/backends/matplotlib/tiled.py b/src/plopp/backends/matplotlib/tiled.py index f670f5d87..299543bb9 100644 --- a/src/plopp/backends/matplotlib/tiled.py +++ b/src/plopp/backends/matplotlib/tiled.py @@ -103,10 +103,13 @@ 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. """ + # self.fig.tight_layout() # Ensure tight layout for correct representation + return self.figures.ravel()[0]._repr_mimebundle_(*args, **kwargs) + if is_interactive_backend(): return self.fig.canvas._repr_mimebundle_(include=include, exclude=exclude) else: From e99e6f77146f695142d192fbd0b96b01169eecce Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 18:35:03 +0200 Subject: [PATCH 14/37] fix tiled spacing --- src/plopp/backends/matplotlib/tiled.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/plopp/backends/matplotlib/tiled.py b/src/plopp/backends/matplotlib/tiled.py index 299543bb9..3f49695d5 100644 --- a/src/plopp/backends/matplotlib/tiled.py +++ b/src/plopp/backends/matplotlib/tiled.py @@ -68,8 +68,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 +83,15 @@ def __init__( layout='constrained', ) + is_widget_backend = hasattr(self.fig.canvas, "on_widget_constructed") + print( + f"Using {'widget' if is_widget_backend else 'static'} backend for tiled figure." + ) + 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 ) @@ -107,19 +116,8 @@ def _repr_mimebundle_(self, *args, **kwargs) -> dict: """ Mimebundle display representation for jupyter notebooks. """ - # self.fig.tight_layout() # Ensure tight layout for correct representation return self.figures.ravel()[0]._repr_mimebundle_(*args, **kwargs) - 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 - def save(self, filename: str, **kwargs: Any) -> None: """ Save the figure to file. From 5e1578175a4afa8a953ef11c3fbea2b7ccb8179e Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 18:36:36 +0200 Subject: [PATCH 15/37] lint --- src/plopp/backends/matplotlib/tiled.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/plopp/backends/matplotlib/tiled.py b/src/plopp/backends/matplotlib/tiled.py index 3f49695d5..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: @@ -84,9 +83,6 @@ def __init__( ) is_widget_backend = hasattr(self.fig.canvas, "on_widget_constructed") - print( - f"Using {'widget' if is_widget_backend else 'static'} backend for tiled figure." - ) if hspace is None: hspace = 0.2 if is_widget_backend else 0.02 if wspace is None: From bae1d886d5ff018982c62d5d9e80c4ec2534dbb7 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 18:43:30 +0200 Subject: [PATCH 16/37] some type hints and docstrings --- src/plopp/backends/matplotlib/canvas.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index ab0f5e80a..856393747 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -258,9 +258,15 @@ def __init__( self.ylabel = ylabel def before_render(self): + """ + Function to be called before rendering the figure. + """ self.update_log_buttons() - def _on_mouse_move(self, event): + 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.get_visible() @@ -317,6 +323,10 @@ def to_widget(self): return self.to_image() def update_log_buttons(self): + """ + Update the background color of the log buttons to indicate whether the + corresponding axis is in log scale or not. + """ if not self.is_widget(): return self._logx_button.set_bbox( @@ -345,6 +355,9 @@ def update_log_buttons(self): ) def _on_log_button_click(self, event: MouseEvent): + """ + Handle clicks on the log buttons. + """ if event.inaxes in ({self.cax} - {None}): if self._logc_button.contains(event)[0]: self._toggle_color_norm() From 80efc715b28d08d719847ff40e8338d37f21e1fe Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 11 Jun 2026 18:48:18 +0200 Subject: [PATCH 17/37] refactor --- src/plopp/backends/matplotlib/canvas.py | 83 ++++++++----------------- 1 file changed, 26 insertions(+), 57 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 856393747..1ef05d34d 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -187,59 +187,40 @@ def __init__( self.fig.canvas.toolbar_visible = False self.fig.canvas.header_visible = False - fontsize = 9 - - self._logx_button = self.ax.text( - 0.985, - -0.02, - "logX", - transform=self.ax.transAxes, - ha="right", - va="top", - visible=False, - fontsize=fontsize, - zorder=np.inf, - ) + # fontsize = 9 + + args = { + "transform": self.ax.transAxes, + "ha": "right", + "va": "top", + "visible": False, + "fontsize": 9, + "zorder": np.inf, + } - self._logy_button = self.ax.text( - -0.015, - 0.98, - "logY", - transform=self.ax.transAxes, - ha="right", - va="top", - visible=False, - fontsize=fontsize, - zorder=np.inf, - ) + self._logx_button = self.ax.text(0.985, -0.02, "logX", **args) + self._logy_button = self.ax.text(-0.015, 0.98, "logY", **args) if self.cax is not None: - self._logc_button = self.cax.text( - 0.5, - 0.98, - "log", - transform=self.cax.transAxes, - ha="center", - va="top", - visible=False, - fontsize=fontsize, - zorder=np.inf, - ) + args = { + "transform": self.cax.transAxes, + "ha": "center", + "visible": False, + "fontsize": 9, + "zorder": np.inf, + } + self._logc_button = self.cax.text(0.5, 0.98, "log", va="top", **args) self._fitc_button = self.cax.text( 0.5, 0.02, "fit", - transform=self.cax.transAxes, - ha="center", va="bottom", - visible=False, - fontsize=fontsize, bbox={ "boxstyle": "round,pad=0.3", "facecolor": "0.9", "edgecolor": "0.5", }, - zorder=np.inf, + **args, ) else: self._logc_button = None @@ -329,27 +310,15 @@ def update_log_buttons(self): """ if not self.is_widget(): return - self._logx_button.set_bbox( - { - "boxstyle": "round,pad=0.3", - "facecolor": "0.65" if self.xscale == "log" else "0.9", - "edgecolor": "0.5", - } - ) - - self._logy_button.set_bbox( - { - "boxstyle": "round,pad=0.3", - "facecolor": "0.65" if self.yscale == "log" else "0.9", - "edgecolor": "0.5", - } - ) + buttons = [(self._logx_button, self.xscale), (self._logy_button, self.yscale)] if self._logc_button is not None: - self._logc_button.set_bbox( + buttons.append((self._logc_button, self.cax.get_yscale())) + for button, scale in buttons: + button.set_bbox( { "boxstyle": "round,pad=0.3", - "facecolor": "0.65" if self.cax.get_yscale() == "log" else "0.9", + "facecolor": "0.65" if scale == "log" else "0.9", "edgecolor": "0.5", } ) From 1173d4e0e1a5f20647b1fa295462303eb6b2863d Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Mon, 15 Jun 2026 10:27:47 +0200 Subject: [PATCH 18/37] lint --- src/plopp/backends/matplotlib/canvas.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 1ef05d34d..e25d0a08f 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -246,7 +246,8 @@ def before_render(self): def _on_mouse_move(self, event: MouseEvent): """ - Show log buttons when hovering over the axes or colorbar, and hide them otherwise. + Show log buttons when hovering over the axes or colorbar, and hide them + otherwise. """ need_redraw = False From da2d378b9f1888b00b1cb222cb15c0645156e896 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 17 Jun 2026 17:38:39 +0200 Subject: [PATCH 19/37] make canvastogglebutton class --- src/plopp/backends/matplotlib/canvas.py | 171 ++++++++++--------- src/plopp/backends/pythreejs/canvas.py | 3 - src/plopp/graphics/graphicalview.py | 1 - tests/backends/matplotlib/mpl_canvas_test.py | 103 +++++++++++ 4 files changed, 198 insertions(+), 80 deletions(-) create mode 100644 tests/backends/matplotlib/mpl_canvas_test.py diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index e25d0a08f..c6e037f16 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -55,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. @@ -183,44 +247,25 @@ def __init__( if self.is_widget(): # Hide the native toolbar: it will be replaced by our own. Also hide the - # (ugly) header in ipympl figures which takes up a lot of vertical space. + # header in ipympl figures which takes up a lot of vertical space. self.fig.canvas.toolbar_visible = False self.fig.canvas.header_visible = False - # fontsize = 9 - - args = { - "transform": self.ax.transAxes, - "ha": "right", - "va": "top", - "visible": False, - "fontsize": 9, - "zorder": np.inf, - } - - self._logx_button = self.ax.text(0.985, -0.02, "logX", **args) - self._logy_button = self.ax.text(-0.015, 0.98, "logY", **args) + 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", - "visible": False, - "fontsize": 9, - "zorder": np.inf, - } - self._logc_button = self.cax.text(0.5, 0.98, "log", va="top", **args) - self._fitc_button = self.cax.text( - 0.5, - 0.02, - "fit", - va="bottom", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": "0.9", - "edgecolor": "0.5", - }, - **args, + 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 ) else: self._logc_button = None @@ -238,12 +283,6 @@ def __init__( if ylabel is not None: self.ylabel = ylabel - def before_render(self): - """ - Function to be called before rendering the figure. - """ - self.update_log_buttons() - def _on_mouse_move(self, event: MouseEvent): """ Show log buttons when hovering over the axes or colorbar, and hide them @@ -251,27 +290,27 @@ def _on_mouse_move(self, event: MouseEvent): """ need_redraw = False - logxy_visible = self._logx_button.get_visible() + logxy_visible = self._logx_button.visible inside = self._axes_bbox.contains(event.x, event.y) if inside and (not logxy_visible): - self._logx_button.set_visible(True) - self._logy_button.set_visible(True) + self._logx_button.visible = True + self._logy_button.visible = True need_redraw = True elif (not inside) and logxy_visible: - self._logx_button.set_visible(False) - self._logy_button.set_visible(False) + 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.get_visible() + logc_visible = self._logc_button.visible inside_cax = self._cbar_bbox.contains(event.x, event.y) if inside_cax and (not logc_visible): - self._logc_button.set_visible(True) - self._fitc_button.set_visible(True) + self._logc_button.visible = True + self._fitc_button.visible = True need_redraw = True elif (not inside_cax) and logc_visible: - self._logc_button.set_visible(False) - self._fitc_button.set_visible(False) + self._logc_button.visible = False + self._fitc_button.visible = False need_redraw = True if need_redraw: @@ -304,44 +343,24 @@ def to_widget(self): return VBox([self.fig.canvas]) return self.to_image() - def update_log_buttons(self): - """ - Update the background color of the log buttons to indicate whether the - corresponding axis is in log scale or not. - """ - if not self.is_widget(): - return - - buttons = [(self._logx_button, self.xscale), (self._logy_button, self.yscale)] - if self._logc_button is not None: - buttons.append((self._logc_button, self.cax.get_yscale())) - for button, scale in buttons: - button.set_bbox( - { - "boxstyle": "round,pad=0.3", - "facecolor": "0.65" if scale == "log" else "0.9", - "edgecolor": "0.5", - } - ) - def _on_log_button_click(self, event: MouseEvent): """ Handle clicks on the log buttons. """ if event.inaxes in ({self.cax} - {None}): - if self._logc_button.contains(event)[0]: + if self._logc_button.contains(event): + self._logc_button.toggle() self._toggle_color_norm() - self.update_log_buttons() - elif self._fitc_button.contains(event)[0]: + elif self._fitc_button.contains(event): self._autoscale_colors() self.draw() return need_redraw = False - if self._logx_button.contains(event)[0]: + if self._logx_button.contains(event): self.toggle_logx() need_redraw = True - elif self._logy_button.contains(event)[0]: + elif self._logy_button.contains(event): self.toggle_logy() need_redraw = True if need_redraw: @@ -531,7 +550,7 @@ def xscale(self) -> Literal['linear', 'log']: @xscale.setter def xscale(self, scale: Literal['linear', 'log']): self.ax.set_xscale(scale) - self.update_log_buttons() + self._logx_button.value = scale == 'log' @property def yscale(self) -> Literal['linear', 'log']: @@ -543,7 +562,7 @@ def yscale(self) -> Literal['linear', 'log']: @yscale.setter def yscale(self, scale: Literal['linear', 'log']): self.ax.set_yscale(scale) - self.update_log_buttons() + self._logy_button.value = scale == 'log' @property def xmin(self) -> float: diff --git a/src/plopp/backends/pythreejs/canvas.py b/src/plopp/backends/pythreejs/canvas.py index a219ad703..f1887cf3e 100644 --- a/src/plopp/backends/pythreejs/canvas.py +++ b/src/plopp/backends/pythreejs/canvas.py @@ -80,9 +80,6 @@ def __init__( height=height, ) - def before_render(self): - return - def to_widget(self): # The max_width is set to prevent overflow, see # https://github.com/scipp/plopp/issues/169. diff --git a/src/plopp/graphics/graphicalview.py b/src/plopp/graphics/graphicalview.py index 55776a4c1..3dbf6d18f 100644 --- a/src/plopp/graphics/graphicalview.py +++ b/src/plopp/graphics/graphicalview.py @@ -153,7 +153,6 @@ def __init__( # artist maker. self._kwargs['mask_color'] = mask_color - self.canvas.before_render() self.render() def autoscale(self): diff --git a/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py new file mode 100644 index 000000000..cd59b6a61 --- /dev/null +++ b/tests/backends/matplotlib/mpl_canvas_test.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2023 Scipp contributors (https://github.com/scipp) + +import matplotlib.pyplot as plt +import numpy as np +import pytest +import scipp as sc + +import plopp as pp +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=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 + # Convert axes coordinates to display coordinates + x, y = canvas.ax.transAxes.transform(but.position) + # 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() + 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=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 + # Convert axes coordinates to display coordinates + x, y = canvas.ax.transAxes.transform(but.position) + # 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() + 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 From 1f3daa11ee2fa305c1b7c48b9b396bc2ac63c657 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 17 Jun 2026 18:05:50 +0200 Subject: [PATCH 20/37] draw before getting position --- tests/backends/matplotlib/mpl_canvas_test.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py index cd59b6a61..a67df7e04 100644 --- a/tests/backends/matplotlib/mpl_canvas_test.py +++ b/tests/backends/matplotlib/mpl_canvas_test.py @@ -1,12 +1,8 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) -import matplotlib.pyplot as plt -import numpy as np import pytest -import scipp as sc -import plopp as pp from plopp.data.testing import data_array pytestmark = pytest.mark.usefixtures("_parametrize_interactive_1d_backends") @@ -45,13 +41,14 @@ def test_clicking_logx_button_toggles_xscale(ndim): assert canvas.xscale == 'linear' but = canvas._logx_button - # Convert axes coordinates to display coordinates - x, y = canvas.ax.transAxes.transform(but.position) # 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 @@ -87,13 +84,14 @@ def test_clicking_logy_button_toggles_yscale(ndim): assert canvas.yscale == 'linear' but = canvas._logy_button - # Convert axes coordinates to display coordinates - x, y = canvas.ax.transAxes.transform(but.position) # 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 From 6bcc9ec0861d92b27b7f3deb086c2442adabb0c5 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 17 Jun 2026 19:17:48 +0200 Subject: [PATCH 21/37] fix some tests --- src/plopp/backends/matplotlib/canvas.py | 8 ++++ tests/backends/matplotlib/mpl_canvas_test.py | 42 ++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index c6e037f16..26db1c61b 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -305,6 +305,14 @@ def _on_mouse_move(self, event: MouseEvent): 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 diff --git a/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py index a67df7e04..80d07a1d3 100644 --- a/tests/backends/matplotlib/mpl_canvas_test.py +++ b/tests/backends/matplotlib/mpl_canvas_test.py @@ -99,3 +99,45 @@ def test_clicking_logy_button_toggles_yscale(ndim): 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, None)) + assert fig.view.colormapper.norm == 'log' + assert but.value + + fig.canvas._on_log_button_click(MouseEvent(x, y - 5, None)) + assert fig.view.colormapper.norm == 'linear' + assert not but.value From 2953cf7a6b8676764c99bf2dd913a8d2e5733f09 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 17 Jun 2026 19:33:59 +0200 Subject: [PATCH 22/37] fix last tests --- tests/backends/matplotlib/mpl_canvas_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py index 80d07a1d3..b9f3cab0f 100644 --- a/tests/backends/matplotlib/mpl_canvas_test.py +++ b/tests/backends/matplotlib/mpl_canvas_test.py @@ -134,10 +134,10 @@ def test_clicking_logc_button_toggles_colormapper_norm(): 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, None)) + 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, None)) + fig.canvas._on_log_button_click(MouseEvent(x, y - 5, fig.canvas.cax)) assert fig.view.colormapper.norm == 'linear' assert not but.value From d246196c4736d5792f66449f37633f4826e53d68 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Wed, 17 Jun 2026 19:44:11 +0200 Subject: [PATCH 23/37] remove old toolbar tests --- src/plopp/graphics/colormapper.py | 5 ++- tests/graphics/canvas_test.py | 69 ------------------------------- 2 files changed, 4 insertions(+), 70 deletions(-) diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index 428bddc60..34f15f2bc 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -226,7 +226,10 @@ def to_widget(self): self.widget = HoverButtonWidget() self._update_colorbar_widget() - # TODO: this doesn't seem to work on widget creation + + # TODO: When the widget is created, if logc=True as passed in the arguments, + # we need to set the button state according to the value of logc. + # For some reason, setting `log_toggle_value` here does not seem to work. self.widget.log_toggle_value = self._logc def toggle_log_button(): diff --git a/tests/graphics/canvas_test.py b/tests/graphics/canvas_test.py index 17d7cd94f..b563cb53e 100644 --- a/tests/graphics/canvas_test.py +++ b/tests/graphics/canvas_test.py @@ -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' From 5e0308847bb8c58ed6725415a80d1261fca1a89d Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 09:43:01 +0200 Subject: [PATCH 24/37] fix some more tests --- src/plopp/backends/matplotlib/canvas.py | 10 ++++++++-- src/plopp/graphics/colormapper.py | 5 +++-- src/plopp/widgets/hoverbutton.py | 4 ++-- tests/graphics/colormapper_test.py | 18 ++++++++---------- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 26db1c61b..4ab668f30 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -245,6 +245,10 @@ 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. @@ -558,7 +562,8 @@ def xscale(self) -> Literal['linear', 'log']: @xscale.setter def xscale(self, scale: Literal['linear', 'log']): self.ax.set_xscale(scale) - self._logx_button.value = scale == 'log' + if self._logx_button is not None: + self._logx_button.value = scale == 'log' @property def yscale(self) -> Literal['linear', 'log']: @@ -570,7 +575,8 @@ def yscale(self) -> Literal['linear', 'log']: @yscale.setter def yscale(self, scale: Literal['linear', 'log']): self.ax.set_yscale(scale) - self._logy_button.value = scale == 'log' + if self._logy_button is not None: + self._logy_button.value = scale == 'log' @property def xmin(self) -> float: diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index 34f15f2bc..384f823d4 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -209,8 +209,9 @@ def __init__( # current implementation. self.cax.set_navigate(False) - self._canvas._toggle_color_norm = self.toggle_norm - self._canvas._autoscale_colors = self.autoscale + 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 diff --git a/src/plopp/widgets/hoverbutton.py b/src/plopp/widgets/hoverbutton.py index f312b0aa9..765587605 100644 --- a/src/plopp/widgets/hoverbutton.py +++ b/src/plopp/widgets/hoverbutton.py @@ -62,7 +62,7 @@ class HoverButtonWidget(anywidget.AnyWidget): // Function to update SVG function updateSVG() { - const svgData = model.get('svg_data'); + const svgData = new TextDecoder().decode(model.get('svg_data')); svgContainer.innerHTML = svgData; const svg = svgContainer.querySelector('svg'); if (svg) { @@ -115,7 +115,7 @@ class HoverButtonWidget(anywidget.AnyWidget): """ # Traitlets - svg_data = traitlets.Unicode('').tag(sync=True) + svg_data = traitlets.Bytes(b'').tag(sync=True) log_toggle_value = traitlets.Bool(False).tag(sync=True) def __init__(self, **kwargs): diff --git a/tests/graphics/colormapper_test.py b/tests/graphics/colormapper_test.py index 88ce2331f..5041fa284 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(): From 6094bfd9794ca0806873a4278e8e6a52558e7190 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 11:28:33 +0200 Subject: [PATCH 25/37] fix log button state on initialization for 3d plots cbar --- src/plopp/graphics/colormapper.py | 7 +------ src/plopp/widgets/hoverbutton.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index 384f823d4..aef79f360 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -225,14 +225,9 @@ def to_widget(self): """ from ..widgets.hoverbutton import HoverButtonWidget - self.widget = HoverButtonWidget() + self.widget = HoverButtonWidget(log_value=self._logc) self._update_colorbar_widget() - # TODO: When the widget is created, if logc=True as passed in the arguments, - # we need to set the button state according to the value of logc. - # For some reason, setting `log_toggle_value` here does not seem to work. - self.widget.log_toggle_value = self._logc - def toggle_log_button(): self.toggle_norm() self.widget.log_toggle_value = self._logc diff --git a/src/plopp/widgets/hoverbutton.py b/src/plopp/widgets/hoverbutton.py index 765587605..85c8ab053 100644 --- a/src/plopp/widgets/hoverbutton.py +++ b/src/plopp/widgets/hoverbutton.py @@ -72,9 +72,17 @@ class HoverButtonWidget(anywidget.AnyWidget): } } + 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'; @@ -99,11 +107,8 @@ class HoverButtonWidget(anywidget.AnyWidget): // Listen for SVG changes model.on('change:svg_data', updateSVG); - // Listen for toggle state changes - model.on('change:log_toggle_value', () => { - const toggleValue = model.get('log_toggle_value'); - log_button.style.backgroundColor = toggleValue ? 'gray' : 'lightgray'; - }); + // Future changes + model.on('change:log_toggle_value', updateLogButton); // Assemble widget container.appendChild(svgContainer); @@ -118,8 +123,9 @@ class HoverButtonWidget(anywidget.AnyWidget): svg_data = traitlets.Bytes(b'').tag(sync=True) log_toggle_value = traitlets.Bool(False).tag(sync=True) - def __init__(self, **kwargs): + 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): From e81850e80d304ce045c79b5dedfa3d92de66f8d7 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 11:29:07 +0200 Subject: [PATCH 26/37] remove unused import --- tests/graphics/canvas_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/graphics/canvas_test.py b/tests/graphics/canvas_test.py index b563cb53e..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 = { From 3ec83c736f5ce680691085e511f8ebb50d4ffa88 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 11:48:25 +0200 Subject: [PATCH 27/37] fix more colormapper tests --- src/plopp/graphics/colormapper.py | 9 +-- tests/backends/matplotlib/mpl_canvas_test.py | 10 +++ tests/graphics/colormapper_test.py | 73 ++++++++++++-------- 3 files changed, 58 insertions(+), 34 deletions(-) diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py index aef79f360..c7e295c15 100644 --- a/src/plopp/graphics/colormapper.py +++ b/src/plopp/graphics/colormapper.py @@ -227,12 +227,7 @@ def to_widget(self): self.widget = HoverButtonWidget(log_value=self._logc) self._update_colorbar_widget() - - def toggle_log_button(): - self.toggle_norm() - self.widget.log_toggle_value = self._logc - - self.widget.on_log_button_click(toggle_log_button) + self.widget.on_log_button_click(self.toggle_norm) def fit(): self.autoscale() @@ -423,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/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py index b9f3cab0f..ad205b8d9 100644 --- a/tests/backends/matplotlib/mpl_canvas_test.py +++ b/tests/backends/matplotlib/mpl_canvas_test.py @@ -18,6 +18,11 @@ def __init__(self, x, y, 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 @@ -61,6 +66,11 @@ def test_clicking_logx_button_toggles_xscale(ndim): @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 diff --git a/tests/graphics/colormapper_test.py b/tests/graphics/colormapper_test.py index 5041fa284..34180ce70 100644 --- a/tests/graphics/colormapper_test.py +++ b/tests/graphics/colormapper_test.py @@ -372,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'} - - -@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_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' + + +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' From 29092320f0eca304fb1384674b787fe6df258657 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 12:07:29 +0200 Subject: [PATCH 28/37] copyright year --- tests/backends/matplotlib/mpl_canvas_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py index ad205b8d9..8204c1bc7 100644 --- a/tests/backends/matplotlib/mpl_canvas_test.py +++ b/tests/backends/matplotlib/mpl_canvas_test.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2023 Scipp contributors (https://github.com/scipp) +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) import pytest From 676a0ffcce652f223a227458701bf503a65e762a Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 14:04:00 +0200 Subject: [PATCH 29/37] remove unused toolbar tools from code and api reference --- docs/api-reference/index.md | 3 --- src/plopp/widgets/tools.py | 14 -------------- 2 files changed, 17 deletions(-) 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/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.""" From f68418478a6516c8e38a86a670005ad07dc38eb9 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 14:04:47 +0200 Subject: [PATCH 30/37] remove redundant setting buttons to none --- src/plopp/backends/matplotlib/canvas.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 4ab668f30..9fa59f575 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -271,9 +271,6 @@ def __init__( self._fitc_button = CanvasToggleButton( ax=self.cax, label="fit", position=(0.5, 0.02), va="bottom", **args ) - else: - self._logc_button = None - self._fitc_button = None 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) From 0b3366ca61d09fe75e0a2c590b4735fff44680e6 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 14:06:21 +0200 Subject: [PATCH 31/37] only compute bbox on draw if interactive --- src/plopp/backends/matplotlib/canvas.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 9fa59f575..183f2e292 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -384,6 +384,10 @@ def draw(self): 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) self._axes_bbox = Bbox( From 7da358cd30f36de6f70d8ca4145f1f4e63c4dede Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 14:28:38 +0200 Subject: [PATCH 32/37] declare private canvas members as None --- src/plopp/backends/matplotlib/canvas.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index 183f2e292..dc2855228 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -221,6 +221,8 @@ def __init__( 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) From 12f8e9b29335344b33e1e6427691b5adb99c4de6 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 14:29:13 +0200 Subject: [PATCH 33/37] use dicts to store home callbacks to avoid growing list indefinitely --- src/plopp/backends/matplotlib/figure.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/plopp/backends/matplotlib/figure.py b/src/plopp/backends/matplotlib/figure.py index c96d492c5..50aa5e9d0 100644 --- a/src/plopp/backends/matplotlib/figure.py +++ b/src/plopp/backends/matplotlib/figure.py @@ -165,19 +165,23 @@ def _setup_linked_home_buttons(self): that belong to the same figure, e.g. subplots. """ if not hasattr(self.fig, '_plopp_home_data'): - self.fig._plopp_home_data = {'callbacks': [], 'toolbars': []} + self.fig._plopp_home_data = {'callbacks': {}, 'toolbars': {}} data = self.fig._plopp_home_data - # Store the original callback - data['callbacks'].append(self.toolbar['home'].callback) - data['toolbars'].append(self.toolbar) + 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']: + for callback in data['callbacks'].values(): callback() # Update all toolbars (including this one) to use the linked callback - for toolbar in data['toolbars']: + for toolbar in data['toolbars'].values(): toolbar['home'].callback = linked_home From 4ad20b35c4d998c20db04d36aeb78738ecba7354 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 15:01:14 +0200 Subject: [PATCH 34/37] add explanations for magic numbers --- src/plopp/backends/matplotlib/canvas.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index dc2855228..cce881f08 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -392,6 +392,14 @@ def draw(self): 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], @@ -399,6 +407,10 @@ def draw(self): ] ) 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( [ From 57f8a56b4e210a25033fe625994071d5dd226792 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 15:05:02 +0200 Subject: [PATCH 35/37] more readable syntax --- src/plopp/backends/matplotlib/canvas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plopp/backends/matplotlib/canvas.py b/src/plopp/backends/matplotlib/canvas.py index cce881f08..a4a29e9c2 100644 --- a/src/plopp/backends/matplotlib/canvas.py +++ b/src/plopp/backends/matplotlib/canvas.py @@ -358,7 +358,7 @@ def _on_log_button_click(self, event: MouseEvent): """ Handle clicks on the log buttons. """ - if event.inaxes in ({self.cax} - {None}): + 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() From 12d0eaf2838ea6094896aafc347f21d0645542f0 Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Thu, 18 Jun 2026 15:08:46 +0200 Subject: [PATCH 36/37] move buttons closer to center on 3d plot cbar --- src/plopp/widgets/hoverbutton.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plopp/widgets/hoverbutton.py b/src/plopp/widgets/hoverbutton.py index 85c8ab053..995d82f42 100644 --- a/src/plopp/widgets/hoverbutton.py +++ b/src/plopp/widgets/hoverbutton.py @@ -33,7 +33,7 @@ class HoverButtonWidget(anywidget.AnyWidget): log_button.textContent = 'log'; log_button.style.position = 'absolute'; log_button.style.top = '5%'; - log_button.style.left = '20px'; + log_button.style.left = '30px'; log_button.style.transform = 'translate(-50%, -50%)'; log_button.style.padding = '4px 4px'; log_button.style.fontSize = '1em'; @@ -49,7 +49,7 @@ class HoverButtonWidget(anywidget.AnyWidget): fit_button.textContent = 'fit'; fit_button.style.position = 'absolute'; fit_button.style.bottom = '5%'; - fit_button.style.left = '20px'; + fit_button.style.left = '30px'; fit_button.style.transform = 'translate(-50%, 50%)'; fit_button.style.padding = '4px 4px'; fit_button.style.fontSize = '1em'; From a5a09acb4e419bd88028214e5bf292630f67e73f Mon Sep 17 00:00:00 2001 From: Neil Vaytet Date: Fri, 19 Jun 2026 09:43:07 +0200 Subject: [PATCH 37/37] add test rescale all axes --- tests/backends/matplotlib/mpl_canvas_test.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/backends/matplotlib/mpl_canvas_test.py b/tests/backends/matplotlib/mpl_canvas_test.py index 8204c1bc7..dc9783d30 100644 --- a/tests/backends/matplotlib/mpl_canvas_test.py +++ b/tests/backends/matplotlib/mpl_canvas_test.py @@ -1,6 +1,7 @@ # 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 @@ -151,3 +152,20 @@ def test_clicking_logc_button_toggles_colormapper_norm(): 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)