Skip to content

Commit c481207

Browse files
authored
Merge pull request #237 from Chris-N-K/theme_detection_update
Theme detection and update improvements
2 parents cf1b129 + 2d1f804 commit c481207

20 files changed

+87
-167
lines changed

Diff for: MANIFEST.in

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
include LICENSE
22
include README.md
3-
recursive-include * *.mplstyle
43

54
recursive-exclude * __pycache__
65
recursive-exclude * *.py[co]

Diff for: docs/user_guide.rst

+3-8
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,6 @@ To use these:
4040

4141
Customising plots
4242
-----------------
43-
`Matplotlib style sheets <https://matplotlib.org/stable/tutorials/introductory/customizing.html#defining-your-own-style>`__ can be used to customise
44-
the plots generated by ``napari-matplotlib``.
45-
To use a custom style sheet:
46-
47-
1. Save it as ``napari-matplotlib.mplstyle``
48-
2. Put it in the Matplotlib configuration directory.
49-
The location of this directory varies on different computers,
50-
and can be found by calling :func:`matplotlib.get_configdir()`.
43+
``napari-matplotlib`` uses colours from the current napari theme to customise the
44+
Matplotlib plots. See `the example on creating a new napari theme
45+
<https://napari.org/stable/gallery/new_theme.html>`_ for a helpful guide.

Diff for: pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ write_to = "src/napari_matplotlib/_version.py"
77

