mirror of https://github.com/JoshKarpel/spiel
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.
519 lines
15 KiB
Python
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__)
|