diff --git a/MANIFEST.in b/MANIFEST.in index d625d95e..7ce16f9b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,5 @@ include LICENSE include README.md -recursive-include * *.mplstyle recursive-exclude * __pycache__ recursive-exclude * *.py[co] diff --git a/docs/user_guide.rst b/docs/user_guide.rst index fbd48db1..253e3149 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -40,11 +40,6 @@ To use these: Customising plots ----------------- -`Matplotlib style sheets `__ can be used to customise -the plots generated by ``napari-matplotlib``. -To use a custom style sheet: - -1. Save it as ``napari-matplotlib.mplstyle`` -2. Put it in the Matplotlib configuration directory. - The location of this directory varies on different computers, - and can be found by calling :func:`matplotlib.get_configdir()`. +``napari-matplotlib`` uses colours from the current napari theme to customise the +Matplotlib plots. See `the example on creating a new napari theme +`_ for a helpful guide. diff --git a/pyproject.toml b/pyproject.toml index dea0fd6a..22ff307f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ write_to = "src/napari_matplotlib/_version.py" [tool.pytest.ini_options] qt_api = "pyqt6" -addopts = "--mpl" +addopts = "--mpl --mpl-baseline-relative" filterwarnings = [ "error", # Coming from vispy diff --git a/src/napari_matplotlib/base.py b/src/napari_matplotlib/base.py index 0ff5e389..fb9e485c 100644 --- a/src/napari_matplotlib/base.py +++ b/src/napari_matplotlib/base.py @@ -2,7 +2,6 @@ from pathlib import Path from typing import Optional -import matplotlib import matplotlib.style as mplstyle import napari from matplotlib.backends.backend_qtagg import ( # type: ignore[attr-defined] @@ -10,17 +9,15 @@ NavigationToolbar2QT, ) from matplotlib.figure import Figure +from napari.utils.events import Event +from napari.utils.theme import get_theme from qtpy.QtGui import QIcon from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget -from .util import Interval, from_napari_css_get_size_of +from .util import Interval, from_napari_css_get_size_of, style_sheet_from_theme __all__ = ["BaseNapariMPLWidget", "NapariMPLWidget", "SingleAxesWidget"] -_CUSTOM_STYLE_PATH = ( - Path(matplotlib.get_configdir()) / "napari-matplotlib.mplstyle" -) - class BaseNapariMPLWidget(QWidget): """ @@ -45,18 +42,17 @@ def __init__( ): super().__init__(parent=parent) self.viewer = napari_viewer - self._mpl_style_sheet_path: Optional[Path] = None + self.napari_theme_style_sheet = style_sheet_from_theme( + get_theme(napari_viewer.theme, as_dict=False) + ) # Sets figure.* style - with mplstyle.context(self.mpl_style_sheet_path): + with mplstyle.context(self.napari_theme_style_sheet): self.canvas = FigureCanvasQTAgg() # type: ignore[no-untyped-call] self.canvas.figure.set_layout_engine("constrained") self.toolbar = NapariNavigationToolbar(self.canvas, parent=self) self._replace_toolbar_icons() - # callback to update when napari theme changed - # TODO: this isn't working completely (see issue #140) - # most of our styling respects the theme change but not all self.viewer.events.theme.connect(self._on_napari_theme_changed) self.setLayout(QVBoxLayout()) @@ -68,24 +64,6 @@ def figure(self) -> Figure: """Matplotlib figure.""" return self.canvas.figure - @property - def mpl_style_sheet_path(self) -> Path: - """ - Path to the set Matplotlib style sheet. - """ - if self._mpl_style_sheet_path is not None: - return self._mpl_style_sheet_path - elif (_CUSTOM_STYLE_PATH).exists(): - return _CUSTOM_STYLE_PATH - elif self._napari_theme_has_light_bg(): - return Path(__file__).parent / "styles" / "light.mplstyle" - else: - return Path(__file__).parent / "styles" / "dark.mplstyle" - - @mpl_style_sheet_path.setter - def mpl_style_sheet_path(self, path: Path) -> None: - self._mpl_style_sheet_path = Path(path) - def add_single_axes(self) -> None: """ Add a single Axes to the figure. @@ -94,13 +72,21 @@ def add_single_axes(self) -> None: """ # Sets axes.* style. # Does not set any text styling set by axes.* keys - with mplstyle.context(self.mpl_style_sheet_path): + with mplstyle.context(self.napari_theme_style_sheet): self.axes = self.figure.add_subplot() - def _on_napari_theme_changed(self) -> None: + def _on_napari_theme_changed(self, event: Event) -> None: """ Called when the napari theme is changed. + + Parameters + ---------- + event : napari.utils.events.Event + Event that triggered the callback. """ + self.napari_theme_style_sheet = style_sheet_from_theme( + get_theme(event.value, as_dict=False) + ) self._replace_toolbar_icons() def _napari_theme_has_light_bg(self) -> bool: @@ -211,15 +197,18 @@ def current_z(self) -> int: """ return self.viewer.dims.current_step[0] - def _on_napari_theme_changed(self) -> None: + def _on_napari_theme_changed(self, event: Event) -> None: """Update MPL toolbar and axis styling when `napari.Viewer.theme` is changed. - Note: - At the moment we only handle the default 'light' and 'dark' napari themes. + Parameters + ---------- + event : napari.utils.events.Event + Event that triggered the callback. """ - super()._on_napari_theme_changed() - self.clear() - self.draw() + super()._on_napari_theme_changed(event) + # use self._draw instead of self.draw to cope with redraw while there are no + # layers, this makes the self.clear() obsolete + self._draw() def _setup_callbacks(self) -> None: """ @@ -252,13 +241,15 @@ def _draw(self) -> None: """ # Clearing axes sets new defaults, so need to make sure style is applied when # this happens - with mplstyle.context(self.mpl_style_sheet_path): + with mplstyle.context(self.napari_theme_style_sheet): + # everything should be done in the style context self.clear() - if self.n_selected_layers in self.n_layers_input and all( - isinstance(layer, self.input_layer_types) for layer in self.layers - ): - self.draw() - self.canvas.draw() # type: ignore[no-untyped-call] + if self.n_selected_layers in self.n_layers_input and all( + isinstance(layer, self.input_layer_types) + for layer in self.layers + ): + self.draw() + self.canvas.draw() # type: ignore[no-untyped-call] def clear(self) -> None: """ @@ -300,7 +291,7 @@ def clear(self) -> None: """ Clear the axes. """ - with mplstyle.context(self.mpl_style_sheet_path): + with mplstyle.context(self.napari_theme_style_sheet): self.axes.clear() diff --git a/src/napari_matplotlib/styles/README.md b/src/napari_matplotlib/styles/README.md deleted file mode 100644 index 79d3c417..00000000 --- a/src/napari_matplotlib/styles/README.md +++ /dev/null @@ -1,3 +0,0 @@ -This folder contains default built-in Matplotlib style sheets. -See https://matplotlib.org/stable/tutorials/introductory/customizing.html#defining-your-own-style -for more info on Matplotlib style sheets. diff --git a/src/napari_matplotlib/styles/dark.mplstyle b/src/napari_matplotlib/styles/dark.mplstyle deleted file mode 100644 index 1658f9b4..00000000 --- a/src/napari_matplotlib/styles/dark.mplstyle +++ /dev/null @@ -1,12 +0,0 @@ -# Dark-theme napari colour scheme for matplotlib plots - -# text (very light grey - almost white): #f0f1f2 -# foreground (mid grey): #414851 -# background (dark blue-gray): #262930 - -figure.facecolor : none -axes.labelcolor : f0f1f2 -axes.facecolor : none -axes.edgecolor : 414851 -xtick.color : f0f1f2 -ytick.color : f0f1f2 diff --git a/src/napari_matplotlib/styles/light.mplstyle b/src/napari_matplotlib/styles/light.mplstyle deleted file mode 100644 index 3b8d7d1d..00000000 --- a/src/napari_matplotlib/styles/light.mplstyle +++ /dev/null @@ -1,12 +0,0 @@ -# Light-theme napari colour scheme for matplotlib plots - -# text (very dark grey - almost black): #3b3a39 -# foreground (mid grey): #d6d0ce -# background (brownish beige): #efebe9 - -figure.facecolor : none -axes.labelcolor : 3b3a39 -axes.facecolor : none -axes.edgecolor : d6d0ce -xtick.color : 3b3a39 -ytick.color : 3b3a39 diff --git a/src/napari_matplotlib/tests/baseline/test_custom_theme.png b/src/napari_matplotlib/tests/baseline/test_custom_theme.png index a668c103..ffa4635b 100644 Binary files a/src/napari_matplotlib/tests/baseline/test_custom_theme.png and b/src/napari_matplotlib/tests/baseline/test_custom_theme.png differ diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png index b98a0170..74b84b2d 100644 Binary files a/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png and b/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png differ diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png index 3b90586e..4675192b 100644 Binary files a/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png and b/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png differ diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_2D.png b/src/napari_matplotlib/tests/baseline/test_histogram_2D.png index b043bba8..856d22d3 100644 Binary files a/src/napari_matplotlib/tests/baseline/test_histogram_2D.png and b/src/napari_matplotlib/tests/baseline/test_histogram_2D.png differ diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_3D.png b/src/napari_matplotlib/tests/baseline/test_histogram_3D.png index 724314e1..b5670bca 100644 Binary files a/src/napari_matplotlib/tests/baseline/test_histogram_3D.png and b/src/napari_matplotlib/tests/baseline/test_histogram_3D.png differ diff --git a/src/napari_matplotlib/tests/baseline/test_slice_2D.png b/src/napari_matplotlib/tests/baseline/test_slice_2D.png index d39920be..c1e67637 100644 Binary files a/src/napari_matplotlib/tests/baseline/test_slice_2D.png and b/src/napari_matplotlib/tests/baseline/test_slice_2D.png differ diff --git a/src/napari_matplotlib/tests/baseline/test_slice_3D.png b/src/napari_matplotlib/tests/baseline/test_slice_3D.png index cf563de5..046293f3 100644 Binary files a/src/napari_matplotlib/tests/baseline/test_slice_3D.png and b/src/napari_matplotlib/tests/baseline/test_slice_3D.png differ diff --git a/src/napari_matplotlib/tests/data/test_theme.mplstyle b/src/napari_matplotlib/tests/data/test_theme.mplstyle deleted file mode 100644 index 2f94b31f..00000000 --- a/src/napari_matplotlib/tests/data/test_theme.mplstyle +++ /dev/null @@ -1,15 +0,0 @@ -# Dark-theme napari colour scheme for matplotlib plots - -#f4b8b2 # light red -#b2e4f4 # light blue -#0aa3fc # dark blue -#008939 # dark green - -figure.facecolor : f4b8b2 # light red -axes.facecolor : b2e4f4 # light blue -axes.edgecolor : 0aa3fc # dark blue - -xtick.color : 008939 # dark green -xtick.labelcolor : 008939 # dark green -ytick.color : 008939 # dark green -ytick.labelcolor : 008939 # dark green diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png b/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png index 75965607..9237dbdc 100644 Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png differ diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png index 10219106..a11bda5f 100644 Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png differ diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png index 3e648eec..cd42a8a2 100644 Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png differ diff --git a/src/napari_matplotlib/tests/test_theme.py b/src/napari_matplotlib/tests/test_theme.py index 1042d3f3..2310f32f 100644 --- a/src/napari_matplotlib/tests/test_theme.py +++ b/src/napari_matplotlib/tests/test_theme.py @@ -1,15 +1,8 @@ -import os -import shutil -from copy import deepcopy -from pathlib import Path - -import matplotlib import napari import numpy as np import pytest -from matplotlib.colors import to_rgba -from napari_matplotlib import HistogramWidget, ScatterWidget +from napari_matplotlib import ScatterWidget from napari_matplotlib.base import NapariMPLWidget @@ -127,66 +120,3 @@ def test_no_theme_side_effects(make_napari_viewer): unrelated_figure.tight_layout() return unrelated_figure - - -@pytest.mark.mpl_image_compare -def test_custom_theme(make_napari_viewer, theme_path, brain_data): - viewer = make_napari_viewer() - viewer.theme = "dark" - - widget = ScatterWidget(viewer) - widget.mpl_style_sheet_path = theme_path - - viewer.add_image(brain_data[0], **brain_data[1], name="brain") - viewer.add_image( - brain_data[0] * -1, **brain_data[1], name="brain_reversed" - ) - - viewer.layers.selection.clear() - viewer.layers.selection.add(viewer.layers[0]) - viewer.layers.selection.add(viewer.layers[1]) - - return deepcopy(widget.figure) - - -def find_mpl_stylesheet(name: str) -> Path: - """Find the built-in matplotlib stylesheet.""" - return Path(matplotlib.__path__[0]) / f"mpl-data/stylelib/{name}.mplstyle" - - -def test_custom_stylesheet(make_napari_viewer, image_data): - """ - Test that a stylesheet in the current directory is given precidence. - - Do this by copying over a stylesheet from matplotlib's built in styles, - naming it correctly, and checking the colours are as expected. - """ - # Copy Solarize_Light2 as if it was a user-overriden stylesheet. - style_sheet_path = ( - Path(matplotlib.get_configdir()) / "napari-matplotlib.mplstyle" - ) - if style_sheet_path.exists(): - pytest.skip("Won't ovewrite existing custom style sheet.") - shutil.copy( - find_mpl_stylesheet("Solarize_Light2"), - style_sheet_path, - ) - - try: - viewer = make_napari_viewer() - viewer.add_image(image_data[0], **image_data[1]) - widget = HistogramWidget(viewer) - assert widget.mpl_style_sheet_path == style_sheet_path - ax = widget.figure.gca() - - # The axes should have a light brownish grey background: - assert ax.get_facecolor() == to_rgba("#eee8d5") - assert ax.patch.get_facecolor() == to_rgba("#eee8d5") - - # The figure background and axis gridlines are light yellow: - assert widget.figure.patch.get_facecolor() == to_rgba("#fdf6e3") - for gridline in ax.get_xgridlines() + ax.get_ygridlines(): - assert gridline.get_visible() is True - assert gridline.get_color() == "#b0b0b0" - finally: - os.remove(style_sheet_path) diff --git a/src/napari_matplotlib/util.py b/src/napari_matplotlib/util.py index 7d72c9e2..ed994256 100644 --- a/src/napari_matplotlib/util.py +++ b/src/napari_matplotlib/util.py @@ -3,6 +3,7 @@ import napari.qt import tinycss2 +from napari.utils.theme import Theme from qtpy.QtCore import QSize @@ -138,3 +139,49 @@ def from_napari_css_get_size_of( RuntimeWarning, ) return QSize(*fallback) + + +def style_sheet_from_theme(theme: Theme) -> dict[str, str]: + """Translate napari theme to a matplotlib style dictionary. + + Parameters + ---------- + theme : napari.utils.theme.Theme + Napari theme object representing the theme of the current viewer. + + Returns + ------- + Dict[str, str] + Matplotlib compatible style dictionary. + """ + return { + "axes.edgecolor": theme.secondary.as_hex(), + # BUG: could be the same as napari canvas, but facecolors do not get + # updated upon redraw for what ever reason + #'axes.facecolor':theme.canvas.as_hex(), + "axes.facecolor": "none", + "axes.labelcolor": theme.text.as_hex(), + "boxplot.boxprops.color": theme.text.as_hex(), + "boxplot.capprops.color": theme.text.as_hex(), + "boxplot.flierprops.markeredgecolor": theme.text.as_hex(), + "boxplot.whiskerprops.color": theme.text.as_hex(), + "figure.edgecolor": theme.secondary.as_hex(), + # BUG: should be the same as napari background, but facecolors do not get + # updated upon redraw for what ever reason + #'figure.facecolor':theme.background.as_hex(), + "figure.facecolor": "none", + "grid.color": theme.foreground.as_hex(), + # COMMENT: the hard coded colors are to match the previous behaviour + # alternativly we could use the theme to style the legend as well + #'legend.edgecolor':theme.secondary.as_hex(), + "legend.edgecolor": "black", + #'legend.facecolor':theme.background.as_hex(), + "legend.facecolor": "white", + #'legend.labelcolor':theme.text.as_hex() + "legend.labelcolor": "black", + "text.color": theme.text.as_hex(), + "xtick.color": theme.secondary.as_hex(), + "xtick.labelcolor": theme.text.as_hex(), + "ytick.color": theme.secondary.as_hex(), + "ytick.labelcolor": theme.text.as_hex(), + }