Skip to content

Napari light dark themes as stylesheets instead of set up by hand. #157

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 43 additions & 26 deletions src/napari_matplotlib/base.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is where we're now less flexible. I figured we should fallback to the default mpl style ☝️

We could also do something more complicated with self._theme_has_light_bg and choosing a light or dark mpl style to contrast.

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.
Expand All @@ -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:
"""
Expand All @@ -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).
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions src/napari_matplotlib/stylesheets/napari-dark.mplstyle
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions src/napari_matplotlib/stylesheets/napari-light.mplstyle
Original file line number Diff line number Diff line change
@@ -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
Binary file modified src/napari_matplotlib/tests/baseline/test_histogram_2D.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/napari_matplotlib/tests/baseline/test_histogram_3D.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 53 additions & 2 deletions src/napari_matplotlib/tests/test_theme.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"