Slide Transitions (#207)
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 132 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@ -0,0 +1,101 @@
|
|||||||
|
# Slide Transitions
|
||||||
|
|
||||||
|
!!! warning "Under construction!"
|
||||||
|
|
||||||
|
Transitions are a new and experiment feature in Spiel
|
||||||
|
and the interface might change dramatically from version to version.
|
||||||
|
If you plan on using transitions, we recommend pinning the
|
||||||
|
exact version of Spiel your presentation was developed in to ensure stability.
|
||||||
|
|
||||||
|
## Setting Transitions
|
||||||
|
|
||||||
|
To set the default transition for the entire deck,
|
||||||
|
which will be used if a slide does not override it,
|
||||||
|
set [`Deck.default_transition`][spiel.Deck.default_transition] to
|
||||||
|
a **type** that implements the [`Transition`][spiel.Transition]
|
||||||
|
protocol.
|
||||||
|
|
||||||
|
For example, the default transition is [`Swipe`][spiel.Swipe],
|
||||||
|
so not passing `default_transition` at all is equivalent to
|
||||||
|
|
||||||
|
```python
|
||||||
|
from spiel import Deck, Swipe
|
||||||
|
|
||||||
|
deck = Deck(name=f"Spiel Demo Deck", default_transition=Swipe)
|
||||||
|
```
|
||||||
|
|
||||||
|
To override the deck-wide default for an individual slide,
|
||||||
|
specify the transition type in the [`@slide`][spiel.Deck.slide] decorator:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from spiel import Deck, Swipe
|
||||||
|
|
||||||
|
deck = Deck(name=f"Spiel Demo Deck")
|
||||||
|
|
||||||
|
@deck.slide(title="My Title", transition=Swipe)
|
||||||
|
def slide():
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, in the arguments to [`Slide`][spiel.Slide]:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from spiel import Slide, Swipe
|
||||||
|
|
||||||
|
slide = Slide(title="My Title", transition=Swipe)
|
||||||
|
```
|
||||||
|
|
||||||
|
In either case, the specified transition will be used when
|
||||||
|
transitioning **to** that slide.
|
||||||
|
It does not matter whether the slide is the "next" or "previous"
|
||||||
|
slide: the slide being moved to determines which transition
|
||||||
|
effect will be used.
|
||||||
|
|
||||||
|
## Disabling Transitions
|
||||||
|
|
||||||
|
In any of the above examples, you can also set `default_transition`/`transition` to `None`.
|
||||||
|
In that case, there will be no transition effect when moving to the slide;
|
||||||
|
it will just be displayed on the next render, already in-place.
|
||||||
|
|
||||||
|
## Writing Custom Transitions
|
||||||
|
|
||||||
|
To implement your own custom transition, you must write a class which implements
|
||||||
|
the [`Transition`][spiel.Transition] [protocol](https://docs.python.org/3/library/typing.html#typing.Protocol).
|
||||||
|
|
||||||
|
The protocol is:
|
||||||
|
|
||||||
|
```python title="Transition Protocol"
|
||||||
|
--8<-- "../spiel/transitions/protocol.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
As an example, consider the [`Swipe`][spiel.Swipe] transition included in Spiel:
|
||||||
|
|
||||||
|
```python title="Swipe Transition"
|
||||||
|
--8<-- "../spiel/transitions/swipe.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
The transition effect is implemented using
|
||||||
|
[Textual CSS styles](https://textual.textualize.io/styles/)
|
||||||
|
on the [widgets](https://textual.textualize.io/guide/widgets/)
|
||||||
|
that represent the "from" and "to" widgets.
|
||||||
|
|
||||||
|
Because the slide widgets are on [different layers](https://textual.textualize.io/styles/layers/),
|
||||||
|
they would normally both try to render in the "upper left corner" of the screen,
|
||||||
|
and since the `from` slide is on the upper layer, it would be the one that actually gets rendered.
|
||||||
|
|
||||||
|
In `Swipe.initialize`, the `to` widget is moved to either the left or the right
|
||||||
|
(depending on the transition direction) by `100%`, i.e., it's own width.
|
||||||
|
This puts the slides side-by-side, with the `to` slide fully off-screen.
|
||||||
|
|
||||||
|
As the transition progresses, the horizontal offsets of the two widgets are adjusted in lockstep
|
||||||
|
so that they appear to move across the screen.
|
||||||
|
Again, the direction of offset adjustment depends on the transition direction.
|
||||||
|
The absolute value of the horizontal offsets always sums to `100%`, which keeps the slides glued together
|
||||||
|
as they move across the screen.
|
||||||
|
|
||||||
|
When `progress=100` in the final state, the `to` widget will be at zero horizontal offset,
|
||||||
|
and the `from` widget will be at plus or minus `100%`, fully moved off-screen.
|
||||||
|
|
||||||
|
!!! tip "Contribute your transitions!"
|
||||||
|
|
||||||
|
If you have developed a cool transition, consider [contributing it to Spiel](./contributing.md)!
|
@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.reactive import reactive
|
||||||
|
|
||||||
|
from spiel.screens.screen import SpielScreen
|
||||||
|
from spiel.slide import Slide
|
||||||
|
from spiel.transitions.protocol import Direction, Transition
|
||||||
|
from spiel.triggers import Triggers
|
||||||
|
from spiel.widgets.fixed_slide import FixedSlideWidget
|
||||||
|
from spiel.widgets.footer import Footer
|
||||||
|
|
||||||
|
|
||||||
|
class SlideTransitionScreen(SpielScreen):
|
||||||
|
DEFAULT_CSS = """\
|
||||||
|
SlideTransitionScreen {
|
||||||
|
layout: vertical;
|
||||||
|
overflow: hidden hidden;
|
||||||
|
layers: below above;
|
||||||
|
}
|
||||||
|
|
||||||
|
FixedSlideWidget#from {
|
||||||
|
layer: above;
|
||||||
|
}
|
||||||
|
|
||||||
|
FixedSlideWidget#to {
|
||||||
|
layer: below;
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer {
|
||||||
|
layer: above;
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer#dummy {
|
||||||
|
layer: below;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
progress = reactive(0, init=False, layout=True)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
from_slide: Slide,
|
||||||
|
from_triggers: Triggers,
|
||||||
|
to_slide: Slide,
|
||||||
|
transition: Type[Transition],
|
||||||
|
direction: Direction,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.from_slide = from_slide
|
||||||
|
self.from_triggers = from_triggers
|
||||||
|
self.to_slide = to_slide
|
||||||
|
self.transition = transition()
|
||||||
|
self.direction = direction
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
from_widget = FixedSlideWidget(self.from_slide, triggers=self.from_triggers, id="from")
|
||||||
|
to_widget = FixedSlideWidget(self.to_slide, id="to")
|
||||||
|
|
||||||
|
self.transition.initialize(
|
||||||
|
from_widget=from_widget,
|
||||||
|
to_widget=to_widget,
|
||||||
|
direction=self.direction,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield from_widget
|
||||||
|
yield to_widget
|
||||||
|
|
||||||
|
yield Footer()
|
||||||
|
yield Footer(
|
||||||
|
id="dummy"
|
||||||
|
) # a dummy footer to hold space on the "below" layer, won't be displayed
|
||||||
|
|
||||||
|
def watch_progress(self, new_progress: float) -> None:
|
||||||
|
from_widget = self.query_one("#from")
|
||||||
|
to_widget = self.query_one("#to")
|
||||||
|
|
||||||
|
self.transition.progress(
|
||||||
|
from_widget=from_widget,
|
||||||
|
to_widget=to_widget,
|
||||||
|
direction=self.direction,
|
||||||
|
progress=new_progress,
|
||||||
|
)
|
@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
|
||||||
|
class Direction(Enum):
|
||||||
|
"""
|
||||||
|
An enumeration that describes which direction a slide transition
|
||||||
|
animation should move in: whether we're going to the next slide,
|
||||||
|
or to the previous slide.
|
||||||
|
"""
|
||||||
|
|
||||||
|
Next = "next"
|
||||||
|
"""Indicates that the transition should handle going to the next slide."""
|
||||||
|
|
||||||
|
Previous = "previous"
|
||||||
|
"""Indicates that the transition should handle going to the previous slide."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Transition(Protocol):
|
||||||
|
"""
|
||||||
|
A protocol that describes how to implement a transition animation.
|
||||||
|
|
||||||
|
See [Writing Custom Transitions](./transitions.md#writing-custom-transitions)
|
||||||
|
for more details on how to implement the protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
self,
|
||||||
|
from_widget: Widget,
|
||||||
|
to_widget: Widget,
|
||||||
|
direction: Direction,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
A hook function to set up any CSS that should be present at the start of the transition.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_widget: The widget showing the slide that we are leaving.
|
||||||
|
to_widget: The widget showing the slide that we are entering.
|
||||||
|
direction: The desired direction of the transition animation.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def progress(
|
||||||
|
self,
|
||||||
|
from_widget: Widget,
|
||||||
|
to_widget: Widget,
|
||||||
|
direction: Direction,
|
||||||
|
progress: float,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
A hook function that is called each time the `progress`
|
||||||
|
of the transition animation updates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_widget: The widget showing the slide that we are leaving.
|
||||||
|
to_widget: The widget showing the slide that we are entering.
|
||||||
|
direction: The desired direction of the transition animation.
|
||||||
|
progress: The progress of the animation, as a percentage
|
||||||
|
(e.g., initial state is `0`, final state is `100`).
|
||||||
|
Note that this is **not necessarily** bounded between `0` and `100`,
|
||||||
|
nor is it necessarily [monotonically increasing](https://en.wikipedia.org/wiki/Monotonic_function),
|
||||||
|
depending on the underlying Textual animation easing function,
|
||||||
|
which may overshoot or bounce.
|
||||||
|
However, it will always start at `0` and end at `100`,
|
||||||
|
no matter which `direction` the transition should move in.
|
||||||
|
"""
|
||||||
|
...
|
@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from spiel.transitions.protocol import Direction, Transition
|
||||||
|
|
||||||
|
|
||||||
|
class Swipe(Transition):
|
||||||
|
"""
|
||||||
|
A transition where the current and incoming slide are placed side-by-side
|
||||||
|
and gradually slide across the screen,
|
||||||
|
with the current slide leaving and the incoming slide entering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
self,
|
||||||
|
from_widget: Widget,
|
||||||
|
to_widget: Widget,
|
||||||
|
direction: Direction,
|
||||||
|
) -> None:
|
||||||
|
to_widget.styles.offset = ("100%" if direction is Direction.Next else "-100%", 0)
|
||||||
|
|
||||||
|
def progress(
|
||||||
|
self,
|
||||||
|
from_widget: Widget,
|
||||||
|
to_widget: Widget,
|
||||||
|
direction: Direction,
|
||||||
|
progress: float,
|
||||||
|
) -> None:
|
||||||
|
match direction:
|
||||||
|
case Direction.Next:
|
||||||
|
from_widget.styles.offset = (f"-{progress:.2f}%", 0)
|
||||||
|
to_widget.styles.offset = (f"{100 - progress:.2f}%", 0)
|
||||||
|
case Direction.Previous:
|
||||||
|
from_widget.styles.offset = (f"{progress:.2f}%", 0)
|
||||||
|
to_widget.styles.offset = (f"-{100 - progress:.2f}%", 0)
|
@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from rich.box import HEAVY
|
||||||
|
from rich.console import RenderableType
|
||||||
|
from rich.errors import NotRenderableError
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.protocol import is_renderable
|
||||||
|
from rich.style import Style
|
||||||
|
from rich.traceback import Traceback
|
||||||
|
|
||||||
|
import spiel
|
||||||
|
from spiel.exceptions import SpielException
|
||||||
|
from spiel.slide import Slide
|
||||||
|
from spiel.triggers import Triggers
|
||||||
|
from spiel.widgets.widget import SpielWidget
|
||||||
|
|
||||||
|
|
||||||
|
class FixedSlideWidget(SpielWidget):
|
||||||
|
def __init__(self, slide: Slide, triggers: Triggers | None = None, id: str | None = None):
|
||||||
|
super().__init__(id=id)
|
||||||
|
|
||||||
|
self.slide = slide
|
||||||
|
self.triggers = triggers or Triggers.new()
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
try:
|
||||||
|
self.remove_class("error")
|
||||||
|
r = self.slide.render(triggers=self.triggers)
|
||||||
|
if is_renderable(r):
|
||||||
|
return r
|
||||||
|
else:
|
||||||
|
raise NotRenderableError(f"object {r!r} is not renderable")
|
||||||
|
except Exception:
|
||||||
|
self.add_class("error")
|
||||||
|
et, ev, tr = sys.exc_info()
|
||||||
|
if et is None or ev is None or tr is None:
|
||||||
|
raise SpielException("Expected to be handling an exception, but wasn't.")
|
||||||
|
return Panel(
|
||||||
|
Traceback.from_exception(
|
||||||
|
exc_type=et,
|
||||||
|
exc_value=ev,
|
||||||
|
traceback=tr,
|
||||||
|
suppress=(spiel,),
|
||||||
|
),
|
||||||
|
title="Slide content failed to render",
|
||||||
|
border_style=Style(bold=True, color="red1"),
|
||||||
|
box=HEAVY,
|
||||||
|
)
|
@ -0,0 +1,14 @@
|
|||||||
|
tests:
|
||||||
|
@watch spiel/ tests/ docs/
|
||||||
|
|
||||||
|
pytest
|
||||||
|
|
||||||
|
types:
|
||||||
|
@watch spiel/ tests/ docs/
|
||||||
|
|
||||||
|
mypy
|
||||||
|
|
||||||
|
docs:
|
||||||
|
@restart
|
||||||
|
|
||||||
|
mkdocs serve --strict
|
@ -1,54 +1,10 @@
|
|||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pytest_mock import MockFixture
|
from pytest_mock import MockFixture
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from spiel.cli import cli
|
from spiel.cli import cli
|
||||||
from spiel.constants import DEMO_FILE, PACKAGE_NAME, __version__
|
|
||||||
|
|
||||||
|
|
||||||
def test_help(runner: CliRunner) -> None:
|
|
||||||
result = runner.invoke(cli, ["--help"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_help_via_main() -> None:
|
|
||||||
result = subprocess.run([sys.executable, "-m", PACKAGE_NAME, "--help"])
|
|
||||||
|
|
||||||
print(result.stdout)
|
|
||||||
assert result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_version(runner: CliRunner) -> None:
|
|
||||||
result = runner.invoke(cli, ["version"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert __version__ in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_plain_version(runner: CliRunner) -> None:
|
|
||||||
result = runner.invoke(cli, ["version", "--plain"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert __version__ in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_present_deck_on_missing_file(runner: CliRunner, tmp_path: Path) -> None:
|
|
||||||
result = runner.invoke(cli, ["present", str(tmp_path / "missing.py")])
|
|
||||||
|
|
||||||
assert result.exit_code == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("stdin", [""])
|
|
||||||
def test_display_demo_deck(runner: CliRunner, stdin: str) -> None:
|
|
||||||
result = runner.invoke(cli, ["present", str(DEMO_FILE)], input=stdin)
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_demo_display(runner: CliRunner) -> None:
|
def test_demo_display(runner: CliRunner) -> None:
|
@ -0,0 +1,20 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from spiel.cli import cli
|
||||||
|
from spiel.constants import PACKAGE_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def test_help(runner: CliRunner) -> None:
|
||||||
|
result = runner.invoke(cli, ["--help"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_via_main() -> None:
|
||||||
|
result = subprocess.run([sys.executable, "-m", PACKAGE_NAME, "--help"])
|
||||||
|
|
||||||
|
print(result.stdout)
|
||||||
|
assert result.returncode == 0
|
@ -0,0 +1,20 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from spiel.cli import cli
|
||||||
|
from spiel.constants import DEMO_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def test_present_on_missing_file(runner: CliRunner, tmp_path: Path) -> None:
|
||||||
|
result = runner.invoke(cli, ["present", str(tmp_path / "missing.py")])
|
||||||
|
|
||||||
|
assert result.exit_code == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stdin", [""])
|
||||||
|
def test_display_demo(runner: CliRunner, stdin: str) -> None:
|
||||||
|
result = runner.invoke(cli, ["present", str(DEMO_FILE)], input=stdin)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
@ -0,0 +1,18 @@
|
|||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from spiel import __version__
|
||||||
|
from spiel.cli import cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_version(runner: CliRunner) -> None:
|
||||||
|
result = runner.invoke(cli, ["version"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert __version__ in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_version(runner: CliRunner) -> None:
|
||||||
|
result = runner.invoke(cli, ["version", "--plain"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert __version__ in result.stdout
|
@ -1,34 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
from spiel.app import load_deck
|
|
||||||
from spiel.cli import cli
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_cli_command_fails_if_file_exists(runner: CliRunner, tmp_path: Path) -> None:
|
|
||||||
target = tmp_path / "foo_bar.py"
|
|
||||||
target.touch()
|
|
||||||
|
|
||||||
result = runner.invoke(cli, ["init", str(target)])
|
|
||||||
|
|
||||||
assert result.exit_code == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def init_file(runner: CliRunner, tmp_path: Path) -> Path:
|
|
||||||
target = tmp_path / "foo_bar.py"
|
|
||||||
runner.invoke(cli, ["init", str(target)])
|
|
||||||
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def test_title_slide_header_injection(init_file: Path) -> None:
|
|
||||||
assert "# Foo Bar" in init_file.read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_load_init_file(init_file: Path) -> None:
|
|
||||||
deck = load_deck(init_file)
|
|
||||||
|
|
||||||
assert deck.name == "Foo Bar"
|
|
@ -0,0 +1,12 @@
|
|||||||
|
import pytest
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def from_widget() -> Widget:
|
||||||
|
return Widget()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def to_widget() -> Widget:
|
||||||
|
return Widget()
|
@ -0,0 +1,121 @@
|
|||||||
|
import pytest
|
||||||
|
from hypothesis import HealthCheck, given, settings
|
||||||
|
from hypothesis.strategies import floats
|
||||||
|
from textual.css.scalar import Scalar, ScalarOffset, Unit
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from spiel import Direction, Swipe, Transition
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def transition() -> Swipe:
|
||||||
|
return Swipe()
|
||||||
|
|
||||||
|
|
||||||
|
Y = Scalar.parse("0", percent_unit=Unit.HEIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"direction, to_offset",
|
||||||
|
[
|
||||||
|
(Direction.Next, ScalarOffset(Scalar.parse("100%"), Y)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_swipe_initialize(
|
||||||
|
from_widget: Widget,
|
||||||
|
to_widget: Widget,
|
||||||
|
direction: Direction,
|
||||||
|
to_offset: tuple[object, object],
|
||||||
|
) -> None:
|
||||||
|
Swipe().initialize(from_widget=from_widget, to_widget=to_widget, direction=direction)
|
||||||
|
|
||||||
|
assert to_widget.styles.offset == to_offset
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"progress, direction, from_offset, to_offset",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
0,
|
||||||
|
Direction.Next,
|
||||||
|
ScalarOffset(Scalar.parse("-0%"), Y),
|
||||||
|
ScalarOffset(Scalar.parse("100%"), Y),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
25,
|
||||||
|
Direction.Next,
|
||||||
|
ScalarOffset(Scalar.parse("-25%"), Y),
|
||||||
|
ScalarOffset(Scalar.parse("75%"), Y),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
50,
|
||||||
|
Direction.Next,
|
||||||
|
ScalarOffset(Scalar.parse("-50%"), Y),
|
||||||
|
ScalarOffset(Scalar.parse("50%"), Y),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
75,
|
||||||
|
Direction.Next,
|
||||||
|
ScalarOffset(Scalar.parse("-75%"), Y),
|
||||||
|
ScalarOffset(Scalar.parse("25%"), Y),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
75.123,
|
||||||
|
Direction.Next,
|
||||||
|
ScalarOffset(Scalar.parse("-75.12%"), Y),
|
||||||
|
ScalarOffset(Scalar.parse("24.88%"), Y),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
75.126,
|
||||||
|
Direction.Next,
|
||||||
|
ScalarOffset(Scalar.parse("-75.13%"), Y),
|
||||||
|
ScalarOffset(Scalar.parse("24.87%"), Y),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
100,
|
||||||
|
Direction.Next,
|
||||||
|
ScalarOffset(Scalar.parse("-100%"), Y),
|
||||||
|
ScalarOffset(Scalar.parse("0%"), Y),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_swipe_progress(
|
||||||
|
transition: Transition,
|
||||||
|
from_widget: Widget,
|
||||||
|
to_widget: Widget,
|
||||||
|
progress: float,
|
||||||
|
direction: Direction,
|
||||||
|
from_offset: tuple[object, object],
|
||||||
|
to_offset: tuple[object, object],
|
||||||
|
) -> None:
|
||||||
|
transition.initialize(from_widget=from_widget, to_widget=to_widget, direction=direction)
|
||||||
|
|
||||||
|
transition.progress(
|
||||||
|
from_widget=from_widget,
|
||||||
|
to_widget=to_widget,
|
||||||
|
direction=direction,
|
||||||
|
progress=progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert from_widget.styles.offset == from_offset
|
||||||
|
assert to_widget.styles.offset == to_offset
|
||||||
|
|
||||||
|
|
||||||
|
@given(progress=floats(min_value=0, max_value=100))
|
||||||
|
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||||
|
def test_swipe_progress_always_balances_for_right(
|
||||||
|
transition: Transition,
|
||||||
|
from_widget: Widget,
|
||||||
|
to_widget: Widget,
|
||||||
|
progress: float,
|
||||||
|
) -> None:
|
||||||
|
transition.initialize(from_widget=from_widget, to_widget=to_widget, direction=Direction.Next)
|
||||||
|
|
||||||
|
transition.progress(
|
||||||
|
from_widget=from_widget,
|
||||||
|
to_widget=to_widget,
|
||||||
|
direction=Direction.Next,
|
||||||
|
progress=progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert abs(from_widget.styles.offset.x.value) + to_widget.styles.offset.x.value == 100
|