Repository: scipp/plopp

Issue:
Zoom resets when using slicer
If I have zoomed in to a particular range in my data, I'd usually like to keep that zoom level when using the sliders in the slicer. However, it resets the window.

Discussion:

Patch:
diff --git a/src/plopp/backends/plotly/canvas.py b/src/plopp/backends/plotly/canvas.py
index 8345d71..ff37b6e 100644
--- a/src/plopp/backends/plotly/canvas.py
+++ b/src/plopp/backends/plotly/canvas.py
@@ -29,10 +29,6 @@ class Canvas:
         The maximum value for the vertical axis. If a number (without a unit) is
         supplied, it is assumed that the unit is the same as the current vertical axis
         unit.
-    autoscale:
-        The behavior of the axis limits. If ``auto``, the limits automatically
-        adjusts every time the data changes. If ``grow``, the limits are allowed to
-        grow with time but they do not shrink.
     """
 
     def __init__(
@@ -41,7 +37,6 @@ class Canvas:
         title: str | None = None,
         user_vmin: sc.Variable | float = None,
         user_vmax: sc.Variable | float = None,
-        autoscale: Literal['auto', 'grow'] = 'auto',
         **ignored,
     ):
         # Note on the `**ignored`` keyword arguments: the figure which owns the canvas
@@ -80,7 +75,6 @@ class Canvas:
         self.units = {}
         self.dims = {}
         self._own_axes = False
-        self._autoscale = autoscale
         if title:
             self.title = title
         self.bbox = BoundingBox()
diff --git a/src/plopp/backends/pythreejs/canvas.py b/src/plopp/backends/pythreejs/canvas.py
index f79e984..4fca9ba 100644
--- a/src/plopp/backends/pythreejs/canvas.py
+++ b/src/plopp/backends/pythreejs/canvas.py
@@ -47,7 +47,6 @@ class Canvas:
         self.xscale = 'linear'
         self.yscale = 'linear'
         self.zscale = 'linear'
-        self.bbox = BoundingBox()
         self.outline = None
         self.axticks = None
         self.figsize = np.asarray(figsize if figsize is not None else (600, 400))
@@ -58,7 +57,7 @@ class Canvas:
         self.camera = p3.PerspectiveCamera(aspect=width / height)
         self.camera_backup = {}
         self.axes_3d = p3.AxesHelper()
-        self._limits = BoundingBox()
+        self.bbox = BoundingBox()
         self._cached_limits = None
         self.scene = p3.Scene(
             children=[self.camera, self.axes_3d], background="#f0f0f0"
@@ -100,7 +99,9 @@ class Canvas:
         """
         from .outline import Outline
 
-        if self.empty:
+        # If `None` is found in the limits, it means we are waiting first for a call
+        # to autoscale on the parent view.
+        if self.empty or (None in self.bbox.asdict().values()):
             return
 
         limits = (
@@ -299,22 +300,22 @@ class Canvas:
         """
         Get or set the minimum x-axis value.
         """
-        return self._limits.xmin
+        return self.bbox.xmin
 
     @xmin.setter
     def xmin(self, value: float):
-        self._limits.xmin = value
+        self.bbox.xmin = value
 
     @property
     def xmax(self) -> float:
         """
         Get or set the maximum x-axis value.
         """
-        return self._limits.xmax
+        return self.bbox.xmax
 
     @xmax.setter
     def xmax(self, value: float):
-        self._limits.xmax = value
+        self.bbox.xmax = value
 
     @property
     def xrange(self) -> tuple[float, float]:
@@ -332,22 +333,22 @@ class Canvas:
         """
         Get or set the minimum y-axis value.
         """
-        return self._limits.ymin
+        return self.bbox.ymin
 
     @ymin.setter
     def ymin(self, value: float):
-        self._limits.ymin = value
+        self.bbox.ymin = value
 
     @property
     def ymax(self) -> float:
         """
         Get or set the maximum y-axis value.
         """
-        return self._limits.ymax
+        return self.bbox.ymax
 
     @ymax.setter
     def ymax(self, value: float):
-        self._limits.ymax = value
+        self.bbox.ymax = value
 
     @property
     def yrange(self) -> tuple[float, float]:
@@ -365,22 +366,22 @@ class Canvas:
         """
         Get or set the minimum z-axis value.
         """
-        return self._limits.zmin
+        return self.bbox.zmin
 
     @zmin.setter
     def zmin(self, value: float):
-        self._limits.zmin = value
+        self.bbox.zmin = value
 
     @property
     def zmax(self) -> float:
         """
         Get or set the maximum z-axis value.
         """
-        return self._limits.zmax
+        return self.bbox.zmax
 
     @zmax.setter
     def zmax(self, value: float):
-        self._limits.zmax = value
+        self.bbox.zmax = value
 
     @property
     def zrange(self) -> tuple[float, float]:
diff --git a/src/plopp/core/typing.py b/src/plopp/core/typing.py
index d61f65f..76f9176 100644
--- a/src/plopp/core/typing.py
+++ b/src/plopp/core/typing.py
@@ -28,8 +28,6 @@ PlottableMulti = Plottable | dict[str, Plottable]
 
 
 class CanvasLike(Protocol):
-    def autoscale(self) -> None: ...
-
     def draw(self) -> None: ...
 
     def save(self) -> None: ...
diff --git a/src/plopp/graphics/bbox.py b/src/plopp/graphics/bbox.py
index 22e8b9e..0d53f19 100644
--- a/src/plopp/graphics/bbox.py
+++ b/src/plopp/graphics/bbox.py
@@ -71,6 +71,19 @@ class BoundingBox:
             zmax=other.zmax if other.zmax is not None else self.zmax,
         )
 
