From 3d81f0fe79bcd038bb65982dbc682218d184512d Mon Sep 17 00:00:00 2001 From: Sam Cunliffe Date: Wed, 10 May 2023 10:43:29 +0100 Subject: [PATCH 1/4] Solves #46. Introduce a dependency on tinycss2. Then _use_ it via some convenience parsing finding functions. --- setup.cfg | 2 + src/napari_matplotlib/base.py | 12 ++++- src/napari_matplotlib/tests/test_util.py | 18 ++++++- src/napari_matplotlib/util.py | 63 +++++++++++++++++++++++- 4 files changed, 91 insertions(+), 4 deletions(-) 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..11cdcfd8 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_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,12 @@ 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_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..ea531060 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_css_get_size_of def test_interval(): @@ -13,3 +14,18 @@ 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_css_get_size_of("Flibble", (1, 1)) == QSize(123, 456) diff --git a/src/napari_matplotlib/util.py b/src/napari_matplotlib/util.py index 5aacac1d..b0bd927a 100644 --- a/src/napari_matplotlib/util.py +++ b/src/napari_matplotlib/util.py @@ -1,4 +1,8 @@ -from typing import Optional +from typing import List, Optional, Tuple, Union + +import napari.qt +import tinycss2 +from qtpy.QtCore import QSize class Interval: @@ -34,3 +38,60 @@ 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 + return None + + +def from_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") + return QSize(w, h) + return QSize(*fallback) From a2b9db27cbcb12e2f4729ec870a6decb1ca7d221 Mon Sep 17 00:00:00 2001 From: Sam Cunliffe Date: Wed, 10 May 2023 21:33:14 +0100 Subject: [PATCH 2/4] More tests and a fringe case. --- src/napari_matplotlib/tests/test_util.py | 14 ++++++++++++++ src/napari_matplotlib/util.py | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/napari_matplotlib/tests/test_util.py b/src/napari_matplotlib/tests/test_util.py index ea531060..8f9f97a1 100644 --- a/src/napari_matplotlib/tests/test_util.py +++ b/src/napari_matplotlib/tests/test_util.py @@ -29,3 +29,17 @@ def test_get_size_from_css(mocker): """ mocker.patch("napari.qt.get_current_stylesheet").return_value = test_css assert from_css_get_size_of("Flibble", (1, 1)) == 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 + assert from_css_get_size_of("Flobble", (1, 1)) == QSize(1, 1) + + +def test_fallback_if_prelude_not_in_css(): + """Test fallback if given something not in the css""" + assert from_css_get_size_of("AQButtonThatDoesntExist", (1, 1)) == QSize( + 1, 1 + ) diff --git a/src/napari_matplotlib/util.py b/src/napari_matplotlib/util.py index b0bd927a..fde5ec80 100644 --- a/src/napari_matplotlib/util.py +++ b/src/napari_matplotlib/util.py @@ -93,5 +93,6 @@ def from_css_get_size_of( if _has_id(rule.prelude, qt_element_name): w = _get_dimension(rule.content, "max-width") h = _get_dimension(rule.content, "max-height") - return QSize(w, h) + if w and h: + return QSize(w, h) return QSize(*fallback) From 88c3673e617d968f54f850faa446e9da3703f0a4 Mon Sep 17 00:00:00 2001 From: Sam Cunliffe Date: Thu, 11 May 2023 14:46:56 +0100 Subject: [PATCH 3/4] Add warnings and test for them. --- src/napari_matplotlib/tests/test_util.py | 11 ++++++----- src/napari_matplotlib/util.py | 7 +++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/napari_matplotlib/tests/test_util.py b/src/napari_matplotlib/tests/test_util.py index 8f9f97a1..746cf246 100644 --- a/src/napari_matplotlib/tests/test_util.py +++ b/src/napari_matplotlib/tests/test_util.py @@ -28,18 +28,19 @@ def test_get_size_from_css(mocker): } """ mocker.patch("napari.qt.get_current_stylesheet").return_value = test_css - assert from_css_get_size_of("Flibble", (1, 1)) == QSize(123, 456) + assert from_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 - assert from_css_get_size_of("Flobble", (1, 1)) == QSize(1, 1) + with pytest.warns(RuntimeWarning, match="Unable to find DimensionToken"): + assert from_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""" - assert from_css_get_size_of("AQButtonThatDoesntExist", (1, 1)) == QSize( - 1, 1 - ) + doesntexist = "AQButtonThatDoesntExist" + with pytest.warns(RuntimeWarning, match=f"Unable to find {doesntexist}"): + assert from_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 fde5ec80..5edb6958 100644 --- a/src/napari_matplotlib/util.py +++ b/src/napari_matplotlib/util.py @@ -1,4 +1,5 @@ from typing import List, Optional, Tuple, Union +from warnings import warn import napari.qt import tinycss2 @@ -67,6 +68,7 @@ def _get_dimension( and name.value == id_name ): return value.int_value + warn(f"Unable to find DimensionToken for {id_name}", RuntimeWarning) return None @@ -95,4 +97,9 @@ def from_css_get_size_of( 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) From 0a9e3ec1ff25366f0e460e9744550d02c584f00b Mon Sep 17 00:00:00 2001 From: Sam Cunliffe Date: Thu, 11 May 2023 15:05:53 +0100 Subject: [PATCH 4/4] Better function name. It's not a generic CSS parser but rather Napari-specific. Co-authored-by: David Stansby --- src/napari_matplotlib/base.py | 6 ++++-- src/napari_matplotlib/tests/test_util.py | 8 ++++---- src/napari_matplotlib/util.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/napari_matplotlib/base.py b/src/napari_matplotlib/base.py index 11cdcfd8..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_css_get_size_of +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 @@ -194,7 +194,9 @@ class NapariNavigationToolbar(NavigationToolbar2QT): def __init__(self, *args, **kwargs): # type: ignore[no-untyped-def] super().__init__(*args, **kwargs) self.setIconSize( - from_css_get_size_of("QtViewerPushButton", fallback=(28, 28)) + from_napari_css_get_size_of( + "QtViewerPushButton", fallback=(28, 28) + ) ) def _update_buttons_checked(self) -> None: diff --git a/src/napari_matplotlib/tests/test_util.py b/src/napari_matplotlib/tests/test_util.py index 746cf246..b8ebaff4 100644 --- a/src/napari_matplotlib/tests/test_util.py +++ b/src/napari_matplotlib/tests/test_util.py @@ -1,7 +1,7 @@ import pytest from qtpy.QtCore import QSize -from napari_matplotlib.util import Interval, from_css_get_size_of +from napari_matplotlib.util import Interval, from_napari_css_get_size_of def test_interval(): @@ -28,7 +28,7 @@ def test_get_size_from_css(mocker): } """ mocker.patch("napari.qt.get_current_stylesheet").return_value = test_css - assert from_css_get_size_of("Flibble", (1, 2)) == QSize(123, 456) + assert from_napari_css_get_size_of("Flibble", (1, 2)) == QSize(123, 456) def test_fallback_if_missing_dimensions(mocker): @@ -36,11 +36,11 @@ 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_css_get_size_of("Flobble", (1, 2)) == QSize(1, 2) + 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_css_get_size_of(doesntexist, (1, 2)) == QSize(1, 2) + 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 5edb6958..4ae8ca19 100644 --- a/src/napari_matplotlib/util.py +++ b/src/napari_matplotlib/util.py @@ -72,7 +72,7 @@ def _get_dimension( return None -def from_css_get_size_of( +def from_napari_css_get_size_of( qt_element_name: str, fallback: Tuple[int, int] ) -> QSize: """