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