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 unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockFixture
|
||||
from typer.testing import CliRunner
|
||||
|
||||
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:
|
@ -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
|