diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..af3b9f02
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+---
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 13ce3b85..55eb1aa2 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -10,15 +10,16 @@ on:
- main
tags:
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
+ workflow_dispatch:
jobs:
build-docs:
name: Build & Upload Artifact
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- - uses: actions/setup-python@v3
+ - uses: actions/setup-python@v5
with:
python-version: "3.10"
@@ -31,13 +32,13 @@ jobs:
sudo apt install graphviz --yes
- name: Build Docs
- uses: aganders3/headless-gui@v1
+ uses: aganders3/headless-gui@v2
with:
run: make html
working-directory: ./docs
- name: Upload artifact
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: docs
path: docs/_build
@@ -48,8 +49,8 @@ jobs:
needs: build-docs
if: contains(github.ref, 'tags')
steps:
- - uses: actions/checkout@v3
- - uses: actions/download-artifact@v3
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4.3.0
with:
name: docs
diff --git a/.github/workflows/napari_hub_preview.yml b/.github/workflows/napari_hub_preview.yml
deleted file mode 100644
index c204ac45..00000000
--- a/.github/workflows/napari_hub_preview.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-name: napari hub Preview Page # we use this name to find your preview page artifact, so don't change it!
-# For more info on this action, see https://github.com/chanzuckerberg/napari-hub-preview-action/blob/main/action.yml
-
-on:
- pull_request:
- types: [ labeled ]
-
-jobs:
- preview-page:
- if: ${{ github.event.label.name == 'napari hub preview' }}
- name: Preview Page Deploy
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repo
- uses: actions/checkout@v3
-
- - name: napari hub Preview Page Builder
- uses: chanzuckerberg/napari-hub-preview-action@v0.1
- with:
- hub-ref: main
diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml
index 8665e1d2..77853f7e 100644
--- a/.github/workflows/test_and_deploy.yml
+++ b/.github/workflows/test_and_deploy.yml
@@ -12,6 +12,10 @@ on:
workflow_dispatch:
merge_group:
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
test:
name: ${{ matrix.platform }} py${{ matrix.python-version }}
@@ -20,13 +24,13 @@ jobs:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
- python-version: ['3.9', '3.10', '3.11']
+ python-version: ['3.10', '3.11', '3.12']
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v3
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
@@ -50,7 +54,7 @@ jobs:
run: python -m tox
- name: Upload pytest test results
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: pytest-results-${{ matrix.platform }} py${{ matrix.python-version }}
path: reports/
@@ -58,13 +62,15 @@ jobs:
if: ${{ always() }}
- name: Coverage
- uses: codecov/codecov-action@v3
+ uses: codecov/codecov-action@v5
# Don't run coverage on merge queue CI to avoid duplicating reports
# to codecov. See https://github.com/matplotlib/napari-matplotlib/issues/155
if: github.event_name != 'merge_group'
with:
token: ${{ secrets.CODECOV_TOKEN }}
- fail_ci_if_error: true
+ fail_ci_if_error: false
+
+
deploy:
# this will run when you have tagged a commit, starting with "v*"
@@ -77,9 +83,9 @@ jobs:
permissions:
id-token: write
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- name: Set up Python
- uses: actions/setup-python@v2
+ uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install build
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8483e70c..e592dea1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,13 +1,13 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.5.0
+ rev: v5.0.0
hooks:
- id: check-docstring-first
- id: end-of-file-fixer
- id: trailing-whitespace
- - repo: https://github.com/psf/black
- rev: 23.10.1
+ - repo: https://github.com/psf/black-pre-commit-mirror
+ rev: 25.1.0
hooks:
- id: black
@@ -17,14 +17,14 @@ repos:
- id: napari-plugin-checks
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.6.1
+ rev: v1.15.0
hooks:
- id: mypy
additional_dependencies: [numpy, matplotlib]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
- rev: 'v0.1.3'
+ rev: 'v0.11.9'
hooks:
- id: ruff
diff --git a/MANIFEST.in b/MANIFEST.in
index d625d95e..7ce16f9b 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,6 +1,5 @@
include LICENSE
include README.md
-recursive-include * *.mplstyle
recursive-exclude * __pycache__
recursive-exclude * *.py[co]
diff --git a/README.md b/README.md
index e4551d23..fb7aa635 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ A plugin to create Matplotlib plots from napari layers
## Introduction
`napari-matplotlib` is a bridge between `napari` and `matplotlib`, making it easy to create publication quality `Matplotlib` plots based on the data loaded in `napari` layers.
-Documentaiton can be found at https://napari-matplotlib.github.io/
+Documentation can be found at https://napari-matplotlib.github.io/
## Contributing
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 45952311..60dd72ba 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,5 +1,60 @@
Changelog
=========
+
+2.1.0
+-----
+New features
+~~~~~~~~~~~~
+- Added a GUI element to manually set the number of bins in the histogram widgets.
+
+2.0.3
+-----
+Bug fixes
+~~~~~~~~~
+- Fix an error that happened when the histogram widget was open, but a layer that doesn't support
+ histogramming (e.g., a labels layer) was selected.
+
+2.0.2
+-----
+Dependencies
+~~~~~~~~~~~~
+napari-matplotlib now adheres to `SPEC 0 `_, and has:
+
+- Dropped support for Python 3.9
+- Added support for Python 3.12
+- Added a minimum required numpy verison of 1.23
+- Pinned the maximum napari version to ``< 0.5``.
+ Version 3.0 of ``napari-matplotlib`` will introduce support for ``napari`` version 0.5.
+
+2.0.1
+-----
+Bug fixes
+~~~~~~~~~
+- Fixed using the ``HistogramWidget`` with layers containing multiscale data.
+- Make sure ``HistogramWidget`` uses 100 bins (not 99) when floating point data is
+ selected.
+
+2.0.0
+-----
+Changes to custom theming
+~~~~~~~~~~~~~~~~~~~~~~~~~
+``napari-matplotlib`` now uses colours from the current napari theme to customise the
+Matplotlib plots. See `the example on creating a new napari theme
+`_ for a helpful guide on how to
+create custom napari themes.
+
+This means support for custom Matplotlib styles sheets has been removed.
+
+If you spot any issues with the new theming, please report them at
+https://github.com/matplotlib/napari-matplotlib/issues.
+
+Other changes
+~~~~~~~~~~~~~
+- Histogram bin sizes for integer-type data are now force to be an integer.
+- The ``HistogramWidget`` now has two vertical lines showing the contrast limits used
+ to render the selected layer in the main napari window.
+- Added an example gallery for the ``FeaturesHistogramWidget``.
+
1.2.0
-----
Changes
diff --git a/docs/conf.py b/docs/conf.py
index 2517a59c..f1533830 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -13,7 +13,7 @@
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
-import qtgallery
+from sphinx_gallery import scrapers
# -- Project information -----------------------------------------------------
@@ -35,18 +35,58 @@
"sphinx.ext.intersphinx",
]
+
+def reset_napari(gallery_conf, fname): # type: ignore[no-untyped-def]
+ from napari.settings import get_settings
+ from qtpy.QtWidgets import QApplication
+
+ settings = get_settings()
+ settings.appearance.theme = "dark"
+
+ # Disabling `QApplication.exec_` means example scripts can call `exec_`
+ # (scripts work when run normally) without blocking example execution by
+ # sphinx-gallery. (from qtgallery)
+ QApplication.exec_ = lambda _: None
+
+
+def napari_scraper(block, block_vars, gallery_conf): # type: ignore[no-untyped-def]
+ """Basic napari window scraper.
+
+ Looks for any QtMainWindow instances and takes a screenshot of them.
+
+ `app.processEvents()` allows Qt events to propagateo and prevents hanging.
+ """
+ import napari
+
+ imgpath_iter = block_vars["image_path_iterator"]
+
+ if app := napari.qt.get_app():
+ app.processEvents()
+ else:
+ return ""
+
+ img_paths = []
+ for win, img_path in zip(
+ reversed(napari._qt.qt_main_window._QtMainWindow._instances),
+ imgpath_iter,
+ strict=False,
+ ):
+ img_paths.append(img_path)
+ win._window.screenshot(img_path, canvas_only=False)
+
+ napari.Viewer.close_all()
+ app.processEvents()
+
+ return scrapers.figure_rst(img_paths, gallery_conf["src_dir"])
+
+
sphinx_gallery_conf = {
"filename_pattern": ".",
- "image_scrapers": (qtgallery.qtscraper,),
- "reset_modules": (qtgallery.reset_qapp,),
+ "image_scrapers": (napari_scraper,),
+ "reset_modules": (reset_napari,),
}
+suppress_warnings = ["config.cache"]
-qtgallery_conf = {
- "xvfb_size": (640, 480),
- "xvfb_color_depth": 24,
- "xfvb_use_xauth": False,
- "xfvb_extra_args": [],
-}
numpydoc_show_class_members = False
automodapi_inheritance_diagram = True
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
index fbd48db1..253e3149 100644
--- a/docs/user_guide.rst
+++ b/docs/user_guide.rst
@@ -40,11 +40,6 @@ To use these:
Customising plots
-----------------
-`Matplotlib style sheets `__ 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
+`_ for a helpful guide.
diff --git a/examples/features_hist.py b/examples/features_hist.py
new file mode 100644
index 00000000..899ddef3
--- /dev/null
+++ b/examples/features_hist.py
@@ -0,0 +1,42 @@
+"""
+Hisogram of features
+====================
+"""
+
+import napari
+import numpy as np
+import numpy.typing as npt
+from skimage.measure import regionprops_table
+
+# make a test label image
+label_image: npt.NDArray[np.uint16] = np.zeros((100, 100), dtype=np.uint16)
+
+label_image[10:20, 10:20] = 1
+label_image[50:70, 50:70] = 2
+
+feature_table_1 = regionprops_table(
+ label_image, properties=("label", "area", "perimeter")
+)
+feature_table_1["index"] = feature_table_1["label"]
+
+# make the points data
+n_points = 100
+points_data = 100 * np.random.random((100, 2))
+points_features = {
+ "feature_0": np.random.random((n_points,)),
+ "feature_1": np.random.random((n_points,)),
+ "feature_2": np.random.random((n_points,)),
+}
+
+# create the viewer
+viewer = napari.Viewer()
+viewer.add_labels(label_image, features=feature_table_1)
+viewer.add_points(points_data, features=points_features)
+
+# make the widget
+viewer.window.add_plugin_dock_widget(
+ plugin_name="napari-matplotlib", widget_name="FeaturesHistogram"
+)
+
+if __name__ == "__main__":
+ napari.run()
diff --git a/examples/histogram.py b/examples/histogram.py
index ccda491a..b9ceb377 100644
--- a/examples/histogram.py
+++ b/examples/histogram.py
@@ -2,6 +2,7 @@
Histograms
==========
"""
+
import napari
viewer = napari.Viewer()
diff --git a/examples/scatter.py b/examples/scatter.py
index cd812401..00e01ec9 100644
--- a/examples/scatter.py
+++ b/examples/scatter.py
@@ -2,6 +2,7 @@
Scatter plots
=============
"""
+
import napari
viewer = napari.Viewer()
diff --git a/examples/slice.py b/examples/slice.py
index 3e43443e..242a16cc 100644
--- a/examples/slice.py
+++ b/examples/slice.py
@@ -2,6 +2,7 @@
1D slices
=========
"""
+
import napari
viewer = napari.Viewer()
diff --git a/pyproject.toml b/pyproject.toml
index 705b4655..f76831a3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,19 +1,34 @@
[build-system]
-requires = ["setuptools", "wheel", "setuptools_scm"]
+requires = ["setuptools", "setuptools_scm"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
write_to = "src/napari_matplotlib/_version.py"
[tool.pytest.ini_options]
-qt_api = "pyqt6"
-addopts = "--mpl"
filterwarnings = [
"error",
+ "ignore:(?s).*Pyarrow will become a required dependency of pandas",
# Coming from vispy
"ignore:distutils Version classes are deprecated:DeprecationWarning",
"ignore:`np.bool8` is a deprecated alias for `np.bool_`:DeprecationWarning",
+ # Coming from pydantic via napari
+ "ignore:Pickle, copy, and deepcopy support will be removed from itertools in Python 3.14.:DeprecationWarning",
+ # Until we stop supporting older numpy versions (<2.1)
+ "ignore:(?s).*`newshape` keyword argument is deprecated.*$:DeprecationWarning",
+]
+qt_api = "pyqt6"
+addopts = [
+ "--mpl",
+ "--mpl-baseline-relative",
+ "--strict-config",
+ "--strict-markers",
+ "-ra",
]
+minversion = "7"
+testpaths = ["src/napari_matplotlib/tests"]
+log_cli_level = "INFO"
+xfail_strict = true
[tool.black]
line-length = 79
@@ -23,8 +38,11 @@ profile = "black"
line_length = 79
[tool.ruff]
-target-version = "py39"
-select = ["I", "UP", "F", "E", "W", "D"]
+target-version = "py310"
+fix = true
+
+[tool.ruff.lint]
+select = ["B", "I", "UP", "F", "E", "W", "D"]
ignore = [
"D100", # Missing docstring in public module
"D104", # Missing docstring in public package
@@ -34,37 +52,25 @@ ignore = [
"D401", # First line of docstring should be in imperative mood
]
-fix = true
-[tool.ruff.per-file-ignores]
+[tool.ruff.lint.per-file-ignores]
"docs/*" = ["D"]
"examples/*" = ["D"]
"src/napari_matplotlib/tests/*" = ["D"]
-[tool.ruff.pydocstyle]
+[tool.ruff.lint.pydocstyle]
convention = "numpy"
[tool.mypy]
-python_version = "3.9"
+python_version = "3.12"
# Block below are checks that form part of mypy 'strict' mode
-warn_unused_configs = true
-warn_redundant_casts = true
-warn_unused_ignores = true
-strict_equality = true
-strict_concatenate = true
-check_untyped_defs = true
+strict = true
disallow_subclassing_any = false # TODO: fix
-disallow_untyped_decorators = true
-disallow_any_generics = true
-disallow_untyped_calls = true
-disallow_incomplete_defs = true
-disallow_untyped_defs = true
-no_implicit_reexport = true
-warn_return_any = false # TODO: fix
+warn_return_any = false # TODO: fix
ignore_missing_imports = true
+enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
+
[[tool.mypy.overrides]]
-module = [
- "napari_matplotlib/tests/*",
-]
+module = ["napari_matplotlib/tests/*"]
disallow_untyped_defs = false
diff --git a/setup.cfg b/setup.cfg
index f308412c..a3709e66 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
[metadata]
-name = napari_matplotlib
+name = napari-matplotlib
description = A plugin to use Matplotlib with napari
long_description = file: README.md
long_description_content_type = text/markdown
@@ -28,10 +28,10 @@ project_urls =
packages = find:
install_requires =
matplotlib
- napari
- numpy
+ napari>=0.5
+ numpy>=1.23
tinycss2
-python_requires = >=3.9
+python_requires = >=3.10
include_package_data = True
package_dir =
=src
@@ -47,11 +47,10 @@ napari.manifest =
[options.extras_require]
docs =
- napari[all]==0.4.17
+ napari[all]
numpydoc
pydantic<2
pydata-sphinx-theme
- qtgallery
sphinx
sphinx-automodapi
sphinx-gallery
diff --git a/src/napari_matplotlib/base.py b/src/napari_matplotlib/base.py
index 0ff5e389..ca69a548 100644
--- a/src/napari_matplotlib/base.py
+++ b/src/napari_matplotlib/base.py
@@ -1,8 +1,6 @@
import os
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]
@@ -10,17 +8,15 @@
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):
"""
@@ -41,22 +37,21 @@ class BaseNapariMPLWidget(QWidget):
def __init__(
self,
napari_viewer: napari.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
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)
+ )
# 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
self.viewer.events.theme.connect(self._on_napari_theme_changed)
self.setLayout(QVBoxLayout())
@@ -68,24 +63,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.
@@ -94,13 +71,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)
+ )
self._replace_toolbar_icons()
def _napari_theme_has_light_bg(self) -> bool:
@@ -112,7 +97,7 @@ def _napari_theme_has_light_bg(self) -> bool:
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)
+ theme = napari.utils.theme.get_theme(self.viewer.theme)
_, _, bg_lightness = theme.background.as_hsl_tuple()
return bg_lightness > 0.5
@@ -187,7 +172,7 @@ class NapariMPLWidget(BaseNapariMPLWidget):
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
super().__init__(napari_viewer=napari_viewer, parent=parent)
self._setup_callbacks()
@@ -211,15 +196,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:
"""
@@ -236,6 +224,15 @@ def _setup_callbacks(self) -> None:
self._update_layers
)
+ @property
+ def _valid_layer_selection(self) -> bool:
+ """
+ Return `True` if layer selection is valid.
+ """
+ return self.n_selected_layers in self.n_layers_input and all(
+ isinstance(layer, self.input_layer_types) for layer in self.layers
+ )
+
def _update_layers(self, event: napari.utils.events.Event) -> None:
"""
Update the ``layers`` attribute with currently selected layers and re-draw.
@@ -243,7 +240,8 @@ def _update_layers(self, event: napari.utils.events.Event) -> None:
self.layers = list(self.viewer.layers.selection)
self.layers = sorted(self.layers, key=lambda layer: layer.name)
self.on_update_layers()
- self._draw()
+ if self._valid_layer_selection:
+ self._draw()
def _draw(self) -> None:
"""
@@ -252,13 +250,12 @@ 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._valid_layer_selection:
+ self.draw()
+ self.canvas.draw() # type: ignore[no-untyped-call]
def clear(self) -> None:
"""
@@ -291,7 +288,7 @@ class SingleAxesWidget(NapariMPLWidget):
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
super().__init__(napari_viewer=napari_viewer, parent=parent)
self.add_single_axes()
@@ -300,7 +297,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()
diff --git a/src/napari_matplotlib/histogram.py b/src/napari_matplotlib/histogram.py
index f78a8503..85bba9d2 100644
--- a/src/napari_matplotlib/histogram.py
+++ b/src/napari_matplotlib/histogram.py
@@ -1,10 +1,20 @@
-from typing import Any, Optional, cast
+from typing import Any, cast
import napari
import numpy as np
import numpy.typing as npt
from matplotlib.container import BarContainer
-from qtpy.QtWidgets import QComboBox, QLabel, QVBoxLayout, QWidget
+from napari.layers import Image
+from napari.layers._multiscale_data import MultiScaleData
+from qtpy.QtWidgets import (
+ QComboBox,
+ QFormLayout,
+ QGroupBox,
+ QLabel,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
+)
from .base import SingleAxesWidget
from .features import FEATURES_LAYER_TYPES
@@ -15,6 +25,34 @@
_COLORS = {"r": "tab:red", "g": "tab:green", "b": "tab:blue"}
+def _get_bins(
+ data: npt.NDArray[Any],
+ num_bins: int = 100,
+) -> npt.NDArray[np.floating]:
+ """Create evenly spaced bins with a given interval.
+
+ Parameters
+ ----------
+ data : napari.layers.Layer.data
+ Napari layer data.
+ num_bins : integer, optional
+ Number of evenly-spaced bins to create. Defaults to 100.
+
+ Returns
+ -------
+ bin_edges : numpy.ndarray
+ Array of evenly spaced bin edges.
+ """
+ if data.dtype.kind in {"i", "u"}:
+ # Make sure integer data types have integer sized bins
+ step = np.ceil(np.ptp(data) / num_bins)
+ return np.arange(np.min(data), np.max(data) + step, step)
+ else:
+ # For other data types we can use exactly `num_bins` bins
+ # (and `num_bins` + 1 bin edges)
+ return np.linspace(np.min(data), np.max(data), num_bins + 1)
+
+
class HistogramWidget(SingleAxesWidget):
"""
Display a histogram of the currently selected layer.
@@ -26,43 +64,115 @@ class HistogramWidget(SingleAxesWidget):
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
super().__init__(napari_viewer, parent=parent)
+
+ num_bins_widget = QSpinBox()
+ num_bins_widget.setRange(1, 100_000)
+ num_bins_widget.setValue(101)
+ num_bins_widget.setWrapping(False)
+ num_bins_widget.setKeyboardTracking(False)
+
+ # Set bins widget layout
+ bins_selection_layout = QFormLayout()
+ bins_selection_layout.addRow("num bins", num_bins_widget)
+
+ # Group the widgets and add to main layout
+ params_widget_group = QGroupBox("Params")
+ params_widget_group_layout = QVBoxLayout()
+ params_widget_group_layout.addLayout(bins_selection_layout)
+ params_widget_group.setLayout(params_widget_group_layout)
+ self.layout().addWidget(params_widget_group)
+
+ # Add callbacks
+ num_bins_widget.valueChanged.connect(self._draw)
+
+ # Store widgets for later usage
+ self.num_bins_widget = num_bins_widget
+
self._update_layers(None)
+ self.viewer.events.theme.connect(self._on_napari_theme_changed)
- def draw(self) -> None:
+ def on_update_layers(self) -> None:
"""
- Clear the axes and histogram the currently selected layer/slice.
+ Called when the selected layers are updated.
"""
- layer = self.layers[0]
+ super().on_update_layers()
+ if self._valid_layer_selection:
+ self.layers[0].events.contrast_limits.connect(
+ self._update_contrast_lims
+ )
+
+ if not self.layers:
+ return
+
+ # Reset the num bins based on new layer data
+ layer_data = self._get_layer_data(self.layers[0])
+ self._set_widget_nums_bins(data=layer_data)
- if layer.data.ndim - layer.rgb == 3:
+ def _update_contrast_lims(self) -> None:
+ for lim, line in zip(
+ self.layers[0].contrast_limits, self._contrast_lines, strict=False
+ ):
+ line.set_xdata(lim)
+
+ self.figure.canvas.draw()
+
+ def _set_widget_nums_bins(self, data: npt.NDArray[Any]) -> None:
+ """Update num_bins widget with bins determined from the image data"""
+ bins = _get_bins(data)
+ self.num_bins_widget.setValue(bins.size - 1)
+
+ def _get_layer_data(self, layer: napari.layers.Layer) -> npt.NDArray[Any]:
+ """Get the data associated with a given layer"""
+ data = layer.data
+
+ if isinstance(layer.data, MultiScaleData):
+ data = data[layer.data_level]
+
+ if layer.ndim - layer.rgb == 3:
# 3D data, can be single channel or RGB
- data = layer.data[self.current_z]
+ # Slice in z dimension
+ data = data[self.current_z]
self.axes.set_title(f"z={self.current_z}")
- else:
- data = layer.data
+
# Read data into memory if it's a dask array
data = np.asarray(data)
+ return data
+
+ def draw(self) -> None:
+ """
+ Clear the axes and histogram the currently selected layer/slice.
+ """
+ layer: Image = self.layers[0]
+ data = self._get_layer_data(layer)
+
# Important to calculate bins after slicing 3D data, to avoid reading
# whole cube into memory.
- bins = np.linspace(np.min(data), np.max(data), 100)
+ bins = _get_bins(
+ data,
+ num_bins=self.num_bins_widget.value(),
+ )
if layer.rgb:
# Histogram RGB channels independently
for i, c in enumerate("rgb"):
self.axes.hist(
data[..., i].ravel(),
- bins=bins,
+ bins=bins.tolist(),
label=c,
histtype="step",
color=_COLORS[c],
)
else:
- self.axes.hist(data.ravel(), bins=bins, label=layer.name)
+ self.axes.hist(data.ravel(), bins=bins.tolist(), label=layer.name)
+ self._contrast_lines = [
+ self.axes.axvline(lim, color="white")
+ for lim in layer.contrast_limits
+ ]
self.axes.legend()
@@ -78,7 +188,7 @@ class FeaturesHistogramWidget(SingleAxesWidget):
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
super().__init__(napari_viewer, parent=parent)
@@ -94,12 +204,12 @@ def __init__(
self._update_layers(None)
@property
- def x_axis_key(self) -> Optional[str]:
+ def x_axis_key(self) -> str | None:
"""Key to access x axis data from the FeaturesTable"""
return self._x_axis_key
@x_axis_key.setter
- def x_axis_key(self, key: Optional[str]) -> None:
+ def x_axis_key(self, key: str | None) -> None:
self._x_axis_key = key
self._draw()
@@ -123,7 +233,7 @@ def _get_valid_axis_keys(self) -> list[str]:
else:
return self.layers[0].features.keys()
- def _get_data(self) -> tuple[Optional[npt.NDArray[Any]], str]:
+ def _get_data(self) -> tuple[npt.NDArray[Any] | None, str]:
"""Get the plot data.
Returns
@@ -166,10 +276,12 @@ def draw(self) -> None:
# get the colormap from the layer depending on its type
if isinstance(self.layers[0], napari.layers.Points):
colormap = self.layers[0].face_colormap
- self.layers[0].face_color = self.x_axis_key
+ if self.x_axis_key:
+ self.layers[0].face_color = self.x_axis_key
elif isinstance(self.layers[0], napari.layers.Vectors):
colormap = self.layers[0].edge_colormap
- self.layers[0].edge_color = self.x_axis_key
+ if self.x_axis_key:
+ self.layers[0].edge_color = self.x_axis_key
else:
colormap = None
@@ -183,9 +295,9 @@ def draw(self) -> None:
if data is None:
return
- _, bins, patches = self.axes.hist(
- data, bins=50, edgecolor="white", linewidth=0.3
- )
+ bins = _get_bins(data)
+
+ _, bins, patches = self.axes.hist(data, bins=bins.tolist())
patches = cast(BarContainer, patches)
# recolor the histogram plot
diff --git a/src/napari_matplotlib/scatter.py b/src/napari_matplotlib/scatter.py
index 67d6896c..98ebe928 100644
--- a/src/napari_matplotlib/scatter.py
+++ b/src/napari_matplotlib/scatter.py
@@ -1,4 +1,4 @@
-from typing import Any, Optional, Union
+from typing import Any
import napari
import numpy.typing as npt
@@ -100,7 +100,7 @@ class FeaturesScatterWidget(ScatterBaseWidget):
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
super().__init__(napari_viewer, parent=parent)
@@ -118,7 +118,7 @@ def __init__(
self._update_layers(None)
@property
- def x_axis_key(self) -> Union[str, None]:
+ def x_axis_key(self) -> str | None:
"""
Key for the x-axis data.
"""
@@ -133,7 +133,7 @@ def x_axis_key(self, key: str) -> None:
self._draw()
@property
- def y_axis_key(self) -> Union[str, None]:
+ def y_axis_key(self) -> str | None:
"""
Key for the y-axis data.
"""
diff --git a/src/napari_matplotlib/slice.py b/src/napari_matplotlib/slice.py
index 9459fa97..1924bf2b 100644
--- a/src/napari_matplotlib/slice.py
+++ b/src/napari_matplotlib/slice.py
@@ -1,4 +1,4 @@
-from typing import Any, Optional
+from typing import Any
import matplotlib.ticker as mticker
import napari
@@ -30,7 +30,7 @@ class SliceWidget(SingleAxesWidget):
def __init__(
self,
napari_viewer: napari.viewer.Viewer,
- parent: Optional[QWidget] = None,
+ parent: QWidget | None = None,
):
# Setup figure/axes
super().__init__(napari_viewer, parent=parent)
diff --git a/src/napari_matplotlib/styles/README.md b/src/napari_matplotlib/styles/README.md
deleted file mode 100644
index 79d3c417..00000000
--- a/src/napari_matplotlib/styles/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-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.
diff --git a/src/napari_matplotlib/styles/dark.mplstyle b/src/napari_matplotlib/styles/dark.mplstyle
deleted file mode 100644
index 1658f9b4..00000000
--- a/src/napari_matplotlib/styles/dark.mplstyle
+++ /dev/null
@@ -1,12 +0,0 @@
-# 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
diff --git a/src/napari_matplotlib/styles/light.mplstyle b/src/napari_matplotlib/styles/light.mplstyle
deleted file mode 100644
index 3b8d7d1d..00000000
--- a/src/napari_matplotlib/styles/light.mplstyle
+++ /dev/null
@@ -1,12 +0,0 @@
-# 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
diff --git a/src/napari_matplotlib/tests/baseline/test_custom_theme.png b/src/napari_matplotlib/tests/baseline/test_custom_theme.png
index a668c103..ffa4635b 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_custom_theme.png and b/src/napari_matplotlib/tests/baseline/test_custom_theme.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png
index b98a0170..88a28f79 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png and b/src/napari_matplotlib/tests/baseline/test_feature_histogram_points.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png b/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png
index 3b90586e..857d9344 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png and b/src/napari_matplotlib/tests/baseline/test_feature_histogram_vectors.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_2D.png b/src/napari_matplotlib/tests/baseline/test_histogram_2D.png
index b043bba8..b9096e4d 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_histogram_2D.png and b/src/napari_matplotlib/tests/baseline/test_histogram_2D.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png b/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png
new file mode 100644
index 00000000..98e3cde1
Binary files /dev/null and b/src/napari_matplotlib/tests/baseline/test_histogram_2D_bins.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_histogram_3D.png b/src/napari_matplotlib/tests/baseline/test_histogram_3D.png
index 724314e1..ec4ad96d 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_histogram_3D.png and b/src/napari_matplotlib/tests/baseline/test_histogram_3D.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_slice_2D.png b/src/napari_matplotlib/tests/baseline/test_slice_2D.png
index d39920be..c1e67637 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_slice_2D.png and b/src/napari_matplotlib/tests/baseline/test_slice_2D.png differ
diff --git a/src/napari_matplotlib/tests/baseline/test_slice_3D.png b/src/napari_matplotlib/tests/baseline/test_slice_3D.png
index cf563de5..046293f3 100644
Binary files a/src/napari_matplotlib/tests/baseline/test_slice_3D.png and b/src/napari_matplotlib/tests/baseline/test_slice_3D.png differ
diff --git a/src/napari_matplotlib/tests/data/test_theme.mplstyle b/src/napari_matplotlib/tests/data/test_theme.mplstyle
deleted file mode 100644
index 2f94b31f..00000000
--- a/src/napari_matplotlib/tests/data/test_theme.mplstyle
+++ /dev/null
@@ -1,15 +0,0 @@
-# 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
diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png b/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png
index 75965607..9237dbdc 100644
Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_features_scatter_widget_2D.png differ
diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png
index 10219106..a11bda5f 100644
Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_2D.png differ
diff --git a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png
index 3e648eec..cd42a8a2 100644
Binary files a/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png and b/src/napari_matplotlib/tests/scatter/baseline/test_scatter_3D.png differ
diff --git a/src/napari_matplotlib/tests/scatter/test_scatter.py b/src/napari_matplotlib/tests/scatter/test_scatter.py
index a225863d..0c60660c 100644
--- a/src/napari_matplotlib/tests/scatter/test_scatter.py
+++ b/src/napari_matplotlib/tests/scatter/test_scatter.py
@@ -15,7 +15,9 @@ def test_scatter_2D(make_napari_viewer, astronaut_data):
viewer.add_image(astronaut_data[0], **astronaut_data[1], name="astronaut")
viewer.add_image(
- astronaut_data[0] * -1, **astronaut_data[1], name="astronaut_reversed"
+ astronaut_data[0] * -1.0,
+ **astronaut_data[1],
+ name="astronaut_reversed",
)
# De-select existing selection
viewer.layers.selection.clear()
@@ -36,7 +38,7 @@ def test_scatter_3D(make_napari_viewer, brain_data):
viewer.add_image(brain_data[0], **brain_data[1], name="brain")
viewer.add_image(
- brain_data[0] * -1, **brain_data[1], name="brain_reversed"
+ brain_data[0] * -1.0, **brain_data[1], name="brain_reversed"
)
# De-select existing selection
viewer.layers.selection.clear()
diff --git a/src/napari_matplotlib/tests/test_histogram.py b/src/napari_matplotlib/tests/test_histogram.py
index 1ceca519..435973ba 100644
--- a/src/napari_matplotlib/tests/test_histogram.py
+++ b/src/napari_matplotlib/tests/test_histogram.py
@@ -10,6 +10,20 @@
)
+@pytest.mark.mpl_image_compare
+def test_histogram_2D_bins(make_napari_viewer, astronaut_data):
+ viewer = make_napari_viewer()
+ viewer.theme = "light"
+ viewer.add_image(astronaut_data[0], **astronaut_data[1])
+ widget = HistogramWidget(viewer)
+ viewer.window.add_dock_widget(widget)
+ widget.num_bins_widget.setValue(25)
+ fig = widget.figure
+ # Need to return a copy, as original figure is too eagerley garbage
+ # collected by the widget
+ return deepcopy(fig)
+
+
@pytest.mark.mpl_image_compare
def test_histogram_2D(make_napari_viewer, astronaut_data):
viewer = make_napari_viewer()
diff --git a/src/napari_matplotlib/tests/test_theme.py b/src/napari_matplotlib/tests/test_theme.py
index 1042d3f3..5fedc43d 100644
--- a/src/napari_matplotlib/tests/test_theme.py
+++ b/src/napari_matplotlib/tests/test_theme.py
@@ -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
@@ -36,7 +29,7 @@ def _mock_up_theme() -> None:
Based on:
https://napari.org/stable/gallery/new_theme.html
"""
- blue_theme = napari.utils.theme.get_theme("dark", False)
+ blue_theme = napari.utils.theme.get_theme("dark")
blue_theme.label = "blue"
blue_theme.background = "#4169e1" # my favourite shade of blue
napari.utils.theme.register_theme(
@@ -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)
diff --git a/src/napari_matplotlib/tests/test_util.py b/src/napari_matplotlib/tests/test_util.py
index a8792d41..e966cc26 100644
--- a/src/napari_matplotlib/tests/test_util.py
+++ b/src/napari_matplotlib/tests/test_util.py
@@ -26,7 +26,7 @@ def test_interval():
assert 10 not in interval
with pytest.raises(ValueError, match="must be an integer"):
- "string" in interval # type: ignore
+ assert "string" in interval # type: ignore[operator]
with pytest.raises(ValueError, match="must be <= upper_bound"):
Interval(5, 3)
@@ -69,7 +69,10 @@ def test_fallback_if_missing_dimensions(mocker):
test_css = " Flobble { background-color: rgb(0, 97, 163); } "
mocker.patch("napari.qt.get_current_stylesheet").return_value = test_css
with pytest.warns(RuntimeWarning, match="Unable to find DimensionToken"):
- assert from_napari_css_get_size_of("Flobble", (1, 2)) == QSize(1, 2)
+ with pytest.warns(RuntimeWarning, match="Unable to find Flobble"):
+ assert from_napari_css_get_size_of("Flobble", (1, 2)) == QSize(
+ 1, 2
+ )
def test_fallback_if_prelude_not_in_css():
diff --git a/src/napari_matplotlib/util.py b/src/napari_matplotlib/util.py
index 7d72c9e2..8d4150c3 100644
--- a/src/napari_matplotlib/util.py
+++ b/src/napari_matplotlib/util.py
@@ -1,8 +1,8 @@
-from typing import Optional, Union
from warnings import warn
import napari.qt
import tinycss2
+from napari.utils.theme import Theme
from qtpy.QtCore import QSize
@@ -11,7 +11,7 @@ class Interval:
An integer interval.
"""
- def __init__(self, lower_bound: Optional[int], upper_bound: Optional[int]):
+ def __init__(self, lower_bound: int | None, upper_bound: int | None):
"""
Parameters
----------
@@ -47,7 +47,7 @@ def __contains__(self, val: int) -> bool:
return True
@property
- def _helper_text(self) -> Optional[str]:
+ def _helper_text(self) -> str | None:
"""
Helper text for widgets.
"""
@@ -85,9 +85,7 @@ def _has_id(nodes: list[tinycss2.ast.Node], id_name: str) -> bool:
)
-def _get_dimension(
- nodes: list[tinycss2.ast.Node], id_name: str
-) -> Union[int, None]:
+def _get_dimension(nodes: list[tinycss2.ast.Node], id_name: str) -> int | None:
"""
Get the value of the DimensionToken for the IdentToken `id_name`.
@@ -96,14 +94,18 @@ def _get_dimension(
None if no IdentToken is found.
"""
cleaned_nodes = [node for node in nodes if node.type != "whitespace"]
- for name, _, value, _ in zip(*(iter(cleaned_nodes),) * 4):
+ for name, _, value, _ in zip(*(iter(cleaned_nodes),) * 4, strict=False):
if (
name.type == "ident"
and value.type == "dimension"
and name.value == id_name
):
return value.int_value
- warn(f"Unable to find DimensionToken for {id_name}", RuntimeWarning)
+ warn(
+ f"Unable to find DimensionToken for {id_name}",
+ RuntimeWarning,
+ stacklevel=1,
+ )
return None
@@ -136,5 +138,52 @@ def from_napari_css_get_size_of(
f"Unable to find {qt_element_name} or unable to find its size in "
f"the current Napari stylesheet, falling back to {fallback}",
RuntimeWarning,
+ stacklevel=1,
)
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(),
+ }
diff --git a/tox.ini b/tox.ini
index 4ec0c702..f4aed6a8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,12 +1,12 @@
[tox]
-envlist = py{39,310,311}
+envlist = py{310,311,312}
isolated_build = true
[gh-actions]
python =
- 3.9: py39
3.10: py310
3.11: py311
+ 3.12: py312
[testenv]
extras = testing