Add a `Plot` widget (#33)

pull/34/head
Josh Karpel 3 years ago committed by GitHub
parent 30d0ea2ff3
commit bbf4545e6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -25,6 +25,7 @@ exclude_lines =
if 0:
if False:
if __name__ == .__main__.:
if TYPE_CHECKING:
assert False
pragma: unreachable

@ -14,11 +14,13 @@ Decks and Slides
.. autoclass:: Example
Extra Renderables
-----------------
Widgets
-------
.. autoclass:: Image
.. autoclass:: Plot
Triggers
--------

60
poetry.lock generated

@ -133,7 +133,7 @@ toml = ["toml"]
[[package]]
name = "decorator"
version = "5.0.7"
version = "5.0.8"
description = "Decorators for Humans"
category = "main"
optional = false
@ -445,6 +445,14 @@ category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "numpy"
version = "1.20.3"
description = "NumPy is the fundamental package for array computing with Python."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "packaging"
version = "20.9"
@ -977,6 +985,20 @@ category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "uniplot"
version = "0.4.4"
description = "Lightweight plotting to the terminal. 4x resolution via Unicode."
category = "main"
optional = false
python-versions = ">=3.5"
[package.dependencies]
numpy = ">=1.15.0"
[package.extras]
dev = ["black", "mypy", "pytest (>=6.0.1)"]
[[package]]
name = "urllib3"
version = "1.26.4"
@ -1012,7 +1034,7 @@ python-versions = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "a5b471816ee34919303f127be3a72a6b659c13fcfa2dffd737d43a3486e90e27"
content-hash = "533e48018a7ca2101e98d2b8643587bace73062096e54d4b9159017b8d868a6c"
[metadata.files]
alabaster = [
@ -1157,8 +1179,8 @@ coverage = [
{file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"},
]
decorator = [
{file = "decorator-5.0.7-py3-none-any.whl", hash = "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98"},
{file = "decorator-5.0.7.tar.gz", hash = "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060"},
{file = "decorator-5.0.8-py3-none-any.whl", hash = "sha256:77a3141f7f5837b5de43569c35508ca4570022ba501db8c8a2a8b292bd35772a"},
{file = "decorator-5.0.8.tar.gz", hash = "sha256:bff00cfb18698f9a19fa6400451fd7ea894f3845cedd7b8b7b0ce9c53171fefb"},
]
docopt = [
{file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
@ -1302,6 +1324,32 @@ nest-asyncio = [
{file = "nest_asyncio-1.5.1-py3-none-any.whl", hash = "sha256:76d6e972265063fe92a90b9cc4fb82616e07d586b346ed9d2c89a4187acea39c"},
{file = "nest_asyncio-1.5.1.tar.gz", hash = "sha256:afc5a1c515210a23c461932765691ad39e8eba6551c055ac8d5546e69250d0aa"},
]
numpy = [
{file = "numpy-1.20.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:70eb5808127284c4e5c9e836208e09d685a7978b6a216db85960b1a112eeace8"},
{file = "numpy-1.20.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6ca2b85a5997dabc38301a22ee43c82adcb53ff660b89ee88dded6b33687e1d8"},
{file = "numpy-1.20.3-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c5bf0e132acf7557fc9bb8ded8b53bbbbea8892f3c9a1738205878ca9434206a"},
{file = "numpy-1.20.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db250fd3e90117e0312b611574cd1b3f78bec046783195075cbd7ba9c3d73f16"},
{file = "numpy-1.20.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:637d827248f447e63585ca3f4a7d2dfaa882e094df6cfa177cc9cf9cd6cdf6d2"},
{file = "numpy-1.20.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8b7bb4b9280da3b2856cb1fc425932f46fba609819ee1c62256f61799e6a51d2"},
{file = "numpy-1.20.3-cp37-cp37m-win32.whl", hash = "sha256:67d44acb72c31a97a3d5d33d103ab06d8ac20770e1c5ad81bdb3f0c086a56cf6"},
{file = "numpy-1.20.3-cp37-cp37m-win_amd64.whl", hash = "sha256:43909c8bb289c382170e0282158a38cf306a8ad2ff6dfadc447e90f9961bef43"},
{file = "numpy-1.20.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f1452578d0516283c87608a5a5548b0cdde15b99650efdfd85182102ef7a7c17"},
{file = "numpy-1.20.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6e51534e78d14b4a009a062641f465cfaba4fdcb046c3ac0b1f61dd97c861b1b"},
{file = "numpy-1.20.3-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e515c9a93aebe27166ec9593411c58494fa98e5fcc219e47260d9ab8a1cc7f9f"},
{file = "numpy-1.20.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1c09247ccea742525bdb5f4b5ceeacb34f95731647fe55774aa36557dbb5fa4"},
{file = "numpy-1.20.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:66fbc6fed94a13b9801fb70b96ff30605ab0a123e775a5e7a26938b717c5d71a"},
{file = "numpy-1.20.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ea9cff01e75a956dbee133fa8e5b68f2f92175233de2f88de3a682dd94deda65"},
{file = "numpy-1.20.3-cp38-cp38-win32.whl", hash = "sha256:f39a995e47cb8649673cfa0579fbdd1cdd33ea497d1728a6cb194d6252268e48"},
{file = "numpy-1.20.3-cp38-cp38-win_amd64.whl", hash = "sha256:1676b0a292dd3c99e49305a16d7a9f42a4ab60ec522eac0d3dd20cdf362ac010"},
{file = "numpy-1.20.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:830b044f4e64a76ba71448fce6e604c0fc47a0e54d8f6467be23749ac2cbd2fb"},
{file = "numpy-1.20.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:55b745fca0a5ab738647d0e4db099bd0a23279c32b31a783ad2ccea729e632df"},
{file = "numpy-1.20.3-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5d050e1e4bc9ddb8656d7b4f414557720ddcca23a5b88dd7cff65e847864c400"},
{file = "numpy-1.20.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9c65473ebc342715cb2d7926ff1e202c26376c0dcaaee85a1fd4b8d8c1d3b2f"},
{file = "numpy-1.20.3-cp39-cp39-win32.whl", hash = "sha256:16f221035e8bd19b9dc9a57159e38d2dd060b48e93e1d843c49cb370b0f415fd"},
{file = "numpy-1.20.3-cp39-cp39-win_amd64.whl", hash = "sha256:6690080810f77485667bfbff4f69d717c3be25e5b11bb2073e76bb3f578d99b4"},
{file = "numpy-1.20.3-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e465afc3b96dbc80cf4a5273e5e2b1e3451286361b4af70ce1adb2984d392f9"},
{file = "numpy-1.20.3.zip", hash = "sha256:e55185e51b18d788e49fe8305fd73ef4470596b33fc2c1ceb304566b99c71a69"},
]
packaging = [
{file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
{file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
@ -1637,6 +1685,10 @@ typing-extensions = [
{file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"},
{file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
]
uniplot = [
{file = "uniplot-0.4.4-py3-none-any.whl", hash = "sha256:745e33cc6b5507f82290d68039743d38fd35b60cc6e48104f9eedd400f5e1d8d"},
{file = "uniplot-0.4.4.tar.gz", hash = "sha256:bbc9e8bd6afbe50fc3d96d769f5eb0b4de475d96a09374ee0db58cd0d74c35d9"},
]
urllib3 = [
{file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"},
{file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"},

@ -43,6 +43,8 @@ ipython = "^7.23.0"
ipykernel = "^5.5.3"
nbterm = "^0.0.7"
toml = "^0.10.2"
uniplot = "^0.4.4"
numpy = "^1.20.3"
[tool.poetry.dev-dependencies]
pytest = "^6.2.2"

@ -7,6 +7,7 @@ from math import cos, floor, pi
from pathlib import Path
from textwrap import dedent
import numpy as np
from rich.align import Align
from rich.box import SQUARE
from rich.color import Color, blend_rgb
@ -20,6 +21,7 @@ from rich.syntax import Syntax
from rich.text import Text
from spiel import Deck, Image, Options, Slide, __version__, example_panels
from spiel.plot import Plot
deck = Deck(name=f"Spiel Demo Deck (v{__version__})")
options = Options()
@ -27,6 +29,9 @@ options = Options()
SPIEL = "[Spiel](https://github.com/JoshKarpel/spiel)"
RICH = "[Rich](https://rich.readthedocs.io/)"
NBTERM = "[nbterm](https://github.com/davidbrochart/nbterm)"
UNIPLOT = "[Uniplot](https://github.com/olavolav/uniplot)"
IPYTHON = "[IPython](https://ipython.readthedocs.io)"
WSL = "[Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/)"
THIS_DIR = Path(__file__).resolve().parent
@ -35,39 +40,39 @@ THIS_DIR = Path(__file__).resolve().parent
def what():
upper_left_markup = dedent(
f"""\
## What is Spiel?
## What is Spiel?
{SPIEL} is a framework for building and presenting richly-styled presentations in your terminal using Python.
{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)!
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 (or `f` and `b`) to go forwards and backwards through the deck.
Use your right `` and left `` arrows keys (or `f` and `b`) to go forwards and backwards through the deck.
Press `ctrl-c` or `ctrl-k` to exit.
Press `ctrl-c` or `ctrl-k` to exit.
Press `h` at any time to see the help screen, which describes all of the actions you can take.
Press `h` at any time to see the help screen, which describes all of the actions you can take.
To get a copy of the source code for this deck, use the `spiel demo copy` command.
"""
To get a copy of the source code for this deck, use the `spiel demo copy` command.
"""
)
upper_right_markup = dedent(
"""\
## Why use Spiel?
## Why use Spiel?
It's fun!
It's fun!
It's weird!
It's weird!
Why not?
Why not?
Maybe you shouldn't.
Maybe you shouldn't.
Honestly, it's unclear whether it's a good idea.
Honestly, it's unclear whether it's a good idea.
There's always [Powerpoint](https://youtu.be/uNjxe8ShM-8)!
"""
There's always [Powerpoint](https://youtu.be/uNjxe8ShM-8)!
"""
)
lower_left_markup = dedent(
@ -76,8 +81,9 @@ def what():
Please report bugs via [GitHub Issues](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).
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).
"""
)
@ -211,7 +217,7 @@ def triggers(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.
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`.
@ -311,7 +317,7 @@ def watch():
`$ spiel present path/to/deck.py --watch`
If you're on a system without inotify support (e.g., Windows Subsystem for Linux), you may need to use the `--poll` option instead.
If you're on a system without inotify support (e.g., {WSL}), you should use the `--poll` option instead.
"""
)
return Markdown(markup, justify="center")
@ -325,6 +331,8 @@ def image():
{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
@ -343,6 +351,47 @@ def image():
return root
@deck.slide(title="Plots")
def plots(triggers):
markup = dedent(
f"""\
## Plots
{SPIEL} can display plots... sort of!
Spiel includes a `Plot` widget that uses {UNIPLOT} to render plots.
You can even make animated plots by using triggers! Try triggering this slide.
"""
)
x = np.linspace(-3, 3, 1000)
coefficients = np.array([0, 2, -1, 3, -1, 1])
dither = 0.1 * np.sin(triggers.time_since_last_trigger) if triggers.triggered else 0
hermite = 0.9 * np.polynomial.hermite.hermval(x, c=coefficients + dither)
upper_plot = Plot(xs=x, ys=hermite, title="A Hermite Polynomial", y_min=-100, y_max=100)
theta = np.linspace(-3 * np.pi, 3 * np.pi, 1000)
phase = (1 if triggers.triggered else 0) * triggers.time_since_last_trigger
cos = np.cos(theta + phase)
sin = 1.3 * np.sin(theta + phase)
lower_plot = Plot(xs=[theta, theta], ys=[cos, sin], title="[cos(x), 1.3 * sin(x)]")
lower = Layout()
lower.split_column(
Layout(upper_plot),
Layout(lower_plot),
)
root = Layout()
root.split_row(
Layout(Padding(Markdown(markup, justify="center"), pad=(0, 2))),
lower,
)
return root
@deck.example(title="Examples")
def examples():
# This is an example that shows how to use random.choice from the standard library.
@ -376,8 +425,13 @@ def _(example, triggers):
Press `e` to open your `$EDITOR` (`{os.getenv("EDITOR", "not set")}`) on the example code.
Save your changes and exit to come back to the presentation with your updated code.
You can then trigger the example again to run it with the new code.
## Layout Customization
You can customize the example slide's content by providing a custom `layout` function.
If you don't, you'll get the default layout, which looks like just the right half of this slide.
"""
if len(triggers) > 1
if triggers.triggered
else ""
)
@ -390,14 +444,15 @@ def _(example, triggers):
Example slides are driven by the trigger system.
Press `t` to execute the example code and display the output.
You can customize the example slide's content by providing a custom `layout` function.
If you don't, you'll get the default layout, which looks like just the right half of this slide.
{extra}
"""
)
markdown = Markdown(markup, justify="center")
root.split_row(Layout(markdown), example_panels(example))
root.split_row(
Layout(Padding(markdown, pad=(0, 2))),
example_panels(example),
)
return root
@ -413,7 +468,10 @@ def notebooks():
just isn't interactive enough.
To provide a more interactive experience,
{SPIEL} lets you open an IPython REPL on any slide by pressing `i`.
{SPIEL} lets you open a REPL on any slide by pressing `i`.
There are two REPLs available by default: the [builtin Python REPL](https://docs.python.org/3/tutorial/interpreter.html#interactive-mode) and {IPYTHON}.
You can change which REPL to use via `Options`, which will be discussed later.
When you exit the REPL (by pressing `ctrl-d` or executing `exit`),
you'll be back at the same point in your presentation.
@ -444,6 +502,10 @@ def notebooks():
but you can also provide a path to a notebook on disk to initialize from.
This slide has a small example notebook attached to it - try it out!
(In theory, {SPIEL} will be able to support different kinds of terminal notebook viewers in the future,
but right now, only {NBTERM} is packaged by default, and the internal API is not stable or very generic.
Stay tuned!)
"""
)
return Markdown(markup, justify="center")

@ -43,7 +43,7 @@ def example_panels(example: Example) -> ConsoleRenderable:
Align.center(
Panel(
example.output,
title=example.display_command,
title=f"$ {example.display_command}",
title_align="left",
expand=False,
)

@ -0,0 +1,81 @@
import re
from typing import Any, Iterable, Sequence, Union
import numpy as np
import uniplot
from colorama import Fore
from colorama import Style as CStyle
from rich.console import Console, ConsoleOptions
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
Plottable = Union[
np.ndarray,
Sequence[np.ndarray],
]
RE_ANSI_ESCAPE = re.compile(r"(\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))")
COLOR_MAP = {
Fore.RED: "red",
Fore.GREEN: "green",
Fore.BLUE: "blue",
Fore.CYAN: "cyan",
Fore.YELLOW: "yellow",
Fore.MAGENTA: "magenta",
Fore.BLACK: "black",
Fore.WHITE: "white",
}
class Plot:
def __init__(
self,
**plot_args: Any,
) -> None:
self.plot_args = plot_args
def _ansi_to_text(self, s: str) -> Text:
pieces = []
tmp = ""
style = Style.null()
for char in RE_ANSI_ESCAPE.split(s):
if char == CStyle.RESET_ALL:
pieces.append(Text(tmp, style=style))
style = Style.null()
tmp = ""
elif char in COLOR_MAP:
pieces.append(Text(tmp, style=style))
style = Style(color=COLOR_MAP[char])
tmp = ""
else:
tmp += char
# catch leftovers
pieces.append(Text(tmp, style=style))
return Text("", no_wrap=True).join(pieces)
def __rich_console__(self, console: Console, options: ConsoleOptions) -> Iterable[Segment]:
if self.plot_args.get("height") is None and options.height is None:
height = None
else:
# 5 = title + top bar + bottom bar + bottom axis labels + 1
height = max(
(options.height - 5) if options.height else 1, self.plot_args.get("height", 1)
)
plot_args = {
**self.plot_args,
**dict(
height=height,
width=max(options.max_width - 10, self.plot_args.get("width", 1)),
),
}
plot = "\n".join(uniplot.plot_to_string(**plot_args))
text = self._ansi_to_text(plot)
yield from text.__rich_console__(console, options)

@ -25,3 +25,7 @@ class Triggers:
@cached_property
def time_since_first_trigger(self) -> float:
return self.now - self.times[0]
@cached_property
def triggered(self) -> bool:
return len(self) > 1

@ -0,0 +1,26 @@
import pytest
from rich.console import Console
from spiel import Deck, Options
from spiel.load import load_deck
from spiel.main import DEMO_SOURCE
from spiel.present import render_slide
from spiel.state import State
@pytest.fixture
def demo_deck() -> Deck:
return load_deck(DEMO_SOURCE)
@pytest.fixture
def demo_state(demo_deck: Deck) -> State:
return State(deck=demo_deck, console=Console(), options=Options())
def test_can_render_every_demo_slide(demo_state: State, demo_deck: Deck) -> None:
for slide in demo_deck:
for _ in range(10):
demo_state.console.print(render_slide(demo_state, slide))
demo_state.trigger()
demo_state.reset_trigger()
Loading…
Cancel
Save