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