diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a917719b4f..181013bdd5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -73,6 +73,14 @@ updates: day: "sunday" time: "20:00" + - package-ecosystem: "pip" + directory: "/travertino" + schedule: + # Check for updates on Sunday, 8PM UTC + interval: "weekly" + day: "sunday" + time: "20:00" + - package-ecosystem: "pip" directory: "/testbed" ignore: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc074bb4cb..2dabd58127 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: - "gtk" - "iOS" - "toga" + - "travertino" - "textual" - "web" - "winforms" @@ -67,8 +68,8 @@ jobs: build-subdirectory: ${{ matrix.subdir }} attest: ${{ inputs.attest-package }} - core: - name: Test core + core-and-travertino: + name: Test ${{ matrix.package }} (${{ matrix.platform }}, ${{ matrix.python-version }}) runs-on: ${{ matrix.platform }} needs: [ pre-commit, towncrier, package ] continue-on-error: ${{ matrix.experimental }} @@ -77,8 +78,18 @@ jobs: matrix: platform: [ "macos-latest", "ubuntu-latest", "windows-latest" ] python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] + package: ["core", "travertino"] + exclude: + - package: travertino + platform: macos-latest + - package: travertino + platform: windows-latest include: - experimental: false + - package: "core" + tox-suffix: "" + - package: "travertino" + tox-suffix: "-trav" steps: - name: Checkout @@ -97,7 +108,7 @@ jobs: with: requirements: tox extra: dev - project-root: core + project-root: ${{ matrix.package }} - name: Get Packages uses: actions/download-artifact@v4.1.8 @@ -110,16 +121,16 @@ jobs: run: | # The $(ls ...) shell expansion is done in the Github environment; # the value of TOGA_INSTALL_COMMAND will be a literal string without any shell expansions to perform - TOGA_INSTALL_COMMAND="python -m pip install ../$(ls dist/toga_core-*.whl)[dev] ../$(ls dist/toga_dummy-*.whl)" \ - tox -e py-cov - tox -qe coverage$(tr -dc "0-9" <<< "${{ matrix.python-version }}") - mv core/.coverage core/.coverage.${{ matrix.platform }}.${{ matrix.python-version }} + TOGA_INSTALL_COMMAND="python -m pip install ../$(ls dist/toga_core-*.whl)[dev] ../$(ls dist/toga_dummy-*.whl) ../$(ls dist/travertino-*.whl)" + tox -e py-cov${{ matrix.tox-suffix }} + tox -qe coverage$(tr -dc "0-9" <<< "${{ matrix.python-version }}")${{ matrix.tox-suffix }} + mv ${{ matrix.package }}/.coverage ${{ matrix.package }}/.coverage.${{ matrix.platform }}.${{ matrix.python-version }} - name: Store Coverage Data uses: actions/upload-artifact@v4.6.0 with: - name: core-coverage-data-${{ matrix.platform }}-${{ matrix.python-version }} - path: "core/.coverage.*" + name: ${{ matrix.package }}-coverage-data-${{ matrix.platform }}-${{ matrix.python-version }} + path: "${{ matrix.package }}/.coverage.*" if-no-files-found: error include-hidden-files: true @@ -159,15 +170,21 @@ jobs: with: python-version: "3.13" - - name: Get Packages + - name: Get Core Package uses: actions/download-artifact@v4.1.8 with: name: Packages-toga-core path: dist + - name: Get Travertino Package + uses: actions/download-artifact@v4.1.8 + with: + name: Packages-toga-travertino + path: dist + - name: Test run: | - pip install dist/toga_core-*.whl + pip install dist/toga_core-*.whl dist/travertino-*.whl site_packages=$(python -c ' import sys print([path for path in sys.path if "site-packages" in path][0]) @@ -176,17 +193,20 @@ jobs: cd core export MICROPYPATH="$site_packages:.frozen" - echo "Stable Travertino" ${{ steps.micropython.outputs.executable }} micropython_check.py - echo "Development Travertino" - pip install git+https://github.com/beeware/travertino - ${{ steps.micropython.outputs.executable }} micropython_check.py - - core-coverage: - name: Coverage - needs: core + core-and-travertino-coverage: + name: "Coverage: ${{ matrix.package }}" + needs: core-and-travertino runs-on: ubuntu-latest + strategy: + matrix: + package: ["core", "travertino"] + include: + - package: "core" + tox-suffix: "" + - package: "travertino" + tox-suffix: "-trav" steps: - name: Checkout uses: actions/checkout@v4.2.2 @@ -205,28 +225,29 @@ jobs: with: requirements: tox extra: dev - project-root: core + project-root: ${{ matrix.package }} - name: Retrieve Coverage Data uses: actions/download-artifact@v4.1.8 with: - pattern: core-coverage-data-* - path: core + pattern: ${{ matrix.package }}-coverage-data-* + path: ${{ matrix.package }} merge-multiple: true - name: Generate Coverage Report - run: tox -e coverage-html-fail-platform + # Even with "fail" on, Travertino will accept <100%. + run: tox -e coverage${{ matrix.tox-suffix }}-html-fail-platform - name: Upload HTML Coverage Report uses: actions/upload-artifact@v4.6.0 if: failure() with: name: html-coverage-report - path: core/htmlcov + path: ${{ matrix.package }}/htmlcov testbed: name: Testbed - needs: core + needs: core-and-travertino runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false @@ -321,7 +342,7 @@ jobs: platform: "linux" runs-on: "ubuntu-latest" setup-python: false # Use the system Python packages - briefcase-run-args: --config 'requires=["../core","../textual"]' --config 'console_app=true' + briefcase-run-args: --config 'requires=["../core","../textual", "../travertino"]' --config 'console_app=true' app-user-data-path: "$HOME/.local/share/testbed" # install the meta-package build-essential since Briefcase explicitly checks for it pre-command: sudo apt update -y && sudo apt install -y build-essential @@ -329,13 +350,13 @@ jobs: - backend: "textual-macOS" platform: "macOS" runs-on: "macos-latest" - briefcase-run-args: --config 'requires=["../core","../textual"]' --config 'console_app=true' + briefcase-run-args: --config 'requires=["../core","../textual", "../travertino"]' --config 'console_app=true' app-user-data-path: "$HOME/Library/Application Support/org.beeware.toga.testbed" - backend: "textual-windows" platform: "windows" runs-on: "windows-latest" - briefcase-run-args: --config 'requires=["../core","../textual"]' --config 'console_app=true' + briefcase-run-args: --config 'requires=["../core","../textual", "../travertino"]' --config 'console_app=true' app-user-data-path: '$HOME\AppData\Local\Tiberius Yak\Toga Testbed\Data' - backend: "windows" diff --git a/.github/workflows/config-file-deps-bump.yml b/.github/workflows/config-file-deps-bump.yml index f8d42de729..5ccd2b0b33 100644 --- a/.github/workflows/config-file-deps-bump.yml +++ b/.github/workflows/config-file-deps-bump.yml @@ -11,4 +11,4 @@ jobs: uses: beeware/.github/.github/workflows/dep-version-bump.yml@main secrets: inherit with: - subdirectory: . core dummy android cocoa demo gtk iOS testbed textual toga web winforms + subdirectory: . core dummy android cocoa demo gtk iOS testbed textual toga travertino web winforms diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 662300f168..a713e53b4b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,6 +27,7 @@ jobs: - "toga_iOS" - "toga_web" - "toga_winforms" + - "travertino" steps: - name: Get packages uses: dsaltares/fetch-gh-release-asset@1.1.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7726c2a4f..ff8cba2b8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,7 @@ jobs: - "toga_textual" - "toga_web" - "toga_winforms" + - "travertino" steps: - name: Get Packages uses: actions/download-artifact@v4.1.8 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5834611e1..55fe8b19cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,6 +17,14 @@ repos: - repo: https://github.com/PyCQA/isort rev: 5.13.2 hooks: + # isort for Travertino + - id: isort + args: [--settings-path=travertino] + # For some reason, providing "travertino" as an argument doesn't work to specify + # the target, like it would on the command line; it still runs against the + # whole repo. Setting it here seems to work, though. + files: travertino + # isort for the rest of the repo - id: isort - repo: https://github.com/psf/black-pre-commit-mirror rev: 24.10.0 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 4be7c3148e..cb574a449c 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -35,3 +35,5 @@ python: extra_requirements: - dev - docs + - method: pip + path: travertino diff --git a/changes/3086.feature.rst b/changes/3086.feature.rst new file mode 100644 index 0000000000..2901f8805c --- /dev/null +++ b/changes/3086.feature.rst @@ -0,0 +1,3 @@ +The Travertino library, providing the base classes for Toga's style and box model, is now managed as part of the Toga release process. + +TODO: CORRECT THE RELEASE DATE ON THE TRAVERTINO RELEASE NOTES diff --git a/core/pyproject.toml b/core/pyproject.toml index b717b8d9b8..672e1e44a6 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -55,9 +55,6 @@ classifiers = [ "Topic :: Software Development :: User Interfaces", "Topic :: Software Development :: Widget Sets", ] -dependencies = [ - "travertino >= 0.3.0, < 0.4.0", -] [project.optional-dependencies] # Extras used by developers *of* Toga are pinned to specific versions to @@ -104,6 +101,11 @@ pil = "toga.plugins.image_formats.PILConverter" [tool.setuptools_scm] root = ".." +[tool.setuptools_dynamic_dependencies] +dependencies = [ + "travertino == {version}", +] + [tool.coverage.run] parallel = true branch = true diff --git a/core/src/toga/style/mixin.py b/core/src/toga/style/mixin.py index 6761d4a415..cdd0410d56 100644 --- a/core/src/toga/style/mixin.py +++ b/core/src/toga/style/mixin.py @@ -17,20 +17,14 @@ def __delete__(self, widget): def style_mixin(style_cls): mixin_dict = { - "__doc__": f""" - Allows accessing the {style_cls.__name__} {style_cls._doc_link} directly on - the widget. For example, instead of ``widget.style.color``, you can simply - write ``widget.color``. - """ + name: StyleProperty() for name in style_cls._BASE_ALL_PROPERTIES[style_cls] } - try: - _all_properties = style_cls._BASE_ALL_PROPERTIES - except AttributeError: - # Travertino 0.3 compatibility - _all_properties = style_cls._ALL_PROPERTIES - - for name in _all_properties[style_cls]: - mixin_dict[name] = StyleProperty() + mixin_dict["__doc__"] = ( + f"""Allows accessing the {style_cls.__name__} {style_cls._doc_link} directly on + the widget. For example, instead of ``widget.style.color``, you can simply write + ``widget.color``. + """ + ) return type(style_cls.__name__ + "Mixin", (), mixin_dict) diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index 156a0604bf..3fa5848e1e 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -1,7 +1,10 @@ from __future__ import annotations import warnings -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from travertino.colors import rgb, hsl from travertino.constants import ( # noqa: F401 BOLD, @@ -9,6 +12,7 @@ CENTER, COLUMN, CURSIVE, + END, FANTASY, HIDDEN, ITALIC, @@ -25,12 +29,18 @@ SANS_SERIF, SERIF, SMALL_CAPS, + START, SYSTEM, TOP, TRANSPARENT, VISIBLE, ) -from travertino.declaration import BaseStyle, Choices +from travertino.declaration import ( + BaseStyle, + Choices, + directional_property, + validated_property, +) from travertino.layout import BaseBox from travertino.size import BaseIntrinsicSize @@ -56,10 +66,6 @@ # Declaration choices ###################################################################### -# Define here, since they're not available in Travertino 0.3.0 -START = "start" -END = "end" - # Used in backwards compatibility section below ALIGNMENT = "alignment" ALIGN_ITEMS = "align_items" @@ -101,6 +107,44 @@ class IntrinsicSize(BaseIntrinsicSize): _depth = -1 + display: str = validated_property(choices=DISPLAY_CHOICES, initial=PACK) + visibility: str = validated_property(choices=VISIBILITY_CHOICES, initial=VISIBLE) + direction: str = validated_property(choices=DIRECTION_CHOICES, initial=ROW) + align_items: str | None = validated_property(choices=ALIGN_ITEMS_CHOICES) + alignment: str | None = validated_property(choices=ALIGNMENT_CHOICES) # Deprecated + justify_content: str | None = validated_property( + choices=JUSTIFY_CONTENT_CHOICES, initial=START + ) + gap: int = validated_property(choices=GAP_CHOICES, initial=0) + + width: str | int = validated_property(choices=SIZE_CHOICES, initial=NONE) + height: str | int = validated_property(choices=SIZE_CHOICES, initial=NONE) + flex: float = validated_property(choices=FLEX_CHOICES, initial=0) + + margin: int | tuple[int] = directional_property("margin{}") + margin_top: int = validated_property(choices=MARGIN_CHOICES, initial=0) + margin_right: int = validated_property(choices=MARGIN_CHOICES, initial=0) + margin_bottom: int = validated_property(choices=MARGIN_CHOICES, initial=0) + margin_left: int = validated_property(choices=MARGIN_CHOICES, initial=0) + + color: rgb | hsl | str | None = validated_property(choices=COLOR_CHOICES) + background_color: rgb | hsl | str | None = validated_property( + choices=BACKGROUND_COLOR_CHOICES + ) + + text_align: str | None = validated_property(choices=TEXT_ALIGN_CHOICES) + text_direction: str | None = validated_property( + choices=TEXT_DIRECTION_CHOICES, initial=LTR + ) + + font_family: str = validated_property(choices=FONT_FAMILY_CHOICES, initial=SYSTEM) + font_style: str = validated_property(choices=FONT_STYLE_CHOICES, initial=NORMAL) + font_variant: str = validated_property(choices=FONT_VARIANT_CHOICES, initial=NORMAL) + font_weight: str = validated_property(choices=FONT_WEIGHT_CHOICES, initial=NORMAL) + font_size: int = validated_property( + choices=FONT_SIZE_CHOICES, initial=SYSTEM_DEFAULT_FONT_SIZE + ) + @classmethod def _debug(cls, *args: str) -> None: # pragma: no cover print(" " * cls._depth, *args) @@ -243,26 +287,12 @@ def __delattr__(self, name): # Index notation def __getitem__(self, name): - # As long as we're mucking about with backwards compatibility: Travertino 0.3.0 - # doesn't support accessing directional properties via bracket notation, so - # special-case it here to gain access to the FUTURE. - if name in {"padding", "margin"}: - return getattr(self, name) - return super().__getitem__(self._update_property_name(name.replace("-", "_"))) def __setitem__(self, name, value): - if name in {"padding", "margin"}: - setattr(self, name, value) - return - super().__setitem__(self._update_property_name(name.replace("-", "_")), value) def __delitem__(self, name): - if name in {"padding", "margin"}: - delattr(self, name) - return - super().__delitem__(self._update_property_name(name.replace("-", "_"))) ###################################################################### @@ -317,19 +347,7 @@ def apply(self, prop: str, value: object) -> None: # so perform a refresh. self._applicator.refresh() - def layout(self, viewport: Any, _deprecated_usage=None) -> None: - ###################################################################### - # 2024-12: Backwards compatibility for Travertino 0.3.0 - ###################################################################### - - if _deprecated_usage is not None: - # Was called with (self, viewport) - viewport = _deprecated_usage - - ###################################################################### - # End backwards compatibility - ###################################################################### - + def layout(self, viewport: Any) -> None: # self._debug("=" * 80) # self._debug( # f"Layout root {node}, available {viewport.width}x{viewport.height}" @@ -962,49 +980,4 @@ def __css__(self) -> str: return " ".join(css) -Pack.validated_property("display", choices=DISPLAY_CHOICES, initial=PACK) -Pack.validated_property("visibility", choices=VISIBILITY_CHOICES, initial=VISIBLE) -Pack.validated_property("direction", choices=DIRECTION_CHOICES, initial=ROW) -Pack.validated_property("align_items", choices=ALIGN_ITEMS_CHOICES) -Pack.validated_property("alignment", choices=ALIGNMENT_CHOICES) # Deprecated -Pack.validated_property( - "justify_content", choices=JUSTIFY_CONTENT_CHOICES, initial=START -) -Pack.validated_property("gap", choices=GAP_CHOICES, initial=0) - -Pack.validated_property("width", choices=SIZE_CHOICES, initial=NONE) -Pack.validated_property("height", choices=SIZE_CHOICES, initial=NONE) -Pack.validated_property("flex", choices=FLEX_CHOICES, initial=0) - -Pack.validated_property("margin_top", choices=MARGIN_CHOICES, initial=0) -Pack.validated_property("margin_right", choices=MARGIN_CHOICES, initial=0) -Pack.validated_property("margin_bottom", choices=MARGIN_CHOICES, initial=0) -Pack.validated_property("margin_left", choices=MARGIN_CHOICES, initial=0) -Pack.directional_property("margin%s") - -Pack.validated_property("color", choices=COLOR_CHOICES) -Pack.validated_property("background_color", choices=BACKGROUND_COLOR_CHOICES) - -Pack.validated_property("text_align", choices=TEXT_ALIGN_CHOICES) -Pack.validated_property("text_direction", choices=TEXT_DIRECTION_CHOICES, initial=LTR) - -Pack.validated_property("font_family", choices=FONT_FAMILY_CHOICES, initial=SYSTEM) -# Pack.list_property('font_family', choices=FONT_FAMILY_CHOICES) -Pack.validated_property("font_style", choices=FONT_STYLE_CHOICES, initial=NORMAL) -Pack.validated_property("font_variant", choices=FONT_VARIANT_CHOICES, initial=NORMAL) -Pack.validated_property("font_weight", choices=FONT_WEIGHT_CHOICES, initial=NORMAL) -Pack.validated_property( - "font_size", choices=FONT_SIZE_CHOICES, initial=SYSTEM_DEFAULT_FONT_SIZE -) -# Pack.composite_property([ -# 'font_family', 'font_style', 'font_variant', 'font_weight', 'font_size' -# FONT_CHOICES -# ]) - -try: - _all_properties = Pack._BASE_ALL_PROPERTIES -except AttributeError: - # Travertino 0.3 compatibility - _all_properties = Pack._ALL_PROPERTIES - -_all_properties[Pack].update(Pack._ALIASES) +Pack._BASE_ALL_PROPERTIES[Pack].update(Pack._ALIASES) diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index a29502ed1d..447845cff9 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -74,27 +74,6 @@ def __init__( self.applicator = TogaApplicator() - ############################################## - # Backwards compatibility for Travertino 0.3.0 - ############################################## - - # The below if block will execute when using Travertino 0.3.0. For future - # versions of Travertino, these assignments (and the reapply) will already have - # been handled "automatically" by assigning the applicator above; in that case, - # we want to avoid doing a second, redundant style reapplication. - - # This whole section can be removed as soon as there's a newer version of - # Travertino to set as Toga's minimum requirement. - - if not hasattr(self.applicator, "node"): # pragma: no cover - self.applicator.node = self - self.style._applicator = self.applicator - self.style.reapply() - - ############################# - # End backwards compatibility - ############################# - def _create(self) -> Any: """Create a platform-specific implementation of this widget. diff --git a/core/tests/style/pack/layout/test_justify_content.py b/core/tests/style/pack/layout/test_justify_content.py index a84a1c6c01..5d876ccc8f 100644 --- a/core/tests/style/pack/layout/test_justify_content.py +++ b/core/tests/style/pack/layout/test_justify_content.py @@ -52,7 +52,7 @@ def test_justify_content( if text_direction: root.style.text_direction = text_direction - root.style.layout(root, viewport) + root.style.layout(viewport) assert_layout( root, (240, 120) if direction == "row" else (120, 240), @@ -96,7 +96,7 @@ def test_justify_content_flex( delattr(child_style, "width" if direction == "row" else "height") child_style.flex = 1 - root.style.layout(root, viewport) + root.style.layout(viewport) assert_layout( root, (140, 120) if direction == "row" else (120, 140), diff --git a/core/tests/style/pack/utils.py b/core/tests/style/pack/utils.py index 86c3f1f8d9..0dabc6ef11 100644 --- a/core/tests/style/pack/utils.py +++ b/core/tests/style/pack/utils.py @@ -12,19 +12,6 @@ def __init__(self, name, style, size=None, children=None): super().__init__(style=style, children=children, applicator=TogaApplicator()) - ############################################## - # Backwards compatibility for Travertino 0.3.0 - ############################################## - - if not hasattr(self.applicator, "node"): - self.applicator.node = self - self.style._applicator = self.applicator - self.style.reapply() - - ############################# - # End backwards compatibility - ############################# - self.name = name self._impl = Mock() if size: diff --git a/docs/how-to/contribute/code.rst b/docs/how-to/contribute/code.rst index 1026aed7dd..1c5e579e16 100644 --- a/docs/how-to/contribute/code.rst +++ b/docs/how-to/contribute/code.rst @@ -145,21 +145,21 @@ source packages, so we have to manually install each package: .. code-block:: console (venv) $ cd toga - (venv) $ pip install -e "./core[dev]" -e ./dummy -e ./cocoa + (venv) $ pip install -e "./core[dev]" -e ./dummy -e ./cocoa -e ./travertino .. group-tab:: Linux .. code-block:: console (venv) $ cd toga - (venv) $ pip install -e ./core[dev] -e ./dummy -e ./gtk + (venv) $ pip install -e ./core[dev] -e ./dummy -e ./gtk -e ./travertino .. group-tab:: Windows .. code-block:: doscon (venv) C:\...>cd toga - (venv) C:\...>pip install -e ./core[dev] -e ./dummy -e ./winforms + (venv) C:\...>pip install -e ./core[dev] -e ./dummy -e ./winforms -e ./travertino Pre-commit automatically runs during the commit ----------------------------------------------- @@ -488,10 +488,14 @@ app. .. _run-core-test-suite: -Running the core test suite -=========================== +Running the core test suites +============================ Toga uses `tox `__ to manage the testing process. + +Testing Core +------------ + To run the core test suite: .. tabs:: @@ -500,19 +504,19 @@ To run the core test suite: .. code-block:: console - (venv) $ tox -m test + (venv) $ tox -m test-core .. group-tab:: Linux .. code-block:: console - (venv) $ tox -m test + (venv) $ tox -m test-core .. group-tab:: Windows .. code-block:: doscon - (venv) C:\...>tox -m test + (venv) C:\...>tox -m test-core You should get some output indicating that tests have been run. You may see ``SKIPPED`` tests, but shouldn't ever get any ``FAIL`` or ``ERROR`` test @@ -547,6 +551,59 @@ This tells us that line 211, and lines 238-240 are not being executed by the tes suite. You'll need to add new tests (or modify an existing test) to restore this coverage. +Testing Travertino +------------------ + +In addition to the core library, the Toga repository also includes Travertino, a package +that defines the lower-level layout mechanisms and style definitions which core then +builds on. Its test suite can be run just like that of core: + +.. tabs:: + + .. group-tab:: macOS + + .. code-block:: console + + (venv) $ tox -m test-trav + + .. group-tab:: Linux + + .. code-block:: console + + (venv) $ tox -m test-trav + + .. group-tab:: Windows + + .. code-block:: doscon + + (venv) C:\...>tox -m test-trav + +Just as with core, this should report 100% test coverage. + +You can run both the core and Travertino tests with one command: + +.. tabs:: + + .. group-tab:: macOS + + .. code-block:: console + + (venv) $ tox -m test + + .. group-tab:: Linux + + .. code-block:: console + + (venv) $ tox -m test + + .. group-tab:: Windows + + .. code-block:: doscon + + (venv) C:\...>tox -m test + +This will run both test suites, and report the two coverage results one after the other. + Run a subset of tests --------------------- @@ -575,15 +632,37 @@ specific test, using `pytest specifiers (venv) C:\...>tox -e py -- tests/path_to_test_file/test_some_test.py -These test paths are relative to the ``core`` directory. You'll still get a -coverage report when running a part of the test suite - but the coverage results -will only report the lines of code that were executed by the specific tests you -ran. +These test paths are relative to the ``core`` directory. To run a Travertino test +instead, add ``-trav``: + +.. tabs:: + + .. group-tab:: macOS + + .. code-block:: console + + (venv) $ tox -e py-trav -- tests/path_to_test_file/test_some_test.py + + .. group-tab:: Linux + + .. code-block:: console + + (venv) $ tox -e py-trav -- tests/path_to_test_file/test_some_test.py + + .. group-tab:: Windows + + .. code-block:: doscon + + (venv) C:\...>tox -e py-trav -- tests/path_to_test_file/test_some_test.py + +Either way, you'll still get a coverage report when running a part of the test suite - +but the coverage results will only report the lines of code that were executed by the +specific tests you ran. -Running the test suite for multiple Python versions ---------------------------------------------------- +Running the test suites for multiple Python versions +---------------------------------------------------- -Tox can also run the test suite for all supported version of Python. This +Tox can also run the test suites for all supported version of Python. This requires that each version of Python is available from ``Path``. .. tabs:: @@ -637,10 +716,10 @@ most useful prior to committing and pushing your changes. Running the testbed =================== -The core API tests exercise ``toga-core`` - but what about the backends? To verify -the behavior of the backends, Toga has a testbed app. This app uses the core API -to exercise all the behaviors that the backend APIs need to perform - but uses -an actual platform backend to implement that behavior. +The above test suites exercise ``toga-core`` and ``travertino`` - but what about the +backends? To verify the behavior of the backends, Toga has a testbed app. This app uses +the core API to exercise all the behaviors that the backend APIs need to perform - but +uses an actual platform backend to implement that behavior. To run the testbed app, install `Briefcase `__, and run the app in developer diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index e9b403ee51..1adafdffda 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -93,6 +93,7 @@ toolkits tooltip Towncrier Tox +Travertino triaged Triaging tvOS diff --git a/dummy/src/toga_dummy/utils.py b/dummy/src/toga_dummy/utils.py index d5ab2e5f32..9dce87b62f 100644 --- a/dummy/src/toga_dummy/utils.py +++ b/dummy/src/toga_dummy/utils.py @@ -371,15 +371,9 @@ def assert_action_performed_with(_widget, _action, **test_data): found = False except AttributeError: # No raw attribute; use the provided value as-is - try: - if data[key] != value: - found = False - ######################################################## - # Backwards compatibility for Travertino 0.3.0 - # Font.__eq__ throws an AttributeError against non-Fonts - ######################################################## - except AttributeError: + if data[key] != value: found = False + except KeyError: found = False diff --git a/gtk/src/toga_gtk/container.py b/gtk/src/toga_gtk/container.py index 37ab26dbda..564a9434de 100644 --- a/gtk/src/toga_gtk/container.py +++ b/gtk/src/toga_gtk/container.py @@ -103,7 +103,7 @@ def recompute(self): widget.rehint() # Recompute the layout - self._content.interface.style.layout(self._content.interface, self) + self._content.interface.style.layout(self) self.min_width = self._content.interface.layout.min_width self.min_height = self._content.interface.layout.min_height @@ -173,7 +173,7 @@ def do_size_allocate(self, allocation): # Re-evaluate the layout using the allocation size as the basis # for geometry # print("REFRESH LAYOUT", allocation.width, allocation.height) - self._content.interface.style.layout(self._content.interface, self) + self._content.interface.style.layout(self) # Ensure the minimum content size from the layout is retained self.min_width = self._content.interface.layout.min_width diff --git a/pyproject.toml b/pyproject.toml index d4b453b3ad..5dba9590d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ split_on_trailing_comma = true combine_as_imports = true known_third_party = [ "android", # isort defaults to making this first-party for some reason. + "travertino", # In this repo, but still a separate package ] known_first_party = [ "testbed", @@ -61,6 +62,7 @@ known_first_party = [ "toga_web", "toga_winforms", ] +extend_skip = ["travertino"] [tool.towncrier] directory = "changes" diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index e4099d6ed1..355fd7cd97 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -35,6 +35,7 @@ test_sources = [ ] requires = [ "../core", + "../travertino", ] permission.camera = "The testbed needs to exercise Camera APIs" diff --git a/tox.ini b/tox.ini index 847c4a8107..a1325706a7 100644 --- a/tox.ini +++ b/tox.ini @@ -7,15 +7,17 @@ extend-ignore = E203, [tox] -envlist = py{39,310,311,312,313}-cov,coverage +envlist = py{39,310,311,312,313}-cov{,-trav},coverage labels = - test = py-cov,coverage - test39 = py39-cov,coverage39 - test310 = py310-cov,coverage310 - test311 = py311-cov,coverage311 - test312 = py312-cov,coverage312 - test313 = py313-cov,coverage313 - ci = towncrier-check,docs-lint,pre-commit,py{39,310,311,312,313}-cov,coverage-fail-platform + test = py-cov{,-trav},coverage{,-trav} + test-core = py-cov,coverage + test-trav = py-cov-trav,coverage-trav + test39 = py39-cov{,-trav},coverage39{,-trav} + test310 = py310-cov{,-trav},coverage310{,-trav} + test311 = py311-cov{,-trav},coverage311{,-trav} + test312 = py312-cov{,-trav},coverage312{,-trav} + test313 = py313-cov{,-trav},coverage313{,-trav} + ci = towncrier-check,docs-lint,pre-commit,py313-cov{,-trav},coverage{,-trav}-fail-platform skip_missing_interpreters = True [testenv:pre-commit] @@ -25,9 +27,11 @@ deps = commands = pre-commit run --all-files --show-diff-on-failure --color=always # The leading comma generates the "py" environment -[testenv:py{,39,310,311,312,313}{,-cov}] +[testenv:py{,39,310,311,312,313}{,-cov}{,-trav}] depends = pre-commit -changedir = core +changedir = + !trav: core + trav: travertino skip_install = True setenv = TOGA_BACKEND = toga_dummy @@ -35,14 +39,17 @@ allowlist_externals = bash commands = # TOGA_INSTALL_COMMAND is set to a bash command by the CI workflow - # Install as editable so parallel runs don't clobber the build directory for each other - {env:TOGA_INSTALL_COMMAND:python -m pip install {tox_root}{/}core[dev] {tox_root}{/}dummy} + {env:TOGA_INSTALL_COMMAND:python -m pip install {tox_root}{/}core[dev] {tox_root}{/}dummy {tox_root}{/}travertino} !cov: python -m pytest {posargs:-vv --color yes} cov : python -m coverage run -m pytest {posargs:-vv --color yes} -[testenv:coverage{,39,310,311,312,313}{,-html}{,-keep}{,-fail}{,-platform}] -depends = pre-commit,py{,39,310,311,312,313}{,-cov} -changedir = core +[testenv:coverage{,39,310,311,312,313}{,-trav}{,-html}{,-keep}{,-fail}{,-platform}] +depends = + !trav: pre-commit,py{,39,310,311,312,313}{,-cov} + trav: pre-commit,py{,39,310,311,312,313}{,-trav}{,-cov} +changedir = + !trav: core + trav: travertino skip_install = True # by default, coverage should run on oldest supported Python for testing platform coverage. # however, coverage for a particular Python version should match the version used for pytest. @@ -53,17 +60,21 @@ base_python = coverage312: py312 coverage313: py313 deps = - {tox_root}{/}core[dev] + !trav: {tox_root}{/}core[dev] + trav: {tox_root}{/}travertino[dev] setenv = keep: COMBINE_KEEP = --keep fail: REPORT_FAIL_COND = --fail-under=100 - CORE_RCFILE = --rcfile {tox_root}{/}core{/}pyproject.toml + # Even when run with "fail", cut Travertino some slack. + trav: REPORT_FAIL_COND = + !trav: PACKAGE_RCFILE = --rcfile {tox_root}{/}core{/}pyproject.toml + trav: PACKAGE_RCFILE = --rcfile {tox_root}{/}travertino{/}pyproject.toml PROJECT_RCFILE = --rcfile {tox_root}{/}pyproject.toml # disable conditional coverage exclusions for Python version {platform}: COVERAGE_EXCLUDE_PYTHON_VERSION=disable commands_pre = python --version commands = - -python -m coverage combine {env:CORE_RCFILE} {env:COMBINE_KEEP} + -python -m coverage combine {env:PACKAGE_RCFILE} {env:COMBINE_KEEP} html: python -m coverage html {env:PROJECT_RCFILE} --skip-covered --skip-empty python -m coverage report {env:PROJECT_RCFILE} {env:REPORT_FAIL_COND} @@ -90,6 +101,7 @@ suicide_timeout = 1 deps = # editable install so docstrings can be updated for 'all' and 'live' -e {tox_root}{/}core[docs] + -e {tox_root}{/}travertino passenv = # On macOS M1, you need to manually set the location of the PyEnchant # library: diff --git a/travertino/AUTHORS b/travertino/AUTHORS new file mode 100644 index 0000000000..017082d0bc --- /dev/null +++ b/travertino/AUTHORS @@ -0,0 +1,8 @@ +Travertino was originally created in December 2017. + +The PRIMARY AUTHORS are (and/or have been): + Russell Keith-Magee + +And here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS -- +people who have submitted patches, reported bugs, added translations, helped +answer newbie questions, and generally made Travertino that much better: diff --git a/travertino/CHANGELOG.rst b/travertino/CHANGELOG.rst new file mode 100644 index 0000000000..4d2c88e032 --- /dev/null +++ b/travertino/CHANGELOG.rst @@ -0,0 +1,134 @@ +Changelog +========= + +**Note:** As of version 0.5.0, Travertino is now hosted and developed as part of the +Toga repository. It's now released along with — and has the same version number as — +each `Toga release `_. + +For all development beyond 0.5.0, any changes made to Travertino will be logged along +with Toga's overall list of changes for each new release. + +.. towncrier release notes start + +0.5.0 (2025-??-??) +================== + +Features +-------- + +* Validated properties of styles can now be defined as dataclass class attributes. (`#141 `_) +* BaseStyle now supports ``|``, ``|=``, and ``in`` operators. (`#143 `_) +* A ``list_property`` declaration has been added to support storing multi-valueds style elements. (`#148 `_) +* Support for Python 3.13 was added. (`#149 `_) +* Support for Python 3.14 was added. (`#223 `_) +* The constants ``START`` and ``END`` have been added. (`#241 `_) + +Bugfixes +-------- + +* Assigning a new style object to a node that already has an applicator assigned now properly maintains an association between the applicator and the new style, and triggers a style reapplication. (`#224 `_) +* Equality checks between a Font object and a non-Font object will now throw an exception instead of returning False. (`#233 `_) + +Backward Incompatible Changes +----------------------------- + +* The `default` parameter for Choice has been deprecated. (`#139 `_) +* Python 3.8 is no longer supported. (`#223 `_) +* The mechanisms for assigning styles and applicators to nodes, and applying styles, have been reworked. A node will now attempt to apply its style as soon as it is assigned an applicator. This means you should not assign an applicator to a node until the node is sufficiently initialized to apply its style. To accommodate uses that currently do not follow this order, any exceptions resulting from a failed style application are caught, and a runtime warning is issued. In a future version, this will be an exception. (`#224 `_) +* Supplying an applicator to BaseStyle.copy() has been deprecated. If you need to manually assign an applicator to a style, do it separately, after the copy. (`#224 `_) +* The API for ``Style.layout()`` has been formally specified as part of the Travertino API. The initial ``node`` argument is no longer required as part of the ``layout()`` method. A ``Style`` instance can interrogate ``self._applicator.node`` to retrieve the node to which the style is being applied. (`#244 `_) + + +Documentation +------------- + +* The README badges were updated to display correctly on GitHub. (`#170 `_) + + +Misc +---- + +* `#88 `_, `#89 `_, `#90 `_, `#91 `_, `#92 `_, `#93 `_, `#94 `_, `#95 `_, `#96 `_, `#97 `_, `#98 `_, `#99 `_, `#100 `_, `#101 `_, `#102 `_, `#103 `_, `#104 `_, `#105 `_, `#106 `_, `#107 `_, `#108 `_, `#109 `_, `#110 `_, `#111 `_, `#112 `_, `#113 `_, `#114 `_, `#115 `_, `#116 `_, `#117 `_, `#118 `_, `#120 `_, `#121 `_, `#122 `_, `#123 `_, `#124 `_, `#125 `_, `#126 `_, `#127 `_, `#128 `_, `#129 `_, `#130 `_, `#131 `_, `#132 `_, `#133 `_, `#134 `_, `#135 `_, `#136 `_, `#137 `_, `#138 `_, `#140 `_, `#142 `_, `#144 `_, `#145 `_, `#146 `_, `#147 `_, `#150 `_, `#151 `_, `#152 `_, `#154 `_, `#155 `_, `#156 `_, `#157 `_, `#158 `_, `#159 `_, `#160 `_, `#161 `_, `#162 `_, `#163 `_, `#164 `_, `#165 `_, `#166 `_, `#167 `_, `#168 `_, `#169 `_, `#171 `_, `#172 `_, `#173 `_, `#174 `_, `#175 `_, `#176 `_, `#177 `_, `#178 `_, `#179 `_, `#180 `_, `#181 `_, `#182 `_, `#183 `_, `#184 `_, `#185 `_, `#186 `_, `#187 `_, `#188 `_, `#189 `_, `#190 `_, `#191 `_, `#192 `_, `#193 `_, `#194 `_, `#195 `_, `#196 `_, `#197 `_, `#199 `_, `#200 `_, `#202 `_, `#204 `_, `#205 `_, `#206 `_, `#207 `_, `#208 `_, `#209 `_, `#210 `_, `#211 `_, `#212 `_, `#213 `_, `#214 `_, `#215 `_, `#216 `_, `#217 `_, `#218 `_, `#219 `_, `#220 `_, `#221 `_, `#224 `_, `#225 `_, `#226 `_, `#227 `_, `#228 `_, `#229 `_, `#230 `_, `#231 `_, `#232 `_, `#234 `_, `#235 `_, `#236 `_, `#237 `_, `#238 `_, `#239 `_, `#240 `_, `#242 `_, `#245 `_, `#247 `_, `#248 `_ + + +0.3.0 (2023-08-16) +================== + +Features +-------- + +* Layout nodes can now track the minimum permitted layout size in addition to the current actual layout size. (`#78 `_) + + +Backward Incompatible Changes +----------------------------- + +* Support for Python 3.7 was removed. (`#80 `_) + + +Misc +---- + +* `#44 `_, `#45 `_, `#46 `_, `#47 `_, `#48 `_, `#49 `_, `#50 `_, `#51 `_, `#52 `_, `#53 `_, `#54 `_, `#55 `_, `#56 `_, `#57 `_, `#58 `_, `#59 `_, `#60 `_, `#61 `_, `#62 `_, `#63 `_, `#65 `_, `#66 `_, `#67 `_, `#72 `_, `#73 `_, `#74 `_, `#75 `_, `#76 `_, `#77 `_, `#79 `_, `#81 `_, `#82 `_, `#83 `_, `#84 `_, `#85 `_, `#86 `_, `#87 `_ + + +0.2.0 (2023-03-24) +================== + +Features +-------- + +* Node now supports the ``clear`` method in order to clear all children. (`#23 `_) +* Constants for absolute and relative font sizing were added. (`#43 `_) + + +Bugfixes +-------- + +* Handling of ``none`` as a property value has been corrected. (`#3 `_) + + +Improved Documentation +---------------------- + +* Details on towncrier and pre-commit ussage were added to the README. (`#18 `_) + + +Misc +---- + +* `#22 `_, `#24 `_, `#25 `_, `#26 `_, `#30 `_, `#34 `_, `#35 `_, `#36 `_, `#37 `_, `#38 `_, `#39 `_, `#40 `_, `#41 `_, `#42 `_ + + +0.1.3 (2020-05-25) +================== + +Features +-------- + +* Introduced some constants used by Pack that have more general uses. (`#5 `_) +* Added the ability to add, insert and remove children from a node tree. (`#10 `_) +* Added color validation in rgba and hsla constructors (`#17 `_) +* Added support for declaring a system default font size. (`#19 `_) + +Misc +---- + +* `#15 `_, `#16 `_ + + +0.1.2 +===== + +* Added constants for system and message fonts +* Added hash method to fonts and colors + +0.1.1 +===== + +* Added font definitions + +0.1.0 +===== + +Initial release. diff --git a/travertino/CONTRIBUTING.md b/travertino/CONTRIBUTING.md new file mode 100644 index 0000000000..ec4f1c03a0 --- /dev/null +++ b/travertino/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing + +BeeWare <3's contributions! + +Please be aware, BeeWare operates under a Code of Conduct. + +See [CONTRIBUTING to BeeWare](https://beeware.org/contributing) for details. diff --git a/travertino/LICENSE b/travertino/LICENSE new file mode 100644 index 0000000000..d4e6b204aa --- /dev/null +++ b/travertino/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2017 Russell Keith-Magee. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of Travertino nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/travertino/README.rst b/travertino/README.rst new file mode 100644 index 0000000000..fcf086d8ea --- /dev/null +++ b/travertino/README.rst @@ -0,0 +1,97 @@ +.. |logo| image:: https://beeware.org/static/images/defaultlogo.png + :width: 72px + :target: https://beeware.org + +.. |pyversions| image:: https://img.shields.io/pypi/pyversions/travertino.svg + :target: https://pypi.python.org/pypi/travertino + :alt: Python Versions + +.. |version| image:: https://img.shields.io/pypi/v/travertino.svg + :target: https://pypi.python.org/pypi/travertino + :alt: Project version + +.. |maturity| image:: https://img.shields.io/pypi/status/travertino.svg + :target: https://pypi.python.org/pypi/travertino + :alt: Project status + +.. |license| image:: https://img.shields.io/pypi/l/travertino.svg + :target: https://github.com/beeware/toga/blob/main/travertino/LICENSE + :alt: BSD License + +.. |ci| image:: https://github.com/beeware/toga/workflows/CI/badge.svg?branch=main + :target: https://github.com/beeware/toga/actions + :alt: Build Status + +.. |social| image:: https://img.shields.io/discord/836455665257021440?label=Discord%20Chat&logo=discord&style=plastic + :target: https://beeware.org/bee/chat/ + :alt: Discord server + +|logo| + +Travertino +========== + +|pyversions| |version| |maturity| |license| |ci| |social| + +Travertino is a set of constants and utilities for describing user +interfaces, including: + +* colors +* directions +* text alignment +* sizes + +Usage +----- + +Install Travertino: + + $ pip install travertino + +Then in your python code, import and use it:: + + >>> from travertino.colors import color, rgb, + + # Define a new color as an RGB triple + >>> red = rgb(0xff, 0x00, 0x00) + + # Parse a color from a string + >>> color('#dead00') + rgb(0xde, 0xad, 0x00) + + # Reference a pre-defined color + >>> color('RebeccaPurple') + rgb(102, 51, 153) + + +Community +--------- + +Travertino is part of the `BeeWare suite`_. You can talk to the community through: + +* `@beeware@fosstodon.org on Mastodon `__ + +* `Discord `__ + +We foster a welcoming and respectful community as described in our +`BeeWare Community Code of Conduct`_. + +Contributing +------------ + +If you experience problems with Travertino, `log them on GitHub`_. If you +want to contribute code, please `fork the code`_ and `submit a pull request`_. + +Travertino uses `Pre-commit `__ and `TownCrier +`__ to help maintain code quality. For +details on how to use these tools as part of your development environment, see +the `Briefcase code contribution guide +`__. +Although that document is for a different project, the details about setting up +your development environment are the same. + +.. _BeeWare suite: https://beeware.org +.. _BeeWare Community Code of Conduct: https://beeware.org/community/behavior/ +.. _log them on Github: https://github.com/beeware/toga/issues +.. _fork the code: https://github.com/beeware/toga +.. _submit a pull request: https://github.com/beeware/toga/pulls diff --git a/travertino/pyproject.toml b/travertino/pyproject.toml new file mode 100644 index 0000000000..fab6da60a6 --- /dev/null +++ b/travertino/pyproject.toml @@ -0,0 +1,85 @@ +[build-system] +requires = [ + "setuptools==75.6.0", + "setuptools_scm==8.1.0", +] +build-backend = "setuptools.build_meta" + +[project] +dynamic = ["version"] +name = "travertino" +description = "A set of constants and base classes for describing user interface layouts." +readme = "README.rst" +requires-python = ">= 3.9" +license.text = "New BSD" +authors = [ + {name="Russell Keith-Magee", email="russell@keith-magee.com"}, +] +maintainers = [ + {name="BeeWare Team", email="team@beeware.org"}, +] +keywords = [ + "css", + "box model", + "layout", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development", + "Topic :: Software Development :: User Interfaces", +] + +[project.optional-dependencies] +# These are needed in order to run Travertino's test suite. +dev = [ + "coverage[toml] == 7.6.10", + "coverage-conditional-plugin == 0.9.0", + "pytest == 8.3.4", + "tox == 4.23.2", + # typing-extensions needed for TypeAlias added in Py 3.10 + "typing-extensions == 4.12.2 ; python_version < '3.10'", +] + +[project.urls] +Homepage = "https://beeware.org/travertino" +Funding = "https://beeware.org/contributing/membership/" +# Documentation = "https://travertino.readthedocs.io/en/latest/" +Tracker = "https://github.com/beeware/travertino/issues" +Source = "https://github.com/beeware/travertino" + +[tool.isort] +profile = "black" +split_on_trailing_comma = true +combine_as_imports = true + +[tool.setuptools_scm] +root = ".." + +[tool.coverage.run] +parallel = true +branch = true +relative_files = true + +# See notes in the root pyproject.toml file. +source = ["src"] +source_pkgs = ["travertino"] + +[tool.coverage.paths] +source = [ + "src/travertino", + "**/travertino", +] + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" diff --git a/travertino/src/travertino/__init__.py b/travertino/src/travertino/__init__.py new file mode 100644 index 0000000000..f26dcd5f6c --- /dev/null +++ b/travertino/src/travertino/__init__.py @@ -0,0 +1,17 @@ +try: + # Read version from SCM metadata + # This will only exist in a development environment + from setuptools_scm import get_version + + # Excluded from coverage because a pure test environment (such as the one + # used by tox in CI) won't have setuptools_scm + __version__ = get_version("../../..", relative_to=__file__) # pragma: no cover +except (ModuleNotFoundError, LookupError): + # If setuptools_scm isn't in the environment, the call to import will fail. + # If it *is* in the environment, but the code isn't a git checkout (e.g., + # it's been pip installed non-editable) the call to get_version() will fail. + # If either of these occurs, read version from the installer metadata. + + from importlib.metadata import version + + __version__ = version("travertino") diff --git a/travertino/src/travertino/colors.py b/travertino/src/travertino/colors.py new file mode 100644 index 0000000000..a6b91eebc4 --- /dev/null +++ b/travertino/src/travertino/colors.py @@ -0,0 +1,403 @@ +# flake8: NOQA: F405 +from .constants import * + + +class Color: + "A base class for all colorspace representations" + pass + + def __eq__(self, other): + try: + c1 = self.rgba + c2 = other.rgba + + return c1.r == c2.r and c1.g == c2.g and c1.b == c2.b and c1.a == c2.a + except AttributeError: + return False + + @classmethod + def _validate_between(cls, content_name, value, min_value, max_value): + if value < min_value or value > max_value: + raise ValueError( + "{} value should be between {}-{}. Got {}".format( + content_name, min_value, max_value, value + ) + ) + + @classmethod + def _validate_partial(cls, content_name, value): + cls._validate_between(content_name, value, 0, 1) + + @classmethod + def _validate_alpha(cls, value): + cls._validate_partial("alpha", value) + + +class rgba(Color): + "A representation of an RGBA color" + + def __init__(self, r, g, b, a): + self._validate_rgb("red", r) + self._validate_rgb("green", g) + self._validate_rgb("blue", b) + self._validate_alpha(a) + self.r = r + self.g = g + self.b = b + self.a = a + + def __hash__(self): + return hash(("RGBA-color", self.r, self.g, self.b, self.a)) + + def __repr__(self): + return f"rgba({self.r}, {self.g}, {self.b}, {self.a})" + + @classmethod + def _validate_rgb(cls, content_name, value): + cls._validate_between(content_name, value, 0, 255) + + @property + def rgba(self): + return self + + +class rgb(rgba): + "A representation of an RGB color" + + def __init__(self, r, g, b): + super().__init__(r, g, b, 1.0) + + def __repr__(self): + return f"rgb({self.r}, {self.g}, {self.b})" + + +class hsla(Color): + "A representation of an HSLA color" + + def __init__(self, h, s, l, a=1.0): + self._validate_between("hue", h, 0, 360) + self._validate_partial("saturation", s) + self._validate_partial("lightness", l) + self._validate_alpha(a) + self.h = h + self.s = s + self.l = l # NOQA; E741 + self.a = a + + def __hash__(self): + return hash(("HSLA-color", self.h, self.s, self.l, self.a)) + + def __repr__(self): + return f"hsla({self.h}, {self.s}, {self.l}, {self.a})" + + @property + def rgba(self): + c = (1.0 - abs(2.0 * self.l - 1.0)) * self.s + h = self.h / 60.0 + x = c * (1.0 - abs(h % 2 - 1.0)) + m = self.l - 0.5 * c + + if h < 1.0: + r, g, b = c + m, x + m, m + elif h < 2.0: + r, g, b = x + m, c + m, m + elif h < 3.0: + r, g, b = m, c + m, x + m + elif h < 4.0: + r, g, b = m, x + m, c + m + elif h < 5.0: + r, g, b = m, x + m, c + m + else: + r, g, b = c + m, m, x + m + + return rgba( + round(r * 0xFF), + round(g * 0xFF), + round(b * 0xFF), + self.a, + ) + + +class hsl(hsla): + "A representation of an HSL color" + + def __init__(self, h, s, l): + super().__init__(h, s, l, 1.0) + + def __repr__(self): + return f"hsl({self.h}, {self.s}, {self.l})" + + +def color(value): + """Parse a color from a value. + + Accepts: + * rgb() instances + * hsl() instances + * '#rgb' + * '#rgba' + * '#rrggbb' + * '#rrggbbaa' + * '#RGB' + * '#RGBA' + * '#RRGGBB' + * '#RRGGBBAA' + * 'rgb(0, 0, 0)' + * 'rgba(0, 0, 0, 0.0)' + * 'hsl(0, 0%, 0%)' + * 'hsla(0, 0%, 0%, 0.0)' + * A named color + """ + + if isinstance(value, Color): + return value + + elif isinstance(value, str): + if value[0] == "#": + if len(value) == 4: + return rgb( + r=int(value[1] + value[1], 16), + g=int(value[2] + value[2], 16), + b=int(value[3] + value[3], 16), + ) + elif len(value) == 5: + return rgba( + r=int(value[1] + value[1], 16), + g=int(value[2] + value[2], 16), + b=int(value[3] + value[3], 16), + a=int(value[4] + value[4], 16) / 0xFF, + ) + elif len(value) == 7: + return rgb( + r=int(value[1:3], 16), + g=int(value[3:5], 16), + b=int(value[5:7], 16), + ) + elif len(value) == 9: + return rgba( + r=int(value[1:3], 16), + g=int(value[3:5], 16), + b=int(value[5:7], 16), + a=int(value[7:9], 16) / 0xFF, + ) + elif value.startswith("rgba"): + try: + values = value[5:-1].split(",") + if len(values) == 4: + return rgba( + int(values[0]), + int(values[1]), + int(values[2]), + float( + values[3], + ), + ) + except ValueError: + pass + elif value.startswith("rgb"): + try: + values = value[4:-1].split(",") + if len(values) == 3: + return rgb( + int(values[0]), + int(values[1]), + int(values[2]), + ) + except ValueError: + pass + + elif value.startswith("hsla"): + try: + values = value[5:-1].split(",") + if len(values) == 4: + return hsla( + int(values[0]), + int(values[1].strip().rstrip("%")) / 100.0, + int(values[2].strip().rstrip("%")) / 100.0, + float(values[3]), + ) + except ValueError: + pass + + elif value.startswith("hsl"): + try: + values = value[4:-1].split(",") + if len(values) == 3: + return hsl( + int(values[0]), + int(values[1].strip().rstrip("%")) / 100.0, + int(values[2].strip().rstrip("%")) / 100.0, + ) + except ValueError: + pass + else: + try: + return NAMED_COLOR[value.lower()] + except KeyError: + pass + + raise ValueError("Unknown color %s" % value) + + +NAMED_COLOR = { + ALICEBLUE: rgb(0xF0, 0xF8, 0xFF), + ANTIQUEWHITE: rgb(0xFA, 0xEB, 0xD7), + AQUA: rgb(0x00, 0xFF, 0xFF), + AQUAMARINE: rgb(0x7F, 0xFF, 0xD4), + AZURE: rgb(0xF0, 0xFF, 0xFF), + BEIGE: rgb(0xF5, 0xF5, 0xDC), + BISQUE: rgb(0xFF, 0xE4, 0xC4), + BLACK: rgb(0x00, 0x00, 0x00), + BLANCHEDALMOND: rgb(0xFF, 0xEB, 0xCD), + BLUE: rgb(0x00, 0x00, 0xFF), + BLUEVIOLET: rgb(0x8A, 0x2B, 0xE2), + BROWN: rgb(0xA5, 0x2A, 0x2A), + BURLYWOOD: rgb(0xDE, 0xB8, 0x87), + CADETBLUE: rgb(0x5F, 0x9E, 0xA0), + CHARTREUSE: rgb(0x7F, 0xFF, 0x00), + CHOCOLATE: rgb(0xD2, 0x69, 0x1E), + CORAL: rgb(0xFF, 0x7F, 0x50), + CORNFLOWERBLUE: rgb(0x64, 0x95, 0xED), + CORNSILK: rgb(0xFF, 0xF8, 0xDC), + CRIMSON: rgb(0xDC, 0x14, 0x3C), + CYAN: rgb(0x00, 0xFF, 0xFF), + DARKBLUE: rgb(0x00, 0x00, 0x8B), + DARKCYAN: rgb(0x00, 0x8B, 0x8B), + DARKGOLDENROD: rgb(0xB8, 0x86, 0x0B), + DARKGRAY: rgb(0xA9, 0xA9, 0xA9), + DARKGREY: rgb(0xA9, 0xA9, 0xA9), + DARKGREEN: rgb(0x00, 0x64, 0x00), + DARKKHAKI: rgb(0xBD, 0xB7, 0x6B), + DARKMAGENTA: rgb(0x8B, 0x00, 0x8B), + DARKOLIVEGREEN: rgb(0x55, 0x6B, 0x2F), + DARKORANGE: rgb(0xFF, 0x8C, 0x00), + DARKORCHID: rgb(0x99, 0x32, 0xCC), + DARKRED: rgb(0x8B, 0x00, 0x00), + DARKSALMON: rgb(0xE9, 0x96, 0x7A), + DARKSEAGREEN: rgb(0x8F, 0xBC, 0x8F), + DARKSLATEBLUE: rgb(0x48, 0x3D, 0x8B), + DARKSLATEGRAY: rgb(0x2F, 0x4F, 0x4F), + DARKSLATEGREY: rgb(0x2F, 0x4F, 0x4F), + DARKTURQUOISE: rgb(0x00, 0xCE, 0xD1), + DARKVIOLET: rgb(0x94, 0x00, 0xD3), + DEEPPINK: rgb(0xFF, 0x14, 0x93), + DEEPSKYBLUE: rgb(0x00, 0xBF, 0xFF), + DIMGRAY: rgb(0x69, 0x69, 0x69), + DIMGREY: rgb(0x69, 0x69, 0x69), + DODGERBLUE: rgb(0x1E, 0x90, 0xFF), + FIREBRICK: rgb(0xB2, 0x22, 0x22), + FLORALWHITE: rgb(0xFF, 0xFA, 0xF0), + FORESTGREEN: rgb(0x22, 0x8B, 0x22), + FUCHSIA: rgb(0xFF, 0x00, 0xFF), + GAINSBORO: rgb(0xDC, 0xDC, 0xDC), + GHOSTWHITE: rgb(0xF8, 0xF8, 0xFF), + GOLD: rgb(0xFF, 0xD7, 0x00), + GOLDENROD: rgb(0xDA, 0xA5, 0x20), + GRAY: rgb(0x80, 0x80, 0x80), + GREY: rgb(0x80, 0x80, 0x80), + GREEN: rgb(0x00, 0x80, 0x00), + GREENYELLOW: rgb(0xAD, 0xFF, 0x2F), + HONEYDEW: rgb(0xF0, 0xFF, 0xF0), + HOTPINK: rgb(0xFF, 0x69, 0xB4), + INDIANRED: rgb(0xCD, 0x5C, 0x5C), + INDIGO: rgb(0x4B, 0x00, 0x82), + IVORY: rgb(0xFF, 0xFF, 0xF0), + KHAKI: rgb(0xF0, 0xE6, 0x8C), + LAVENDER: rgb(0xE6, 0xE6, 0xFA), + LAVENDERBLUSH: rgb(0xFF, 0xF0, 0xF5), + LAWNGREEN: rgb(0x7C, 0xFC, 0x00), + LEMONCHIFFON: rgb(0xFF, 0xFA, 0xCD), + LIGHTBLUE: rgb(0xAD, 0xD8, 0xE6), + LIGHTCORAL: rgb(0xF0, 0x80, 0x80), + LIGHTCYAN: rgb(0xE0, 0xFF, 0xFF), + LIGHTGOLDENRODYELLOW: rgb(0xFA, 0xFA, 0xD2), + LIGHTGRAY: rgb(0xD3, 0xD3, 0xD3), + LIGHTGREY: rgb(0xD3, 0xD3, 0xD3), + LIGHTGREEN: rgb(0x90, 0xEE, 0x90), + LIGHTPINK: rgb(0xFF, 0xB6, 0xC1), + LIGHTSALMON: rgb(0xFF, 0xA0, 0x7A), + LIGHTSEAGREEN: rgb(0x20, 0xB2, 0xAA), + LIGHTSKYBLUE: rgb(0x87, 0xCE, 0xFA), + LIGHTSLATEGRAY: rgb(0x77, 0x88, 0x99), + LIGHTSLATEGREY: rgb(0x77, 0x88, 0x99), + LIGHTSTEELBLUE: rgb(0xB0, 0xC4, 0xDE), + LIGHTYELLOW: rgb(0xFF, 0xFF, 0xE0), + LIME: rgb(0x00, 0xFF, 0x00), + LIMEGREEN: rgb(0x32, 0xCD, 0x32), + LINEN: rgb(0xFA, 0xF0, 0xE6), + MAGENTA: rgb(0xFF, 0x00, 0xFF), + MAROON: rgb(0x80, 0x00, 0x00), + MEDIUMAQUAMARINE: rgb(0x66, 0xCD, 0xAA), + MEDIUMBLUE: rgb(0x00, 0x00, 0xCD), + MEDIUMORCHID: rgb(0xBA, 0x55, 0xD3), + MEDIUMPURPLE: rgb(0x93, 0x70, 0xDB), + MEDIUMSEAGREEN: rgb(0x3C, 0xB3, 0x71), + MEDIUMSLATEBLUE: rgb(0x7B, 0x68, 0xEE), + MEDIUMSPRINGGREEN: rgb(0x00, 0xFA, 0x9A), + MEDIUMTURQUOISE: rgb(0x48, 0xD1, 0xCC), + MEDIUMVIOLETRED: rgb(0xC7, 0x15, 0x85), + MIDNIGHTBLUE: rgb(0x19, 0x19, 0x70), + MINTCREAM: rgb(0xF5, 0xFF, 0xFA), + MISTYROSE: rgb(0xFF, 0xE4, 0xE1), + MOCCASIN: rgb(0xFF, 0xE4, 0xB5), + NAVAJOWHITE: rgb(0xFF, 0xDE, 0xAD), + NAVY: rgb(0x00, 0x00, 0x80), + OLDLACE: rgb(0xFD, 0xF5, 0xE6), + OLIVE: rgb(0x80, 0x80, 0x00), + OLIVEDRAB: rgb(0x6B, 0x8E, 0x23), + ORANGE: rgb(0xFF, 0xA5, 0x00), + ORANGERED: rgb(0xFF, 0x45, 0x00), + ORCHID: rgb(0xDA, 0x70, 0xD6), + PALEGOLDENROD: rgb(0xEE, 0xE8, 0xAA), + PALEGREEN: rgb(0x98, 0xFB, 0x98), + PALETURQUOISE: rgb(0xAF, 0xEE, 0xEE), + PALEVIOLETRED: rgb(0xDB, 0x70, 0x93), + PAPAYAWHIP: rgb(0xFF, 0xEF, 0xD5), + PEACHPUFF: rgb(0xFF, 0xDA, 0xB9), + PERU: rgb(0xCD, 0x85, 0x3F), + PINK: rgb(0xFF, 0xC0, 0xCB), + PLUM: rgb(0xDD, 0xA0, 0xDD), + POWDERBLUE: rgb(0xB0, 0xE0, 0xE6), + PURPLE: rgb(0x80, 0x00, 0x80), + REBECCAPURPLE: rgb(0x66, 0x33, 0x99), + RED: rgb(0xFF, 0x00, 0x00), + ROSYBROWN: rgb(0xBC, 0x8F, 0x8F), + ROYALBLUE: rgb(0x41, 0x69, 0xE1), + SADDLEBROWN: rgb(0x8B, 0x45, 0x13), + SALMON: rgb(0xFA, 0x80, 0x72), + SANDYBROWN: rgb(0xF4, 0xA4, 0x60), + SEAGREEN: rgb(0x2E, 0x8B, 0x57), + SEASHELL: rgb(0xFF, 0xF5, 0xEE), + SIENNA: rgb(0xA0, 0x52, 0x2D), + SILVER: rgb(0xC0, 0xC0, 0xC0), + SKYBLUE: rgb(0x87, 0xCE, 0xEB), + SLATEBLUE: rgb(0x6A, 0x5A, 0xCD), + SLATEGRAY: rgb(0x70, 0x80, 0x90), + SLATEGREY: rgb(0x70, 0x80, 0x90), + SNOW: rgb(0xFF, 0xFA, 0xFA), + SPRINGGREEN: rgb(0x00, 0xFF, 0x7F), + STEELBLUE: rgb(0x46, 0x82, 0xB4), + TAN: rgb(0xD2, 0xB4, 0x8C), + TEAL: rgb(0x00, 0x80, 0x80), + THISTLE: rgb(0xD8, 0xBF, 0xD8), + TOMATO: rgb(0xFF, 0x63, 0x47), + TURQUOISE: rgb(0x40, 0xE0, 0xD0), + VIOLET: rgb(0xEE, 0x82, 0xEE), + WHEAT: rgb(0xF5, 0xDE, 0xB3), + WHITE: rgb(0xFF, 0xFF, 0xFF), + WHITESMOKE: rgb(0xF5, 0xF5, 0xF5), + YELLOW: rgb(0xFF, 0xFF, 0x00), + YELLOWGREEN: rgb(0x9A, 0xCD, 0x32), +} + + +__all__ = [ + "Color", + "rgba", + "rgb", + "hsla", + "hsl", + "color", + "NAMED_COLOR", + "TRANSPARENT", +] + [name.upper() for name in NAMED_COLOR.keys()] diff --git a/travertino/src/travertino/constants.py b/travertino/src/travertino/constants.py new file mode 100644 index 0000000000..5460c39279 --- /dev/null +++ b/travertino/src/travertino/constants.py @@ -0,0 +1,264 @@ +###################################################################### +# Common constants +###################################################################### + +NORMAL = "normal" +LEFT = "left" +RIGHT = "right" +TOP = "top" +BOTTOM = "bottom" +CENTER = "center" +START = "start" +END = "end" + +###################################################################### +# Direction +###################################################################### + +ROW = "row" +COLUMN = "column" + +###################################################################### +# Visibility +###################################################################### + +VISIBLE = "visible" +HIDDEN = "hidden" +NONE = "none" + +###################################################################### +# Text Justification +###################################################################### + +JUSTIFY = "justify" + +###################################################################### +# Text Direction +###################################################################### + +RTL = "rtl" +LTR = "ltr" + +###################################################################### +# Font family +###################################################################### + +SYSTEM = "system" +MESSAGE = "message" + +SERIF = "serif" +SANS_SERIF = "sans-serif" +CURSIVE = "cursive" +FANTASY = "fantasy" +MONOSPACE = "monospace" + +###################################################################### +# Font Styling +###################################################################### + +ITALIC = "italic" +OBLIQUE = "oblique" + +FONT_STYLES = {ITALIC, OBLIQUE} + +###################################################################### +# Font Variant +###################################################################### + +SMALL_CAPS = "small-caps" + +FONT_VARIANTS = {SMALL_CAPS} + +###################################################################### +# Font boldness +###################################################################### + +BOLD = "bold" + +FONT_WEIGHTS = {BOLD} + +###################################################################### +# Font Size +###################################################################### + +SYSTEM_DEFAULT_FONT_SIZE = -1 + +XX_SMALL = "xx-small" +X_SMALL = "x-small" +SMALL = "small" +MEDIUM = "medium" +LARGE = "large" +X_LARGE = "x-large" +XX_LARGE = "xx-large" +XXX_LARGE = "xxx-large" + +ABSOLUTE_FONT_SIZES = { + XX_SMALL, + X_SMALL, + SMALL, + MEDIUM, + LARGE, + X_LARGE, + XX_LARGE, + XXX_LARGE, +} + +LARGER = "larger" +SMALLER = "smaller" + +RELATIVE_FONT_SIZES = {LARGER, SMALLER} + +###################################################################### +# Colors +###################################################################### + +TRANSPARENT = "transparent" + +ALICEBLUE = "aliceblue" +ANTIQUEWHITE = "antiquewhite" +AQUA = "aqua" +AQUAMARINE = "aquamarine" +AZURE = "azure" +BEIGE = "beige" +BISQUE = "bisque" +BLACK = "black" +BLANCHEDALMOND = "blanchedalmond" +BLUE = "blue" +BLUEVIOLET = "blueviolet" +BROWN = "brown" +BURLYWOOD = "burlywood" +CADETBLUE = "cadetblue" +CHARTREUSE = "chartreuse" +CHOCOLATE = "chocolate" +CORAL = "coral" +CORNFLOWERBLUE = "cornflowerblue" +CORNSILK = "cornsilk" +CRIMSON = "crimson" +CYAN = "cyan" +DARKBLUE = "darkblue" +DARKCYAN = "darkcyan" +DARKGOLDENROD = "darkgoldenrod" +DARKGRAY = "darkgray" +DARKGREY = "darkgrey" +DARKGREEN = "darkgreen" +DARKKHAKI = "darkkhaki" +DARKMAGENTA = "darkmagenta" +DARKOLIVEGREEN = "darkolivegreen" +DARKORANGE = "darkorange" +DARKORCHID = "darkorchid" +DARKRED = "darkred" +DARKSALMON = "darksalmon" +DARKSEAGREEN = "darkseagreen" +DARKSLATEBLUE = "darkslateblue" +DARKSLATEGRAY = "darkslategray" +DARKSLATEGREY = "darkslategrey" +DARKTURQUOISE = "darkturquoise" +DARKVIOLET = "darkviolet" +DEEPPINK = "deeppink" +DEEPSKYBLUE = "deepskyblue" +DIMGRAY = "dimgray" +DIMGREY = "dimgrey" +DODGERBLUE = "dodgerblue" +FIREBRICK = "firebrick" +FLORALWHITE = "floralwhite" +FORESTGREEN = "forestgreen" +FUCHSIA = "fuchsia" +GAINSBORO = "gainsboro" +GHOSTWHITE = "ghostwhite" +GOLD = "gold" +GOLDENROD = "goldenrod" +GRAY = "gray" +GREY = "grey" +GREEN = "green" +GREENYELLOW = "greenyellow" +HONEYDEW = "honeydew" +HOTPINK = "hotpink" +INDIANRED = "indianred" +INDIGO = "indigo" +IVORY = "ivory" +KHAKI = "khaki" +LAVENDER = "lavender" +LAVENDERBLUSH = "lavenderblush" +LAWNGREEN = "lawngreen" +LEMONCHIFFON = "lemonchiffon" +LIGHTBLUE = "lightblue" +LIGHTCORAL = "lightcoral" +LIGHTCYAN = "lightcyan" +LIGHTGOLDENRODYELLOW = "lightgoldenrodyellow" +LIGHTGRAY = "lightgray" +LIGHTGREY = "lightgrey" +LIGHTGREEN = "lightgreen" +LIGHTPINK = "lightpink" +LIGHTSALMON = "lightsalmon" +LIGHTSEAGREEN = "lightseagreen" +LIGHTSKYBLUE = "lightskyblue" +LIGHTSLATEGRAY = "lightslategray" +LIGHTSLATEGREY = "lightslategrey" +LIGHTSTEELBLUE = "lightsteelblue" +LIGHTYELLOW = "lightyellow" +LIME = "lime" +LIMEGREEN = "limegreen" +LINEN = "linen" +MAGENTA = "magenta" +MAROON = "maroon" +MEDIUMAQUAMARINE = "mediumaquamarine" +MEDIUMBLUE = "mediumblue" +MEDIUMORCHID = "mediumorchid" +MEDIUMPURPLE = "mediumpurple" +MEDIUMSEAGREEN = "mediumseagreen" +MEDIUMSLATEBLUE = "mediumslateblue" +MEDIUMSPRINGGREEN = "mediumspringgreen" +MEDIUMTURQUOISE = "mediumturquoise" +MEDIUMVIOLETRED = "mediumvioletred" +MIDNIGHTBLUE = "midnightblue" +MINTCREAM = "mintcream" +MISTYROSE = "mistyrose" +MOCCASIN = "moccasin" +NAVAJOWHITE = "navajowhite" +NAVY = "navy" +OLDLACE = "oldlace" +OLIVE = "olive" +OLIVEDRAB = "olivedrab" +ORANGE = "orange" +ORANGERED = "orangered" +ORCHID = "orchid" +PALEGOLDENROD = "palegoldenrod" +PALEGREEN = "palegreen" +PALETURQUOISE = "paleturquoise" +PALEVIOLETRED = "palevioletred" +PAPAYAWHIP = "papayawhip" +PEACHPUFF = "peachpuff" +PERU = "peru" +PINK = "pink" +PLUM = "plum" +POWDERBLUE = "powderblue" +PURPLE = "purple" +REBECCAPURPLE = "rebeccapurple" +RED = "red" +ROSYBROWN = "rosybrown" +ROYALBLUE = "royalblue" +SADDLEBROWN = "saddlebrown" +SALMON = "salmon" +SANDYBROWN = "sandybrown" +SEAGREEN = "seagreen" +SEASHELL = "seashell" +SIENNA = "sienna" +SILVER = "silver" +SKYBLUE = "skyblue" +SLATEBLUE = "slateblue" +SLATEGRAY = "slategray" +SLATEGREY = "slategrey" +SNOW = "snow" +SPRINGGREEN = "springgreen" +STEELBLUE = "steelblue" +TAN = "tan" +TEAL = "teal" +THISTLE = "thistle" +TOMATO = "tomato" +TURQUOISE = "turquoise" +VIOLET = "violet" +WHEAT = "wheat" +WHITE = "white" +WHITESMOKE = "whitesmoke" +YELLOW = "yellow" +YELLOWGREEN = "yellowgreen" diff --git a/travertino/src/travertino/declaration.py b/travertino/src/travertino/declaration.py new file mode 100644 index 0000000000..e6c115d641 --- /dev/null +++ b/travertino/src/travertino/declaration.py @@ -0,0 +1,463 @@ +from collections import defaultdict +from collections.abc import Mapping, Sequence +from warnings import filterwarnings, warn + +from .colors import color +from .constants import BOTTOM, LEFT, RIGHT, TOP + +# Make sure deprecation warnings are shown by default +filterwarnings("default", category=DeprecationWarning) + + +class ImmutableList: + def __init__(self, iterable): + self._data = list(iterable) + + def __getitem__(self, index): + return self._data[index] + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __eq__(self, other): + return self._data == other + + def __str__(self): + return str(self._data) + + def __repr__(self): + return repr(self._data) + + +class Choices: + "A class to define allowable data types for a property" + + def __init__( + self, + *constants, + default=None, # DEPRECATED + string=False, + integer=False, + number=False, + color=False, + ): + if default is not None: + warn( + "The `default` argument to Choices.__init__ is deprecated. " + "Providing no initial value to a property using it is sufficient.", + DeprecationWarning, + stacklevel=2, + ) + + self.constants = set(constants) + + self.string = string + self.integer = integer + self.number = number + self.color = color + + self._options = sorted(str(c).lower().replace("_", "-") for c in self.constants) + if self.string: + self._options.append("") + if self.integer: + self._options.append("") + if self.number: + self._options.append("") + if self.color: + self._options.append("") + + def validate(self, value): + if self.string: + try: + return value.strip() + except AttributeError: + pass + if self.integer: + try: + return int(value) + except (ValueError, TypeError): + pass + if self.number: + try: + return float(value) + except (ValueError, TypeError): + pass + if self.color: + try: + return color(value) + except ValueError: + pass + for const in self.constants: + if value == const: + return const + + raise ValueError(f"{value!r} is not a valid value") + + def __str__(self): + return ", ".join(self._options) + + +class validated_property: + def __init__(self, choices, initial=None): + """Define a simple validated property attribute. + + :param choices: The available choices. + :param initial: The initial value for the property. + """ + self.choices = choices + self.initial = None + + try: + # If an initial value has been provided, it must be consistent with + # the choices specified. + if initial is not None: + self.initial = self.validate(initial) + except ValueError: + # Unfortunately, __set_name__ hasn't been called yet, so we don't know the + # property's name. + raise ValueError( + f"Invalid initial value {initial!r}. Available choices: {choices}" + ) + + def __set_name__(self, owner, name): + self.name = name + owner._BASE_PROPERTIES[owner].add(name) + owner._BASE_ALL_PROPERTIES[owner].add(name) + + def __get__(self, obj, objtype=None): + if obj is None: + return self + + value = getattr(obj, f"_{self.name}", None) + return self.initial if value is None else value + + def __set__(self, obj, value): + if value is self: + # This happens during autogenerated dataclass __init__ when no value is + # supplied. + return + + if value is None: + raise ValueError( + "Python `None` cannot be used as a style value; " + f"to reset a property, use del `style.{self.name}`." + ) + + value = self.validate(value) + + if value != getattr(obj, f"_{self.name}", self.initial): + setattr(obj, f"_{self.name}", value) + obj.apply(self.name, value) + + def __delete__(self, obj): + try: + delattr(obj, f"_{self.name}") + except AttributeError: + pass + else: + obj.apply(self.name, self.initial) + + @property + def _name_if_set(self, default=""): + return f" {self.name}" if hasattr(self, "name") else default + + def validate(self, value): + try: + return self.choices.validate(value) + except ValueError: + raise ValueError( + f"Invalid value {value!r} for property{self._name_if_set}; " + f"Valid values are: {self.choices}" + ) + + def is_set_on(self, obj): + return hasattr(obj, f"_{self.name}") + + +class list_property(validated_property): + def validate(self, value): + if isinstance(value, str): + value = [value] + elif not isinstance(value, Sequence): + raise TypeError( + f"Value for list property{self._name_if_set} must be a sequence." + ) + + if not value: + name = getattr(self, "name", "prop_name") + raise ValueError( + "List properties cannot be set to an empty sequence; " + f"to reset a property, use del `style.{name}`." + ) + + # This could be a comprehension, but then the error couldn't specify which value + # is at fault. + result = [] + for item in value: + try: + item = self.choices.validate(item) + except ValueError: + raise ValueError( + f"Invalid item value {item!r} for list " + f"property{self._name_if_set}; Valid values are: {self.choices}" + ) + result.append(item) + + return ImmutableList(result) + + +class directional_property: + DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT] + ASSIGNMENT_SCHEMES = { + # T R B L + 1: [0, 0, 0, 0], + 2: [0, 1, 0, 1], + 3: [0, 1, 2, 1], + 4: [0, 1, 2, 3], + } + + def __init__(self, name_format): + """Define a property that proxies for top/right/bottom/left alternatives. + + :param name_format: The format from which to generate subproperties. "{}" will + be replaced with "_top", etc. + """ + self.name_format = name_format + + def __set_name__(self, owner, name): + self.name = name + owner._BASE_ALL_PROPERTIES[owner].add(self.name) + + def format(self, direction): + return self.name_format.format(f"_{direction}") + + def __get__(self, obj, objtype=None): + if obj is None: + return self + + return tuple(obj[self.format(direction)] for direction in self.DIRECTIONS) + + def __set__(self, obj, value): + if value is self: + # This happens during autogenerated dataclass __init__ when no value is + # supplied. + return + + if not isinstance(value, tuple): + value = (value,) + + if order := self.ASSIGNMENT_SCHEMES.get(len(value)): + for direction, index in zip(self.DIRECTIONS, order): + obj[self.format(direction)] = value[index] + else: + raise ValueError( + f"Invalid value for '{self.name}'; value must be a number, or a 1-4 " + f"tuple." + ) + + def __delete__(self, obj): + for direction in self.DIRECTIONS: + del obj[self.format(direction)] + + def is_set_on(self, obj): + return any( + hasattr(obj, self.format(direction)) for direction in self.DIRECTIONS + ) + + +class BaseStyle: + """A base class for style declarations. + + Exposes a dict-like interface. Designed for subclasses to be decorated + with @dataclass(kw_only=True), which most IDEs should be able to interpret and + provide autocompletion of argument names. On Python < 3.10, init=False can be used + to still get the keyword-only behavior from the included __init__. + """ + + _BASE_PROPERTIES = defaultdict(set) + _BASE_ALL_PROPERTIES = defaultdict(set) + + # Give instances a direct reference to their properties. + + @property + def _PROPERTIES(self): + return self._BASE_PROPERTIES[type(self)] + + @property + def _ALL_PROPERTIES(self): + return self._BASE_ALL_PROPERTIES[type(self)] + + # Fallback in case subclass isn't decorated as subclass (probably from using + # previous API) or for pre-3.10, before kw_only argument existed. + def __init__(self, **style): + self.update(**style) + + @property + def _applicator(self): + return getattr(self, "_assigned_applicator", None) + + @_applicator.setter + def _applicator(self, value): + self._assigned_applicator = value + + if value is not None: + try: + self.reapply() + # This is backwards compatibility for Toga, which (at least as of + # 0.4.8), assigns style and applicator before the widget's + # implementation is available. + except Exception: + warn( + "Failed to apply style when assigning applicator, or when " + "assigning a new style once applicator is present. Node should be " + "sufficiently initialized to apply its style before it is assigned " + "an applicator. This will be an exception in a future version.", + RuntimeWarning, + stacklevel=2, + ) + + ###################################################################### + # Interface that style declarations must define + ###################################################################### + + def apply(self, property, value): + raise NotImplementedError( + "Style must define an apply method" + ) # pragma: no cover + + def layout(self, viewport): + raise NotImplementedError( + "Style must define a layout method" + ) # pragma: no cover + + ###################################################################### + # Provide a dict-like interface + ###################################################################### + + def reapply(self): + for name in self._PROPERTIES: + self.apply(name, self[name]) + + def update(self, **styles): + "Set multiple styles on the style definition." + for name, value in styles.items(): + name = name.replace("-", "_") + if name not in self._ALL_PROPERTIES: + raise NameError(f"Unknown style '{name}'") + + self[name] = value + + def copy(self, applicator=None): + "Create a duplicate of this style declaration." + dup = self.__class__() + dup.update(**self) + + if applicator is not None: + warn( + "Providing an applicator to BaseStyle.copy() is deprecated. Set " + "applicator afterward on the returned copy.", + DeprecationWarning, + stacklevel=2, + ) + dup._applicator = applicator + + return dup + + def __getitem__(self, name): + name = name.replace("-", "_") + if name in self._ALL_PROPERTIES: + return getattr(self, name) + raise KeyError(name) + + def __setitem__(self, name, value): + name = name.replace("-", "_") + if name in self._ALL_PROPERTIES: + setattr(self, name, value) + else: + raise KeyError(name) + + def __delitem__(self, name): + name = name.replace("-", "_") + if name in self._ALL_PROPERTIES: + delattr(self, name) + else: + raise KeyError(name) + + def keys(self): + return {name for name in self._PROPERTIES if name in self} + + def items(self): + return [(name, self[name]) for name in self._PROPERTIES if name in self] + + def __len__(self): + return sum(1 for name in self._PROPERTIES if name in self) + + def __contains__(self, name): + return name in self._ALL_PROPERTIES and ( + getattr(self.__class__, name).is_set_on(self) + ) + + def __iter__(self): + yield from (name for name in self._PROPERTIES if name in self) + + def __or__(self, other): + if isinstance(other, BaseStyle): + if self.__class__ is not other.__class__: + return NotImplemented + elif not isinstance(other, Mapping): + return NotImplemented + + result = self.copy() + result.update(**other) + return result + + def __ior__(self, other): + if isinstance(other, BaseStyle): + if self.__class__ is not other.__class__: + return NotImplemented + elif not isinstance(other, Mapping): + return NotImplemented + + self.update(**other) + return self + + ###################################################################### + # Get the rendered form of the style declaration + ###################################################################### + def __str__(self): + return "; ".join( + f"{name.replace('_', '-')}: {value}" for name, value in sorted(self.items()) + ) + + ###################################################################### + # Backwards compatibility + ###################################################################### + + @classmethod + def validated_property(cls, name, choices, initial=None): + warn( + "Defining style properties with class methods is deprecated; use class " + "attributes instead.", + DeprecationWarning, + stacklevel=2, + ) + prop = validated_property(choices, initial) + setattr(cls, name, prop) + prop.__set_name__(cls, name) + + @classmethod + def directional_property(cls, name): + warn( + "Defining style properties with class methods is deprecated; use class " + "attributes instead.", + DeprecationWarning, + stacklevel=2, + ) + name_format = name % "{}" + name = name_format.format("") + prop = directional_property(name_format) + setattr(cls, name, prop) + prop.__set_name__(cls, name) diff --git a/travertino/src/travertino/fonts.py b/travertino/src/travertino/fonts.py new file mode 100644 index 0000000000..a707f887c7 --- /dev/null +++ b/travertino/src/travertino/fonts.py @@ -0,0 +1,199 @@ +from .constants import ( + BOLD, + FONT_STYLES, + FONT_VARIANTS, + FONT_WEIGHTS, + ITALIC, + NORMAL, + OBLIQUE, + SMALL_CAPS, + SYSTEM_DEFAULT_FONT_SIZE, +) + + +class Font: + def __init__(self, family, size, style=NORMAL, variant=NORMAL, weight=NORMAL): + if (family[0] == "'" and family[-1] == "'") or ( + family[0] == '"' and family[-1] == '"' + ): + self.family = family[1:-1] + else: + self.family = family + + try: + self.size = int(size) + except ValueError: + try: + if size.strip().endswith("pt"): + self.size = int(size[:-2]) + else: + raise ValueError(f"Invalid font size {size!r}") + except Exception: + raise ValueError(f"Invalid font size {size!r}") + self.style = style if style in FONT_STYLES else NORMAL + self.variant = variant if variant in FONT_VARIANTS else NORMAL + self.weight = weight if weight in FONT_WEIGHTS else NORMAL + + def __hash__(self): + return hash( + ("FONT", self.family, self.size, self.style, self.variant, self.weight) + ) + + def __repr__(self): + return "".format( + "" if self.style is NORMAL else (self.style + " "), + "" if self.variant is NORMAL else (self.variant + " "), + "" if self.weight is NORMAL else (self.weight + " "), + ( + "system default size" + if self.size == SYSTEM_DEFAULT_FONT_SIZE + else f"{self.size}pt" + ), + self.family, + ) + + def __eq__(self, other): + try: + return ( + self.family == other.family + and self.size == other.size + and self.style == other.style + and self.variant == other.variant + and self.weight == other.weight + ) + except AttributeError: + return False + + def normal_style(self): + "Generate a normal style version of this font" + return Font( + self.family, + self.size, + style=NORMAL, + variant=self.variant, + weight=self.weight, + ) + + def italic(self): + "Generate an italic version of this font" + return Font( + self.family, + self.size, + style=ITALIC, + variant=self.variant, + weight=self.weight, + ) + + def oblique(self): + "Generate an oblique version of this font" + return Font( + self.family, + self.size, + style=OBLIQUE, + variant=self.variant, + weight=self.weight, + ) + + def normal_variant(self): + "Generate a normal variant of this font" + return Font( + self.family, self.size, style=self.style, variant=NORMAL, weight=self.weight + ) + + def small_caps(self): + "Generate a small-caps variant of this font" + return Font( + self.family, + self.size, + style=self.style, + variant=SMALL_CAPS, + weight=self.weight, + ) + + def normal_weight(self): + "Generate a normal weight version of this font" + return Font( + self.family, + self.size, + style=self.style, + variant=self.variant, + weight=NORMAL, + ) + + def bold(self): + "Generate a bold version of this font" + return Font( + self.family, self.size, style=self.style, variant=self.variant, weight=BOLD + ) + + +def font(value): + """Parse a font from a string. + + Accepts: + * Font instances + + style: normal / italic / oblique + variant: normal / small-caps + weight: normal / bold + + style variant weight size family + variant weight size family + weight size family + size family + """ + + if isinstance(value, Font): + return value + + elif isinstance(value, str): + parts = value.split(" ") + + style = None + variant = None + weight = None + size = None + + while size is None: + part = parts.pop(0) + if part == NORMAL: + if style is None: + style = NORMAL + elif variant is None: + variant = NORMAL + elif weight is None: + weight = NORMAL + elif part in FONT_STYLES: + if style is not None: + raise ValueError(f"Invalid font declaration '{value}'") + style = part + elif part in FONT_VARIANTS: + if variant is not None: + raise ValueError(f"Invalid font declaration '{value}'") + if style is None: + style = NORMAL + variant = part + elif part in FONT_WEIGHTS: + if weight is not None: + raise ValueError(f"Invalid font declaration '{value}'") + if style is None: + style = NORMAL + if variant is None: + variant = NORMAL + weight = part + else: + try: + if part.endswith("pt"): + size = int(part[:-2]) + else: + size = int(part) + except ValueError: + raise ValueError(f"Invalid size in font declaration '{value}'") + + if parts[0] == "pt": + parts.pop(0) + + family = " ".join(parts) + return Font(family, size, style=style, variant=variant, weight=weight) + + raise ValueError("Unknown font '%s'" % value) diff --git a/travertino/src/travertino/layout.py b/travertino/src/travertino/layout.py new file mode 100644 index 0000000000..87e5d3ceca --- /dev/null +++ b/travertino/src/travertino/layout.py @@ -0,0 +1,170 @@ +class Viewport: + """ + A viewport is a description of surface onto which content will be + rendered. It stores the size of the surface(in pixels), plus the + pixel density of the viewport. + """ + + def __init__(self, width=0, height=0, dpi=None): + self.width = width + self.height = height + self.dpi = dpi + + +class BaseBox: + """Describe the layout of a box displaying a node. + + Stored properties + ~~~~~~~~~~~~~~~~~ + visible: The node is included in rendering, and is visible. A value of + False indicates the node takes up space, but is not rendered. + + content_width: The width of the content in the box + content_height: The height of the content in the box + content_top: The top position of the content in the box, relative to the box + content_left: The left position of the content in the box, relative to the box + content_bottom: The distance from the bottom of the content to the bottom of the box + content_right: The distance from the right of the content to the right of the box + + origin_top: The absolute position of the top of the box + origin_left: The absolute position of the left of the box + + Computed properties + ~~~~~~~~~~~~~~~~~~~ + width: The overall width of the box + height: The overall height of the box + + absolute_content_top: The absolute position of the top of the content box. + absolute_content_left: The absolute position of the left of the content box. + absolute_content_bottom: The absolute position of the bottom of the content box. + absolute_content_right: The absolute position of the right of the content box. + + """ + + def __init__(self, node): + self.node = node + self._reset() + + def __repr__(self): + return "<{} ({}x{} @ {},{})>".format( + self.__class__.__name__, + self.content_width, + self.content_height, + self.absolute_content_left, + self.absolute_content_top, + ) + + def _reset(self): + # Some properties describing whether this node exists in + # layout *at all*. + self.visible = True + + # Minimum width and height of the content box. + self.min_content_width = 0 + self.min_content_height = 0 + + # Width and height of the content box. + self.content_width = 0 + self.content_height = 0 + + # Box position, relative to the containing box + self._content_top = 0 + self._content_left = 0 + self.content_bottom = 0 + self.content_right = 0 + + self.__origin_top = 0 + self.__origin_left = 0 + + # Set the origin via properties; this forces the calculation of + # absolute positions. + self._origin_top = 0 + self._origin_left = 0 + + ###################################################################### + # Origin handling + ###################################################################### + @property + def _origin_top(self): + return self.__origin_top + + @_origin_top.setter + def _origin_top(self, value): + if value != self.__origin_top: + self.__origin_top = value + for child in self.node.children: + if child.layout: + child.layout._origin_top = self.absolute_content_top + + @property + def _origin_left(self): + return self.__origin_left + + @_origin_left.setter + def _origin_left(self, value): + if value != self.__origin_left: + self.__origin_left = value + for child in self.node.children: + if child.layout: + child.layout._origin_left = self.absolute_content_left + + @property + def width(self): + return self._content_left + self.content_width + self.content_right + + @property + def min_width(self): + return self._content_left + self.min_content_width + self.content_right + + @property + def height(self): + return self._content_top + self.content_height + self.content_bottom + + @property + def min_height(self): + return self._content_top + self.min_content_height + self.content_bottom + + ###################################################################### + # Content box properties + ###################################################################### + @property + def content_top(self): + return self._content_top + + @content_top.setter + def content_top(self, value): + self._content_top = value + for child in self.node.children: + if child.layout: + child.layout._origin_top = self.absolute_content_top + + @property + def content_left(self): + return self._content_left + + @content_left.setter + def content_left(self, value): + self._content_left = value + for child in self.node.children: + if child.layout: + child.layout._origin_left = self.absolute_content_left + + ###################################################################### + # Absolute content box position + ###################################################################### + + @property + def absolute_content_top(self): + return self.__origin_top + self._content_top + + @property + def absolute_content_right(self): + return self.__origin_left + self._content_left + self.content_width + + @property + def absolute_content_bottom(self): + return self.__origin_top + self._content_top + self.content_height + + @property + def absolute_content_left(self): + return self.__origin_left + self._content_left diff --git a/travertino/src/travertino/node.py b/travertino/src/travertino/node.py new file mode 100644 index 0000000000..d95cee6364 --- /dev/null +++ b/travertino/src/travertino/node.py @@ -0,0 +1,196 @@ +class Node: + def __init__(self, style, applicator=None, children=None): + # Parent needs to be primed before style is (potentially) applied with + # assignment of applicator. + self._parent = None + self._root = None + + # Explicitly set the internal attribute first, since the setter for style will + # access the applicator property. + self._applicator = None + + self.style = style + self.applicator = applicator + + if children is None: + self._children = None + else: + self._children = [] + for child in children: + self.add(child) + + @property + def style(self): + """The node's style. + + Assigning a style triggers an application of that style if an applicator has + already been assigned. + """ + return self._style + + @style.setter + def style(self, style): + self._style = style.copy() + self.intrinsic = self.style.IntrinsicSize() + self.layout = self.style.Box(self) + + if self.applicator: + self.style._applicator = self.applicator + + @property + def applicator(self): + """This node's applicator, which handles applying the style. + + Assigning an applicator triggers an application of the node's style. + """ + return self._applicator + + @applicator.setter + def applicator(self, applicator): + if self.applicator: + # If an existing applicator is present, clear its reference to this node. + self.applicator.node = None + + if applicator: + # This needs to happen *before* assigning the applicator to the style, + # below, because as part of receiving the applicator, the style will + # reapply itself. How this happens will vary with applicator + # implementation, but will probably need access to the node. + applicator.node = self + + self._applicator = applicator + # This triggers style.reapply(): + self.style._applicator = applicator + + @property + def root(self): + """The root of the tree containing this node. + + Returns: + The root node. Returns self if this node *is* the root node. + """ + return self._root if self._root else self + + @property + def parent(self): + """The parent of this node. + + Returns: + The parent of this node. Returns None if this node is the root node. + """ + return self._parent + + @property + def children(self): + """The children of this node. + This *always* returns a list, even if the node is a leaf + and cannot have children. + + Returns: + A list of the children for this widget. + """ + if self._children is None: + return [] + else: + return self._children + + @property + def can_have_children(self): + """Determine if the node can have children. + + This does not resolve whether there actually *are* any children; + it only confirms whether children are theoretically allowed. + """ + return self._children is not None + + def add(self, child): + """Add a node as a child of this one. + Args: + child: A node to add as a child to this node. + + Raises: + ValueError: If this node is a leaf, and cannot have children. + """ + if self._children is None: + raise ValueError("Cannot add children") + + self._children.append(child) + child._parent = self + self._set_root(child, self.root) + + def insert(self, index, child): + """Insert a node as a child of this one. + Args: + index: Index of child position. + child: A node to insert as a child to this node. + + Raises: + ValueError: If this node is a leaf, and cannot have children. + """ + if self._children is None: + raise ValueError("Cannot insert child") + + self._children.insert(index, child) + child._parent = self + self._set_root(child, self.root) + + def remove(self, child): + """Remove child from this node. + Args: + child: The child to remove from this node. + + Raises: + ValueError: If this node is a leaf, and cannot have children. + """ + if self._children is None: + raise ValueError("Cannot remove children") + + self._children.remove(child) + child._parent = None + self._set_root(child, None) + + def clear(self): + """Clear all children from this node. + + Raises: + ValueError: If this node is a leaf, and cannot have children. + """ + if self._children is None: + # This is a leaf, so do nothing. + return + + for child in self._children: + child._parent = None + self._set_root(child, None) + self._children = [] + + def refresh(self, viewport): + """Refresh the layout and appearance of the tree this node is contained in.""" + if self._root: + self._root.refresh(viewport) + else: + if self.applicator: + + ###################################################################### + # 2024-12: Backwards compatibility for Toga <= 0.4.8 + ###################################################################### + # Accommodate the earlier signature of layout(), which included the node + # as a parameter. + try: + self.style.layout(viewport) + except TypeError as error: + if "layout() missing 1 required positional argument:" in str(error): + self.style.layout(self, viewport) + else: + raise + ###################################################################### + # End backwards compatibility + ###################################################################### + + self.applicator.set_bounds() + + def _set_root(self, node, root): + # Propagate a root node change through a tree. + node._root = root + for child in node.children: + self._set_root(child, root) diff --git a/travertino/src/travertino/size.py b/travertino/src/travertino/size.py new file mode 100644 index 0000000000..1104386a17 --- /dev/null +++ b/travertino/src/travertino/size.py @@ -0,0 +1,68 @@ +class at_least: + "An annotation to wrap around a value to describe that it is a minimum bound" + + def __init__(self, value): + self.value = value + + def __repr__(self): + return f"at least {self.value}" + + def __eq__(self, other): + try: + return self.value == other.value + except AttributeError: + return False + + +class BaseIntrinsicSize: + """Representation of the intrinsic size of an object. + + width: The width of the node. + height: The height of the node. + ratio: The height between height and width. width = height * ratio + """ + + def __init__(self, width=None, height=None, ratio=None, layout=None): + self._layout = layout + self._width = width + self._height = height + + self._ratio = None + + def __repr__(self): + return f"({self.width}, {self.height})" + + @property + def width(self): + return self._width + + @width.setter + def width(self, value): + if self._width != value: + self._width = value + + if self._layout: + self._layout.dirty(intrinsic_width=value) + + @property + def height(self): + return self._height + + @height.setter + def height(self, value): + if self._height != value: + self._height = value + + if self._layout: + self._layout.dirty(intrinsic_height=value) + + @property + def ratio(self): + return self._ratio + + @ratio.setter + def ratio(self, value): + if self._ratio != value: + self._ratio = value + if self._layout: + self._layout.dirty(intrinsic_ratio=value) diff --git a/travertino/tests/__init__.py b/travertino/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/travertino/tests/colors/__init__.py b/travertino/tests/colors/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/travertino/tests/colors/test_constructor.py b/travertino/tests/colors/test_constructor.py new file mode 100644 index 0000000000..7b5ea1e2db --- /dev/null +++ b/travertino/tests/colors/test_constructor.py @@ -0,0 +1,170 @@ +import pytest + +from travertino.colors import hsl, hsla, rgb, rgba + + +def assert_equal_color(actual, expected): + assert actual.rgba.r == expected.rgba.r + assert actual.rgba.g == expected.rgba.g + assert actual.rgba.b == expected.rgba.b + assert actual.rgba.a == expected.rgba.a + + +@pytest.mark.parametrize( + "constructor, value, string", + [ + (rgb, (10, 20, 30), "rgb(10, 20, 30)"), + (rgba, (10, 20, 30, 0.5), "rgba(10, 20, 30, 0.5)"), + (hsl, (10, 0.2, 0.3), "hsl(10, 0.2, 0.3)"), + (hsla, (10, 0.2, 0.3, 0.5), "hsla(10, 0.2, 0.3, 0.5)"), + ], +) +def test_repr(constructor, value, string): + assert repr(constructor(*value)) == string + + +def test_rgb_hash(): + assert hash(rgb(10, 20, 30)) == hash(rgb(10, 20, 30)) + assert hash(rgb(10, 20, 30)) != hash(rgb(30, 20, 10)) + + +def test_rgba_hash(): + assert hash(rgba(10, 20, 30, 0.5)) == hash(rgba(10, 20, 30, 0.5)) + assert hash(rgba(10, 20, 30, 1.0)) == hash(rgb(10, 20, 30)) + assert hash(rgb(10, 20, 30)) != hash(rgb(30, 20, 10)) + + +def test_hsl_hash(): + assert hash(hsl(10, 0.2, 0.3)) == hash(hsl(10, 0.2, 0.3)) + assert hash(hsl(10, 0.3, 0.2)) != hash(hsl(10, 0.2, 0.3)) + + +def test_hsla_hash(): + assert hash(hsla(10, 0.2, 0.3, 0.5)) == hash(hsla(10, 0.2, 0.3, 0.5)) + assert hash(hsla(10, 0.2, 0.3, 1.0)) == hash(hsl(10, 0.2, 0.3)) + assert hash(hsla(10, 0.3, 0.2, 0.5)) != hash(hsla(10, 0.2, 0.3, 0.5)) + assert hash(hsla(10, 0, 0, 0.5)) != hash(rgba(10, 0, 0, 0.5)) + + +@pytest.mark.parametrize( + "value, expected", + [ + # Blacks + ((0, 0.0, 0.0), (0x00, 0x00, 0x00)), + ((60, 0.0, 0.0), (0x00, 0x00, 0x00)), + ((180, 0.0, 0.0), (0x00, 0x00, 0x00)), + ((240, 0.0, 0.0), (0x00, 0x00, 0x00)), + ((360, 0.0, 0.0), (0x00, 0x00, 0x00)), + # Whites + ((0, 0.0, 1.0), (0xFF, 0xFF, 0xFF)), + ((60, 0.0, 1.0), (0xFF, 0xFF, 0xFF)), + ((180, 0.0, 1.0), (0xFF, 0xFF, 0xFF)), + ((240, 0.0, 1.0), (0xFF, 0xFF, 0xFF)), + ((360, 0.0, 1.0), (0xFF, 0xFF, 0xFF)), + # Grays + ((0, 0.0, 0.2), (0x33, 0x33, 0x33)), + ((0, 0.0, 0.4), (0x66, 0x66, 0x66)), + ((0, 0.0, 0.5), (0x80, 0x80, 0x80)), + ((0, 0.0, 0.6), (0x99, 0x99, 0x99)), + ((0, 0.0, 0.8), (0xCC, 0xCC, 0xCC)), + # Primaries + ((0, 1.0, 0.5), (0xFF, 0x00, 0x00)), + ((60, 1.0, 0.5), (0xFF, 0xFF, 0x00)), + ((120, 1.0, 0.5), (0x00, 0xFF, 0x00)), + ((180, 1.0, 0.5), (0x00, 0xFF, 0xFF)), + ((240, 1.0, 0.5), (0x00, 0x00, 0xFF)), + ((300, 1.0, 0.5), (0xFF, 0x00, 0xFF)), + ((360, 1.0, 0.5), (0xFF, 0x00, 0x00)), + # Muted + ((0, 0.25, 0.25), (0x50, 0x30, 0x30)), + ((60, 0.25, 0.25), (0x50, 0x50, 0x30)), + ((120, 0.25, 0.25), (0x30, 0x50, 0x30)), + ((180, 0.25, 0.25), (0x30, 0x50, 0x50)), + ((240, 0.25, 0.25), (0x30, 0x30, 0x50)), + ((300, 0.25, 0.25), (0x50, 0x30, 0x50)), + ((360, 0.25, 0.25), (0x50, 0x30, 0x30)), + ((0, 0.25, 0.75), (0xCF, 0xAF, 0xAF)), + ((60, 0.25, 0.75), (0xCF, 0xCF, 0xAF)), + ((120, 0.25, 0.75), (0xAF, 0xCF, 0xAF)), + ((180, 0.25, 0.75), (0xAF, 0xCF, 0xCF)), + ((240, 0.25, 0.75), (0xAF, 0xAF, 0xCF)), + ((300, 0.25, 0.75), (0xCF, 0xAF, 0xCF)), + ((360, 0.25, 0.75), (0xCF, 0xAF, 0xAF)), + ((0, 0.75, 0.75), (0xEF, 0x8F, 0x8F)), + ((60, 0.75, 0.75), (0xEF, 0xEF, 0x8F)), + ((120, 0.75, 0.75), (0x8F, 0xEF, 0x8F)), + ((180, 0.75, 0.75), (0x8F, 0xEF, 0xEF)), + ((240, 0.75, 0.75), (0x8F, 0x8F, 0xEF)), + ((300, 0.75, 0.75), (0xEF, 0x8F, 0xEF)), + ((360, 0.75, 0.75), (0xEF, 0x8F, 0x8F)), + ((0, 0.75, 0.25), (0x70, 0x10, 0x10)), + ((60, 0.75, 0.25), (0x70, 0x70, 0x10)), + ((120, 0.75, 0.25), (0x10, 0x70, 0x10)), + ((180, 0.75, 0.25), (0x10, 0x70, 0x70)), + ((240, 0.75, 0.25), (0x10, 0x10, 0x70)), + ((300, 0.75, 0.25), (0x70, 0x10, 0x70)), + ((360, 0.75, 0.25), (0x70, 0x10, 0x10)), + ], +) +def test_hsl(value, expected): + assert_equal_color(hsl(*value), rgb(*expected)) + + +@pytest.mark.parametrize( + "value, expected", + [ + ((60, 0.0, 0.0, 0.3), (0x00, 0x00, 0x00, 0.3)), + ((60, 0.0, 1.0, 0.3), (0xFF, 0xFF, 0xFF, 0.3)), + ((60, 1.0, 0.5, 0.3), (0xFF, 0xFF, 0x00, 0.3)), + ((60, 0.25, 0.25, 0.3), (0x50, 0x50, 0x30, 0.3)), + ((60, 0.25, 0.75, 0.3), (0xCF, 0xCF, 0xAF, 0.3)), + ((60, 0.75, 0.75, 0.3), (0xEF, 0xEF, 0x8F, 0.3)), + ((60, 0.75, 0.25, 0.3), (0x70, 0x70, 0x10, 0.3)), + ], +) +def test_hsl_alpha(value, expected): + assert_equal_color(hsla(*value), rgba(*expected)) + + +@pytest.mark.parametrize( + "constructor, value, name, min, max, actual", + [ + (rgb, (-1, 120, 10), "red", 0, 255, -1), + (rgb, (256, 120, 10), "red", 0, 255, 256), + (rgb, (120, -1, 10), "green", 0, 255, -1), + (rgb, (120, 256, 10), "green", 0, 255, 256), + (rgb, (120, 10, -1), "blue", 0, 255, -1), + (rgb, (120, 10, 256), "blue", 0, 255, 256), + # + (rgba, (-1, 120, 10, 0.5), "red", 0, 255, -1), + (rgba, (256, 120, 10, 0.5), "red", 0, 255, 256), + (rgba, (120, -1, 10, 0.5), "green", 0, 255, -1), + (rgba, (120, 256, 10, 0.5), "green", 0, 255, 256), + (rgba, (120, 10, -1, 0.5), "blue", 0, 255, -1), + (rgba, (120, 10, 256, 0.5), "blue", 0, 255, 256), + (rgba, (120, 10, 60, -0.5), "alpha", 0, 1, -0.5), + (rgba, (120, 10, 60, 1.1), "alpha", 0, 1, 1.1), + # + (hsl, (-1, 0.5, 0.8), "hue", 0, 360, -1), + (hsl, (361, 0.5, 0.8), "hue", 0, 360, 361), + (hsl, (120, -0.1, 0.8), "saturation", 0, 1, -0.1), + (hsl, (120, 1.1, 0.8), "saturation", 0, 1, 1.1), + (hsl, (120, 0.8, -0.1), "lightness", 0, 1, -0.1), + (hsl, (120, 0.8, 1.1), "lightness", 0, 1, 1.1), + # + (hsla, (-1, 0.5, 0.8, 0.5), "hue", 0, 360, -1), + (hsla, (361, 0.5, 0.8, 0.5), "hue", 0, 360, 361), + (hsla, (120, -0.1, 0.8, 0.5), "saturation", 0, 1, -0.1), + (hsla, (120, 1.1, 0.8, 0.5), "saturation", 0, 1, 1.1), + (hsla, (120, 0.8, -0.1, 0.5), "lightness", 0, 1, -0.1), + (hsla, (120, 0.8, 1.1, 0.5), "lightness", 0, 1, 1.1), + (hsla, (120, 0.8, 0.5, -0.1), "alpha", 0, 1, -0.1), + (hsla, (120, 0.8, 0.5, 1.1), "alpha", 0, 1, 1.1), + ], +) +def test_invalid_color_constructor(constructor, value, name, min, max, actual): + with pytest.raises( + ValueError, + match=rf"^{name} value should be between {min}-{max}\. Got {actual}$", + ): + constructor(*value) diff --git a/travertino/tests/colors/test_parsing.py b/travertino/tests/colors/test_parsing.py new file mode 100644 index 0000000000..21c18eb0df --- /dev/null +++ b/travertino/tests/colors/test_parsing.py @@ -0,0 +1,171 @@ +import pytest + +from travertino.colors import color, hsl, hsla, rgb, rgba + + +def assert_equal_hsl(value, expected): + # Nothing fancy - a color is equal if the attributes are all the same + actual = color(value) + assert actual.h == expected.h + assert actual.s == expected.s + assert actual.l == expected.l + assert actual.a == pytest.approx(expected.a, abs=0.001) + + +def assert_equal_rgb(value, expected): + # Nothing fancy - a color is equal if the attributes are all the same + actual = color(value) + assert actual.r == expected.r + assert actual.g == expected.g + assert actual.b == expected.b + assert actual.a == pytest.approx(expected.a, abs=0.001) + + +def test_noop(): + assert_equal_rgb(rgba(1, 2, 3, 0.5), rgba(1, 2, 3, 0.5)) + assert_equal_hsl(hsl(1, 0.2, 0.3), hsl(1, 0.2, 0.3)) + + +@pytest.mark.parametrize( + "value, expected", + [ + ("rgb(1,2,3)", (1, 2, 3)), + ("rgb(1, 2, 3)", (1, 2, 3)), + ("rgb( 1 , 2 , 3)", (1, 2, 3)), + ("#123", (0x11, 0x22, 0x33)), + ("#112233", (0x11, 0x22, 0x33)), + ("#abc", (0xAA, 0xBB, 0xCC)), + ("#ABC", (0xAA, 0xBB, 0xCC)), + ("#abcdef", (0xAB, 0xCD, 0xEF)), + ("#ABCDEF", (0xAB, 0xCD, 0xEF)), + ], +) +def test_rgb(value, expected): + assert_equal_rgb(value, rgb(*expected)) + + +@pytest.mark.parametrize( + "value", + [ + "10, 20", + "a, 10, 20", + "10, b, 20", + "10, 20, c", + "10, 20, 30, 0.5", + ], +) +def test_rgb_invalid(value): + with pytest.raises(ValueError): + color(f"rgb({value})") + + +@pytest.mark.parametrize( + "value, expected", + [ + ("rgba(1,2,3,0.5)", (1, 2, 3, 0.5)), + ("rgba(1, 2, 3, 0.5)", (1, 2, 3, 0.5)), + ("rgba( 1 , 2 , 3 , 0.5)", (1, 2, 3, 0.5)), + ("#1234", (0x11, 0x22, 0x33, 0.2666)), + ("#11223344", (0x11, 0x22, 0x33, 0.2666)), + ("#abcd", (0xAA, 0xBB, 0xCC, 0.8666)), + ("#ABCD", (0xAA, 0xBB, 0xCC, 0.8666)), + ("#abcdefba", (0xAB, 0xCD, 0xEF, 0.7294)), + ("#ABCDEFBA", (0xAB, 0xCD, 0xEF, 0.7294)), + ], +) +def test_rgba(value, expected): + assert_equal_rgb(value, rgba(*expected)) + + +@pytest.mark.parametrize( + "value", + [ + "10, 20, 30", + "a, 10, 20, 0.5", + "10, b, 20, 0.5", + "10, 20, c, 0.5", + "10, 20, 30, c", + "10, 20, 30, 0.5, 5", + ], +) +def test_rgba_invalid(value): + with pytest.raises(ValueError): + color(f"rgba({value})") + + +@pytest.mark.parametrize( + "value", + [ + "1,20%,30%", + "1, 20%, 30%", + "1, 20% , 30%", + ], +) +def test_hsl(value): + assert_equal_hsl(f"hsl({value})", hsl(1, 0.2, 0.3)) + + +@pytest.mark.parametrize( + "value", + [ + "1, 20%", + "a, 20%, 30%", + "1, a, 30%", + "1, 20%, a)", + "1, 20%, 30%, 0.5)", + ], +) +def test_hsl_invalid(value): + with pytest.raises(ValueError): + color(value) + + +@pytest.mark.parametrize( + "value", + [ + "1,20%,30%,0.5", + "1, 20%, 30%, 0.5", + " 1, 20% , 30% , 0.5", + ], +) +def test_hsla(value): + assert_equal_hsl(f"hsla({value})", hsla(1, 0.2, 0.3, 0.5)) + + +@pytest.mark.parametrize( + "value", + [ + "1, 20%, 30%", + "a, 20%, 30%, 0.5", + "1, a, 30%, 0.5", + "1, 20%, a, 0.5", + "1, 20%, 30%, a", + "1, 20%, 30%, 0.5, 5", + ], +) +def test_hsla_invalid(value): + with pytest.raises(ValueError): + color(f"hsla({value})") + + +@pytest.mark.parametrize( + "value, expected", + [ + ("Red", (0xFF, 0, 0)), + ("RED", (0xFF, 0, 0)), + ("red", (0xFF, 0, 0)), + ("rEd", (0xFF, 0, 0)), + ("CornflowerBlue", (0x64, 0x95, 0xED)), + ("cornflowerblue", (0x64, 0x95, 0xED)), + ("CORNFLOWERBLUE", (0x64, 0x95, 0xED)), + ("Cornflowerblue", (0x64, 0x95, 0xED)), + ("CoRnFlOwErBlUe", (0x64, 0x95, 0xED)), + ], +) +def test_named_color(value, expected): + assert_equal_rgb(value, rgb(*expected)) + + +def test_named_color_invalid(): + with pytest.raises(ValueError): + color("not a color") diff --git a/travertino/tests/fonts/__init__.py b/travertino/tests/fonts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/travertino/tests/fonts/test_constructor.py b/travertino/tests/fonts/test_constructor.py new file mode 100644 index 0000000000..8729b51524 --- /dev/null +++ b/travertino/tests/fonts/test_constructor.py @@ -0,0 +1,203 @@ +import pytest + +from travertino.constants import ( + BOLD, + ITALIC, + NORMAL, + OBLIQUE, + SMALL_CAPS, + SYSTEM_DEFAULT_FONT_SIZE, +) +from travertino.fonts import Font + + +def assert_font(font, family, size, style, variant, weight): + assert font.family == family + assert font.size == size + assert font.style == style + assert font.variant == variant + assert font.weight == weight + + +@pytest.mark.parametrize( + "font", + [ + Font("Comic Sans", "12 pt"), + Font("Comic Sans", 12), + Font("Comic Sans", 12, NORMAL, NORMAL, NORMAL), + ], +) +def test_equality(font): + assert font == Font("Comic Sans", "12 pt") + + +@pytest.mark.parametrize( + "font", + [ + Font("Comic Sans", 13), + Font("Comic Sans", 12, ITALIC), + Font("Times New Roman", 12, NORMAL, NORMAL, NORMAL), + "a string", + 5, + ], +) +def test_inqequality(font): + assert font != Font("Comic Sans", "12 pt") + + +def test_hash(): + assert hash(Font("Comic Sans", 12)) == hash(Font("Comic Sans", 12)) + + assert hash(Font("Comic Sans", 12, weight=BOLD)) != hash(Font("Comic Sans", 12)) + + +@pytest.mark.parametrize( + "size, kwargs, string", + [ + (12, {}, "12pt"), + (12, {"style": ITALIC}, "italic 12pt"), + (12, {"style": ITALIC, "variant": SMALL_CAPS}, "italic small-caps 12pt"), + ( + 12, + {"style": ITALIC, "variant": SMALL_CAPS, "weight": BOLD}, + "italic small-caps bold 12pt", + ), + (12, {"variant": SMALL_CAPS, "weight": BOLD}, "small-caps bold 12pt"), + (12, {"weight": BOLD}, "bold 12pt"), + (12, {"style": ITALIC, "weight": BOLD}, "italic bold 12pt"), + # Check system default size handling + (SYSTEM_DEFAULT_FONT_SIZE, {}, "system default size"), + (SYSTEM_DEFAULT_FONT_SIZE, {"style": ITALIC}, "italic system default size"), + ], +) +def test_repr(size, kwargs, string): + assert repr(Font("Comic Sans", size, **kwargs)) == f"" + + +@pytest.mark.parametrize("size", [12, "12", "12pt", "12 pt"]) +def test_simple_construction(size): + assert_font(Font("Comic Sans", size), "Comic Sans", 12, NORMAL, NORMAL, NORMAL) + + +def test_invalid_construction(): + with pytest.raises(ValueError): + Font("Comic Sans", "12 quatloos") + + +@pytest.mark.parametrize( + "family", + [ + "Comics Sans", + "Wingdings", + "'Comic Sans'", + '"Comic Sans"', + ], +) +def test_family(family): + normalized_family = family.replace("'", "").replace('"', "") + assert_font(Font(family, 12), normalized_family, 12, NORMAL, NORMAL, NORMAL) + + +@pytest.mark.parametrize( + "style, result_style", + [ + (ITALIC, ITALIC), + ("italic", ITALIC), + (OBLIQUE, OBLIQUE), + ("oblique", OBLIQUE), + ("something else", NORMAL), + ], +) +def test_style(style, result_style): + assert_font( + Font("Comic Sans", 12, style=style), + "Comic Sans", + 12, + result_style, + NORMAL, + NORMAL, + ) + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"style": ITALIC}, + ], +) +def test_make_normal_style(kwargs): + f = Font("Comic Sans", 12, **kwargs) + assert_font(f.normal_style(), "Comic Sans", 12, NORMAL, NORMAL, NORMAL) + + +@pytest.mark.parametrize( + "method, result", + [ + ("italic", ITALIC), + ("oblique", OBLIQUE), + ], +) +def test_make_slanted(method, result): + f = Font("Comic Sans", 12) + assert_font(getattr(f, method)(), "Comic Sans", 12, result, NORMAL, NORMAL) + + +@pytest.mark.parametrize( + "variant, result", + [ + (SMALL_CAPS, SMALL_CAPS), + ("small-caps", SMALL_CAPS), + ("something else", NORMAL), + ], +) +def test_variant(variant, result): + assert_font( + Font("Comic Sans", 12, variant=variant), + "Comic Sans", + 12, + NORMAL, + result, + NORMAL, + ) + + +@pytest.mark.parametrize("kwargs", [{}, {"variant": SMALL_CAPS}]) +def test_make_normal_variant(kwargs): + f = Font("Comic Sans", 12, **kwargs) + assert_font(f.normal_variant(), "Comic Sans", 12, NORMAL, NORMAL, NORMAL) + + +def test_make_small_caps(): + f = Font("Comic Sans", 12) + assert_font(f.small_caps(), "Comic Sans", 12, NORMAL, SMALL_CAPS, NORMAL) + + +@pytest.mark.parametrize( + "weight, result", + [ + (BOLD, BOLD), + ("bold", BOLD), + ("something else", NORMAL), + ], +) +def test_weight(weight, result): + assert_font( + Font("Comic Sans", 12, weight=weight), + "Comic Sans", + 12, + NORMAL, + NORMAL, + result, + ) + + +@pytest.mark.parametrize("kwargs", [{}, {"weight": BOLD}]) +def test_make_normal_weight(kwargs): + f = Font("Comic Sans", 12, **kwargs) + assert_font(f.normal_weight(), "Comic Sans", 12, NORMAL, NORMAL, NORMAL) + + +def test_make_bold(): + f = Font("Comic Sans", 12) + assert_font(f.bold(), "Comic Sans", 12, NORMAL, NORMAL, BOLD) diff --git a/travertino/tests/fonts/test_parsing.py b/travertino/tests/fonts/test_parsing.py new file mode 100644 index 0000000000..95094b45fa --- /dev/null +++ b/travertino/tests/fonts/test_parsing.py @@ -0,0 +1,134 @@ +import pytest + +from travertino.constants import ( + BOLD, + ITALIC, + NORMAL, + OBLIQUE, + SMALL_CAPS, +) +from travertino.fonts import Font, font + +from .test_constructor import assert_font + + +def test_font_instance(): + f = Font("Comic Sans", 12) + + parsed = font(f) + + assert f == parsed + assert f is parsed + + +@pytest.mark.parametrize( + "string, style, variant, weight", + [ + ("12pt Comic Sans", NORMAL, NORMAL, NORMAL), + ("italic 12pt Comic Sans", ITALIC, NORMAL, NORMAL), + ("italic small-caps 12pt Comic Sans", ITALIC, SMALL_CAPS, NORMAL), + ("italic small-caps bold 12pt Comic Sans", ITALIC, SMALL_CAPS, BOLD), + ("small-caps bold 12pt Comic Sans", NORMAL, SMALL_CAPS, BOLD), + ("italic bold 12 pt Comic Sans", ITALIC, NORMAL, BOLD), + ("bold 12 pt Comic Sans", NORMAL, NORMAL, BOLD), + ], +) +def test_successful_combinations(string, style, variant, weight): + assert_font(font(string), "Comic Sans", 12, style, variant, weight) + + +@pytest.mark.parametrize( + "string", + [ + "12pt Comic Sans", + "12 pt Comic Sans", + "12 Comic Sans", + ], +) +def test_font_sizes(string): + assert_font(font(string), "Comic Sans", 12, NORMAL, NORMAL, NORMAL) + + +def test_invalid_size(): + with pytest.raises(ValueError): + font("12quatloo Comic Sans") + + +@pytest.mark.parametrize("string", ["12pt 'Comic Sans'", '12pt "Comic Sans"']) +def test_font_family(string): + assert_font(font(string), "Comic Sans", 12, NORMAL, NORMAL, NORMAL) + + +@pytest.mark.parametrize( + "string, style, variant", + [ + ("normal 12pt Comic Sans", NORMAL, NORMAL), + ("italic normal 12pt Comic Sans", ITALIC, NORMAL), + ("italic small-caps normal 12pt Comic Sans", ITALIC, SMALL_CAPS), + ], +) +def test_normal(string, style, variant): + assert_font(font(string), "Comic Sans", 12, style, variant, NORMAL) + + +@pytest.mark.parametrize( + "string, style", + [ + ("italic 12pt Comic Sans", ITALIC), + ("oblique 12pt Comic Sans", OBLIQUE), + ], +) +def test_style(string, style): + assert_font(font(string), "Comic Sans", 12, style, NORMAL, NORMAL) + + +def test_invalid_style(): + with pytest.raises(ValueError): + font("wiggly small-caps bold 12pt Comic Sans") + + +def test_variant(): + assert_font( + font("italic small-caps 12pt Comic Sans"), + "Comic Sans", + 12, + ITALIC, + SMALL_CAPS, + NORMAL, + ) + + with pytest.raises(ValueError): + font("italic wiggly bold 12pt Comic Sans") + + +def test_weight(): + assert_font( + font("italic small-caps bold 12pt Comic Sans"), + "Comic Sans", + 12, + ITALIC, + SMALL_CAPS, + BOLD, + ) + + with pytest.raises(ValueError): + font("italic small-caps wiggly 12pt Comic Sans") + + +@pytest.mark.parametrize( + "string", + [ + "oblique italic 12pt Comic Sans", + "italic small-caps oblique 12pt Comic Sans", + "italic small-caps bold small-caps 12pt Comic Sans", + "bold bold 12pt Comic Sans", + ], +) +def test_duplicates(string): + with pytest.raises(ValueError): + font(string) + + +def test_invaid(): + with pytest.raises(ValueError): + font(42) diff --git a/travertino/tests/test_choices.py b/travertino/tests/test_choices.py new file mode 100644 index 0000000000..ff642bf44f --- /dev/null +++ b/travertino/tests/test_choices.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +from warnings import catch_warnings, filterwarnings + +import pytest + +from travertino.colors import NAMED_COLOR, rgb +from travertino.constants import GOLDENROD, NONE, REBECCAPURPLE, TOP +from travertino.declaration import BaseStyle, Choices, validated_property + +from .utils import mock_attr, prep_style_class + + +@prep_style_class +class Style(BaseStyle): + none: str = validated_property(choices=Choices(NONE, REBECCAPURPLE), initial=NONE) + allow_string: str = validated_property( + choices=Choices(string=True), initial="start" + ) + allow_integer: int = validated_property(choices=Choices(integer=True), initial=0) + allow_number: float = validated_property(choices=Choices(number=True), initial=0) + allow_color: str = validated_property( + choices=Choices(color=True), initial="goldenrod" + ) + values: str = validated_property(choices=Choices("a", "b", NONE), initial="a") + multiple_choices: str | float = validated_property( + choices=Choices("a", "b", NONE, number=True, color=True), + initial=None, + ) + string_symbol: str = validated_property(choices=Choices(TOP, NONE)) + + +with catch_warnings(): + filterwarnings("ignore", category=DeprecationWarning) + + @mock_attr("apply") + class DeprecatedStyle(BaseStyle): + pass + + DeprecatedStyle.validated_property( + "none", choices=Choices(NONE, REBECCAPURPLE), initial=NONE + ) + DeprecatedStyle.validated_property( + "allow_string", choices=Choices(string=True), initial="start" + ) + DeprecatedStyle.validated_property( + "allow_integer", choices=Choices(integer=True), initial=0 + ) + DeprecatedStyle.validated_property( + "allow_number", choices=Choices(number=True), initial=0 + ) + DeprecatedStyle.validated_property( + "allow_color", choices=Choices(color=True), initial="goldenrod" + ) + DeprecatedStyle.validated_property( + "values", choices=Choices("a", "b", NONE), initial="a" + ) + DeprecatedStyle.validated_property( + "multiple_choices", + choices=Choices("a", "b", NONE, number=True, color=True), + initial=None, + ) + DeprecatedStyle.validated_property("string_symbol", choices=Choices(TOP, NONE)) + + +def assert_property(obj, name, value): + assert getattr(obj, name) == value + + obj.apply.assert_called_once_with(name, value) + obj.apply.reset_mock() + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_none(StyleClass): + style = StyleClass() + assert style.none == NONE + + with pytest.raises(ValueError): + style.none = 10 + + with pytest.raises(ValueError): + style.none = 3.14159 + + with pytest.raises(ValueError): + style.none = "#112233" + + with pytest.raises(ValueError): + style.none = "a" + + with pytest.raises(ValueError): + style.none = "b" + + # Set the property to a different explicit value + style.none = REBECCAPURPLE + assert_property(style, "none", REBECCAPURPLE) + + # A Travertino NONE is an explicit value + style.none = NONE + assert_property(style, "none", NONE) + + # Set the property to a different explicit value + style.none = REBECCAPURPLE + assert_property(style, "none", REBECCAPURPLE) + + # A Python None is invalid + with pytest.raises(ValueError): + style.none = None + + # The property can be reset + del style.none + assert_property(style, "none", NONE) + + with pytest.raises( + ValueError, + match=r"Invalid value 'invalid' for property none; Valid values are: " + r"none, rebeccapurple", + ): + style.none = "invalid" + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_allow_string(StyleClass): + style = StyleClass() + assert style.allow_string == "start" + + with pytest.raises(ValueError): + style.allow_string = 10 + + with pytest.raises(ValueError): + style.allow_string = 3.14159 + + style.allow_string = REBECCAPURPLE + assert_property(style, "allow_string", "rebeccapurple") + + style.allow_string = "#112233" + assert_property(style, "allow_string", "#112233") + + style.allow_string = "a" + assert_property(style, "allow_string", "a") + + style.allow_string = "b" + assert_property(style, "allow_string", "b") + + # A Travertino NONE is an explicit string value + style.allow_string = NONE + assert_property(style, "allow_string", NONE) + + # A Python None is invalid + with pytest.raises(ValueError): + style.allow_string = None + + # The property can be reset + del style.allow_string + assert_property(style, "allow_string", "start") + + with pytest.raises( + ValueError, + match=r"Invalid value 99 for property allow_string; Valid values are: ", + ): + style.allow_string = 99 + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_allow_integer(StyleClass): + style = StyleClass() + assert style.allow_integer == 0 + + style.allow_integer = 10 + assert_property(style, "allow_integer", 10) + + # This is an odd case; Python happily rounds floats to integers. + # It's more trouble than it's worth to correct this. + style.allow_integer = 3.14159 + assert_property(style, "allow_integer", 3) + + with pytest.raises(ValueError): + style.allow_integer = REBECCAPURPLE + + with pytest.raises(ValueError): + style.allow_integer = "#112233" + + with pytest.raises(ValueError): + style.allow_integer = "a" + + with pytest.raises(ValueError): + style.allow_integer = "b" + + # A Travertino NONE is an explicit string value + with pytest.raises(ValueError): + style.allow_integer = NONE + + # A Python None is invalid + with pytest.raises(ValueError): + style.allow_integer = None + + # The property can be reset + del style.allow_integer + assert_property(style, "allow_integer", 0) + + # Check the error message + with pytest.raises( + ValueError, + match=( + r"Invalid value 'invalid' for property allow_integer; Valid values are: " + r"" + ), + ): + style.allow_integer = "invalid" + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_allow_number(StyleClass): + style = StyleClass() + assert style.allow_number == 0 + + style.allow_number = 10 + assert_property(style, "allow_number", 10.0) + + style.allow_number = 3.14159 + assert_property(style, "allow_number", 3.14159) + + with pytest.raises(ValueError): + style.allow_number = REBECCAPURPLE + + with pytest.raises(ValueError): + style.allow_number = "#112233" + + with pytest.raises(ValueError): + style.allow_number = "a" + + with pytest.raises(ValueError): + style.allow_number = "b" + + # A Travertino NONE is an explicit string value + with pytest.raises(ValueError): + style.allow_number = NONE + + # A Python None is invalid + with pytest.raises(ValueError): + style.allow_number = None + + # The property can be reset + del style.allow_number + assert_property(style, "allow_number", 0) + + with pytest.raises( + ValueError, + match=( + r"Invalid value 'invalid' for property allow_number; Valid values are: " + r"" + ), + ): + style.allow_number = "invalid" + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_allow_color(StyleClass): + style = StyleClass() + assert style.allow_color == NAMED_COLOR[GOLDENROD] + + with pytest.raises(ValueError): + style.allow_color = 10 + + with pytest.raises(ValueError): + style.allow_color = 3.14159 + + style.allow_color = REBECCAPURPLE + assert_property(style, "allow_color", NAMED_COLOR[REBECCAPURPLE]) + + style.allow_color = "#112233" + assert_property(style, "allow_color", rgb(0x11, 0x22, 0x33)) + + with pytest.raises(ValueError): + style.allow_color = "a" + + with pytest.raises(ValueError): + style.allow_color = "b" + + # A Travertino NONE is an explicit string value + with pytest.raises(ValueError): + style.allow_color = NONE + + # A Python None is invalid + with pytest.raises(ValueError): + style.allow_color = None + + # The property can be reset + del style.allow_color + assert_property(style, "allow_color", NAMED_COLOR["goldenrod"]) + + with pytest.raises( + ValueError, + match=( + r"Invalid value 'invalid' for property allow_color; Valid values are: " + r"" + ), + ): + style.allow_color = "invalid" + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_values(StyleClass): + style = StyleClass() + assert style.values == "a" + + with pytest.raises(ValueError): + style.values = 10 + + with pytest.raises(ValueError): + style.values = 3.14159 + + with pytest.raises(ValueError): + style.values = REBECCAPURPLE + + with pytest.raises(ValueError): + style.values = "#112233" + + style.values = NONE + assert_property(style, "values", NONE) + + style.values = "b" + assert_property(style, "values", "b") + + # A Python None is invalid + with pytest.raises(ValueError): + style.values = None + + # The property can be reset + del style.values + assert_property(style, "values", "a") + + with pytest.raises( + ValueError, + match=( + r"Invalid value 'invalid' for property values; Valid values are: a, b, " + r"none" + ), + ): + style.values = "invalid" + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_multiple_choices(StyleClass): + style = StyleClass() + + style.multiple_choices = 10 + assert_property(style, "multiple_choices", 10.0) + + style.multiple_choices = 3.14159 + assert_property(style, "multiple_choices", 3.14159) + + style.multiple_choices = REBECCAPURPLE + assert_property(style, "multiple_choices", NAMED_COLOR[REBECCAPURPLE]) + + style.multiple_choices = "#112233" + assert_property(style, "multiple_choices", rgb(0x11, 0x22, 0x33)) + + style.multiple_choices = "a" + assert_property(style, "multiple_choices", "a") + + style.multiple_choices = NONE + assert_property(style, "multiple_choices", NONE) + + style.multiple_choices = "b" + assert_property(style, "multiple_choices", "b") + + # A Python None is invalid + with pytest.raises(ValueError): + style.multiple_choices = None + + # The property can be reset + # There's no initial value, so the property is None + del style.multiple_choices + assert style.multiple_choices is None + + # Check the error message + with pytest.raises( + ValueError, + match=( + r"Invalid value 'invalid' for property multiple_choices; Valid values are: " + r"a, b, none, , " + ), + ): + style.multiple_choices = "invalid" + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_string_symbol(StyleClass): + style = StyleClass() + + # Set a symbolic value using the string value of the symbol + # We can't just use the string directly, though - that would + # get optimized by the compiler. So we create a string and + # transform it into the value we want. + val = "TOP" + style.string_symbol = val.lower() + + # Both equality and instance checking should work. + assert_property(style, "string_symbol", TOP) + assert style.string_symbol is TOP diff --git a/travertino/tests/test_declaration.py b/travertino/tests/test_declaration.py new file mode 100644 index 0000000000..cf8a0b0450 --- /dev/null +++ b/travertino/tests/test_declaration.py @@ -0,0 +1,837 @@ +from __future__ import annotations + +from unittest.mock import call +from warnings import catch_warnings, filterwarnings + +import pytest + +from travertino.declaration import ( + BaseStyle, + Choices, + ImmutableList, + directional_property, + list_property, + validated_property, +) + +from .utils import mock_attr, prep_style_class + +VALUE1 = "value1" +VALUE2 = "value2" +VALUE3 = "value3" +VALUE_CHOICES = Choices(VALUE1, VALUE2, VALUE3, None, integer=True) +DEFAULT_VALUE_CHOICES = Choices(VALUE1, VALUE2, VALUE3, integer=True) + + +@prep_style_class +class Style(BaseStyle): + # Some properties with explicit initial values + explicit_const: str | int = validated_property( + choices=VALUE_CHOICES, initial=VALUE1 + ) + explicit_value: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + explicit_none: str | int | None = validated_property( + choices=VALUE_CHOICES, initial=None + ) + + # A property with an implicit default value. + # This usually means the default is platform specific. + implicit: str | int | None = validated_property(choices=DEFAULT_VALUE_CHOICES) + + # A set of directional properties + thing: tuple[str | int] | str | int = directional_property("thing{}") + thing_top: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + thing_right: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + thing_bottom: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + thing_left: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + + # Doesn't need to be tested in deprecated API: + list_prop: list[str] = list_property(choices=VALUE_CHOICES, initial=(VALUE2,)) + + +with catch_warnings(): + filterwarnings("ignore", category=DeprecationWarning) + + @mock_attr("apply") + class DeprecatedStyle(BaseStyle): + pass + + # Some properties with explicit initial values + DeprecatedStyle.validated_property( + "explicit_const", choices=VALUE_CHOICES, initial=VALUE1 + ) + DeprecatedStyle.validated_property( + "explicit_value", choices=VALUE_CHOICES, initial=0 + ) + DeprecatedStyle.validated_property( + "explicit_none", choices=VALUE_CHOICES, initial=None + ) + + # A property with an implicit default value. + # This usually means the default is platform specific. + DeprecatedStyle.validated_property("implicit", choices=DEFAULT_VALUE_CHOICES) + + # A set of directional properties + DeprecatedStyle.validated_property("thing_top", choices=VALUE_CHOICES, initial=0) + DeprecatedStyle.validated_property("thing_right", choices=VALUE_CHOICES, initial=0) + DeprecatedStyle.validated_property("thing_bottom", choices=VALUE_CHOICES, initial=0) + DeprecatedStyle.validated_property("thing_left", choices=VALUE_CHOICES, initial=0) + DeprecatedStyle.directional_property("thing%s") + + +class StyleSubclass(Style): + pass + + +class DeprecatedStyleSubclass(DeprecatedStyle): + pass + + +class Sibling(BaseStyle): + pass + + +@prep_style_class +@mock_attr("reapply") +class MockedReapplyStyle(BaseStyle): + pass + + +def test_invalid_style(): + with pytest.raises(ValueError): + # Define an invalid initial value on a validated property + validated_property(choices=VALUE_CHOICES, initial="something") + + with pytest.raises(ValueError): + # Same for list property + list_property(choices=VALUE_CHOICES, initial=["something"]) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_positional_argument(StyleClass): + # Could be the subclass or inherited __init__, depending on Python version / API + # used. + with pytest.raises( + TypeError, match=r"__init__\(\) takes 1 positional argument but 2 were given" + ): + StyleClass(5) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_create_and_copy(StyleClass): + style = StyleClass(explicit_const=VALUE2, implicit=VALUE3) + + dup = style.copy() + assert dup.explicit_const == VALUE2 + assert dup.explicit_value == 0 + assert dup.implicit == VALUE3 + + +def test_deprecated_copy(): + style = MockedReapplyStyle() + + with pytest.warns(DeprecationWarning): + style_copy = style.copy(applicator=object()) + + style_copy.reapply.assert_called_once() + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_reapply(StyleClass): + style = StyleClass(explicit_const=VALUE2, implicit=VALUE3) + + style.reapply() + style.apply.assert_has_calls( + [ + call("explicit_const", VALUE2), + call("explicit_value", 0), + call("explicit_none", None), + call("implicit", VALUE3), + call("thing_left", 0), + call("thing_top", 0), + call("thing_right", 0), + call("thing_bottom", 0), + ], + any_order=True, + ) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_property_with_explicit_const(StyleClass): + style = StyleClass() + + # Default value is VALUE1 + assert style.explicit_const is VALUE1 + style.apply.assert_not_called() + + # Modify the value + style.explicit_const = 10 + + assert style.explicit_const == 10 + style.apply.assert_called_once_with("explicit_const", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set the value to the same value. + # No dirty notification is sent + style.explicit_const = 10 + assert style.explicit_const == 10 + style.apply.assert_not_called() + + # Set the value to something new + # A dirty notification is set. + style.explicit_const = 20 + assert style.explicit_const == 20 + style.apply.assert_called_once_with("explicit_const", 20) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property + del style.explicit_const + assert style.explicit_const is VALUE1 + style.apply.assert_called_once_with("explicit_const", VALUE1) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property again. + # The underlying attribute won't exist, so this + # should be a no-op. + del style.explicit_const + assert style.explicit_const is VALUE1 + style.apply.assert_not_called() + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_property_with_explicit_value(StyleClass): + style = StyleClass() + + # Default value is 0 + assert style.explicit_value == 0 + style.apply.assert_not_called() + + # Modify the value + style.explicit_value = 10 + + assert style.explicit_value == 10 + style.apply.assert_called_once_with("explicit_value", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set the value to the same value. + # No dirty notification is sent + style.explicit_value = 10 + assert style.explicit_value == 10 + style.apply.assert_not_called() + + # Set the value to something new + # A dirty notification is set. + style.explicit_value = 20 + assert style.explicit_value == 20 + style.apply.assert_called_once_with("explicit_value", 20) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property + del style.explicit_value + assert style.explicit_value == 0 + style.apply.assert_called_once_with("explicit_value", 0) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_property_with_explicit_none(StyleClass): + style = StyleClass() + + # Default value is None + assert style.explicit_none is None + style.apply.assert_not_called() + + # Modify the value + style.explicit_none = 10 + + assert style.explicit_none == 10 + style.apply.assert_called_once_with("explicit_none", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set the property to the same value. + # No dirty notification is sent + style.explicit_none = 10 + assert style.explicit_none == 10 + style.apply.assert_not_called() + + # Set the property to something new + # A dirty notification is set. + style.explicit_none = 20 + assert style.explicit_none == 20 + style.apply.assert_called_once_with("explicit_none", 20) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property + del style.explicit_none + assert style.explicit_none is None + style.apply.assert_called_once_with("explicit_none", None) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_property_with_implicit_default(StyleClass): + style = StyleClass() + + # Default value is None + assert style.implicit is None + style.apply.assert_not_called() + + # Modify the value + style.implicit = 10 + + assert style.implicit == 10 + style.apply.assert_called_once_with("implicit", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set the value to the same value. + # No dirty notification is sent + style.implicit = 10 + assert style.implicit == 10 + style.apply.assert_not_called() + + # Set the value to something new + # A dirty notification is set. + style.implicit = 20 + assert style.implicit == 20 + style.apply.assert_called_once_with("implicit", 20) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property + del style.implicit + assert style.implicit is None + style.apply.assert_called_once_with("implicit", None) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_set_initial_no_apply(StyleClass): + """If a property hasn't been set, assigning it its initial value shouldn't apply.""" + style = StyleClass() + + # 0 is the initial value + style.explicit_value = 0 + + style.apply.assert_not_called() + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_directional_property(StyleClass): + style = StyleClass() + + # Default value is 0 + assert style.thing == (0, 0, 0, 0) + assert style.thing_top == 0 + assert style.thing_right == 0 + assert style.thing_bottom == 0 + assert style.thing_left == 0 + style.apply.assert_not_called() + + # Set a value in one axis + style.thing_top = 10 + + assert style.thing == (10, 0, 0, 0) + assert style.thing_top == 10 + assert style.thing_right == 0 + assert style.thing_bottom == 0 + assert style.thing_left == 0 + style.apply.assert_called_once_with("thing_top", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a single item + style.thing = (10,) + + assert style.thing == (10, 10, 10, 10) + assert style.thing_top == 10 + assert style.thing_right == 10 + assert style.thing_bottom == 10 + assert style.thing_left == 10 + style.apply.assert_has_calls( + [ + call("thing_right", 10), + call("thing_bottom", 10), + call("thing_left", 10), + ] + ) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a single item + style.thing = 30 + + assert style.thing == (30, 30, 30, 30) + assert style.thing_top == 30 + assert style.thing_right == 30 + assert style.thing_bottom == 30 + assert style.thing_left == 30 + style.apply.assert_has_calls( + [ + call("thing_top", 30), + call("thing_right", 30), + call("thing_bottom", 30), + call("thing_left", 30), + ] + ) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a 2 values + style.thing = (10, 20) + + assert style.thing == (10, 20, 10, 20) + assert style.thing_top == 10 + assert style.thing_right == 20 + assert style.thing_bottom == 10 + assert style.thing_left == 20 + style.apply.assert_has_calls( + [ + call("thing_top", 10), + call("thing_right", 20), + call("thing_bottom", 10), + call("thing_left", 20), + ] + ) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a 3 values + style.thing = (10, 20, 30) + + assert style.thing == (10, 20, 30, 20) + assert style.thing_top == 10 + assert style.thing_right == 20 + assert style.thing_bottom == 30 + assert style.thing_left == 20 + style.apply.assert_called_once_with("thing_bottom", 30) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a 4 values + style.thing = (10, 20, 30, 40) + + assert style.thing == (10, 20, 30, 40) + assert style.thing_top == 10 + assert style.thing_right == 20 + assert style.thing_bottom == 30 + assert style.thing_left == 40 + style.apply.assert_called_once_with("thing_left", 40) + + # Set a value directly with an invalid number of values + with pytest.raises(ValueError): + style.thing = () + + with pytest.raises(ValueError): + style.thing = (10, 20, 30, 40, 50) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear a value on one axis + del style.thing_top + + assert style.thing == (0, 20, 30, 40) + assert style.thing_top == 0 + assert style.thing_right == 20 + assert style.thing_bottom == 30 + assert style.thing_left == 40 + style.apply.assert_called_once_with("thing_top", 0) + + # Restore the top thing + style.thing_top = 10 + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear a value directly + del style.thing + + assert style.thing == (0, 0, 0, 0) + assert style.thing_top == 0 + assert style.thing_right == 0 + assert style.thing_bottom == 0 + assert style.thing_left == 0 + style.apply.assert_has_calls( + [ + call("thing_right", 0), + call("thing_bottom", 0), + call("thing_left", 0), + ] + ) + + +@pytest.mark.parametrize( + "value, expected", + [ + ([VALUE1], [VALUE1]), + (VALUE1, [VALUE1]), + ([VALUE1, VALUE3], [VALUE1, VALUE3]), + ([VALUE2, VALUE1], [VALUE2, VALUE1]), + ([VALUE2, VALUE3, 1, 2, VALUE1], [VALUE2, VALUE3, 1, 2, VALUE1]), + # Duplicates are kept, but "normalized" via validation. + ( + [VALUE3, 1, VALUE3, "1", True, " 1", VALUE2], + [VALUE3, 1, VALUE3, 1, 1, 1, VALUE2], + ), + # Other sequences should work too. + ((VALUE1, VALUE3), [VALUE1, VALUE3]), + ], +) +def test_list_property(value, expected): + style = Style() + style.list_prop = value + assert style.list_prop == expected + + +@pytest.mark.parametrize( + "value, error, match", + [ + ( + 5, + TypeError, + r"Value for list property list_prop must be a sequence\.", + ), + ( + # Fails because it's only a generator, not a comprehension: + (i for i in [VALUE1, VALUE3]), + TypeError, + r"Value for list property list_prop must be a sequence.", + ), + ( + [VALUE3, VALUE1, "bogus"], + ValueError, + r"Invalid item value 'bogus' for list property list_prop; " + r"Valid values are: none, value1, value2, value3, ", + ), + ( + (), + ValueError, + r"List properties cannot be set to an empty sequence; " + r"to reset a property, use del `style.list_prop`\.", + ), + ( + [], + ValueError, + r"List properties cannot be set to an empty sequence; " + r"to reset a property, use del `style.list_prop`\.", + ), + ], +) +def test_list_property_invalid(value, error, match): + style = Style() + with pytest.raises(error, match=match): + style.list_prop = value + + +def test_list_property_immutable(): + style = Style() + style.list_prop = [1, 2, 3, VALUE2] + prop = style.list_prop + + with pytest.raises(TypeError, match=r"does not support item assignment"): + prop[0] = 5 + + with pytest.raises(TypeError, match=r"doesn't support item deletion"): + del prop[1] + + with pytest.raises(AttributeError): + prop.insert(2, VALUE1) + + with pytest.raises(AttributeError): + prop.append(VALUE3) + + with pytest.raises(AttributeError): + prop.clear() + + with pytest.raises(AttributeError): + prop.reverse() + + with pytest.raises(AttributeError): + prop.pop() + + with pytest.raises(AttributeError): + prop.remove(VALUE2) + + with pytest.raises(AttributeError): + prop.extend([5, 6, 7]) + + with pytest.raises(TypeError, match=r"unsupported operand type\(s\)"): + prop += [4, 3, VALUE1] + + with pytest.raises(TypeError, match=r"unsupported operand type\(s\)"): + prop += ImmutableList([4, 3, VALUE1]) + + with pytest.raises(AttributeError): + prop.sort() + + +def test_list_property_list_like(): + style = Style() + style.list_prop = [1, 2, 3, VALUE2] + prop = style.list_prop + + assert isinstance(prop, ImmutableList) + assert prop == [1, 2, 3, VALUE2] + assert prop == ImmutableList([1, 2, 3, VALUE2]) + assert str(prop) == repr(prop) == "[1, 2, 3, 'value2']" + assert len(prop) == 4 + + count = 0 + for _ in prop: + count += 1 + assert count == 4 + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_set_multiple_properties(StyleClass): + style = StyleClass() + + # Set a pair of properties + style.update(explicit_value=20, explicit_none=10) + + assert style.explicit_const is VALUE1 + assert style.explicit_none == 10 + assert style.explicit_value == 20 + style.apply.assert_has_calls( + [ + call("explicit_value", 20), + call("explicit_none", 10), + ], + any_order=True, + ) + + # Set a different pair of properties + style.update(explicit_const=VALUE2, explicit_value=30) + + assert style.explicit_const is VALUE2 + assert style.explicit_value == 30 + assert style.explicit_none == 10 + style.apply.assert_has_calls( + [ + call("explicit_const", VALUE2), + call("explicit_value", 30), + ], + any_order=True, + ) + + # Clear the applicator mock + style.apply.reset_mock() + + # Setting a non-property + with pytest.raises(NameError): + style.update(not_a_property=10) + + style.apply.assert_not_called() + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_str(StyleClass): + style = StyleClass() + + style.update( + explicit_const=VALUE2, + explicit_value=20, + thing=(30, 40, 50, 60), + ) + + assert ( + str(style) == "explicit-const: value2; " + "explicit-value: 20; " + "thing-bottom: 50; " + "thing-left: 60; " + "thing-right: 40; " + "thing-top: 30" + ) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_dict(StyleClass): + "Style declarations expose a dict-like interface" + style = StyleClass() + + style.update( + explicit_const=VALUE2, + explicit_value=20, + thing=(30, 40, 50, 60), + ) + + expected_keys = { + "explicit_const", + "explicit_value", + "thing_bottom", + "thing_left", + "thing_right", + "thing_top", + } + + assert style.keys() == expected_keys + + assert sorted(style.items()) == sorted( + [ + ("explicit_const", "value2"), + ("explicit_value", 20), + ("thing_bottom", 50), + ("thing_left", 60), + ("thing_right", 40), + ("thing_top", 30), + ] + ) + + # Properties that are set are in the keys. + for name in expected_keys: + assert name in style + + # Directional properties with one or more of the aliased properties set also count. + assert "thing" in style + + # Valid properties that haven't been set are not in the keys. + assert "implicit" not in style + assert "explicit_none" not in style + + # Neither are invalid properties. + assert "invalid_property" not in style + + # A property can be set, retrieved and cleared using the attribute name + style["thing-bottom"] = 10 + assert style["thing-bottom"] == 10 + del style["thing-bottom"] + assert style["thing-bottom"] == 0 + + # A property can be set, retrieved and cleared using the Python attribute name + style["thing_bottom"] = 10 + assert style["thing_bottom"] == 10 + del style["thing_bottom"] + assert style["thing_bottom"] == 0 + + # Property aliases can be accessed as well. + style["thing"] = 5 + assert style["thing"] == (5, 5, 5, 5) + del style["thing"] + assert style["thing"] == (0, 0, 0, 0) + + # Clearing a valid property isn't an error + del style["thing_bottom"] + assert style["thing_bottom"] == 0 + + # Non-existent properties raise KeyError + with pytest.raises(KeyError): + style["no-such-property"] = "no-such-value" + + with pytest.raises(KeyError): + style["no-such-property"] + + with pytest.raises(KeyError): + del style["no-such-property"] + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +@pytest.mark.parametrize("instantiate", [True, False]) +def test_union_operators(StyleClass, instantiate): + """Styles support | and |= with dicts and with their own class.""" + left = StyleClass(explicit_value=VALUE1, implicit=VALUE2) + + style_dict = {"thing_top": 5, "implicit": VALUE3} + right = StyleClass(**style_dict) if instantiate else style_dict + + # Standard operator + result = left | right + + # Original objects unchanged + assert left["explicit_value"] == VALUE1 + assert left["implicit"] == VALUE2 + + assert right["thing_top"] == 5 + assert right["implicit"] == VALUE3 + + # Unshared properties assigned + assert result["explicit_const"] == VALUE1 + assert result["thing_top"] == 5 + + # Common property overridden by second operand + assert result["implicit"] == VALUE3 + + # In-place version + left |= right + + # Common property updated on lefthand + assert left["explicit_value"] == VALUE1 + assert left["implicit"] == VALUE3 + + # Righthand unchanged + assert right["thing_top"] == 5 + assert right["implicit"] == VALUE3 + + +@pytest.mark.parametrize( + "StyleClass, OtherClass", + [ + (Style, StyleSubclass), + (Style, Sibling), + (Style, int), + (Style, list), + (DeprecatedStyle, DeprecatedStyleSubclass), + (DeprecatedStyle, Sibling), + (DeprecatedStyle, int), + (DeprecatedStyle, list), + ], +) +def test_union_operators_invalid_type(StyleClass, OtherClass): + """Styles do not support | or |= with other style classes or with non-mappings.""" + + left = StyleClass() + right = OtherClass() + + with pytest.raises(TypeError, match=r"unsupported operand type"): + left | right + + with pytest.raises(TypeError, match=r"unsupported operand type"): + left |= right + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +@pytest.mark.parametrize( + "right, error", + [ + ({"implicit": "bogus_value"}, ValueError), + ({"bogus_key": 3.12}, NameError), + ], +) +def test_union_operators_invalid_key_value(StyleClass, right, error): + """Operators will accept any mapping, but invalid keys/values are still an error.""" + left = StyleClass() + + with pytest.raises(error): + left | right + + with pytest.raises(error): + left |= right + + +def test_deprecated_class_methods(): + class OldStyle(BaseStyle): + pass + + with pytest.warns(DeprecationWarning): + OldStyle.validated_property("implicit", choices=DEFAULT_VALUE_CHOICES) + + with pytest.warns(DeprecationWarning): + OldStyle.directional_property("thing%s") diff --git a/travertino/tests/test_layout.py b/travertino/tests/test_layout.py new file mode 100644 index 0000000000..d6c417f120 --- /dev/null +++ b/travertino/tests/test_layout.py @@ -0,0 +1,407 @@ +import pytest + +from travertino.declaration import BaseStyle +from travertino.layout import BaseBox, Viewport +from travertino.node import Node +from travertino.size import BaseIntrinsicSize + + +class Style(BaseStyle): + class IntrinsicSize(BaseIntrinsicSize): + pass + + class Box(BaseBox): + pass + + +def test_viewport_default(): + viewport = Viewport() + + assert viewport.width == 0 + assert viewport.height == 0 + assert viewport.dpi is None + + +def test_viewport_constructor(): + viewport = Viewport(width=640, height=480, dpi=96) + + assert viewport.width == 640 + assert viewport.height == 480 + assert viewport.dpi == 96 + + +class TestBox: + pass + + +@pytest.fixture +def box(): + box = TestBox() + + box.maxDiff = None + + box.grandchild1_1 = Node(style=Style()) + box.grandchild1_1.layout.min_content_width = 5 + box.grandchild1_1.layout.content_width = 10 + box.grandchild1_1.layout.min_content_height = 8 + box.grandchild1_1.layout.content_height = 16 + + box.grandchild1_2 = Node(style=Style()) + + box.child1 = Node(style=Style(), children=[box.grandchild1_1, box.grandchild1_2]) + box.child1.layout.min_content_width = 5 + box.child1.layout.content_width = 10 + box.child1.layout.min_content_height = 8 + box.child1.layout.content_height = 16 + box.child2 = Node(style=Style(), children=[]) + + box.node = Node(style=Style(), children=[box.child1, box.child2]) + box.node.layout.min_content_width = 5 + box.node.layout.content_width = 10 + box.node.layout.min_content_height = 8 + box.node.layout.content_height = 16 + + return box + + +def assert_layout(box, expected): + actual = { + "origin": (box._origin_left, box._origin_top), + "min_size": (box.min_width, box.min_height), + "size": (box.width, box.height), + "content": (box.content_width, box.content_height), + "relative": ( + box.content_top, + box.content_right, + box.content_bottom, + box.content_left, + ), + "absolute": ( + box.absolute_content_top, + box.absolute_content_right, + box.absolute_content_bottom, + box.absolute_content_left, + ), + } + assert actual == expected + + +def test_repr(box): + box.node.layout._origin_top = 1 + box.node.layout._origin_left = 2 + assert repr(box.node.layout) == "" + + +def test_initial(box): + # Core attributes have been stored + assert_layout( + box.node.layout, + { + "origin": (0, 0), + "min_size": (5, 8), + "size": (10, 16), + "content": (10, 16), + "relative": (0, 0, 0, 0), + "absolute": (0, 10, 16, 0), + }, + ) + + +@pytest.mark.parametrize( + "dimension, val1, expected1, val2, expected2", + [ + ( + "content_top", + 5, + { + "origin": (0, 0), + "min_size": (5, 13), + "size": (10, 21), + "content": (10, 16), + "relative": (5, 0, 0, 0), + "absolute": (5, 10, 21, 0), + }, + 7, + { + "origin": (0, 0), + "min_size": (5, 15), + "size": (10, 23), + "content": (10, 16), + "relative": (7, 0, 0, 0), + "absolute": (7, 10, 23, 0), + }, + ), + ( + "content_left", + 5, + { + "origin": (0, 0), + "min_size": (10, 8), + "size": (15, 16), + "content": (10, 16), + "relative": (0, 0, 0, 5), + "absolute": (0, 15, 16, 5), + }, + 7, + { + "origin": (0, 0), + "min_size": (12, 8), + "size": (17, 16), + "content": (10, 16), + "relative": (0, 0, 0, 7), + "absolute": (0, 17, 16, 7), + }, + ), + ( + "min_content_width", + 8, + { + "origin": (0, 0), + "min_size": (8, 8), + "size": (10, 16), + "content": (10, 16), + "relative": (0, 0, 0, 0), + "absolute": (0, 10, 16, 0), + }, + 9, + { + "origin": (0, 0), + "min_size": (9, 8), + "size": (10, 16), + "content": (10, 16), + "relative": (0, 0, 0, 0), + "absolute": (0, 10, 16, 0), + }, + ), + ( + "content_width", + 5, + { + "origin": (0, 0), + "min_size": (5, 8), + "size": (5, 16), + "content": (5, 16), + "relative": (0, 0, 0, 0), + "absolute": (0, 5, 16, 0), + }, + 7, + { + "origin": (0, 0), + "min_size": (5, 8), + "size": (7, 16), + "content": (7, 16), + "relative": (0, 0, 0, 0), + "absolute": (0, 7, 16, 0), + }, + ), + ( + "min_content_height", + 7, + { + "origin": (0, 0), + "min_size": (5, 7), + "size": (10, 16), + "content": (10, 16), + "relative": (0, 0, 0, 0), + "absolute": (0, 10, 16, 0), + }, + 8, + { + "origin": (0, 0), + "min_size": (5, 8), + "size": (10, 16), + "content": (10, 16), + "relative": (0, 0, 0, 0), + "absolute": (0, 10, 16, 0), + }, + ), + ( + "content_height", + 10, + { + "origin": (0, 0), + "min_size": (5, 8), + "size": (10, 10), + "content": (10, 10), + "relative": (0, 0, 0, 0), + "absolute": (0, 10, 10, 0), + }, + 12, + { + "origin": (0, 0), + "min_size": (5, 8), + "size": (10, 12), + "content": (10, 12), + "relative": (0, 0, 0, 0), + "absolute": (0, 10, 12, 0), + }, + ), + ], +) +def test_set_content_dimension(box, dimension, val1, expected1, val2, expected2): + setattr(box.node.layout, dimension, val1) + assert_layout(box.node.layout, expected1) + + # Set to a new value + setattr(box.node.layout, dimension, val2) + assert_layout(box.node.layout, expected2) + + +def test_descendent_offsets(box): + box.node.layout.content_top = 7 + box.node.layout.content_left = 8 + + box.child1.layout.content_top = 9 + box.child1.layout.content_left = 10 + + box.grandchild1_1.layout.content_top = 11 + box.grandchild1_1.layout.content_left = 12 + + assert_layout( + box.node.layout, + { + "origin": (0, 0), + "min_size": (13, 15), + "size": (18, 23), + "content": (10, 16), + "relative": (7, 0, 0, 8), + "absolute": (7, 18, 23, 8), + }, + ) + + assert_layout( + box.child1.layout, + { + "origin": (8, 7), + "min_size": (15, 17), + "size": (20, 25), + "content": (10, 16), + "relative": (9, 0, 0, 10), + "absolute": (16, 28, 32, 18), + }, + ) + + assert_layout( + box.grandchild1_1.layout, + { + "origin": (18, 16), + "min_size": (17, 19), + "size": (22, 27), + "content": (10, 16), + "relative": (11, 0, 0, 12), + "absolute": (27, 40, 43, 30), + }, + ) + + # Modify the grandchild position + box.grandchild1_1.layout.content_top = 13 + box.grandchild1_1.layout.content_left = 14 + + # Only the grandchild position has changed. + assert_layout( + box.node.layout, + { + "origin": (0, 0), + "min_size": (13, 15), + "size": (18, 23), + "content": (10, 16), + "relative": (7, 0, 0, 8), + "absolute": (7, 18, 23, 8), + }, + ) + + assert_layout( + box.child1.layout, + { + "origin": (8, 7), + "min_size": (15, 17), + "size": (20, 25), + "content": (10, 16), + "relative": (9, 0, 0, 10), + "absolute": (16, 28, 32, 18), + }, + ) + + assert_layout( + box.grandchild1_1.layout, + { + "origin": (18, 16), + "min_size": (19, 21), + "size": (24, 29), + "content": (10, 16), + "relative": (13, 0, 0, 14), + "absolute": (29, 42, 45, 32), + }, + ) + + # Modify the child position + box.child1.layout.content_top = 15 + box.child1.layout.content_left = 16 + + # The child and grandchild positions have changed. + assert_layout( + box.node.layout, + { + "origin": (0, 0), + "min_size": (13, 15), + "size": (18, 23), + "content": (10, 16), + "relative": (7, 0, 0, 8), + "absolute": (7, 18, 23, 8), + }, + ) + + assert_layout( + box.child1.layout, + { + "origin": (8, 7), + "min_size": (21, 23), + "size": (26, 31), + "content": (10, 16), + "relative": (15, 0, 0, 16), + "absolute": (22, 34, 38, 24), + }, + ) + + assert_layout( + box.grandchild1_1.layout, + { + "origin": (24, 22), + "min_size": (19, 21), + "size": (24, 29), + "content": (10, 16), + "relative": (13, 0, 0, 14), + "absolute": (35, 48, 51, 38), + }, + ) + + +def test_absolute_equalities(box): + # Move the box around and set some borders. + layout = box.node.layout + + layout.origin_top = 100 + layout.origin_left = 200 + + layout.content_top = 50 + layout.content_left = 75 + layout.content_right = 42 + layout.content_bottom = 37 + + assert ( + layout.absolute_content_left + layout.content_width + == layout.absolute_content_right + ) + assert ( + layout.absolute_content_top + layout.content_height + == layout.absolute_content_bottom + ) + + assert ( + layout.content_left + layout.content_width + layout.content_right + == layout.width + ) + assert ( + layout.content_top + layout.content_height + layout.content_bottom + == layout.height + ) diff --git a/travertino/tests/test_node.py b/travertino/tests/test_node.py new file mode 100644 index 0000000000..5eb92c8f30 --- /dev/null +++ b/travertino/tests/test_node.py @@ -0,0 +1,468 @@ +from unittest.mock import Mock +from warnings import catch_warnings, filterwarnings + +import pytest + +from travertino.declaration import BaseStyle, Choices, validated_property +from travertino.layout import BaseBox, Viewport +from travertino.node import Node +from travertino.size import BaseIntrinsicSize + +from .utils import mock_attr, prep_style_class + + +@prep_style_class +@mock_attr("reapply") +class Style(BaseStyle): + int_prop: int = validated_property(Choices(integer=True)) + + class IntrinsicSize(BaseIntrinsicSize): + pass + + class Box(BaseBox): + pass + + def layout(self, viewport): + # A simple layout scheme that allocates twice the viewport size. + self._applicator.node.layout.content_width = viewport.width * 2 + self._applicator.node.layout.content_height = viewport.height * 2 + + +@prep_style_class +class OldStyle(Style): + # Uses two-argument layout(), as in Toga <= 0.4.8 + def layout(self, node, viewport): + # A simple layout scheme that allocates twice the viewport size. + super().layout(viewport) + + +@prep_style_class +class TypeErrorStyle(Style): + # Uses the correct signature, but raises an unrelated TypeError in layout + def layout(self, viewport): + raise TypeError("An unrelated TypeError has occurred somewhere in layout()") + + +@prep_style_class +class OldTypeErrorStyle(Style): + # Just to be extra safe... + def layout(self, node, viewport): + raise TypeError("An unrelated TypeError has occurred somewhere in layout()") + + +@prep_style_class +class BrokenStyle(BaseStyle): + def reapply(self): + raise AttributeError("Missing attribute, node not ready for style application") + + class IntrinsicSize(BaseIntrinsicSize): + pass + + class Box(BaseBox): + pass + + def layout(self, viewport): + # A simple layout scheme that allocates twice the viewport size. + self._applicator.node.layout.content_width = viewport.width * 2 + self._applicator.node.layout.content_height = viewport.height * 2 + + +class AttributeTestStyle(BaseStyle): + class IntrinsicSize(BaseIntrinsicSize): + pass + + class Box(BaseBox): + pass + + def reapply(self): + assert self._applicator.node.style is self + + +def test_create_leaf(): + """A leaf can be created""" + style = Style() + leaf = Node(style=style) + + assert leaf._children is None + assert leaf.children == [] + assert not leaf.can_have_children + + # An unattached leaf is a root + assert leaf.parent is None + assert leaf.root == leaf + + # A leaf can't have children + child = Node(style=style) + + with pytest.raises(ValueError): + leaf.add(child) + + +def test_create_node(): + """A node can be created with children""" + style = Style() + + child1 = Node(style=style) + child2 = Node(style=style) + child3 = Node(style=style) + + node = Node(style=style, children=[child1, child2, child3]) + + assert node.children == [child1, child2, child3] + assert node.can_have_children + + # The node is the root as well. + assert node.parent is None + assert node.root == node + + # The children all point at the node. + assert child1.parent == node + assert child1.root == node + + assert child2.parent == node + assert child2.root == node + + assert child3.parent == node + assert child3.root == node + + # Create another node + new_node = Node(style=style, children=[]) + + assert new_node.children == [] + assert new_node.can_have_children + + # Add the old node as a child of the new one. + new_node.add(node) + + # The new node is the root + assert new_node.parent is None + assert new_node.root == new_node + + # The node is the root as well. + assert node.parent == new_node + assert node.root == new_node + + # The children all point at the node. + assert child1.parent == node + assert child1.root == new_node + + assert child2.parent == node + assert child2.root == new_node + + assert child3.parent == node + assert child3.root == new_node + + +@pytest.mark.parametrize("StyleClass", [Style, OldStyle]) +def test_refresh(StyleClass): + """The layout can be refreshed, and the applicator invoked""" + + # Define an applicator that tracks the node being rendered and its size + class Applicator: + def __init__(self, node): + self.tasks = [] + self.node = node + + def set_bounds(self): + self.tasks.append( + ( + self.node, + self.node.layout.content_width, + self.node.layout.content_height, + ) + ) + + class TestNode(Node): + def __init__(self, style, children=None): + super().__init__( + style=style, applicator=Applicator(self), children=children + ) + + # Define a simple 2 level tree of nodes. + style = StyleClass() + child1 = TestNode(style=style) + child2 = TestNode(style=style) + child3 = TestNode(style=style) + + node = TestNode(style=style, children=[child1, child2, child3]) + + # Refresh the root node + node.refresh(Viewport(width=10, height=20)) + + # Check the output is as expected + assert node.applicator.tasks == [(node, 20, 40)] + assert child1.applicator.tasks == [] + assert child2.applicator.tasks == [] + assert child3.applicator.tasks == [] + + # Reset the applicator + node.applicator.tasks = [] + + # Refresh a child node + child1.refresh(Viewport(width=15, height=25)) + + # The root node was rendered, not the child. + assert node.applicator.tasks == [(node, 30, 50)] + assert child1.applicator.tasks == [] + assert child2.applicator.tasks == [] + assert child3.applicator.tasks == [] + + +@pytest.mark.parametrize("StyleClass", [TypeErrorStyle, OldTypeErrorStyle]) +def test_type_error_in_layout(StyleClass): + """The shim shouldn't hide unrelated TypeErrors.""" + + class Applicator: + def set_bounds(self): + pass + + node = Node(style=StyleClass(), applicator=Applicator()) + with pytest.raises(TypeError, match=r"unrelated TypeError"): + node.refresh(Viewport(50, 50)) + + +def test_add(): + """Nodes can be added as children to another node""" + + style = Style() + node = Node(style=style, children=[]) + + child = Node(style=style) + node.add(child) + + assert child in node.children + assert child.parent == node + assert child.root == node.root + + +def test_insert(): + """Node can be inserted at a specific position as a child""" + + style = Style() + child1 = Node(style=style) + child2 = Node(style=style) + child3 = Node(style=style) + node = Node(style=style, children=[child1, child2, child3]) + + child4 = Node(style=style) + + index = 2 + node.insert(index, child4) + + assert child4 in node.children + assert child4.parent == node + assert child4.root == node.root + + assert node.children.index(child4) == index + + +def test_remove(): + """Children can be removed from node""" + + style = Style() + child1 = Node(style=style) + child2 = Node(style=style) + child3 = Node(style=style) + node = Node(style=style, children=[child1, child2, child3]) + + node.remove(child1) + + assert child1 not in node.children + assert child1.parent is None + assert child1.root == child1 + + +def test_clear(): + """Node can be inserted at a specific position as a child""" + style = Style() + children = [Node(style=style), Node(style=style), Node(style=style)] + node = Node(style=style, children=children) + + for child in children: + assert child in node.children + assert child.parent == node + assert child.root == node + assert node.children == children + + node.clear() + + for child in children: + assert child not in node.children + assert child.parent is None + assert child.root == child + + assert node.children == [] + + +def test_create_with_no_applicator(): + """A node can be created without an applicator.""" + style = Style(int_prop=5) + node = Node(style=style) + + # Style copies on assignment. + assert isinstance(node.style, Style) + assert node.style == style + assert node.style is not style + + # Since no applicator has been assigned, style wasn't applied. + node.style.reapply.assert_not_called() + + +def test_create_with_applicator(): + """A node can be created with an applicator.""" + style = Style(int_prop=5) + applicator = Mock() + node = Node(style=style, applicator=applicator) + + # Style copies on assignment. + assert isinstance(node.style, Style) + assert node.style == style + assert node.style is not style + + # Applicator assignment does *not* copy. + assert node.applicator is applicator + # Applicator gets a reference back to its node and to the style. + assert applicator.node is node + assert node.style._applicator is applicator + + # Assigning a non-None applicator should always apply style. + node.style.reapply.assert_called_once() + + +@pytest.mark.parametrize( + "node", + [ + Node(style=Style()), + Node(style=Style(), applicator=Mock()), + ], +) +def test_assign_applicator(node): + """A node can be assigned an applicator after creation.""" + node.style.reapply.reset_mock() + + applicator = Mock() + node.applicator = applicator + + # Applicator assignment does *not* copy. + assert node.applicator is applicator + # Applicator gets a reference back to its node and to the style. + assert applicator.node is node + assert node.style._applicator is applicator + + # Assigning a non-None applicator should always apply style. + node.style.reapply.assert_called_once() + + +@pytest.mark.parametrize( + "node", + [ + Node(style=Style()), + Node(style=Style(), applicator=Mock()), + ], +) +def test_assign_applicator_none(node): + """A node can have its applicator set to None.""" + node.style.reapply.reset_mock() + + node.applicator = None + assert node.applicator is None + + # Should be updated on style as well + assert node.style._applicator is None + # Assigning None to applicator does not trigger reapply. + node.style.reapply.assert_not_called() + + +def assign_new_applicator(): + """Assigning a new applicator clears reference to node on the old applicator.""" + applicator_1 = Mock() + node = Node(style=Style(), applicator=applicator_1) + + assert applicator_1.node is node + + applicator_2 = Mock() + node.applicator = applicator_2 + + assert applicator_1.node is None + assert applicator_2.node is node + + +def assign_new_applicator_none(): + """Assigning None to applicator clears reference to node on the old applicator.""" + applicator = Mock() + node = Node(style=Style(), applicator=applicator) + + assert applicator.node is node + + node.applicator = None + + assert applicator.node is None + + +def test_assign_style_with_applicator(): + """Assigning a new style triggers a reapply if an applicator is already present.""" + style_1 = Style(int_prop=5) + node = Node(style=style_1, applicator=Mock()) + + node.style.reapply.reset_mock() + style_2 = Style(int_prop=10) + node.style = style_2 + + # Style copies on assignment. + assert isinstance(node.style, Style) + assert node.style == style_2 + assert node.style is not style_2 + + assert node.style != style_1 + + # Since an applicator has already been assigned, assigning style applies the style. + node.style.reapply.assert_called_once() + + +def test_assign_style_with_no_applicator(): + """Assigning new style doesn't trigger a reapply if an applicator isn' present.""" + style_1 = Style(int_prop=5) + node = Node(style=style_1) + + node.style.reapply.reset_mock() + style_2 = Style(int_prop=10) + node.style = style_2 + + # Style copies on assignment. + assert isinstance(node.style, Style) + assert node.style == style_2 + assert node.style is not style_2 + + assert node.style != style_1 + + # Since no applicator was present, style should not be applied. + node.style.reapply.assert_not_called() + + +def test_apply_before_node_is_ready(): + """Triggering a reapply raises a warning if the node is not ready to apply style.""" + style = BrokenStyle() + applicator = Mock() + + with pytest.warns(RuntimeWarning): + node = Node(style=style) + node.applicator = applicator + + with pytest.warns(RuntimeWarning): + node.style = BrokenStyle() + + with pytest.warns(RuntimeWarning): + Node(style=style, applicator=applicator) + + +def test_applicator_has_node_reference(): + """Applicator should have a reference to its node before style is first applied.""" + + # We can't just check it after creating the widget, because at that point the + # reapply will have already happened. AttributeTestStyle has a reapply() method + # that asserts the reference trail of style -> applicator -> node -> style is + # already intact at the point that reapply is called. + + with catch_warnings(): + filterwarnings("error", category=RuntimeWarning) + Node(style=AttributeTestStyle(), applicator=Mock()) diff --git a/travertino/tests/test_size.py b/travertino/tests/test_size.py new file mode 100644 index 0000000000..ebe4cf2b96 --- /dev/null +++ b/travertino/tests/test_size.py @@ -0,0 +1,138 @@ +from typing import NamedTuple +from unittest.mock import Mock + +import pytest + +from travertino.size import BaseIntrinsicSize, at_least + + +class Size(NamedTuple): + width: int + height: int + ratio: float + + def change(self, dimension, value): + return self._replace(**{dimension: value}) + + +BASE_SIZE = Size(width=1, height=2, ratio=0.1) + + +class TestBox: + pass + + +@pytest.fixture +def box(): + box = TestBox() + + box.maxDiff = None + + box.layout = Mock() + box.size = BaseIntrinsicSize(layout=box.layout) + box.size._width, box.size._height, box.size._ratio = BASE_SIZE + + assert_size(box.size, BASE_SIZE) + + return box + + +def assert_size(size, values): + assert (size.width, size.height, size.ratio) == values + + +def test_at_least_repr(): + assert repr(at_least(10)) == "at least 10" + + +def test_size_repr(box): + assert repr(box.size) == "(1, 2)" + box.size.width = at_least(10) + assert repr(box.size) == "(at least 10, 2)" + + +@pytest.mark.parametrize("dimension", ["width", "height"]) +def test_set_dimension(box, dimension): + setattr(box.size, dimension, 10) + assert_size(box.size, BASE_SIZE.change(dimension, 10)) + + # Layout has been dirtied. + box.layout.dirty.assert_called_once_with(**{f"intrinsic_{dimension}": 10}) + + # Clean the layout + box.layout.dirty.reset_mock() + + # Set the width to the same value + setattr(box.size, dimension, 10) + assert_size(box.size, BASE_SIZE.change(dimension, 10)) + + # Layout has NOT been dirtied. + box.layout.dirty.assert_not_called() + + # Set the width to something new + setattr(box.size, dimension, 20) + assert_size(box.size, BASE_SIZE.change(dimension, 20)) + + # Layout has been dirtied. + box.layout.dirty.assert_called_once_with(**{f"intrinsic_{dimension}": 20}) + + +@pytest.mark.parametrize("dimension", ["width", "height"]) +def test_set_dimension_at_least(box, dimension): + setattr(box.size, dimension, at_least(10)) + assert_size(box.size, BASE_SIZE.change(dimension, at_least(10))) + + # Layout has been dirtied. + box.layout.dirty.assert_called_once_with(**{f"intrinsic_{dimension}": at_least(10)}) + + # Clean the layout + box.layout.dirty.reset_mock() + + # Set the width to the same value + setattr(box.size, dimension, at_least(10)) + assert_size(box.size, BASE_SIZE.change(dimension, at_least(10))) + + # Layout has NOT been dirtied. + box.layout.dirty.assert_not_called() + + # Set the width to the same value, but not as a minimum + setattr(box.size, dimension, 10) + assert_size(box.size, BASE_SIZE.change(dimension, 10)) + + # Layout has been dirtied. + box.layout.dirty.assert_called_once_with(**{f"intrinsic_{dimension}": 10}) + + # Clean the layout + box.layout.dirty.reset_mock() + + # Set the width to something new + setattr(box.size, dimension, at_least(20)) + assert_size(box.size, BASE_SIZE.change(dimension, at_least(20))) + + # Layout has been dirtied. + box.layout.dirty.assert_called_once_with(**{f"intrinsic_{dimension}": at_least(20)}) + + +def test_set_ratio(box): + box.size.ratio = 0.5 + assert_size(box.size, (1, 2, 0.5)) + + # Layout has been dirtied. + box.layout.dirty.assert_called_once_with(intrinsic_ratio=0.5) + + # Clean the layout + box.layout.dirty.reset_mock() + + # Set the ratio to the same value + box.size.ratio = 0.5 + assert_size(box.size, (1, 2, 0.5)) + + # Layout has NOT been dirtied. + box.layout.dirty.assert_not_called() + + # Set the ratio to something else + box.size.ratio = 0.75 + assert_size(box.size, (1, 2, 0.75)) + + # Layout has been dirtied. + box.layout.dirty.assert_called_once_with(intrinsic_ratio=0.75) diff --git a/travertino/tests/utils.py b/travertino/tests/utils.py new file mode 100644 index 0000000000..0649494db8 --- /dev/null +++ b/travertino/tests/utils.py @@ -0,0 +1,29 @@ +import sys +from dataclasses import dataclass +from unittest.mock import Mock + +if sys.version_info < (3, 10): + _DATACLASS_KWARGS = {"init": False} +else: + _DATACLASS_KWARGS = {"kw_only": True} + + +def prep_style_class(cls): + """Decorator to apply dataclass and mock apply.""" + return mock_attr("apply")(dataclass(**_DATACLASS_KWARGS)(cls)) + + +def mock_attr(attr): + """Mock an arbitrary attribute of a class.""" + + def returned_decorator(cls): + orig_init = cls.__init__ + + def __init__(self, *args, **kwargs): + setattr(self, attr, Mock()) + orig_init(self, *args, **kwargs) + + cls.__init__ = __init__ + return cls + + return returned_decorator