+    def asdict(self) -> dict[str, float | None]:
+        """
+        Return the bounding box as a dictionary.
+        """
+        return {
+            'xmin': self.xmin,
+            'xmax': self.xmax,
+            'ymin': self.ymin,
+            'ymax': self.ymax,
+            'zmin': self.zmin,
+            'zmax': self.zmax,
+        }
+
 
 def axis_bounds(
     keys: tuple[str, str],
diff --git a/src/plopp/graphics/colormapper.py b/src/plopp/graphics/colormapper.py
index 67207d6..00739c4 100644
--- a/src/plopp/graphics/colormapper.py
+++ b/src/plopp/graphics/colormapper.py
@@ -1,7 +1,6 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
 
-from collections.abc import Iterable
 from copy import copy
 from functools import reduce
 from typing import Any, Literal
@@ -75,10 +74,6 @@ class ColorMapper:
         The name of the colormap for masked data.
     norm:
         The colorscale normalization.
-    autoscale:
-        The behavior of the color range limits. If ``auto``, the limits automatically
-        adjusts every time the data changes. If ``grow``, the limits are allowed to
-        grow with time but they do not shrink.
     vmin:
         The minimum value for the colorscale range. If a number (without a unit) is
         supplied, it is assumed that the unit is the same as the data unit.
@@ -98,7 +93,6 @@ class ColorMapper:
         cmap: str = 'viridis',
         mask_cmap: str = 'gray',
         norm: Literal['linear', 'log'] = 'linear',
-        autoscale: Literal['auto', 'grow'] = 'auto',
         vmin: sc.Variable | float | None = None,
         vmax: sc.Variable | float | None = None,
         nan_color: str | None = None,
@@ -112,7 +106,7 @@ class ColorMapper:
         self.vmin = np.inf
         self.vmax = -np.inf
         self.norm = norm
-        self._autoscale = autoscale
+        self.set_colors_on_update = True
 
         # Note that we need to set vmin/vmax for the LogNorm, if not an error is
         # raised when making the colorbar before any call to update is made.
@@ -175,8 +169,7 @@ class ColorMapper:
     def autoscale(self):
         """
         Re-compute the global min and max range of values by iterating over all the
-        artists. If autoscale is set to ``'auto'``, the limits adjust to he current
-        range. If it is set to ``'grow'``, limits can grow but not shrink.
+        artists and adjust the limits.
         """
         limits = [
             fix_empty_range(find_limits(artist._data, scale=self.norm))
@@ -186,11 +179,11 @@ class ColorMapper:
         vmax = reduce(max, [v[1] for v in limits])
         if self.user_vmin is not None:
             self.vmin = self.user_vmin
-        elif (vmin.value < self.vmin) or (self._autoscale == 'auto'):
+        else:
             self.vmin = vmin.value
         if self.user_vmax is not None:
             self.vmax = self.user_vmax
-        elif (vmax.value > self.vmax) or (self._autoscale == 'auto'):
+        else:
             self.vmax = vmax.value
 
         if self.vmin >= self.vmax:
@@ -201,7 +194,21 @@ class ColorMapper:
                 self.vmin = self.user_vmin
                 self.vmax = self.user_vmin + abs(self.user_vmin) * 0.1
 
-    def _set_artists_colors(self, keys: Iterable):
+        # Synchronize the underlying normalizer limits to the current state.
+        # Note that the order matters here, as for a normalizer vmin cannot be set above
+        # the current vmax.
+        if self.vmin >= self.normalizer.vmax:
+            self.normalizer.vmax = self.vmax
+            self.normalizer.vmin = self.vmin
+        else:
+            self.normalizer.vmin = self.vmin
+            self.normalizer.vmax = self.vmax
+
+        self._set_artists_colors()
+        if self.colorbar is not None:
+            self._update_colorbar_widget()
+
+    def _set_artists_colors(self):
         """
         Update the colors of all the artists apart from the one that triggered the
         update, as those get updated by the figure.
@@ -211,22 +218,9 @@ class ColorMapper:
         keys:
             List of artists to update.
         """
-        for k in keys:
+        for k in self.artists.keys():
             self.artists[k].set_colors(self.rgba(self.artists[k].data))
 
-    def _set_normalizer_limits(self):
-        """
-        Synchronize the underlying normalizer limits to the current state.
-        """
-        # Note that the order matters here, as for a normalizer vmin cannot be set above
-        # the current vmax.
-        if self.vmin >= self.normalizer.vmax:
-            self.normalizer.vmax = self.vmax
-            self.normalizer.vmin = self.vmin
-        else:
-            self.normalizer.vmin = self.vmin
-            self.normalizer.vmax = self.vmax
-
     def update(self, *args, **kwargs):
         """
         Update the colorscale bounds taking into account new values,
@@ -234,7 +228,7 @@ class ColorMapper:
         We also update the colorbar widget if it exists.
         """
         new = dict(*args, **kwargs)
-        for key, data in new.items():
+        for data in new.values():
             if self.name is None:
                 self.name = data.name
                 # If name is None, this is the first time update is called
@@ -253,16 +247,8 @@ class ColorMapper:
                 if self.unit is not None:
                     text += f'{" " if self.name else ""}[{self.unit}]'
                 self.cax.set_ylabel(text)
-            old_bounds = np.array([self.vmin, self.vmax])
-            self.autoscale()
-            self._set_normalizer_limits()
-
-            if not np.allclose(old_bounds, np.array([self.vmin, self.vmax])):
-                self._update_colorbar_widget()
-                keys = self.artists.keys()
-            else:
-                keys = [key]
-            self._set_artists_colors(keys)
+        if self.set_colors_on_update:
+            self._set_artists_colors()
 
     def toggle_norm(self):
         """
@@ -272,9 +258,6 @@ class ColorMapper:
         self.normalizer = _get_normalizer(self.norm)
         self.vmin = np.inf
         self.vmax = -np.inf
-        self.autoscale()
-        self._set_normalizer_limits()
-        self._set_artists_colors(self.artists.keys())
         if self.colorbar is not None:
             self.colorbar.mappable.norm = self.normalizer
-            self._update_colorbar_widget()
+        self.autoscale()
diff --git a/src/plopp/graphics/graphicalview.py b/src/plopp/graphics/graphicalview.py
index 008c86c..401199a 100644
--- a/src/plopp/graphics/graphicalview.py
+++ b/src/plopp/graphics/graphicalview.py
@@ -44,7 +44,6 @@ class GraphicalView(View):
         norm: Literal['linear', 'log'] = 'linear',
         vmin: sc.Variable | float | None = None,
         vmax: sc.Variable | float | None = None,
-        autoscale: Literal['auto', 'grow'] = 'auto',
         scale: dict[str, str] | None = None,
         aspect: Literal['auto', 'equal'] = 'auto',
         grid: bool = False,
@@ -64,8 +63,8 @@ class GraphicalView(View):
         self._artist_maker = artist_maker
         self._kwargs = kwargs
         self._repr_format = format
-        self._autoscale = autoscale
-        self._bbox = BoundingBox()
+        self.bbox = BoundingBox()
+        self.draw_on_update = True
 
         self.canvas = canvas_maker(
             cbar=cbar,
@@ -89,7 +88,6 @@ class GraphicalView(View):
                 norm=norm,
                 vmin=vmin,
                 vmax=vmax,
-                autoscale=autoscale,
                 canvas=self.canvas,
                 figsize=getattr(self.canvas, "figsize", None),
             )
@@ -108,20 +106,20 @@ class GraphicalView(View):
             scales['zscale'] = self.canvas.zscale
         for artist in self.artists.values():
             bbox = bbox.union(artist.bbox(**scales))
-        self._bbox = self._bbox.union(bbox) if self._autoscale == 'grow' else bbox
-        self._bbox = self._bbox.override(self.canvas.bbox)
+        self.bbox = bbox
+        self.bbox = self.bbox.override(self.canvas.bbox)
         self.canvas.xrange = (
-            _none_if_not_finite(self._bbox.xmin),
-            _none_if_not_finite(self._bbox.xmax),
+            _none_if_not_finite(self.bbox.xmin),
+            _none_if_not_finite(self.bbox.xmax),
         )
         self.canvas.yrange = (
-            _none_if_not_finite(self._bbox.ymin),
-            _none_if_not_finite(self._bbox.ymax),
+            _none_if_not_finite(self.bbox.ymin),
+            _none_if_not_finite(self.bbox.ymax),
         )
         if hasattr(self.canvas, 'zrange'):
             self.canvas.zrange = (
-                _none_if_not_finite(self._bbox.zmin),
-                _none_if_not_finite(self._bbox.zmax),
+                _none_if_not_finite(self.bbox.zmin),
+                _none_if_not_finite(self.bbox.zmax),
             )
         self.canvas.draw()
 
@@ -197,8 +195,33 @@ class GraphicalView(View):
 
         if self.colormapper is not None:
             self.colormapper.update(**new)
+
+        if self.draw_on_update:
+            self.canvas.draw()
+
+    def fit_to_data(self) -> None:
+        """
+        Autoscale axes and colormapper.
+        """
+        # Autoscale the colormapper first to make use of the single draw call made by
+        # ``self.autoscale()``
+        if not self.artists:
+            return
+        if self.colormapper is not None:
+            self.colormapper.autoscale()
         self.autoscale()
 
+    def render(self) -> None:
+        """
+        At the end of figure creation, this function is called to request data from
+        all parent nodes and draw the figure.
+        In addition, we call the home method to autoscale the axes and colormapper.
+        """
+        self.draw_on_update = False
+        super().render()
+        self.fit_to_data()
+        self.draw_on_update = True
+
     def remove(self, key: str) -> None:
         """
         Remove an object from the scene.
diff --git a/src/plopp/plotting/plot.py b/src/plopp/plotting/plot.py
index 5fc4d8e..cb68dee 100644
--- a/src/plopp/plotting/plot.py
+++ b/src/plopp/plotting/plot.py
@@ -27,7 +27,6 @@ def plot(
     title: str | None = None,
     vmin: Variable | float | None = None,
     vmax: Variable | float | None = None,
-    autoscale: Literal['auto', 'grow'] = 'auto',
     legend: bool | tuple[float, float] = True,
     **kwargs,
 ) -> FigureLike:
@@ -68,10 +67,6 @@ def plot(
     vmax:
         Upper bound for data to be displayed (y-axis for 1d plots, colorscale for
         2d plots).
-    autoscale:
-        The behavior of the axis (1d plots) or the color range limits (2d plots).
-        If ``auto``, the limits automatically adjusts every time the data changes.
-        If ``grow``, the limits are allowed to grow with time but they do not shrink.
     legend:
         Show legend if ``True``. If ``legend`` is a tuple, it should contain the
         ``(x, y)`` coordinates of the legend's anchor point in axes coordinates.
@@ -96,7 +91,6 @@ def plot(
         'title': title,
         'vmin': vmin,
         'vmax': vmax,
-        'autoscale': autoscale,
         'figsize': figsize,
         **kwargs,
     }
diff --git a/src/plopp/plotting/slicer.py b/src/plopp/plotting/slicer.py
index 53ac9bd..873bf4c 100644
--- a/src/plopp/plotting/slicer.py
+++ b/src/plopp/plotting/slicer.py
@@ -1,10 +1,8 @@
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
 
-import warnings
-from functools import partial, reduce
+from functools import partial
 from itertools import groupby
-from typing import Literal
 
 from scipp.typing import VariableLike
 
@@ -42,17 +40,16 @@ class Slicer:
         case of a 2-dimensional input, while the last two dims will be kept in the case
         of higher dimensional inputs.
     autoscale:
-        The behavior of the y-axis (1d plots) and color range (2d plots) limits.
-        If ``auto``, the limits automatically adjusts every time the data changes.
-        If ``grow``, the limits are allowed to grow with time but they do not shrink.
-        If ``fixed``, the limits are fixed to the full data range and do not change
-        with time.
+        Automatically adjust range of the y-axis (1d plots) or color scale (2d plots)
+        every time the data changes if ``True``.
     coords:
         If supplied, use these coords instead of the input's dimension coordinates.
     vmin:
         The minimum value of the y-axis (1d plots) or color range (2d plots).
     vmax:
         The maximum value of the y-axis (1d plots) or color range (2d plots).
+    cbar:
+        Whether to display a colorbar for 2D plots.
     **kwargs:
         The additional arguments are forwarded to the underlying 1D or 2D figures.
     """
@@ -62,10 +59,11 @@ class Slicer:
         obj: PlottableMulti,
         *,
         keep: list[str] | None = None,
-        autoscale: Literal['auto', 'grow', 'fixed'] = 'auto',
+        autoscale: bool = True,
         coords: list[str] | None = None,
         vmin: VariableLike | float = None,
         vmax: VariableLike | float = None,
+        cbar: bool = True,
         **kwargs,
     ):
         nodes = input_to_nodes(
@@ -101,20 +99,6 @@ class Slicer:
                 f"were not found in the input's dimensions {dims}."
             )
 
-        if autoscale == 'fixed':
-            if None not in (vmin, vmax):
-                warnings.warn(
-                    'Slicer plot: autoscale is set to "fixed", but vmin and vmax '
-                    'are also specified. They will override the autoscale setting.',
-                    RuntimeWarning,
-                    stacklevel=2,
-                )
-            if vmin is None:
-                vmin = reduce(min, [node().data.min() for node in nodes])
-            if vmax is None:
-                vmax = reduce(max, [node().data.max() for node in nodes])
-            autoscale = 'auto'  # Change back to something the figure understands
-
         from ..widgets import SliceWidget, slice_dims
 
         self.slider = SliceWidget(
@@ -129,16 +113,25 @@ class Slicer:
         elif ndims == 2:
             if len(self.slice_nodes) > 1:
                 raise_multiple_inputs_for_2d_plot_error(origin='slicer')
-            make_figure = imagefigure
+            make_figure = partial(imagefigure, cbar=cbar)
         else:
             raise ValueError(
                 f'Slicer plot: the number of dims to be kept must be 1 or 2, '
                 f'but {ndims} were requested.'
             )
 
-        self.figure = make_figure(
-            *self.slice_nodes, autoscale=autoscale, vmin=vmin, vmax=vmax, **kwargs
-        )
+        self.figure = make_figure(*self.slice_nodes, vmin=vmin, vmax=vmax, **kwargs)
+
+        if autoscale:
+            self.figure.view.draw_on_update = False
+            self.slider._plopp_observe_(
+                lambda _: self.figure.view.fit_to_data(), names='value'
+            )
+            if ndims == 2:
+                # Do not set colors on update, as this is done during the autoscale.
+                # This way, we avoid paying the cost of setting the colors twice.
+                self.figure.view.colormapper.set_colors_on_update = False
+
         require_interactive_figure(self.figure, 'slicer')
         self.figure.bottom_bar.add(self.slider)
 
@@ -147,10 +140,11 @@ def slicer(
     obj: PlottableMulti,
     *,
     keep: list[str] | None = None,
-    autoscale: Literal['auto', 'grow', 'fixed'] = 'auto',
+    autoscale: bool = True,
     coords: list[str] | None = None,
     vmin: VariableLike | float = None,
     vmax: VariableLike | float = None,
+    cbar: bool = True,
     **kwargs,
 ) -> FigureLike:
     """
@@ -167,17 +161,16 @@ def slicer(
         case of a 2-dimensional input, while the last two dims will be kept in the case
         of higher dimensional inputs.
     autoscale:
-        The behavior of the y-axis (1d plots) and color range (2d plots) limits.
-        If ``auto``, the limits automatically adjusts every time the data changes.
-        If ``grow``, the limits are allowed to grow with time but they do not shrink.
-        If ``fixed``, the limits are fixed to the full data range and do not change
-        with time.
+        Automatically adjust range of the y-axis (1d plots) or color scale (2d plots)
+        every time the data changes if ``True``.
     coords:
         If supplied, use these coords instead of the input's dimension coordinates.
     vmin:
         The minimum value of the y-axis (1d plots) or color range (2d plots).
     vmax:
         The maximum value of the y-axis (1d plots) or color range (2d plots).
+    cbar:
+        Whether to display a colorbar for 2D plots.
     **kwargs:
         See :py:func:`plopp.plot` for the full list of figure customization arguments.
     """
@@ -188,5 +181,6 @@ def slicer(
         vmin=vmin,
         vmax=vmax,
         coords=coords,
+        cbar=cbar,
         **kwargs,
     ).figure
diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py
index f910b58..52edab2 100644
--- a/src/plopp/widgets/clip3d.py
+++ b/src/plopp/widgets/clip3d.py
@@ -201,7 +201,7 @@ class ClippingPlanes(ipw.HBox):
 
     def __init__(self, fig: BaseFig):
         self._view = fig.view
-        bbox = self._view._bbox
+        bbox = self._view.bbox
         canvas = self._view.canvas
 
         self._limits = (
diff --git a/src/plopp/widgets/toolbar.py b/src/plopp/widgets/toolbar.py
index 14541c7..14ccbd0 100644
--- a/src/plopp/widgets/toolbar.py
+++ b/src/plopp/widgets/toolbar.py
@@ -68,7 +68,7 @@ def make_toolbar_canvas2d(view: GraphicalView) -> Toolbar:
         view.autoscale()
 
     tool_list = {
-        'home': tools.HomeTool(view.autoscale),
+        'home': tools.HomeTool(view.fit_to_data),
         'panzoom': tools.PanZoomTool(view.canvas.panzoom),
         'logx': tools.LogxTool(logx, value=view.canvas.xscale == 'log'),
         'logy': tools.LogyTool(logy, value=view.canvas.yscale == 'log'),


Test patch:
diff --git a/tests/backends/matplotlib/mpl_image_test.py b/tests/backends/matplotlib/mpl_image_test.py
index edffde5..904d725 100644
--- a/tests/backends/matplotlib/mpl_image_test.py
+++ b/tests/backends/matplotlib/mpl_image_test.py
@@ -19,10 +19,12 @@ def test_update_on_one_mesh_changes_colors_on_second_mesh():
     old_b_colors = f.artists[b.id]._mesh.get_facecolors()
     a.func = lambda: da1 * 2.1
     a.notify_children('updated a')
+    f.view.colormapper.autoscale()  # Autoscale the colorbar limits
     # No change because the update did not change the colorbar limits
     assert np.allclose(old_b_colors, f.artists[b.id]._mesh.get_facecolors())
     a.func = lambda: da1 * 5.0
     a.notify_children('updated a')
+    f.view.colormapper.autoscale()  # Autoscale the colorbar limits
     assert not np.allclose(old_b_colors, f.artists[b.id]._mesh.get_facecolors())
 
 
diff --git a/tests/graphics/colormapper_test.py b/tests/graphics/colormapper_test.py
index 8b928ed..cc95200 100644
--- a/tests/graphics/colormapper_test.py
+++ b/tests/graphics/colormapper_test.py
@@ -2,6 +2,7 @@
 # Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
 
 from dataclasses import dataclass
+from difflib import SequenceMatcher
 from functools import partial
 from typing import Callable
 
@@ -15,6 +16,10 @@ from plopp.data.testing import data_array, scatter
 from plopp.graphics.colormapper import ColorMapper
 
 
+def string_similarity(a, b):
+    return SequenceMatcher(None, a, b).ratio()
+
+
 class DummyChild:
     def __init__(self, data):
         self._data = data
@@ -58,48 +63,57 @@ def test_autoscale():
     mapper['data'] = artist
 
     mapper.update(data=da)
+    mapper.autoscale()
     assert mapper.vmin == da.min().value
     assert mapper.vmax == da.max().value
 
+    # Limits grow
     const = 2.3
     artist.update(da * const)
     mapper.update(data=da * const)
+    mapper.autoscale()
     assert mapper.vmin == (da.min() * const).value
     assert mapper.vmax == (da.max() * const).value
 
-
-def test_auto_rescale_limits_can_shrink():
-    da = data_array(ndim=2, unit='K')
-    mapper = ColorMapper(autoscale='auto')
-    artist = DummyChild(da)
-    mapper['data'] = artist
-
-    mapper.update(data=da)
-    assert mapper.vmin == da.min().value
-    assert mapper.vmax == da.max().value
-
+    # Limits shrink
     const = 0.5
     artist.update(da * const)
     mapper.update(data=da * const)
+    mapper.autoscale()
     assert mapper.vmin == da.min().value * const
     assert mapper.vmax == da.max().value * const
 
 
-def test_grow_rescale_limits_do_not_shrink():
+def test_update_without_autoscale_does_not_change_limits():
     da = data_array(ndim=2, unit='K')
-    mapper = ColorMapper(autoscale='grow')
+    mapper = ColorMapper()
     artist = DummyChild(da)
     mapper['data'] = artist
 
     mapper.update(data=da)
+    mapper.autoscale()
     assert mapper.vmin == da.min().value
     assert mapper.vmax == da.max().value
 
+    backup = mapper.vmin, mapper.vmax
+
+    # Limits grow
+    const = 2.3
+    artist.update(da * const)
+    mapper.update(data=da * const)
+    assert mapper.vmin != (da.min() * const).value
+    assert mapper.vmin == backup[0]
+    assert mapper.vmax != (da.max() * const).value
+    assert mapper.vmax == backup[1]
+
+    # Limits shrink
     const = 0.5
     artist.update(da * const)
     mapper.update(data=da * const)
-    assert mapper.vmin == da.min().value
-    assert mapper.vmax == da.max().value
+    assert mapper.vmin != da.min().value * const
+    assert mapper.vmin == backup[0]
+    assert mapper.vmax != da.max().value * const
+    assert mapper.vmax == backup[1]
 
 
 def test_correct_normalizer_limits():
@@ -108,6 +122,7 @@ def test_correct_normalizer_limits():
     artist = DummyChild(da)
     mapper['data'] = artist
     mapper.update(data=da)
+    mapper.autoscale()
     assert mapper.vmin == da.min().value
     assert mapper.vmax == da.max().value
     # The normalizer initially has limits [0, 1].
@@ -126,6 +141,7 @@ def test_vmin_vmax():
     artist = DummyChild(da)
     mapper['data'] = artist
     mapper.update(data=da)
+    mapper.autoscale()
     assert mapper.user_vmin == vmin.value
     assert mapper.user_vmax == vmax.value
     assert mapper.vmin == vmin.value
@@ -140,6 +156,7 @@ def test_vmin_vmax_no_variable():
     artist = DummyChild(da)
     mapper['data'] = artist
     mapper.update(data=da)
+    mapper.autoscale()
     assert mapper.user_vmin == vmin
     assert mapper.user_vmax == vmax
     assert mapper.vmin == vmin
@@ -151,6 +168,7 @@ def test_toggle_norm():
     da = data_array(ndim=2, unit='K')
     mapper['child1'] = DummyChild(da)
     mapper.update(child1=da)
+    mapper.autoscale()
     assert mapper.norm == 'linear'
     assert isinstance(mapper.normalizer, Normalize)
     assert mapper.vmin == da.min().value
@@ -170,12 +188,14 @@ def test_update_changes_limits():
     mapper['data'] = artist
 
     mapper.update(data=da)
+    mapper.autoscale()
     assert mapper.normalizer.vmin == da.min().value
     assert mapper.normalizer.vmax == da.max().value
 
     const = 2.3
     artist.update(da * const)
     mapper.update(data=da * const)
+    mapper.autoscale()
     assert mapper.normalizer.vmin == (da.min() * const).value
     assert mapper.normalizer.vmax == (da.max() * const).value
 
@@ -202,6 +222,7 @@ def test_colorbar_updated_on_rescale():
     mapper[key] = artist
 
     mapper.update(data=da)
+    mapper.autoscale()
     _ = mapper.to_widget()
     old_image = mapper.widget.value
     old_image_array = old_image
@@ -209,43 +230,53 @@ def test_colorbar_updated_on_rescale():
     # Update with the same values should not make a new colorbar image
     artist.update(da)
     mapper.update(data=da)
-    assert old_image is mapper.widget.value
+    mapper.autoscale()
+    assert string_similarity(old_image_array, mapper.widget.value) > 0.9
 
-    # Update with new values should make a new colorbar image
+    # Update with a smaller range should make a new colorbar image
+    const = 0.8
+    artist.update(da * const)
+    mapper.update(data=da * const)
+    mapper.autoscale()
+    assert string_similarity(old_image_array, mapper.widget.value) < 0.9
+
+    # Update with larger range should make a new colorbar image
     const = 2.3
     artist.update(da * const)
     mapper.update(data=da * const)
-    assert old_image_array != mapper.widget.value
+    mapper.autoscale()
+    assert string_similarity(old_image_array, mapper.widget.value) < 0.9
 
 
-def test_colorbar_does_not_update_on_rescale_if_limits_can_only_grow():
+def test_colorbar_does_not_update_if_no_autoscale():
     da = data_array(ndim=2, unit='K')
-    mapper = ColorMapper(autoscale='grow')
+    mapper = ColorMapper()
     artist = DummyChild(da)
     key = 'data'
     mapper[key] = artist
 
     mapper.update(data=da)
+    mapper.autoscale()
     _ = mapper.to_widget()
     old_image = mapper.widget.value
     old_image_array = old_image
 
-    # Update with the same values should not make a new colorbar image
+    # Update with the same values
     artist.update(da)
     mapper.update(data=da)
     assert old_image is mapper.widget.value
 
-    # Update with a smaller range should not make a new colorbar image
+    # Update with a smaller range
     const = 0.8
     artist.update(da * const)
     mapper.update(data=da * const)
     assert old_image is mapper.widget.value
 
-    # Update with new values should make a new colorbar image
+    # Update with larger range
     const = 2.3
     artist.update(da * const)
     mapper.update(data=da * const)
-    assert old_image_array != mapper.widget.value
+    assert old_image_array is mapper.widget.value
 
 
 def test_colorbar_is_not_created_if_cbar_false():
@@ -263,32 +294,36 @@ def test_colorbar_cbar_false_overrides_cax():
     assert mapper.colorbar is None
 
 
-def test_autoscale_auto_vmin_set():
+def test_autoscale_vmin_set():
     da = data_array(ndim=2, unit='K')
-    mapper = ColorMapper(autoscale='auto', vmin=-0.5)
+    mapper = ColorMapper(vmin=-0.5)
     artist = DummyChild(da)
     key = 'data'
     mapper[key] = artist
     mapper.update(data=da)
+    mapper.autoscale()
     assert mapper.vmin == -0.5
     assert mapper.vmax == da.max().value
     # Make sure it handles when da.max() is greater than vmin
     mapper.update(data=da - sc.scalar(5.0, unit='K'))
+    mapper.autoscale()
     assert mapper.vmin == -0.5
     assert mapper.vmin < mapper.vmax
 
 
-def test_autoscale_auto_vmax_set():
+def test_autoscale_vmax_set():
     da = data_array(ndim=2, unit='K')
-    mapper = ColorMapper(autoscale='auto', vmax=0.5)
+    mapper = ColorMapper(vmax=0.5)
     artist = DummyChild(da)
     key = 'data'
     mapper[key] = artist
     mapper.update(data=da)
+    mapper.autoscale()
     assert mapper.vmax == 0.5
     assert mapper.vmin == da.min().value
     # Make sure it handles when da.min() is greater than vmax
     mapper.update(data=da + sc.scalar(5.0, unit='K'))
+    mapper.autoscale()
     assert mapper.vmax == 0.5
     assert mapper.vmin < mapper.vmax
 
diff --git a/tests/plotting/plot_1d_test.py b/tests/plotting/plot_1d_test.py
index 52debf3..cdc546e 100644
--- a/tests/plotting/plot_1d_test.py
+++ b/tests/plotting/plot_1d_test.py
@@ -410,40 +410,31 @@ def test_plot_1d_data_with_binedges():
     da.plot()
 
 
-def test_update_grows_limits():
+def test_autoscale_after_update_grows_limits():
     da = data_array(ndim=1)
     fig = da.plot()
     old_lims = fig.canvas.yrange
     [key] = fig.artists.keys()
     fig.update({key: da * 2.5})
+    fig.view.autoscale()
     new_lims = fig.canvas.yrange
     assert new_lims[0] < old_lims[0]
     assert new_lims[1] > old_lims[1]
 
 
-def test_update_does_shrink_limits_if_auto_mode():
+def test_autoscale_after_update_shrinks_limits():
     da = data_array(ndim=1)
-    fig = da.plot(autoscale='auto')
+    fig = da.plot()
     old_lims = fig.canvas.yrange
     [key] = fig.artists.keys()
     const = 0.5
     fig.update({key: da * const})
+    fig.view.autoscale()
     new_lims = fig.canvas.yrange
     assert new_lims[0] == old_lims[0] * const
     assert new_lims[1] == old_lims[1] * const
 
 
-def test_update_does_not_shrink_limits_if_grow_mode():
-    da = data_array(ndim=1)
-    fig = da.plot(autoscale='grow')
-    old_lims = fig.canvas.yrange
-    [key] = fig.artists.keys()
-    fig.update({key: da * 0.5})
-    new_lims = fig.canvas.yrange
-    assert new_lims[0] == old_lims[0]
-    assert new_lims[1] == old_lims[1]
-
-
 def test_vmin():
     da = data_array(ndim=1)
     fig = da.plot(vmin=sc.scalar(-0.5, unit='m/s'))
@@ -471,6 +462,17 @@ def test_vmin_vmax():
     assert np.allclose(fig.canvas.yrange, [-0.5, 0.68])
 
 
+def test_autoscale_after_update_does_not_change_limits_if_vmin_vmax():
+    da = data_array(ndim=1)
+    fig = da.plot(vmin=-0.51, vmax=0.78)
+    assert np.allclose(fig.canvas.yrange, [-0.51, 0.78])
+    old_lims = fig.canvas.yrange
+    [key] = fig.artists.keys()
+    fig.update({key: da * 2.5})
+    fig.view.autoscale()
+    assert np.allclose(fig.canvas.yrange, old_lims)
+
+
 def test_vmin_vmax_no_variable():
     da = data_array(ndim=1)
     fig = da.plot(vmin=-0.5, vmax=0.68)
diff --git a/tests/plotting/slicer_test.py b/tests/plotting/slicer_test.py
index d453db0..84b85ab 100644
--- a/tests/plotting/slicer_test.py
+++ b/tests/plotting/slicer_test.py
@@ -156,13 +156,34 @@ def test_raises_when_number_of_keep_dims_requested_is_bad():
 
 
 @pytest.mark.usefixtures('_use_ipympl')
-def test_autoscale_fixed():
+def test_update_triggers_autoscale():
     da = sc.DataArray(
         data=sc.arange('x', 5 * 10 * 20).fold(dim='x', sizes={'z': 20, 'y': 10, 'x': 5})
     )
-    sl = Slicer(da, keep=['y', 'x'], autoscale='fixed')
-    assert sl.figure.view.colormapper.vmin == 0
-    assert sl.figure.view.colormapper.vmax == 5 * 10 * 20 - 1
-    sl.slider.controls['z']['slider'].value = 5
-    assert sl.figure.view.colormapper.vmin == 0
-    assert sl.figure.view.colormapper.vmax == 5 * 10 * 20 - 1
+    # `autoscale=True` should be the default, but there is no guarantee that it will
+    # not change in the future, so we explicitly set it here to make the test robust.
+    sl = Slicer(da, keep=['y', 'x'], autoscale=True)
+    cm = sl.figure.view.colormapper
+    # Colormapper fits to the values in the first slice
+    assert cm.vmin == 0
+    assert cm.vmax == 5 * 10 * 1 - 1
+    sl.slider.controls['z']['slider'].value = 6
+    # Colormapper range fits to the values in the new slice
+    assert cm.vmin == 5 * 10 * 6
+    assert cm.vmax == 5 * 10 * 7 - 1
+
+
+@pytest.mark.usefixtures('_use_ipympl')
+def test_no_autoscale():
+    da = sc.DataArray(
+        data=sc.arange('x', 5 * 10 * 20).fold(dim='x', sizes={'z': 20, 'y': 10, 'x': 5})
+    )
+    sl = Slicer(da, keep=['y', 'x'], autoscale=False)
+    cm = sl.figure.view.colormapper
+    # Colormapper fits to the values in the first slice
+    assert cm.vmin == 0
+    assert cm.vmax == 5 * 10 * 1 - 1
+    sl.slider.controls['z']['slider'].value = 6
+    # Colormapper range does not change
+    assert cm.vmin == 0
+    assert cm.vmax == 5 * 10 * 1 - 1