Skip to content

Theme detection and update improvements #237

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 8 commits into from
Jan 13, 2024
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: 0 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
include LICENSE
include README.md
recursive-include * *.mplstyle

recursive-exclude * __pycache__
recursive-exclude * *.py[co]
Expand Down
11 changes: 3 additions & 8 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ To use these:

Customising plots
-----------------
`Matplotlib style sheets <https://matplotlib.org/stable/tutorials/introductory/customizing.html#defining-your-own-style>`__ can be used to customise
the plots generated by ``napari-matplotlib``.
To use a custom style sheet:

1. Save it as ``napari-matplotlib.mplstyle``
2. Put it in the Matplotlib configuration directory.
The location of this directory varies on different computers,
and can be found by calling :func:`matplotlib.get_configdir()`.
``napari-matplotlib`` uses colours from the current napari theme to customise the
Matplotlib plots. See `the example on creating a new napari theme
<https://napari.org/stable/gallery/new_theme.html>`_ for a helpful guide.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ write_to = "src/napari_matplotlib/_version.py"

[tool.pytest.ini_options]
qt_api = "pyqt6"
addopts = "--mpl"
addopts = "--mpl --mpl-baseline-relative"
filterwarnings = [
"error",
# Coming from vispy
Expand Down
79 changes: 35 additions & 44 deletions src/napari_matplotlib/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,22 @@
from pathlib import Path
from typing import Optional

import matplotlib
import matplotlib.style as mplstyle
import napari
from matplotlib.backends.backend_qtagg import ( # type: ignore[attr-defined]
FigureCanvasQTAgg,
NavigationToolbar2QT,
)
from matplotlib.figure import Figure
from napari.utils.events import Event
from napari.utils.theme import get_theme
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget

from .util import Interval, from_napari_css_get_size_of
from .util import Interval, from_napari_css_get_size_of, style_sheet_from_theme

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

_CUSTOM_STYLE_PATH = (
Path(matplotlib.get_configdir()) / "napari-matplotlib.mplstyle"
)


class BaseNapariMPLWidget(QWidget):
"""
Expand All @@ -45,18 +42,17 @@ def __init__(
):
super().__init__(parent=parent)
self.viewer = napari_viewer
self._mpl_style_sheet_path: Optional[Path] = None
self.napari_theme_style_sheet = style_sheet_from_theme(
get_theme(napari_viewer.theme, as_dict=False)
)

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

self.canvas.figure.set_layout_engine("constrained")
self.toolbar = NapariNavigationToolbar(self.canvas, parent=self)
self._replace_toolbar_icons()
# 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
Comment on lines -57 to -59
Copy link
Collaborator

Choose a reason for hiding this comment

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

Fixes #140 ?

Copy link
Contributor Author

@Chris-N-K Chris-N-K Jan 12, 2024

Choose a reason for hiding this comment

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

Yes it fixes the axelabel text colour issue, but is kind of a work around for the background issue.
All the text on the canvas (except in he legend) is now linked to the napari text colour which should always be high contrast to the background colour.
The background is transparent, which is not optimal in all cases, but for what ever reason I was not able to implement it in a way that the background is updated correctly.
I guess there might be an underlying bug not directly connected to napari-matplotlib but either napari or and this is my guess the way matplotlib updates the canvas.

self.viewer.events.theme.connect(self._on_napari_theme_changed)

self.setLayout(QVBoxLayout())
Expand All @@ -68,24 +64,6 @@ 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 (_CUSTOM_STYLE_PATH).exists():
return _CUSTOM_STYLE_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.
Expand All @@ -94,13 +72,21 @@ def add_single_axes(self) -> None:
"""
# Sets axes.* style.
# Does not set any text styling set by axes.* keys
with mplstyle.context(self.mpl_style_sheet_path):
with mplstyle.context(self.napari_theme_style_sheet):
self.axes = self.figure.add_subplot()

def _on_napari_theme_changed(self) -> None:
def _on_napari_theme_changed(self, event: Event) -> None:
"""
Called when the napari theme is changed.

Parameters
----------
event : napari.utils.events.Event
Event that triggered the callback.
"""
self.napari_theme_style_sheet = style_sheet_from_theme(
get_theme(event.value, as_dict=False)
)
self._replace_toolbar_icons()

def _napari_theme_has_light_bg(self) -> bool:
Expand Down Expand Up @@ -211,15 +197,18 @@ def current_z(self) -> int:
"""
return self.viewer.dims.current_step[0]

def _on_napari_theme_changed(self) -> None:
def _on_napari_theme_changed(self, event: Event) -> 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.
Parameters
----------
event : napari.utils.events.Event
Event that triggered the callback.
"""
super()._on_napari_theme_changed()
self.clear()
self.draw()
super()._on_napari_theme_changed(event)
# use self._draw instead of self.draw to cope with redraw while there are no
# layers, this makes the self.clear() obsolete
self._draw()

def _setup_callbacks(self) -> None:
"""
Expand Down Expand Up @@ -252,13 +241,15 @@ def _draw(self) -> None:
"""
# Clearing axes sets new defaults, so need to make sure style is applied when
# this happens
with mplstyle.context(self.mpl_style_sheet_path):
with mplstyle.context(self.napari_theme_style_sheet):
# everything should be done in the style context
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.canvas.draw() # type: ignore[no-untyped-call]
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.canvas.draw() # type: ignore[no-untyped-call]

def clear(self) -> None:
"""
Expand Down Expand Up @@ -300,7 +291,7 @@ def clear(self) -> None:
"""
Clear the axes.
"""
with mplstyle.context(self.mpl_style_sheet_path):
with mplstyle.context(self.napari_theme_style_sheet):
self.axes.clear()


Expand Down
3 changes: 0 additions & 3 deletions src/napari_matplotlib/styles/README.md

This file was deleted.

12 changes: 0 additions & 12 deletions src/napari_matplotlib/styles/dark.mplstyle

This file was deleted.

12 changes: 0 additions & 12 deletions src/napari_matplotlib/styles/light.mplstyle

This file was deleted.

Binary file modified src/napari_matplotlib/tests/baseline/test_custom_theme.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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_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.
Binary file modified src/napari_matplotlib/tests/baseline/test_slice_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_slice_3D.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 0 additions & 15 deletions src/napari_matplotlib/tests/data/test_theme.mplstyle

This file was deleted.

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/scatter/baseline/test_scatter_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/scatter/baseline/test_scatter_3D.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 1 addition & 71 deletions src/napari_matplotlib/tests/test_theme.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import os
import shutil
from copy import deepcopy
from pathlib import Path

import matplotlib
import napari
import numpy as np
import pytest
from matplotlib.colors import to_rgba

from napari_matplotlib import HistogramWidget, ScatterWidget
from napari_matplotlib import ScatterWidget
from napari_matplotlib.base import NapariMPLWidget


Expand Down Expand Up @@ -127,66 +120,3 @@ 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)


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_custom_stylesheet(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.
"""
# Copy Solarize_Light2 as if it was a user-overriden stylesheet.
style_sheet_path = (
Path(matplotlib.get_configdir()) / "napari-matplotlib.mplstyle"
)
if style_sheet_path.exists():
pytest.skip("Won't ovewrite existing custom style sheet.")
shutil.copy(
find_mpl_stylesheet("Solarize_Light2"),
style_sheet_path,
)

try:
viewer = make_napari_viewer()
viewer.add_image(image_data[0], **image_data[1])
widget = HistogramWidget(viewer)
assert widget.mpl_style_sheet_path == style_sheet_path
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() == "#b0b0b0"
finally:
os.remove(style_sheet_path)
47 changes: 47 additions & 0 deletions src/napari_matplotlib/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import napari.qt
import tinycss2
from napari.utils.theme import Theme
from qtpy.QtCore import QSize


Expand Down Expand Up @@ -138,3 +139,49 @@ def from_napari_css_get_size_of(
RuntimeWarning,
)
return QSize(*fallback)


def style_sheet_from_theme(theme: Theme) -> dict[str, str]:
"""Translate napari theme to a matplotlib style dictionary.

Parameters
----------
theme : napari.utils.theme.Theme
Napari theme object representing the theme of the current viewer.

Returns
-------
Dict[str, str]
Matplotlib compatible style dictionary.
"""
return {
"axes.edgecolor": theme.secondary.as_hex(),
# BUG: could be the same as napari canvas, but facecolors do not get
# updated upon redraw for what ever reason
#'axes.facecolor':theme.canvas.as_hex(),
"axes.facecolor": "none",
"axes.labelcolor": theme.text.as_hex(),
"boxplot.boxprops.color": theme.text.as_hex(),
"boxplot.capprops.color": theme.text.as_hex(),
"boxplot.flierprops.markeredgecolor": theme.text.as_hex(),
"boxplot.whiskerprops.color": theme.text.as_hex(),
"figure.edgecolor": theme.secondary.as_hex(),
# BUG: should be the same as napari background, but facecolors do not get
# updated upon redraw for what ever reason
#'figure.facecolor':theme.background.as_hex(),
"figure.facecolor": "none",
"grid.color": theme.foreground.as_hex(),
# COMMENT: the hard coded colors are to match the previous behaviour
# alternativly we could use the theme to style the legend as well
#'legend.edgecolor':theme.secondary.as_hex(),
"legend.edgecolor": "black",
#'legend.facecolor':theme.background.as_hex(),
"legend.facecolor": "white",
#'legend.labelcolor':theme.text.as_hex()
"legend.labelcolor": "black",
"text.color": theme.text.as_hex(),
"xtick.color": theme.secondary.as_hex(),
"xtick.labelcolor": theme.text.as_hex(),
"ytick.color": theme.secondary.as_hex(),
"ytick.labelcolor": theme.text.as_hex(),
}