diff --git a/setup.cfg b/setup.cfg index 730e5c1b..dc7d0a03 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ install_requires = matplotlib napari numpy + tinycss2 python_requires = >=3.8 include_package_data = True package_dir = @@ -57,6 +58,7 @@ testing = pyqt6 pytest pytest-cov + pytest-mock pytest-mpl pytest-qt tox diff --git a/src/napari_matplotlib/base.py b/src/napari_matplotlib/base.py index fcd60c53..264de4d2 100644 --- a/src/napari_matplotlib/base.py +++ b/src/napari_matplotlib/base.py @@ -12,7 +12,7 @@ from qtpy.QtGui import QIcon from qtpy.QtWidgets import QVBoxLayout, QWidget -from .util import Interval +from .util import Interval, from_napari_css_get_size_of # Icons modified from # https://github.com/matplotlib/matplotlib/tree/main/lib/matplotlib/mpl-data/images @@ -52,7 +52,9 @@ def __init__(self, napari_viewer: napari.viewer.Viewer): self.canvas.figure.patch.set_facecolor("none") self.canvas.figure.set_layout_engine("constrained") - self.toolbar = NapariNavigationToolbar(self.canvas, self) + self.toolbar = NapariNavigationToolbar( + self.canvas, self + ) # type: ignore[no-untyped-call] self._replace_toolbar_icons() self.setLayout(QVBoxLayout()) @@ -189,6 +191,14 @@ def _replace_toolbar_icons(self) -> None: class NapariNavigationToolbar(NavigationToolbar2QT): """Custom Toolbar style for Napari.""" + def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def] + super().__init__(*args, **kwargs) + self.setIconSize( + from_napari_css_get_size_of( + "QtViewerPushButton", fallback=(28, 28) + ) + ) + def _update_buttons_checked(self) -> None: """Update toggle tool icons when selected/unselected.""" super()._update_buttons_checked() diff --git a/src/napari_matplotlib/tests/test_util.py b/src/napari_matplotlib/tests/test_util.py index d77ecce1..b8ebaff4 100644 --- a/src/napari_matplotlib/tests/test_util.py +++ b/src/napari_matplotlib/tests/test_util.py @@ -1,6 +1,7 @@ import pytest +from qtpy.QtCore import QSize -from napari_matplotlib.util import Interval +from napari_matplotlib.util import Interval, from_napari_css_get_size_of def test_interval(): @@ -13,3 +14,33 @@ def test_interval(): with pytest.raises(ValueError, match="must be an integer"): "string" in interval # type: ignore + + +def test_get_size_from_css(mocker): + """Test getting the max-width and max-height from something in css""" + test_css = """ + Flibble { + min-width : 0; + max-width : 123px; + min-height : 0px; + max-height : 456px; + padding: 0px; + } + """ + mocker.patch("napari.qt.get_current_stylesheet").return_value = test_css + assert from_napari_css_get_size_of("Flibble", (1, 2)) == QSize(123, 456) + + +def test_fallback_if_missing_dimensions(mocker): + """Test fallback if given something that doesn't have dimensions""" + 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) + + +def test_fallback_if_prelude_not_in_css(): + """Test fallback if given something not in the css""" + doesntexist = "AQButtonThatDoesntExist" + with pytest.warns(RuntimeWarning, match=f"Unable to find {doesntexist}"): + assert from_napari_css_get_size_of(doesntexist, (1, 2)) == QSize(1, 2) diff --git a/src/napari_matplotlib/util.py b/src/napari_matplotlib/util.py index 5aacac1d..4ae8ca19 100644 --- a/src/napari_matplotlib/util.py +++ b/src/napari_matplotlib/util.py @@ -1,4 +1,9 @@ -from typing import Optional +from typing import List, Optional, Tuple, Union +from warnings import warn + +import napari.qt +import tinycss2 +from qtpy.QtCore import QSize class Interval: @@ -34,3 +39,67 @@ def __contains__(self, val: int) -> bool: if self.upper is not None and val > self.upper: return False return True + + +def _has_id(nodes: List[tinycss2.ast.Node], id_name: str) -> bool: + """ + Is `id_name` in IdentTokens in the list of CSS `nodes`? + """ + return any( + [node.type == "ident" and node.value == id_name for node in nodes] + ) + + +def _get_dimension( + nodes: List[tinycss2.ast.Node], id_name: str +) -> Union[int, None]: + """ + Get the value of the DimensionToken for the IdentToken `id_name`. + + Returns + ------- + 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): + 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) + return None + + +def from_napari_css_get_size_of( + qt_element_name: str, fallback: Tuple[int, int] +) -> QSize: + """ + Get the size of `qt_element_name` from napari's current stylesheet. + + TODO: Confirm that the napari.qt.get_current_stylesheet changes with napari + theme (docs seem to indicate it should) + + Returns + ------- + QSize of the element if it's found, the `fallback` if it's not found.. + """ + rules = tinycss2.parse_stylesheet( + napari.qt.get_current_stylesheet(), + skip_comments=True, + skip_whitespace=True, + ) + w, h = None, None + for rule in rules: + if _has_id(rule.prelude, qt_element_name): + w = _get_dimension(rule.content, "max-width") + h = _get_dimension(rule.content, "max-height") + if w and h: + return QSize(w, h) + warn( + f"Unable to find {qt_element_name} or unable to find its size in " + f"the current Napari stylesheet, falling back to {fallback}", + RuntimeWarning, + ) + return QSize(*fallback)