Skip to content

Match plugin text/axis/icon colours to the napari theme. #138

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
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
2 changes: 1 addition & 1 deletion docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ New features

Visual improvements
~~~~~~~~~~~~~~~~~~~
- The background of ``napari-matplotlib`` figures and axes is now transparent.
- The background of ``napari-matplotlib`` figures and axes is now transparent, and the text and axis colour respects the ``napari`` theme.
- The icons in the Matplotlib toolbar are now the same size as icons in the napari window.

Changes
Expand Down
77 changes: 61 additions & 16 deletions src/napari_matplotlib/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ class BaseNapariMPLWidget(QWidget):

def __init__(
self,
napari_viewer: napari.Viewer,
parent: Optional[QWidget] = None,
):
super().__init__(parent=parent)
self.viewer = napari_viewer

self.canvas = FigureCanvas()

Expand All @@ -50,6 +52,10 @@ def __init__(
self.canvas, parent=self
) # type: ignore[no-untyped-call]
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
self.viewer.events.theme.connect(self._on_theme_change)

self.setLayout(QVBoxLayout())
self.layout().addWidget(self.toolbar)
Expand All @@ -69,25 +75,64 @@ def add_single_axes(self) -> None:
self.axes = self.figure.subplots()
self.apply_napari_colorscheme(self.axes)

@staticmethod
def apply_napari_colorscheme(ax: Axes) -> None:
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("white")
ax.spines[spine].set_color(fg_colour)

ax.xaxis.label.set_color("white")
ax.yaxis.label.set_color("white")
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="white")
ax.tick_params(axis="y", colors="white")
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.
"""
self._replace_toolbar_icons()
if self.figure.gca():
self.apply_napari_colorscheme(self.figure.gca())
Copy link
Collaborator Author

@samcunliffe samcunliffe May 31, 2023

Choose a reason for hiding this comment

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

Not sure if this is the correct line, but I still don't quite have a perfect UX when switching the theme whilst the widgets are open...

Screen.Recording.2023-05-31.at.16.51.00.mov

Re-opening or doing something to the plot seems to fix.

Copy link
Member

Choose a reason for hiding this comment

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

A bit of a dive into the napari source, and you might want to use something like this line to trigger something when the theme is changed?

https://github.com/napari/napari/blob/46dcc9c0976c35ec9dc64be60235f4addcadaa5c/napari/_qt/qt_main_window.py#L578

Copy link
Collaborator Author

@samcunliffe samcunliffe May 31, 2023

Choose a reason for hiding this comment

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

😞

napari.settings.get_settings().appearance.events.theme.connect(
    self._on_theme_change
)

gives the same behaviour. I wonder if it might be a different way to do the same thing as

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

The theme-change event is undocumented* (scroll a bit from the start of § Viewer events) so I'd be up for asking and/or fixing the docs for napari.


*) It's also duplicated: that page is made via a script in napari/docs.


def _theme_has_light_bg(self) -> bool:
"""
Does this theme have a light background?

Returns
-------
bool
True if theme's background colour has hsl lighter than 50%, False if darker.
"""
theme = napari.utils.theme.get_theme(self.viewer.theme, as_dict=False)
_, _, bg_lightness = theme.background.as_hsl_tuple()
return bg_lightness > 0.5

def _get_path_to_icon(self) -> Path:
"""
Get the icons directory (which is theme-dependent).
"""
if self._theme_has_light_bg():
return ICON_ROOT / "black"
else:
return ICON_ROOT / "white"

def _replace_toolbar_icons(self) -> None:
# Modify toolbar icons and some tooltips
"""
Modifies toolbar icons to match the napari theme, and add some tooltips.
"""
icon_dir = self._get_path_to_icon()
for action in self.toolbar.actions():
text = action.text()
if text == "Pan":
Expand All @@ -101,7 +146,7 @@ def _replace_toolbar_icons(self) -> None:
"Click again to deactivate"
)
if len(text) > 0: # i.e. not a separator item
icon_path = os.path.join(ICON_ROOT, text + ".png")
icon_path = os.path.join(icon_dir, text + ".png")
action.setIcon(QIcon(icon_path))


Expand Down Expand Up @@ -138,9 +183,7 @@ def __init__(
napari_viewer: napari.viewer.Viewer,
parent: Optional[QWidget] = None,
):
super().__init__(parent=parent)

self.viewer = napari_viewer
super().__init__(napari_viewer=napari_viewer, parent=parent)
self._setup_callbacks()
self.layers: List[napari.layers.Layer] = []

Expand Down Expand Up @@ -235,22 +278,24 @@ def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def]
def _update_buttons_checked(self) -> None:
"""Update toggle tool icons when selected/unselected."""
super()._update_buttons_checked()
icon_dir = self.parentWidget()._get_path_to_icon()

# changes pan/zoom icons depending on state (checked or not)
if "pan" in self._actions:
if self._actions["pan"].isChecked():
self._actions["pan"].setIcon(
QIcon(os.path.join(ICON_ROOT, "Pan_checked.png"))
QIcon(os.path.join(icon_dir, "Pan_checked.png"))
)
else:
self._actions["pan"].setIcon(
QIcon(os.path.join(ICON_ROOT, "Pan.png"))
QIcon(os.path.join(icon_dir, "Pan.png"))
)
if "zoom" in self._actions:
if self._actions["zoom"].isChecked():
self._actions["zoom"].setIcon(
QIcon(os.path.join(ICON_ROOT, "Zoom_checked.png"))
QIcon(os.path.join(icon_dir, "Zoom_checked.png"))
)
else:
self._actions["zoom"].setIcon(
QIcon(os.path.join(ICON_ROOT, "Zoom.png"))
QIcon(os.path.join(icon_dir, "Zoom.png"))
)
Binary file added src/napari_matplotlib/icons/black/Back.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 added src/napari_matplotlib/icons/black/Customize.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 added src/napari_matplotlib/icons/black/Forward.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 added src/napari_matplotlib/icons/black/Home.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 added src/napari_matplotlib/icons/black/Pan.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 added src/napari_matplotlib/icons/black/Pan_checked.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 added src/napari_matplotlib/icons/black/Save.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 added src/napari_matplotlib/icons/black/Subplots.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 added src/napari_matplotlib/icons/black/Zoom.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.
Binary file not shown.
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_scatter.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
20 changes: 20 additions & 0 deletions src/napari_matplotlib/tests/test_theme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import pytest

from napari_matplotlib.base import NapariMPLWidget


@pytest.mark.parametrize(
"theme_name, expected_icons",
[("dark", "white"), ("light", "black")],
)
def test_theme_mpl_toolbar_icons(
make_napari_viewer, theme_name, expected_icons
):
"""Check that the icons are taken from the correct folder for each napari theme."""
viewer = make_napari_viewer()
viewer.theme = theme_name
path_to_icons = NapariMPLWidget(viewer)._get_path_to_icon()
assert path_to_icons.exists(), "The theme points to non-existant icons."
assert (
path_to_icons.stem == expected_icons
), "The theme is selecting unexpected icons."