diff --git a/src/napari_matplotlib/base.py b/src/napari_matplotlib/base.py index fda2d2d5..a944d08c 100644 --- a/src/napari_matplotlib/base.py +++ b/src/napari_matplotlib/base.py @@ -1,9 +1,10 @@ import os +import warnings from pathlib import Path from typing import List, Optional, Tuple +import matplotlib.style import napari -from matplotlib.axes import Axes from matplotlib.backends.backend_qtagg import ( FigureCanvas, NavigationToolbar2QT, @@ -40,10 +41,8 @@ def __init__( ): super().__init__(parent=parent) self.viewer = napari_viewer - + self.apply_style() self.canvas = FigureCanvas() - - self.canvas.figure.patch.set_facecolor("none") self.canvas.figure.set_layout_engine("constrained") self.toolbar = NapariNavigationToolbar( self.canvas, parent=self @@ -70,28 +69,43 @@ def add_single_axes(self) -> None: The Axes is saved on the ``.axes`` attribute for later access. """ self.axes = self.figure.subplots() - self.apply_napari_colorscheme(self.axes) - - def apply_napari_colorscheme(self, ax: Axes) -> None: - """Apply napari-compatible colorscheme to an Axes.""" - # get the foreground colours from current theme - theme = napari.utils.theme.get_theme(self.viewer.theme, as_dict=False) - fg_colour = theme.foreground.as_hex() # fg is a muted contrast to bg - text_colour = theme.text.as_hex() # text is high contrast to bg - # changing color of axes background to transparent - ax.set_facecolor("none") - - # changing colors of all axes - for spine in ax.spines: - ax.spines[spine].set_color(fg_colour) + def apply_style(self) -> None: + """ + Any user-supplied stylesheet takes highest precidence, otherwise apply + the napari-compatible colorscheme (depends on the napari theme). + """ + if self._apply_user_stylesheet_if_present(): + return + + stylesheet_dir = self._get_path_to_mpl_stylesheets() + if self.viewer.theme == "dark": + matplotlib.style.use(stylesheet_dir / "napari-dark.mplstyle") + elif self.viewer.theme == "light": + matplotlib.style.use(stylesheet_dir / "napari-light.mplstyle") + else: + warnings.warn( + f"Napari theme '{self.viewer.theme}' is not supported by" + " napari-matplotlib. Will fall back to the matplotlib default." + ) + matplotlib.style.use("default") + return - ax.xaxis.label.set_color(text_colour) - ax.yaxis.label.set_color(text_colour) + def _apply_user_stylesheet_if_present(self) -> bool: + """ + Apply the user-supplied stylesheet if present. - # changing colors of axes labels - ax.tick_params(axis="x", colors=text_colour) - ax.tick_params(axis="y", colors=text_colour) + Returns + ------- + True if the stylesheet was present and applied. + False otherwise. + """ + if (Path.cwd() / "user.mplstyle").exists(): + matplotlib.style.use("./user.mplstyle") + return True + # TODO: can put more complicated stuff in here. Like a config dir, + # or take a given named file from the matplotlib user styles + return False def _on_theme_change(self) -> None: """Update MPL toolbar and axis styling when `napari.Viewer.theme` is changed. @@ -100,8 +114,8 @@ def _on_theme_change(self) -> None: At the moment we only handle the default 'light' and 'dark' napari themes. """ self._replace_toolbar_icons() - if self.figure.gca(): - self.apply_napari_colorscheme(self.figure.gca()) + self.apply_style() + # self.canvas.reload() def _theme_has_light_bg(self) -> bool: """ @@ -116,6 +130,9 @@ def _theme_has_light_bg(self) -> bool: _, _, bg_lightness = theme.background.as_hsl_tuple() return bg_lightness > 0.5 + def _get_path_to_mpl_stylesheets(self) -> Path: + return Path(__file__).parent / "stylesheets" + def _get_path_to_icon(self) -> Path: """ Get the icons directory (which is theme-dependent). @@ -245,7 +262,7 @@ def _draw(self) -> None: isinstance(layer, self.input_layer_types) for layer in self.layers ): self.draw() - self.apply_napari_colorscheme(self.figure.gca()) + self.apply_style() self.canvas.draw() def clear(self) -> None: diff --git a/src/napari_matplotlib/stylesheets/napari-dark.mplstyle b/src/napari_matplotlib/stylesheets/napari-dark.mplstyle new file mode 100644 index 00000000..1658f9b4 --- /dev/null +++ b/src/napari_matplotlib/stylesheets/napari-dark.mplstyle @@ -0,0 +1,12 @@ +# 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/stylesheets/napari-light.mplstyle b/src/napari_matplotlib/stylesheets/napari-light.mplstyle new file mode 100644 index 00000000..be78f2ce --- /dev/null +++ b/src/napari_matplotlib/stylesheets/napari-light.mplstyle @@ -0,0 +1,12 @@ +# Light-theme napari colour scheme for matplotlib plots + +# text (): #3b3a39 +# foreground (): #d6d0ce +# background (): #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_histogram_2D.png b/src/napari_matplotlib/tests/baseline/test_histogram_2D.png index b76d1e10..aeb4fb02 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 2dffdcb2..3e6abfb6 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/test_theme.py b/src/napari_matplotlib/tests/test_theme.py index dfeb5b5f..129a79ba 100644 --- a/src/napari_matplotlib/tests/test_theme.py +++ b/src/napari_matplotlib/tests/test_theme.py @@ -1,8 +1,13 @@ +import shutil +from pathlib import Path + +import matplotlib import napari import numpy as np import pytest +from matplotlib.colors import to_rgba -from napari_matplotlib import ScatterWidget +from napari_matplotlib import HistogramWidget, ScatterWidget from napari_matplotlib.base import NapariMPLWidget @@ -49,8 +54,23 @@ def test_theme_background_check(make_napari_viewer): assert widget._theme_has_light_bg() is True _mock_up_theme() + with pytest.warns(UserWarning, match="theme 'blue' is not supported"): + viewer.theme = "blue" + assert widget._theme_has_light_bg() is True + + +def test_unknown_theme_raises_warning(make_napari_viewer): + """ + Check that widget construction warns if it doesn't recognise napari's theme. + + Note that testing for the expected warning when theme is changed _after_ the + widget is created is part of ``test_theme_background_check``. + """ + viewer = make_napari_viewer() + _mock_up_theme() # creates the 'blue' theme which is not a standard napari theme viewer.theme = "blue" - assert widget._theme_has_light_bg() is True + with pytest.warns(UserWarning, match="theme 'blue' is not supported"): + HistogramWidget(viewer) @pytest.mark.parametrize( @@ -88,3 +108,34 @@ def test_titles_respect_theme( assert ax.xaxis.label.get_color() == expected_text_colour assert ax.yaxis.label.get_color() == expected_text_colour + + +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_stylesheet_in_cwd(tmpdir, 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. + """ + with tmpdir.as_cwd(): + # Copy Solarize_Light2 to current dir as if it was a user-overriden stylesheet. + shutil.copy(find_mpl_stylesheet("Solarize_Light2"), "./user.mplstyle") + viewer = make_napari_viewer() + viewer.add_image(image_data[0], **image_data[1]) + widget = HistogramWidget(viewer) + 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() == "#fdf6e3"