Skip to content

Allow style sheets to be used #175

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

Merged
merged 4 commits into from
Jun 21, 2023
Merged
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
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
include LICENSE
include README.md
recursive-include * *.mplstyle

recursive-exclude * __pycache__
recursive-exclude * *.py[co]
Expand Down
1 change: 0 additions & 1 deletion docs/guide/third_party.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------
Expand Down
85 changes: 47 additions & 38 deletions src/napari_matplotlib/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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?

Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions src/napari_matplotlib/scatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/napari_matplotlib/styles/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions src/napari_matplotlib/styles/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/styles/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 (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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions src/napari_matplotlib/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from pathlib import Path

import numpy as np
import pytest
Expand Down Expand Up @@ -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"
15 changes: 15 additions & 0 deletions src/napari_matplotlib/tests/data/test_theme.mplstyle
Original file line number Diff line number Diff line change
@@ -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
28 changes: 25 additions & 3 deletions src/napari_matplotlib/tests/test_theme.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from copy import deepcopy

import napari
import numpy as np
import pytest
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)