diff --git a/MANIFEST.in b/MANIFEST.in index 7ce16f9b..d625d95e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include LICENSE include README.md +recursive-include * *.mplstyle recursive-exclude * __pycache__ recursive-exclude * *.py[co] diff --git a/docs/guide/third_party.rst b/docs/guide/third_party.rst index 4a793e6c..d892ba8d 100644 --- a/docs/guide/third_party.rst +++ b/docs/guide/third_party.rst @@ -32,7 +32,6 @@ The following properties and methods are useful for working with the figure and - `~.BaseNapariMPLWidget.figure` provides access to the figure - :meth:`~.BaseNapariMPLWidget.add_single_axes` adds a single axes to the figure, which can be accessed using the ``.axes`` attribute. -- :meth:`~.BaseNapariMPLWidget.apply_napari_colorscheme` can be used to apply the napari colorscheme to any Axes you setup manually on the figure. Working with napari layers -------------------------- diff --git a/src/napari_matplotlib/base.py b/src/napari_matplotlib/base.py index 8c717d6a..89c6d3de 100644 --- a/src/napari_matplotlib/base.py +++ b/src/napari_matplotlib/base.py @@ -2,8 +2,8 @@ from pathlib import Path from typing import List, Optional, Tuple +import matplotlib.style as mplstyle import napari -from matplotlib.axes import Axes from matplotlib.backends.backend_qtagg import ( FigureCanvas, NavigationToolbar2QT, @@ -40,8 +40,11 @@ def __init__( ): super().__init__(parent=parent) self.viewer = napari_viewer + self._mpl_style_sheet_path: Optional[Path] = None - self.canvas = FigureCanvas() + # Sets figure.* style + with mplstyle.context(self.mpl_style_sheet_path): + self.canvas = FigureCanvas() self.canvas.figure.patch.set_facecolor("none") self.canvas.figure.set_layout_engine("constrained") @@ -52,7 +55,7 @@ def __init__( # 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_theme_change) + self.viewer.events.theme.connect(self._on_napari_theme_changed) self.setLayout(QVBoxLayout()) self.layout().addWidget(self.toolbar) @@ -63,47 +66,40 @@ 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 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. The Axes is saved on the ``.axes`` attribute for later access. """ - self.axes = self.figure.subplots() - self.apply_napari_colorscheme(self.axes) + # Sets axes.* style. + # Does not set any text styling set by axes.* keys + with mplstyle.context(self.mpl_style_sheet_path): + self.axes = self.figure.subplots() - 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) - - ax.xaxis.label.set_color(text_colour) - ax.yaxis.label.set_color(text_colour) - - # changing colors of axes labels - ax.tick_params(axis="x", colors=text_colour) - ax.tick_params(axis="y", colors=text_colour) - - def _on_theme_change(self) -> 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. + def _on_napari_theme_changed(self) -> None: + """ + Called when the napari theme is changed. """ self._replace_toolbar_icons() - if self.figure.gca(): - self.apply_napari_colorscheme(self.figure.gca()) - def _theme_has_light_bg(self) -> bool: + def _napari_theme_has_light_bg(self) -> bool: """ Does this theme have a light background? @@ -124,7 +120,7 @@ def _get_path_to_icon(self) -> Path: https://github.com/matplotlib/matplotlib/tree/main/lib/matplotlib/mpl-data/images """ icon_root = Path(__file__).parent / "icons" - if self._theme_has_light_bg(): + if self._napari_theme_has_light_bg(): return icon_root / "black" else: return icon_root / "white" @@ -211,6 +207,16 @@ def current_z(self) -> int: """ return self.viewer.dims.current_step[0] + def _on_napari_theme_changed(self) -> 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. + """ + super()._on_napari_theme_changed() + self.clear() + self.draw() + def _setup_callbacks(self) -> None: """ Sets up callbacks. @@ -240,12 +246,14 @@ def _draw(self) -> None: Clear current figure, check selected layers are correct, and draw new figure if so. """ - self.clear() + # Clearing axes sets new defaults, so need to make sure style is applied when + # this happens + with mplstyle.context(self.mpl_style_sheet_path): + 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.apply_napari_colorscheme(self.figure.gca()) self.canvas.draw() def clear(self) -> None: @@ -288,7 +296,8 @@ def clear(self) -> None: """ Clear the axes. """ - self.axes.clear() + with mplstyle.context(self.mpl_style_sheet_path): + self.axes.clear() class NapariNavigationToolbar(NavigationToolbar2QT): diff --git a/src/napari_matplotlib/scatter.py b/src/napari_matplotlib/scatter.py index 334f941c..db86c7f3 100644 --- a/src/napari_matplotlib/scatter.py +++ b/src/napari_matplotlib/scatter.py @@ -23,6 +23,8 @@ def draw(self) -> None: """ Scatter the currently selected layers. """ + if len(self.layers) == 0: + return x, y, x_axis_name, y_axis_name = self._get_data() if x.size > self._threshold_to_switch_to_histogram: diff --git a/src/napari_matplotlib/styles/README.md b/src/napari_matplotlib/styles/README.md new file mode 100644 index 00000000..79d3c417 --- /dev/null +++ b/src/napari_matplotlib/styles/README.md @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..1658f9b4 --- /dev/null +++ b/src/napari_matplotlib/styles/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/styles/light.mplstyle b/src/napari_matplotlib/styles/light.mplstyle new file mode 100644 index 00000000..3b8d7d1d --- /dev/null +++ b/src/napari_matplotlib/styles/light.mplstyle @@ -0,0 +1,12 @@ +# 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 new file mode 100644 index 00000000..65c43a49 Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_custom_theme.png differ diff --git a/src/napari_matplotlib/tests/conftest.py b/src/napari_matplotlib/tests/conftest.py index b90f9ad1..6b2a813f 100644 --- a/src/napari_matplotlib/tests/conftest.py +++ b/src/napari_matplotlib/tests/conftest.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import numpy as np import pytest @@ -52,3 +53,8 @@ def set_strict_qt(): os.environ[env_var] = old_val else: del os.environ[env_var] + + +@pytest.fixture +def theme_path(): + return Path(__file__).parent / "data" / "test_theme.mplstyle" diff --git a/src/napari_matplotlib/tests/data/test_theme.mplstyle b/src/napari_matplotlib/tests/data/test_theme.mplstyle new file mode 100644 index 00000000..2f94b31f --- /dev/null +++ b/src/napari_matplotlib/tests/data/test_theme.mplstyle @@ -0,0 +1,15 @@ +# 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/test_theme.py b/src/napari_matplotlib/tests/test_theme.py index cf841d2b..7af61f17 100644 --- a/src/napari_matplotlib/tests/test_theme.py +++ b/src/napari_matplotlib/tests/test_theme.py @@ -1,3 +1,5 @@ +from copy import deepcopy + import napari import numpy as np import pytest @@ -43,14 +45,14 @@ def test_theme_background_check(make_napari_viewer): widget = NapariMPLWidget(viewer) viewer.theme = "dark" - assert widget._theme_has_light_bg() is False + assert widget._napari_theme_has_light_bg() is False viewer.theme = "light" - assert widget._theme_has_light_bg() is True + assert widget._napari_theme_has_light_bg() is True _mock_up_theme() viewer.theme = "blue" - assert widget._theme_has_light_bg() is True + assert widget._napari_theme_has_light_bg() is True @pytest.mark.parametrize( @@ -118,3 +120,23 @@ 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)