You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
spiel/spiel/demo/demo.py

519 lines
15 KiB
Python

#!/usr/bin/env python
import inspect
import shutil
import socket
from collections.abc import Callable, Iterator
from datetime import datetime
from math import cos, floor, pi
from pathlib import Path
from textwrap import dedent
from click import edit
from rich.align import Align
from rich.box import HEAVY, SQUARE
from rich.color import Color, blend_rgb
from rich.console import Group, RenderableType
from rich.layout import Layout
from rich.markdown import Markdown
from rich.padding import Padding
from rich.panel import Panel
from rich.style import Style
from rich.syntax import Syntax
from rich.text import Text
from spiel import Slide, SuspendType, Triggers, present
from spiel.deck import Deck
from spiel.renderables.image import Image
deck = Deck(name="Spiel Demo Deck")
SPIEL = "[Spiel](https://github.com/JoshKarpel/spiel)"
RICH = "[Rich](https://rich.readthedocs.io/)"
IPYTHON = "[IPython](https://ipython.readthedocs.io)"
WSL = "[Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/)"
THIS_FILE = Path(__file__).resolve()
THIS_DIR = THIS_FILE.parent
def pad_markdown(markup: str) -> RenderableType:
return Padding(Markdown(dedent(markup), justify="center"), pad=(0, 5))
@deck.slide(title="What is Spiel?")
def what() -> RenderableType:
upper_left = pad_markdown(
f"""\
## What is Spiel?
{SPIEL} is a framework for building and presenting richly-styled presentations in your terminal using Python.
Spiel uses {RICH} to render slide content.
Anything you can display with Rich, you can display with Spiel (plus some other things)!
Use your right `→` and left `←` arrows keys to go forwards and backwards through the deck.
Press `ctrl-c` to exit.
Press `?` at any time to see the help screen, which describes all of the built-in actions you can take.
To get a copy of the source code for this deck, use the `spiel demo copy` command.
"""
)
upper_right = pad_markdown(
"""\
## Why use Spiel?
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 = pad_markdown(
"""\
## Contributing
Please report bugs via [GitHub Issues](https://github.com/JoshKarpel/spiel/issues).
If you have ideas about how Spiel can be improved,
or you have a cool deck to show off,
please post to [GitHub Discussions](https://github.com/JoshKarpel/spiel/discussions).
"""
)
lower_right = pad_markdown(
"""\
## Inspirations
Brandon Rhodes' [PyCon 2017](https://youtu.be/66P5FMkWoVU) and [North Bay Python 2017](https://youtu.be/rrMnmLyYjU8) talks.
David Beazley's [Lambda Calculus from the Ground Up](https://youtu.be/pkCLMl0e_0k) tutorial at PyCon 2019.
LaTeX's [Beamer](https://ctan.org/pkg/beamer) document class.
"""
)
root = Layout()
upper = Layout()
lower = Layout()
upper.split_row(
Layout(upper_left),
Layout(upper_right),
)
lower.split_row(
Layout(lower_left),
Layout(lower_right),
)
root.split_column(upper, lower)
return root
def make_code_panel_from_object(obj: type | Callable[..., object]) -> RenderableType:
lines, line_number = inspect.getsourcelines(obj)
return make_code_panel(line_number, lines)
def make_code_panel(line_number: int, lines: list[str], title: str | None = None) -> RenderableType:
return Align.center(
Panel(
Syntax(
"".join(lines),
lexer="python",
line_numbers=True,
start_line=line_number,
),
title=title,
box=SQUARE,
border_style=Style(dim=True),
height=len(lines) + 2,
expand=False,
)
)
@deck.slide(title="Decks and Slides")
def code() -> RenderableType:
markup = f"""\
## Decks are made of Slides
Here's the code for `Deck` and `Slide`!
The source code is pulled directly from the definitions via [inspect.getsource](https://docs.python.org/3/library/inspect.html#inspect.getsource).
({RICH} supports syntax highlighting, so {SPIEL} does too!)
"""
root = Layout()
upper = Layout(pad_markdown(markup), size=len(markup.split("\n")) + 1)
lower = Layout()
root.split_column(upper, lower)
lower.split_row(
Layout(make_code_panel_from_object(Deck)),
Layout(make_code_panel_from_object(Slide)),
)
return root
@deck.slide(title="Dynamic Content")
def dynamic() -> RenderableType:
width = shutil.get_terminal_size().columns
width_limit = 80
home = Path.home()
home_dir_contents = list(home.iterdir())
return Group(
Align.center(
pad_markdown(
"""\
## Slides can have dynamic content!
Since slides are created using normal Python code,
any output you can imagine producing via Python can make it into your slides.
Here are some examples:
"""
),
),
Align.center(
Panel(
Text(
(
f"Your terminal is {width} cells wide (try resizing it or adjusting your font size!)"
if width > width_limit
else f"Your terminal is only {width} cells wide! Get a bigger monitor!"
),
style=Style(color="green1" if width > width_limit else "red"),
justify="center",
)
),
),
Align.center(
Panel(
Text.from_markup(
f"The local timezone on this computer ([bold]{socket.gethostname()}[/bold]) is [bold]{datetime.now().astimezone().tzinfo}[/bold]",
style="bright_cyan",
justify="center",
)
),
),
Align.center(
Panel(
Text(
f"There are {len([f for f in home_dir_contents if f.is_file()])} files and {len([f for f in home_dir_contents if f.is_dir()])} directories in {home}",
style=Style(color="yellow"),
justify="center",
)
),
),
)
@deck.slide(title="Triggers")
def triggers(triggers: Triggers) -> RenderableType:
info = pad_markdown(
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 "animations".
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.
"""
)
white = Color.parse("bright_white")
black = Color.parse("black")
red = Color.parse("bright_red")
green = Color.parse("bright_green")
fade_time = 3
lines = [
Text(
f"Triggered at {time - triggers[0]:.3f}!",
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
]
fun = Padding(
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",
)
),
pad=(1, 0),
)
return Group(info, fun)
def make_reveals(triggers: Triggers) -> Iterator[RenderableType]:
return triggers.take(
Align.center(r)
for r in [
Text("Reveal 1", style=Style(color="black", bgcolor="#E40303")),
Text("Reveal 2", style=Style(color="black", bgcolor="#FF8C00")),
Text("Reveal 3", style=Style(color="black", bgcolor="#FFED00")),
Text("Reveal 4", style=Style(color="black", bgcolor="#008026")),
Text("Reveal 5", style=Style(color="black", bgcolor="#24408E")),
Text("Reveal 6", style=Style(color="black", bgcolor="#732982")),
]
)
@deck.slide(title="Triggers: Reveals")
def bullets(triggers: Triggers) -> RenderableType:
info_upper = pad_markdown(
f"""\
## Triggers: Reveals
Triggers can be useful even without considering their tracking of relative time.
We can track the number of times the slide has been triggered to gradually
reveal content.
`{Triggers.take.__qualname__}` makes this straightforward:
"""
)
info_lower = pad_markdown(
"""\
Trigger this slide (press `t`) a few times to reveal some content.
Press `r` to hide the content again (by resetting the trigger state).
"""
)
return Group(
info_upper,
Padding(make_code_panel_from_object(make_reveals), pad=(0, 0, 1, 0)),
info_lower,
*make_reveals(triggers),
)
def make_bullet(triggers: Triggers) -> RenderableType:
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
bullet = Padding(
bounce_character,
pad=(0, before, 0, (half_width - bounce_position - 1)),
)
return Align.center(
Panel(bullet, title="Bouncing Bullet", padding=0),
vertical="middle",
)
@deck.slide(title="Triggers: Animations")
def bouncing_bullet(triggers: Triggers) -> RenderableType:
info = pad_markdown(
f"""\
## Triggers: Animations
Here's an example of how triggers can be used to build
more complex animations.
The position and facing direction of the bullet are calculated deterministically
based on the time since the first trigger time (the automatic one
from when the slide starts being presented).
There is no state stored in the slide function itself.
The apparent motion is based on {SPIEL} evaluating the
slide content function with different `Trigger` values.
"""
)
return Group(info, make_bullet(triggers), make_code_panel_from_object(make_bullet))
@deck.slide(title="Views")
def grid() -> RenderableType:
return pad_markdown(
"""\
## Deck View
Try pressing `d` to go into "deck" view.
You can move between slides in deck view using your arrow keys (right `→`, left `←`, up `↑`, and down `↓`).
Press `enter` or `escape` to go back to "slide" view (this view),
on the currently-selected slide.
"""
)
@deck.slide(title="Displaying Images")
def image() -> RenderableType:
markup = f"""\
## Images
{SPIEL} can display images... sort of!
Spiel includes an `Image` widget that can render images by interpolating pixel values.
If you see big chunks of constant color instead of smooth gradients, your terminal is probably not configured for "truecolor" mode.
If your terminal supports truecolor (it probably does), try setting the environment variable `COLORTERM` to `truecolor`.
For example, for `bash`, you could add
`export COLORTERM=truecolor`
to your `.bashrc` file, then restart your shell.
"""
image_path = THIS_DIR / "tree.jpg"
root = Layout()
root.split_row(
Layout(pad_markdown(markup)),
Layout(
Panel.fit(
Align.center(Image.from_file(image_path), vertical="middle"),
subtitle=image_path.name,
box=HEAVY,
padding=0,
)
),
)
return root
@deck.slide(title="Watch Mode")
def watch() -> RenderableType:
return pad_markdown(
f"""\
## Developing a Deck
{SPIEL} will reload your deck as you edit it to make development easier.
The reload is triggered whenever any files under the path passed to the
`--watch` argument of `spiel present` changes.
That path defaults to the parent directory of the deck file.
"""
)
def edit_this_file(suspend: SuspendType) -> None:
with suspend():
edit(filename=__file__)
@deck.slide(
title="Bindings",
bindings={
"e": edit_this_file,
},
)
def bindings() -> RenderableType:
edit_this_file_source_lines, line_number = inspect.getsourcelines(edit_this_file)
this_slide_source_lines, _ = inspect.getsourcelines(bindings)
source_lines = [
*edit_this_file_source_lines,
"\n", # blank line between the two functions
*this_slide_source_lines[:7], # up through the slide's def
" ...", # replace the slide body with a "..."
]
code = make_code_panel(line_number, source_lines, title=THIS_FILE.name)
info_upper = pad_markdown(
f"""\
## Custom Per-Slide Key Bindings
Custom keybindings can be added on a per-slide basis using the `bindings` argument of `@slide`,
which takes a mapping of key names to callables to call when that key is pressed.
If the callable takes an argument named `suspend`,
it will be passed a function that, when used as a context manager,
suspends {SPIEL} while inside the `with` block.
A binding has been registered on this slide that suspends {SPIEL}
and opens your `$EDITOR` on the demo deck's Python file:
"""
)
info_lower = pad_markdown(
"""\
Try pressing `e`!
Due to reloading, any changes you make will be reflected in the
presentation you're seeing right now.
"""
)
return Group(info_upper, code, info_lower)
class DemoRenderFailure(Exception):
pass
@deck.slide(title="Render Failure")
def failure() -> RenderableType:
raise DemoRenderFailure(
"""Woops!
An exception was raised while rendering this slide.
When this happens, Spiel will display the stack trace to help you debug the problem.
Deck reloading will still happen, so you can fix the error without stopping Spiel.
This error display will also be shown if you return slide content that can't be rendered by Rich.
"""
)
if __name__ == "__main__":
present(__file__)