88
[tool.pytest.ini_options]
99
qt_api = "pyqt6"
10-
addopts = "--mpl"
10+
addopts = "--mpl --mpl-baseline-relative"
1111
filterwarnings = [
1212
"error",
1313
# Coming from vispy

Diff for: src/napari_matplotlib/base.py

+35-44
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,22 @@
22
from pathlib import Path
33
from typing import Optional
44

5-
import matplotlib
65
import matplotlib.style as mplstyle
76
import napari
87
from matplotlib.backends.backend_qtagg import ( # type: ignore[attr-defined]
98
FigureCanvasQTAgg,
109
NavigationToolbar2QT,
1110
)
1211
from matplotlib.figure import Figure
12+
from napari.utils.events import Event
13+
from napari.utils.theme import get_theme
1314
from qtpy.QtGui import QIcon
1415
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
1516

16-
from .util import Interval, from_napari_css_get_size_of
17+
from .util import Interval, from_napari_css_get_size_of, style_sheet_from_theme
1718

1819
__all__ = ["BaseNapariMPLWidget", "NapariMPLWidget", "SingleAxesWidget"]
1920

20-
_CUSTOM_STYLE_PATH = (
21-
Path(matplotlib.get_configdir()) / "napari-matplotlib.mplstyle"
22-
)
23-
2421

2522
class BaseNapariMPLWidget(QWidget):
2623
"""
@@ -45,18 +42,17 @@ def __init__(
4542
):
4643
super().__init__(parent=parent)
4744
self.viewer = napari_viewer
48-
self._mpl_style_sheet_path: Optional[Path] = None
45+
self.napari_theme_style_sheet = style_sheet_from_theme(
46+
get_theme(napari_viewer.theme, as_dict=False)
47+
)
4948

5049
# Sets figure.* style
51-
with mplstyle.context(self.mpl_style_sheet_path):
50+
with mplstyle.context(self.napari_theme_style_sheet):
5251
self.canvas = FigureCanvasQTAgg() # type: ignore[no-untyped-call]
5352

5453
self.canvas.figure.set_layout_engine("constrained")
5554
self.toolbar = NapariNavigationToolbar(self.canvas, parent=self)
5655
self._replace_toolbar_icons()
57-
# callback to update when napari theme changed
58-
# TODO: this isn't working completely (see issue #140)
59-
# most of our styling respects the theme change but not all
6056
self.viewer.events.theme.connect(self._on_napari_theme_changed)
6157

6258
self.setLayout(QVBoxLayout())
@@ -68,24 +64,6 @@ def figure(self) -> Figure:
6864
"""Matplotlib figure."""
6965
return self.canvas.figure
7066

71-
@property
72-
def mpl_style_sheet_path(self) -> Path:
73-
"""
74-
Path to the set Matplotlib style sheet.
75-
"""
76-
if self._mpl_style_sheet_path is not None:
77-
return self._mpl_style_sheet_path
78-
elif (_CUSTOM_STYLE_PATH).exists():
79-
return _CUSTOM_STYLE_PATH
80-
elif self._napari_theme_has_light_bg():
81-
return Path(__file__).parent / "styles" / "light.mplstyle"
82-
else:
83-
return Path(__file__).parent / "styles" / "dark.mplstyle"
84-
85-
@mpl_style_sheet_path.setter
86-
def mpl_style_sheet_path(self, path: Path) -> None:
87-
self._mpl_style_sheet_path = Path(path)
88-
8967
def add_single_axes(self) -> None:
9068
"""
9169
Add a single Axes to the figure.
@@ -94,13 +72,21 @@ def add_single_axes(self) -> None:
9472
"""
9573
# Sets axes.* style.
9674
# Does not set any text styling set by axes.* keys
97-
with mplstyle.context(self.mpl_style_sheet_path):
75+
with mplstyle.context(self.napari_theme_style_sheet):
9876
self.axes = self.figure.add_subplot()
9977

100-
def _on_napari_theme_changed(self) -> None:
78+
def _on_napari_theme_changed(self, event: Event) -> None:
10179
"""
10280
Called when the napari theme is changed.
81+
82+
Parameters
83+
----------
84+
event : napari.utils.events.Event
85+
Event that triggered the callback.
10386
"""
87+
self.napari_theme_style_sheet = style_sheet_from_theme(
88+
get_theme(event.value, as_dict=False)
89+
)
10490
self._replace_toolbar_icons()
10591

10692
def _napari_theme_has_light_bg(self) -> bool:
@@ -211,15 +197,18 @@ def current_z(self) -> int:
211197
"""
212198
return self.viewer.dims.current_step[0]
213199

214-
def _on_napari_theme_changed(self) -> None:
200+
def _on_napari_theme_changed(self, event: Event) -> None:
215201
"""Update MPL toolbar and axis styling when `napari.Viewer.theme` is changed.
216202
217-
Note:
218-
At the moment we only handle the default 'light' and 'dark' napari themes.
203+
Parameters
204+
----------
205+
event : napari.utils.events.Event
206+
Event that triggered the callback.
219207
"""
220-
super()._on_napari_theme_changed()
221-
self.clear()
222-
self.draw()
208+
super()._on_napari_theme_changed(event)
209+
# use self._draw instead of self.draw to cope with redraw while there are no
210+
# layers, this makes the self.clear() obsolete
211+
self._draw()
223212

224213
def _setup_callbacks(self) -> None:
225214
"""
@@ -252,13 +241,15 @@ def _draw(self) -> None:
252241
"""
253242
# Clearing axes sets new defaults, so need to make sure style is applied when
254243
# this happens
255-
with mplstyle.context(self.mpl_style_sheet_path):
244+
with mplstyle.context(self.napari_theme_style_sheet):
245+
# everything should be done in the style context
256246
self.clear()
257-
if self.n_selected_layers in self.n_layers_input and all(
258-
isinstance(layer, self.input_layer_types) for layer in self.layers
259-
):
260-
self.draw()
261-
self.canvas.draw() # type: ignore[no-untyped-call]
247+
if self.n_selected_layers in self.n_layers_input and all(
248+
isinstance(layer, self.input_layer_types)
249+
for layer in self.layers
250+
):
251+
self.draw()
252+
self.canvas.draw() # type: ignore[no-untyped-call]
262253

263254
def clear(self) -> None:
264255
"""
@@ -300,7 +291,7 @@ def clear(self) -> None:
300291
"""
301292
Clear the axes.
302293
"""
303-
with mplstyle.context(self.mpl_style_sheet_path):
294+
with mplstyle.context(self.napari_theme_style_sheet):
304295
self.axes.clear()
305296

306297

Diff for: src/napari_matplotlib/styles/README.md

-3
This file was deleted.

Diff for: src/napari_matplotlib/styles/dark.mplstyle

-12
This file was deleted.

Diff for: src/napari_matplotlib/styles/light.mplstyle

-12
This file was deleted.
-3.26 KB
Loading
Loading
Loading
59 Bytes
Loading
92 Bytes
Loading
237 Bytes
Loading
4 Bytes
Loading

Diff for: src/napari_matplotlib/tests/data/test_theme.mplstyle

-15
This file was deleted.
Loading
158 Bytes
Loading
311 Bytes
Loading

Diff for: src/napari_matplotlib/tests/test_theme.py

+1-71
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
1-
import os
2-
import shutil
3-
from copy import deepcopy
4-
from pathlib import Path
5-
6-
import matplotlib
71
import napari
82
import numpy as np
93
import pytest
10-
from matplotlib.colors import to_rgba
114

12-
from napari_matplotlib import HistogramWidget, ScatterWidget
5+
from napari_matplotlib import ScatterWidget
136
from napari_matplotlib.base import NapariMPLWidget
147

158

@@ -127,66 +120,3 @@ def test_no_theme_side_effects(make_napari_viewer):
127120
unrelated_figure.tight_layout()
128121

129122
return unrelated_figure
130-
131-
132-
@pytest.mark.mpl_image_compare
133-
def test_custom_theme(make_napari_viewer, theme_path, brain_data):
134-
viewer = make_napari_viewer()
135-
viewer.theme = "dark"
136-
137-
widget = ScatterWidget(viewer)
138-
widget.mpl_style_sheet_path = theme_path
139-
140-
viewer.add_image(brain_data[0], **brain_data[1], name="brain")
141-
viewer.add_image(
142-
brain_data[0] * -1, **brain_data[1], name="brain_reversed"
143-
)
144-
145-
viewer.layers.selection.clear()
146-
viewer.layers.selection.add(viewer.layers[0])
147-
viewer.layers.selection.add(viewer.layers[1])
148-
149-
return deepcopy(widget.figure)
150-
151-
152-
def find_mpl_stylesheet(name: str) -> Path:
153-
"""Find the built-in matplotlib stylesheet."""
154-
return Path(matplotlib.__path__[0]) / f"mpl-data/stylelib/{name}.mplstyle"
155-
156-
157-
def test_custom_stylesheet(make_napari_viewer, image_data):
158-
"""
159-
Test that a stylesheet in the current directory is given precidence.
160-
161-
Do this by copying over a stylesheet from matplotlib's built in styles,
162-
naming it correctly, and checking the colours are as expected.
163-
"""
164-
# Copy Solarize_Light2 as if it was a user-overriden stylesheet.
165-
style_sheet_path = (
166-
Path(matplotlib.get_configdir()) / "napari-matplotlib.mplstyle"
167-
)
168-
if style_sheet_path.exists():
169-
pytest.skip("Won't ovewrite existing custom style sheet.")
170-
shutil.copy(
171-
find_mpl_stylesheet("Solarize_Light2"),
172-
style_sheet_path,
173-
)
174-
175-
try:
176-
viewer = make_napari_viewer()
177-
viewer.add_image(image_data[0], **image_data[1])
178-
widget = HistogramWidget(viewer)
179-
assert widget.mpl_style_sheet_path == style_sheet_path
180-
ax = widget.figure.gca()
181-
182-
# The axes should have a light brownish grey background:
183-
assert ax.get_facecolor() == to_rgba("#eee8d5")
184-
assert ax.patch.get_facecolor() == to_rgba("#eee8d5")
185-
186-
# The figure background and axis gridlines are light yellow:
187-
assert widget.figure.patch.get_facecolor() == to_rgba("#fdf6e3")
188-
for gridline in ax.get_xgridlines() + ax.get_ygridlines():
189-
assert gridline.get_visible() is True
190-
assert gridline.get_color() == "#b0b0b0"
191-
finally:
192-
os.remove(style_sheet_path)

Diff for: src/napari_matplotlib/util.py

+47
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import napari.qt
55
import tinycss2
6+
from napari.utils.theme import Theme
67
from qtpy.QtCore import QSize
78

89

@@ -138,3 +139,49 @@ def from_napari_css_get_size_of(
138139
RuntimeWarning,
139140
)
140141
return QSize(*fallback)
142+
143+
144+
def style_sheet_from_theme(theme: Theme) -> dict[str, str]:
145+
"""Translate napari theme to a matplotlib style dictionary.
146+
147+
Parameters
148+
----------
149+
theme : napari.utils.theme.Theme
150+
Napari theme object representing the theme of the current viewer.
151+
152+
Returns
153+
-------
154+
Dict[str, str]
155+
Matplotlib compatible style dictionary.
156+
"""
157+
return {
158+
"axes.edgecolor": theme.secondary.as_hex(),
159+
# BUG: could be the same as napari canvas, but facecolors do not get
160+
# updated upon redraw for what ever reason
161+
#'axes.facecolor':theme.canvas.as_hex(),
162+
"axes.facecolor": "none",
163+
"axes.labelcolor": theme.text.as_hex(),
164+
"boxplot.boxprops.color": theme.text.as_hex(),
165+
"boxplot.capprops.color": theme.text.as_hex(),
166+
"boxplot.flierprops.markeredgecolor": theme.text.as_hex(),
167+
"boxplot.whiskerprops.color": theme.text.as_hex(),
168+
"figure.edgecolor": theme.secondary.as_hex(),
169+
# BUG: should be the same as napari background, but facecolors do not get
170+
# updated upon redraw for what ever reason
171+
#'figure.facecolor':theme.background.as_hex(),
172+
"figure.facecolor": "none",
173+
"grid.color": theme.foreground.as_hex(),
174+
# COMMENT: the hard coded colors are to match the previous behaviour
175+
# alternativly we could use the theme to style the legend as well
176+
#'legend.edgecolor':theme.secondary.as_hex(),
177+
"legend.edgecolor": "black",
178+
#'legend.facecolor':theme.background.as_hex(),
179+
"legend.facecolor": "white",
180+
#'legend.labelcolor':theme.text.as_hex()
181+
"legend.labelcolor": "black",
182+
"text.color": theme.text.as_hex(),
183+
"xtick.color": theme.secondary.as_hex(),
184+
"xtick.labelcolor": theme.text.as_hex(),
185+
"ytick.color": theme.secondary.as_hex(),
186+
"ytick.labelcolor": theme.text.as_hex(),
187+
}

0 commit comments

Comments
 (0)