Add Trigger mechanism (#10)

pull/11/head
Josh Karpel 3 years ago committed by GitHub
parent c2756b80dd
commit 09ec4ae422
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -8,4 +8,4 @@ __python_version__ = ".".join(map(str, sys.version_info))
DECK = "DECK"
TARGET_RPS = 20
TARGET_RPS = 30

@ -2,11 +2,13 @@ import inspect
import shutil
import socket
from datetime import datetime
from math import cos, floor, pi
from pathlib import Path
from textwrap import dedent
from rich.align import Align
from rich.box import SQUARE
from rich.color import Color, blend_rgb
from rich.console import RenderGroup
from rich.layout import Layout
from rich.markdown import Markdown
@ -55,14 +57,25 @@ def what():
It's fun!
It's weird!
Why not?
Maybe you shouldn't.
Honestly, it's unclear whether it's a good idea.
There's always [Powerpoint](https://youtu.be/uNjxe8ShM-8)!
"""
)
lower_left_markup = dedent(
f"""\
## Reporting Bugs
## Contributing
Please report bugs via [GitHub Issues](https://github.com/JoshKarpel/spiel/issues).
Please report bugs on the [GitHub Issue Tracker](https://github.com/JoshKarpel/spiel/issues).
If you have ideas about how Spiel can be improved or a cool deck to show off,
please post them via [GitHub Discussions](https://github.com/JoshKarpel/spiel/discussions).
"""
)
@ -138,7 +151,7 @@ def code():
return root
@DECK.slide(title="Dynamic Content", from_function=True)
@DECK.slide(title="Dynamic Content")
def dynamic():
home = Path.home()
width = shutil.get_terminal_size().columns
@ -187,16 +200,100 @@ def dynamic():
)
@DECK.slide(title="Triggers")
def triggers(triggers):
info = Markdown(
dedent(
f"""\
## Triggers
Triggers are a mechanism for making dynamic content that depends on *relative* time.
Triggers can be used to implement effects like fades, motion, and other "animated" effects.
Each slide is triggered once when it starts being displayed.
You can trigger it again (as many times as you'd like) by pressing `t`.
You can reset the trigger state by pressing `r`.
This slide has been triggered {len(triggers)} times.
It was last triggered {triggers.time_since_last_trigger:.2f} seconds ago.
"""
),
justify="center",
)
bounce_period = 10
width = 50
half_width = width // 2
bounce_time = triggers.time_since_first_trigger % bounce_period
bounce_character = "" if bounce_time < (1 / 2) * bounce_period else ""
bounce_position = floor(half_width * cos(2 * pi * bounce_time / bounce_period))
before = half_width + bounce_position
ball = Align.center(
Panel(
Padding(
bounce_character,
pad=(0, before, 0, (half_width - bounce_position - 1)),
),
title="Bouncing Bullet",
padding=0,
)
)
white = Color.parse("bright_white")
black = Color.parse("black")
red = Color.parse("bright_red")
green = Color.parse("bright_green")
fade_time = 3
lines = [
Text(
"Triggered!",
style=Style(
color=(
Color.from_triplet(
blend_rgb(
black.get_truecolor(),
white.get_truecolor(),
cross_fade=min((triggers.now - time) / fade_time, 1),
)
)
)
),
)
for time in triggers.times
]
fun = Align.center(
Panel(
Text("\n", justify="center").join(lines),
border_style=Style(
color=Color.from_triplet(
blend_rgb(
green.get_truecolor(),
red.get_truecolor(),
cross_fade=min(triggers.time_since_last_trigger / fade_time, 1),
)
),
),
title="Trigger Tracker",
)
)
return RenderGroup(info, fun, ball if len(triggers) > 2 else Text(""))
@DECK.slide(title="Views")
def grid():
markup = dedent(
"""\
## Multiple Views
Try pressing 'd' to go into "deck" view.
Press 's' to go back to "slide" view.
Try pressing `d` to go into "deck" view.
Press `s` to go back to "slide" view.
Press 'j', then enter a slide number (like '3') to jump to a slide.
Press `j`, then enter a slide number (like `3`) to jump to a slide.
"""
)
return Markdown(markup, justify="center")

@ -1,9 +1,7 @@
from dataclasses import dataclass
from textwrap import dedent
from rich.align import Align
from rich.console import Console, ConsoleRenderable, RenderGroup
from rich.markdown import Markdown
from rich.padding import Padding
from rich.style import Style
from rich.table import Column, Table
@ -20,17 +18,6 @@ class Help:
state: State
def __rich__(self) -> ConsoleRenderable:
markup = dedent(
"""\
## Help
There are a variety of *actions* that you can use to control Spiel during a presentation.
Each action is triggered by pressing one of a certain set of keys (like `j`) or combinations of keys (like `ctrl-k`).
Some actions may only be available in certain modes.
"""
)
action_table = Table(
Column(
"Action",
@ -66,7 +53,6 @@ class Help:
return Padding(
RenderGroup(
Markdown(markup, justify="center"),
Align(action_table, align="center"),
Align(version_details(self.state.console), align="center"),
),

@ -204,6 +204,21 @@ def input_handler(
NOT_HELP = [Mode.SLIDE, Mode.DECK]
@input_handler("h", help=f"Enter {Mode.HELP} mode.")
def help_mode(state: State) -> None:
state.mode = Mode.HELP
@input_handler("s", help=f"Enter {Mode.SLIDE} mode.")
def slide_mode(state: State) -> None:
state.mode = Mode.SLIDE
@input_handler("d", help=f"Enter {Mode.DECK} mode.")
def deck_mode(state: State) -> None:
state.mode = Mode.DECK
@input_handler(SpecialCharacters.Right, "f", modes=NOT_HELP, help="Move to the next slide.")
def next_slide(state: State) -> None:
state.next_slide()
@ -262,19 +277,22 @@ def jump_to_slide(state: State) -> None:
return jump()
@input_handler("s", help=f"Enter {Mode.SLIDE} mode.")
def slide_mode(state: State) -> None:
state.mode = Mode.SLIDE
@input_handler("d", help=f"Enter {Mode.DECK} mode.")
def deck_mode(state: State) -> None:
state.mode = Mode.DECK
@input_handler(
"t",
modes=[Mode.SLIDE],
help="Trigger the slide: marks the current time and make it available to the slide's content rendering function.",
)
def trigger(state: State) -> None:
state.trigger()
@input_handler("h", help=f"Enter {Mode.HELP} mode.")
def help_mode(state: State) -> None:
state.mode = Mode.HELP
@input_handler(
"r",
modes=[Mode.SLIDE],
help="Reset the trigger state to as if the slide just started being displayed.",
)
def reset_trigger(state: State) -> None:
state.reset_trigger()
@input_handler("p", help="Toggle profiling information.")

@ -48,6 +48,7 @@ class DeckReloader(FileSystemEventHandler):
self.last_reload = now()
try:
self.state.deck = load_deck(self.deck_path)
self.state.reset_trigger()
self.state.set_message(
lambda: Text(
f"Reloaded deck from {self.deck_path} {self.last_reload.diff_for_humans(None, False)}",

@ -71,10 +71,10 @@ def _present(path: Path, mode: Mode, slide: int, profiling: bool, watch: bool, p
state = State(
console=Console(),
deck=load_deck(path),
mode=mode,
profiling=profiling,
)
state.mode = mode
state.jump_to_slide(slide - 1)
watcher = (

@ -1,6 +1,7 @@
import sys
from itertools import islice
from math import ceil
from time import monotonic
from rich.console import ConsoleRenderable
from rich.layout import Layout
@ -22,8 +23,11 @@ from .state import State
from .utils import clamp, joinify
def render_slide(slide: Slide) -> ConsoleRenderable:
return Padding(slide.content, pad=1)
def render_slide(state: State, slide: Slide) -> ConsoleRenderable:
return Padding(
slide.render(trigger_times=state.trigger_times),
pad=1,
)
def split_layout_into_deck_grid(root: Layout, state: State) -> Layout:
@ -51,7 +55,7 @@ def split_layout_into_deck_grid(root: Layout, state: State) -> Layout:
is_active_slide = slide is state.current_slide
layout.update(
Panel(
slide.content,
slide.render([monotonic()]),
title=joinify(" | ", [slide_number, slide.title]),
border_style=Style(
color="bright_cyan" if is_active_slide else None,
@ -74,7 +78,7 @@ def present_deck(state: State) -> None:
body = Layout(name="body", ratio=1)
if state.mode is Mode.SLIDE:
body.update(render_slide(current_slide))
body.update(render_slide(state, current_slide))
elif state.mode is Mode.DECK:
split_layout_into_deck_grid(body, state)
elif state.mode is Mode.HELP:

@ -1,10 +1,15 @@
from collections import deque
from time import monotonic
from typing import Deque
from typing import Deque, Optional
from spiel.constants import TARGET_RPS
class RPSCounter:
def __init__(self, render_history_length: int = 100) -> None:
def __init__(self, render_history_length: Optional[int] = None) -> None:
if render_history_length is None:
render_history_length = 3 * TARGET_RPS
self.render_time_history: Deque[float] = deque(maxlen=render_history_length)
def mark(self) -> None:

@ -1,30 +1,57 @@
from __future__ import annotations
import inspect
from dataclasses import dataclass, field
from typing import Callable, Iterator, List
from functools import cached_property
from time import monotonic
from typing import Any, Callable, Dict, Iterator, List, Tuple, Union
from rich.console import ConsoleRenderable
from rich.text import Text
MakeRenderable = Callable[[], ConsoleRenderable]
MakeRenderable = Callable[..., ConsoleRenderable]
RenderableLike = Union[MakeRenderable, ConsoleRenderable]
@dataclass(frozen=True)
class Triggers:
times: Tuple[float, ...]
now: float = field(default_factory=monotonic)
def __len__(self) -> int:
return len(self.times)
def __getitem__(self, idx: int) -> float:
return self.times[idx]
def __iter__(self) -> Iterator[float]:
return iter(self.times)
@cached_property
def time_since_last_trigger(self) -> float:
return self.now - self.times[-1]
@cached_property
def time_since_first_trigger(self) -> float:
return self.now - self.times[0]
@dataclass
class Slide:
content: ConsoleRenderable = field(default_factory=Text)
content: RenderableLike = field(default_factory=Text)
title: str = ""
@classmethod
def from_function(
cls,
function: MakeRenderable,
title: str = "",
) -> Slide:
class Dynamic(ConsoleRenderable):
def __rich__(self) -> ConsoleRenderable:
return function()
def render(self, trigger_times: List[float]) -> ConsoleRenderable:
if callable(self.content):
signature = inspect.signature(self.content)
kwargs: Dict[str, Any] = {}
if "triggers" in signature.parameters:
kwargs["triggers"] = Triggers(times=tuple(trigger_times))
return cls(content=Dynamic(), title=title)
return self.content(**kwargs)
else:
return self.content
@dataclass
@ -48,14 +75,10 @@ class Deck:
def slide(
self,
title: str = "",
from_function: bool = False,
) -> Callable[[MakeRenderable], MakeRenderable]:
def decorator(slide_function: MakeRenderable) -> MakeRenderable:
if from_function:
slide = Slide.from_function(slide_function, title=title)
else:
slide = Slide(slide_function(), title=title)
def slideify(content: MakeRenderable) -> MakeRenderable:
slide = Slide(content=content, title=title)
self.add_slides(slide)
return slide_function
return content
return decorator
return slideify

@ -1,5 +1,6 @@
from dataclasses import dataclass
from typing import Callable, Union
from dataclasses import dataclass, field
from time import monotonic
from typing import Callable, List, Union
from rich.console import Console
from rich.style import Style
@ -16,10 +17,20 @@ class State:
console: Console
deck: Deck
_current_slide_idx: int = 0
mode: Mode = Mode.SLIDE
_mode: Mode = Mode.SLIDE
_message: TextLike = Text("")
trigger_times: List[float] = field(default_factory=list)
profiling: bool = False
@property
def mode(self) -> Mode:
return self._mode
@mode.setter
def mode(self, mode: Mode) -> None:
self._mode = mode
self.reset_trigger()
@property
def current_slide_idx(self) -> int:
return self._current_slide_idx
@ -27,6 +38,7 @@ class State:
@current_slide_idx.setter
def current_slide_idx(self, idx: int) -> None:
self._current_slide_idx = max(0, min(len(self.deck) - 1, idx))
self.reset_trigger()
def next_slide(self, move: int = 1) -> None:
self.current_slide_idx += move
@ -64,6 +76,13 @@ class State:
def deck_grid_width(self) -> int:
return max(self.console.size.width // 30, 1)
def trigger(self) -> None:
self.trigger_times.append(monotonic())
def reset_trigger(self) -> None:
self.trigger_times.clear()
self.trigger()
def toggle_profiling(self) -> bool:
self.profiling = not self.profiling
return self.profiling

@ -15,13 +15,17 @@ from spiel.state import State
"make_slide",
[
lambda: Slide(Text("foobar")),
lambda: Slide.from_function(lambda: Text("foobar")),
lambda: Slide(lambda: Text("foobar")),
lambda: Slide(lambda triggers: Text("foobar")),
],
)
def test_can_render_slide(
make_slide: Callable[[], Slide], console: Console, output: StringIO
make_slide: Callable[[], Slide],
console: Console,
output: StringIO,
three_slide_state: State,
) -> None:
renderable = render_slide(make_slide())
renderable = render_slide(state=three_slide_state, slide=make_slide())
console.print(renderable)

@ -0,0 +1,64 @@
import pytest
from spiel.slides import Triggers
@pytest.mark.parametrize(
"triggers, expected",
[
(Triggers(()), 0),
(Triggers((0, 1)), 2),
(Triggers((0, 1, 2)), 3),
],
)
def test_length(triggers: Triggers, expected: int) -> None:
assert len(triggers) == expected
@pytest.mark.parametrize(
"triggers, idx, expected",
[
(Triggers((0, 1)), 0, 0),
(Triggers((0, 1)), 1, 1),
(Triggers((0, 1, 2)), -1, 2),
],
)
def test_getitem(triggers: Triggers, idx: int, expected: int) -> None:
assert triggers[idx] == expected
@pytest.mark.parametrize(
"triggers",
[
Triggers(()),
Triggers((0, 1)),
Triggers((0, 1, 2)),
],
)
def test_iter(triggers: Triggers) -> None:
assert tuple(iter(triggers)) == triggers.times
@pytest.mark.parametrize(
"triggers, expected",
[
(Triggers((0, 1), now=5), 4),
(Triggers((0, 1), now=1), 0),
(Triggers((0, 1, 2), now=3), 1),
],
)
def test_time_since_last_trigger(triggers: Triggers, expected: float) -> None:
assert triggers.time_since_last_trigger == expected
@pytest.mark.parametrize(
"triggers, expected",
[
(Triggers((0, 1), now=5), 5),
(Triggers((0, 1), now=1), 1),
(Triggers((0, 1, 2), now=3), 3),
(Triggers((3, 2), now=4), 1),
],
)
def test_time_since_first_trigger(triggers: Triggers, expected: float) -> None:
assert triggers.time_since_first_trigger == expected
Loading…
Cancel
Save