More improvements to demo deck (#4)

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

@ -1,12 +1,12 @@
import inspect
import os
import shutil
import socket
import tempfile
from datetime import datetime
from pathlib import Path
from textwrap import dedent
from rich.align import Align
from rich.box import SQUARE
from rich.console import RenderGroup
from rich.layout import Layout
from rich.markdown import Markdown
@ -21,6 +21,10 @@ SPIEL = "[Spiel](https://github.com/JoshKarpel/spiel)"
RICH = "[Rich](https://rich.readthedocs.io/)"
DECK = Deck(name=f"Spiel Demo Deck (v{__version__})")
@DECK.slide(title="What is Spiel?")
def what():
left_markup = dedent(
f"""\
@ -64,9 +68,10 @@ def what():
),
)
return Slide(root, title="What is Spiel?")
return root
@DECK.slide(title="Decks and Slides")
def code():
markup = dedent(
f"""\
@ -76,7 +81,7 @@ def code():
The source code is pulled directly from the definitions via [`inspect.getsource`](https://docs.python.org/3/library/inspect.html#inspect.getsource).
(Because {RICH} supports syntax highlighting, so does {SPIEL}!)
({RICH} supports syntax highlighting, so {SPIEL} does too!)
"""
)
root = Layout()
@ -84,73 +89,78 @@ def code():
lower = Layout()
root.split_column(upper, lower)
lower.split_row(
Layout(
def make_code_panel(obj):
lines, line_number = inspect.getsourcelines(obj)
return Panel(
Syntax(
inspect.getsource(Deck),
"".join(lines),
lexer_name="python",
line_numbers=True,
start_line=line_number,
),
),
Layout(
Syntax(
inspect.getsource(Slide),
lexer_name="python",
),
),
box=SQUARE,
border_style=Style(dim=True),
height=len(lines) + 2,
)
lower.split_row(
Layout(make_code_panel(Deck)),
Layout(make_code_panel(Slide)),
)
return Slide(root, title="Decks and Slides")
return root
@DECK.slide(title="Dynamic Content", from_function=True)
def dynamic():
tmp_dir = tempfile.gettempdir()
home = Path.home()
width = shutil.get_terminal_size().columns
return Slide(
RenderGroup(
Align(
Text(
f"Your slides can have very dynamic content, like this!",
style=Style(color="bright_magenta", bold=True, italic=True),
),
align="center",
width_limit = 80
return RenderGroup(
Align(
Text(
f"Slides can have dynamic content!",
style=Style(color="bright_magenta", bold=True, italic=True),
justify="center",
),
Align(
Panel(
Text(
f"The time on this computer, {socket.gethostname()}, is {datetime.now()}",
style=Style(color="bright_cyan", bold=True, italic=True),
justify="center",
)
),
align="center",
align="center",
),
Align(
Panel(
Text(
f"Your terminal is {width} characters wide."
if width > width_limit
else f"Your terminal is only {width} characters wide! Get a bigger monitor!",
style=Style(color="green1" if width > width_limit else "red"),
justify="center",
)
),
Align(
Panel(
Text(
f"Your terminal is {width} characters wide."
if width > 80
else f"Your terminal is only {width} characters wide! Get a bigger monitor!",
style=Style(color="green1" if width > 80 else "red"),
justify="center",
)
),
align="center",
align="center",
),
Align(
Panel(
Text(
f"The time on this computer, {socket.gethostname()}, is {datetime.now()}",
style=Style(color="bright_cyan", bold=True, italic=True),
justify="center",
)
),
Align(
Panel(
Text(
f"There are {len(os.listdir(tmp_dir))} entries under {tmp_dir} right now.",
style=Style(color="yellow"),
justify="center",
)
),
align="center",
align="center",
),
Align(
Panel(
Text(
f"There are {len([f for f in home.iterdir() if f.is_file()])} files in {home} right now.",
style=Style(color="yellow"),
justify="center",
)
),
align="center",
),
title="Dynamic Content",
)
@DECK.slide(title="Views")
def grid():
markup = dedent(
"""\
@ -160,30 +170,22 @@ def grid():
Press 's' to go back to "slide" view.
"""
)
return Slide(Markdown(markup, justify="center"), title="Views")
return Markdown(markup, justify="center")
@DECK.slide(title="Watch Mode")
def watch():
markup = dedent(
f"""\
## Developing a Deck
{SPIEL} can reload your deck as you edit it if you add the `--watch` option to `display`:
{SPIEL} can reload your deck as you edit it if you add the `--watch` option to `present`:
`$ spiel display examples/demo.py --watch`
`$ spiel present examples/demo.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.
When you're ready to present your deck for real, just drop the `--watch` option.
"""
)
return Slide(Markdown(markup, justify="center"), title="Watch Mode")
DECK = Deck(name=f"Spiel Demo Deck (v{__version__})").add_slides(
what(),
code(),
dynamic,
grid(),
watch(),
)
return Markdown(markup, justify="center")

46
poetry.lock generated

@ -96,6 +96,34 @@ category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "hypothesis"
version = "6.8.3"
description = "A library for property-based testing"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
attrs = ">=19.2.0"
sortedcontainers = ">=2.1.0,<3.0.0"
[package.extras]
all = ["black (>=19.10b0)", "click (>=7.0)", "django (>=2.2)", "dpcontracts (>=0.4)", "lark-parser (>=0.6.5)", "libcst (>=0.3.16)", "numpy (>=1.9.0)", "pandas (>=0.25)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "importlib-resources (>=3.3.0)", "importlib-metadata", "backports.zoneinfo (>=0.2.1)", "tzdata (>=2020.4)"]
cli = ["click (>=7.0)", "black (>=19.10b0)"]
codemods = ["libcst (>=0.3.16)"]
dateutil = ["python-dateutil (>=1.4)"]
django = ["pytz (>=2014.1)", "django (>=2.2)"]
dpcontracts = ["dpcontracts (>=0.4)"]
ghostwriter = ["black (>=19.10b0)"]
lark = ["lark-parser (>=0.6.5)"]
numpy = ["numpy (>=1.9.0)"]
pandas = ["pandas (>=0.25)"]
pytest = ["pytest (>=4.6)"]
pytz = ["pytz (>=2014.1)"]
redis = ["redis (>=3.0.0)"]
zoneinfo = ["importlib-resources (>=3.3.0)", "backports.zoneinfo (>=0.2.1)", "tzdata (>=2020.4)"]
[[package]]
name = "iniconfig"
version = "1.1.1"
@ -337,6 +365,14 @@ category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "sortedcontainers"
version = "2.3.0"
description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "toml"
version = "0.10.2"
@ -392,7 +428,7 @@ watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "980d52ae5399ae29b8d4e26e95998d1b68be728ab6a3d3590ec614584702432a"
content-hash = "9974dc0dd215be9f95714f25e9a7f002205ff2886477923f0a626ba87144dea6"
[metadata.files]
apipkg = [
@ -484,6 +520,10 @@ filelock = [
{file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
{file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
]
hypothesis = [
{file = "hypothesis-6.8.3-py3-none-any.whl", hash = "sha256:b7abb537a211a8ec8a3f9416ea645fc7c47f606f08678d4755086933755852d7"},
{file = "hypothesis-6.8.3.tar.gz", hash = "sha256:c6dfff27150552a9156b0afaca4b96ad7ee4b669d947bab8185612e228eb0139"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
@ -602,6 +642,10 @@ six = [
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
]
sortedcontainers = [
{file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"},
{file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},

@ -33,6 +33,7 @@ pytest-xdist = "^2.2.1"
mypy = "^0.812"
pytest-mypy = "^0.8.1"
pytest-mock = "^3.5.1"
hypothesis = "^6.8.3"
[tool.poetry.scripts]
spiel = 'spiel.main:app'

@ -1,6 +1,6 @@
from dataclasses import dataclass
from datetime import date
from pendulum import now
from rich.console import ConsoleRenderable
from rich.style import Style
from rich.table import Column, Table
@ -29,7 +29,7 @@ class Footer(Stateful):
),
Column(
style=Style(dim=True),
justify="center",
justify="right",
),
Column(
style=Style(dim=True),
@ -46,7 +46,7 @@ class Footer(Stateful):
],
),
self.state.message,
date.today().isoformat(),
now().format("YYYY-MM-DD hh:mm A"),
f"[{self.state.current_slide_idx + 1:>0{self.longest_slide_number_length}d} / {len(self.state.deck)}]",
)
return grid

@ -5,7 +5,17 @@ import termios
from contextlib import contextmanager
from enum import Enum, unique
from itertools import product
from typing import Callable, Iterator, MutableMapping, NoReturn, Optional, TextIO, Tuple, Union
from typing import (
Callable,
Iterable,
Iterator,
MutableMapping,
NoReturn,
Optional,
TextIO,
Tuple,
Union,
)
from .exceptions import DuplicateInputHandler
from .modes import Mode
@ -115,6 +125,7 @@ InputHandler = Callable[[State], Optional[NoReturn]]
InputHandlerKey = Tuple[Character, Mode]
InputHandlerDecorator = Callable[[InputHandler], InputHandler]
InputHandlers = MutableMapping[InputHandlerKey, InputHandler]
INPUT_HANDLERS: InputHandlers = {} # type: ignore
@ -131,7 +142,7 @@ def handle_input(state: State, stream: TextIO) -> Optional[NoReturn]:
def action(
*characters: Character,
modes: Optional[Iterator[Mode]] = None,
modes: Optional[Iterable[Mode]] = None,
handlers: InputHandlers = INPUT_HANDLERS,
) -> InputHandlerDecorator:
def decorator(func: InputHandler) -> InputHandler:
@ -157,6 +168,16 @@ def previous_slide(state: State) -> None:
state.previous_slide()
@action(SpecialCharacters.Up, modes=[Mode.DECK])
def up_grid_row(state: State) -> None:
state.previous_slide(move=state.deck_grid_width)
@action(SpecialCharacters.Down, modes=[Mode.DECK])
def down_grid_row(state: State) -> None:
state.next_slide(move=state.deck_grid_width)
@action("d")
def deck_mode(state: State) -> None:
state.mode = Mode.DECK

@ -11,7 +11,6 @@ from spiel.present import present_deck
from spiel.state import State
app = Typer()
console = Console()
@app.command()
@ -25,7 +24,7 @@ def present(
help="If enabled, poll the filesystem for changes (implies --watch). Use this option on systems that don't support file modification notifications.",
),
) -> None:
state = State(deck=load_deck(path))
state = State(console=Console(), deck=load_deck(path))
watcher = (
DeckWatcher(event_handler=DeckReloader(state, path), path=path, poll=poll)
@ -34,9 +33,9 @@ def present(
)
with watcher:
present_deck(console, state)
present_deck(state)
@app.command()
def version() -> None:
console.print(Text(f"{PACKAGE_NAME} {__version__}"))
Console().print(Text(f"{PACKAGE_NAME} {__version__}"))

@ -1,7 +1,7 @@
import sys
from itertools import islice
from math import ceil
from rich.console import Console
from rich.layout import Layout
from rich.live import Live
from rich.padding import Padding
@ -13,29 +13,35 @@ from .footer import Footer
from .input import handle_input, no_echo
from .modes import Mode
from .state import State
from .utils import joinify
from .utils import clamp, joinify
def present_deck(console: Console, state: State) -> None:
def get_renderable() -> Layout:
footer = Layout(Footer(state), name="footer", size=1)
def present_deck(state: State) -> None:
footer = Layout(Footer(state), name="footer", size=1)
console = state.console
def get_renderable() -> Layout:
current_slide = state.deck[state.current_slide_idx]
body = Layout(name="body", ratio=1)
if state.mode is Mode.SLIDE:
body.update(Padding(current_slide.render(), pad=1))
body.update(Padding(current_slide.content, pad=1))
elif state.mode is Mode.DECK:
n = console.size.width // 30
row_of_current_slide = state.current_slide_idx // n
slides = islice(
enumerate(state.deck.slides, start=1),
n * max(0, row_of_current_slide - (n // 2)) if n ** 2 < len(state.deck) else 0,
None,
grid_width = state.deck_grid_width
row_of_current_slide = state.current_slide_idx // grid_width
num_rows = ceil(len(state.deck) / grid_width)
start_row = clamp(
value=row_of_current_slide - (grid_width // 2),
lower=0,
upper=max(num_rows - grid_width, 0),
)
start_slide_idx = grid_width * start_row
slides = islice(enumerate(state.deck.slides, start=1), start_slide_idx, None)
rows = [Layout(name=str(r)) for r in range(n)]
cols = [[Layout(name=f"{r}-{c}") for c in range(n)] for r, _ in enumerate(rows)]
rows = [Layout(name=str(r)) for r in range(grid_width)]
cols = [
[Layout(name=f"{r}-{c}") for c in range(grid_width)] for r, _ in enumerate(rows)
]
body.split_column(*rows)
for row, layouts in zip(rows, cols):
@ -47,7 +53,7 @@ def present_deck(console: Console, state: State) -> None:
is_active_slide = slide is state.current_slide
layout.update(
Panel(
slide.render(),
slide.content,
title=joinify(" | ", [slide_number, slide.title]),
border_style=Style(
color="bright_cyan" if is_active_slide else None,

@ -6,44 +6,53 @@ from typing import Callable, List, Union
from rich.console import ConsoleRenderable, RichCast
from rich.text import Text
Renderable = Union[RichCast, ConsoleRenderable]
Contentlike = Union[Renderable, Callable[[], Renderable]]
MakeRenderable = Callable[[], ConsoleRenderable]
@dataclass
class Slide:
content: Contentlike = field(default_factory=Text)
content: ConsoleRenderable = field(default_factory=Text)
title: str = ""
def render(self) -> Renderable:
if callable(self.content):
return self.content()
else:
return self.content
@classmethod
def from_function(
cls,
function: MakeRenderable,
title: str = "",
) -> Slide:
class Dynamic(ConsoleRenderable):
def __rich__(self) -> ConsoleRenderable:
return function()
def __call__(self) -> Slide:
return self
Slidelike = Union[Slide, Callable[[], Slide]]
return cls(content=Dynamic(), title=title)
@dataclass
class Deck:
name: str
_slides: List[Slidelike] = field(default_factory=list)
@property
def slides(self) -> List[Slide]:
return [slide() for slide in self._slides]
slides: List[Slide] = field(default_factory=list)
def __getitem__(self, idx: int) -> Slide:
return self.slides[idx]()
return self.slides[idx]
def __len__(self) -> int:
return len(self.slides)
def add_slides(self, *slides: Slidelike) -> Deck:
self._slides.extend(slides)
def add_slides(self, *slides: Slide) -> Deck:
self.slides.extend(slides)
return self
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)
self.add_slides(slide)
return slide_function
return decorator

@ -1,20 +1,22 @@
from dataclasses import dataclass
from typing import Callable, Union
from rich.console import Console
from rich.text import Text
from .modes import Mode
from .slides import Deck, Slide
Textlike = Union[Text, Callable[[], Text]]
TextLike = Union[Text, Callable[[], Text]]
@dataclass
class State:
console: Console
deck: Deck
_current_slide_idx: int = 0
mode: Mode = Mode.SLIDE
_message: Textlike = Text("")
_message: TextLike = Text("")
@property
def current_slide_idx(self) -> int:
@ -44,9 +46,13 @@ class State:
else:
return self._message
def set_message(self, message: Textlike) -> None:
def set_message(self, message: TextLike) -> None:
self._message = message
@property
def deck_grid_width(self) -> int:
return self.console.size.width // 30
@dataclass
class Stateful:

@ -3,3 +3,11 @@ from typing import Any, Iterable, Optional
def joinify(joiner: str, items: Iterable[Optional[Any]]) -> str:
return joiner.join(map(str, filter(None, items)))
def clamp(value: int, lower: int, upper: int) -> int:
if lower > upper:
raise ValueError(
f"Upper bound ({upper}) for clamp must be greater than lower bound ({lower})."
)
return max(min(value, upper), lower)

@ -1,11 +1,13 @@
import sys
import traceback
from io import StringIO
from pathlib import Path
from textwrap import dedent
from typing import Callable, List, Union
import pytest
from click.testing import Result
from rich.console import Console
from typer.testing import CliRunner
from spiel.main import app
@ -28,7 +30,7 @@ def cli(runner: CliRunner) -> CLI:
result = runner.invoke(app, real_args)
print("result:", result)
if result.exc_info is not None:
if result.exc_info is not None: # pragma: debugging
print("traceback:\n")
exc_type, exc_val, exc_tb = result.exc_info
traceback.print_exception(exc_val, exc_val, exc_tb, file=sys.stdout)
@ -49,8 +51,22 @@ def three_slide_deck() -> Deck:
@pytest.fixture
def three_slide_state(three_slide_deck: Deck) -> State:
return State(deck=three_slide_deck)
def output() -> StringIO:
return StringIO()
@pytest.fixture
def console(output: StringIO) -> Console:
return Console(
file=output,
force_terminal=True,
width=80,
)
@pytest.fixture
def three_slide_state(console: Console, three_slide_deck: Deck) -> State:
return State(console=console, deck=three_slide_deck)
@pytest.fixture

@ -0,0 +1,22 @@
from typing import Tuple
import pytest
from hypothesis import given
from hypothesis import strategies as st
from spiel.utils import clamp
@given(st.tuples(st.integers(), st.integers(), st.integers()).filter(lambda x: x[1] <= x[2]))
def test_clamp(value_lower_upper: Tuple[int, int, int]) -> None:
value, lower, upper = value_lower_upper
clamped = clamp(value, lower, upper)
assert clamped <= upper
assert clamped >= lower
@given(st.tuples(st.integers(), st.integers(), st.integers()).filter(lambda x: x[1] > x[2]))
def test_clamp_raises_for_bad_bounds(value_lower_upper: Tuple[int, int, int]) -> None:
value, lower, upper = value_lower_upper
with pytest.raises(ValueError):
clamp(value, lower, upper)

@ -0,0 +1,16 @@
from io import StringIO
from rich.console import Console
from spiel.footer import Footer
from spiel.state import State
def test_deck_name_in_footer(console: Console, output: StringIO, three_slide_state: State) -> None:
footer = Footer(state=three_slide_state)
console.print(footer)
result = output.getvalue()
print(repr(result))
assert three_slide_state.deck.name in result

@ -1,4 +1,22 @@
from spiel.input import deck_mode, next_slide, previous_slide, slide_mode
import os
import string
from random import sample
from typing import List
import hypothesis.strategies as st
from hypothesis import given, settings
from rich.console import Console
from rich.text import Text
from spiel import Deck, Slide
from spiel.input import (
INPUT_HANDLERS,
InputHandler,
deck_mode,
next_slide,
previous_slide,
slide_mode,
)
from spiel.modes import Mode
from spiel.state import State
@ -27,3 +45,23 @@ def test_enter_slide_mode(three_slide_state: State) -> None:
slide_mode(three_slide_state)
assert three_slide_state.mode is Mode.SLIDE
@given(input_handlers=st.lists(st.sampled_from(list(set(INPUT_HANDLERS.values())))))
@settings(max_examples=1_000 if os.getenv("CI") else 100)
def test_input_sequences_dont_crash(input_handlers: List[InputHandler]) -> None:
state = State(
console=Console(),
deck=Deck(
name="deck",
slides=[
Slide(
Text(f"This is slide {n + 1}"), title="".join(sample(string.ascii_letters, 30))
)
for n in range(30)
],
),
)
for input_handler in input_handlers:
input_handler(state)

@ -3,6 +3,7 @@ from textwrap import dedent
from time import sleep
import pytest
from rich.console import Console
from spiel.constants import DECK
from spiel.exceptions import NoDeckFound
@ -28,7 +29,7 @@ def test_can_load_deck_from_valid_file(file_with_empty_deck: Path) -> None:
def test_reloader_triggers_when_file_modified(file_with_empty_deck: Path) -> None:
state = State(load_deck(file_with_empty_deck))
state = State(console=Console(), deck=load_deck(file_with_empty_deck))
reloader = DeckReloader(state=state, deck_path=file_with_empty_deck)
with DeckWatcher(event_handler=reloader, path=file_with_empty_deck, poll=True):

Loading…
Cancel
Save