mirror of https://github.com/JoshKarpel/spiel
Add Decks (#2)
parent
172eb33c19
commit
52de31de1c
@ -1,3 +1,3 @@
|
||||
# orate
|
||||
# spiel
|
||||
|
||||
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/JoshKarpel/orate/main.svg)](https://results.pre-commit.ci/badge/github/JoshKarpel/orate/main.svg)
|
||||
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/JoshKarpel/spiel/main.svg)](https://results.pre-commit.ci/latest/github/JoshKarpel/spiel/main)
|
||||
|
@ -0,0 +1,89 @@
|
||||
import socket
|
||||
from datetime import datetime
|
||||
|
||||
from rich.align import Align
|
||||
from rich.console import ConsoleRenderable
|
||||
from rich.layout import Layout
|
||||
from rich.markdown import Markdown
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
|
||||
from spiel import Deck, Slide
|
||||
|
||||
DECK = Deck(name="Spiel Demo Deck")
|
||||
|
||||
left_markup = """\
|
||||
## What is Spiel?
|
||||
|
||||
[Spiel](https://github.com/JoshKarpel/spiel) is a framework for building slide decks in Python.
|
||||
|
||||
Spiel uses [Rich](https://rich.readthedocs.io/) to render slide content.
|
||||
"""
|
||||
|
||||
right_markup = """\
|
||||
## Why Spiel?
|
||||
|
||||
It's fun!
|
||||
|
||||
It's weird!
|
||||
"""
|
||||
|
||||
layout = Layout()
|
||||
left = Layout(
|
||||
Markdown(
|
||||
left_markup,
|
||||
justify="center",
|
||||
),
|
||||
ratio=2,
|
||||
)
|
||||
buffer = Layout(" ")
|
||||
right = Layout(
|
||||
Markdown(
|
||||
right_markup,
|
||||
justify="center",
|
||||
),
|
||||
ratio=2,
|
||||
)
|
||||
layout.split_row(left, buffer, right)
|
||||
|
||||
DECK.add_slide(
|
||||
Slide(
|
||||
content=layout,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Now:
|
||||
def __rich__(self) -> ConsoleRenderable:
|
||||
return Align(
|
||||
Text(
|
||||
f"Right now, at {datetime.now()}!",
|
||||
style=Style(color="bright_cyan", bold=True, italic=True),
|
||||
),
|
||||
align="center",
|
||||
)
|
||||
|
||||
|
||||
DECK.add_slide(
|
||||
Slide(
|
||||
content=Now(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Where:
|
||||
def __rich__(self) -> ConsoleRenderable:
|
||||
return Align(
|
||||
Text(
|
||||
f"Right here, at {socket.gethostname()}!",
|
||||
style=Style(color="bright_cyan", bold=True, italic=True),
|
||||
),
|
||||
align="right",
|
||||
)
|
||||
|
||||
|
||||
DECK.add_slide(
|
||||
Slide(
|
||||
content=Where(),
|
||||
)
|
||||
)
|
@ -0,0 +1,14 @@
|
||||
import string
|
||||
from random import sample
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from spiel import Deck, Slide
|
||||
|
||||
DECK = Deck(
|
||||
name="Many Slides",
|
||||
slides=[
|
||||
Slide(Text(f"This is slide {n + 1}"), title="".join(sample(string.ascii_letters, 30)))
|
||||
for n in range(30)
|
||||
],
|
||||
)
|
@ -1,3 +0,0 @@
|
||||
from .main import app
|
||||
|
||||
app()
|
@ -1,13 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
app = typer.Typer()
|
||||
console = Console()
|
||||
|
||||
|
||||
@app.command()
|
||||
def display(path: Path) -> None:
|
||||
console.print(Text(str(path), justify="center"))
|
@ -0,0 +1,2 @@
|
||||
from .constants import __version__
|
||||
from .slides import Deck, Slide
|
@ -0,0 +1,4 @@
|
||||
from .constants import PACKAGE_NAME
|
||||
from .main import app
|
||||
|
||||
app(prog_name=PACKAGE_NAME)
|
@ -0,0 +1,6 @@
|
||||
from importlib import metadata
|
||||
|
||||
PACKAGE_NAME = "spiel"
|
||||
__version__ = metadata.version(PACKAGE_NAME)
|
||||
|
||||
DECK = "DECK"
|
@ -0,0 +1,14 @@
|
||||
class SpielException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DuplicateInputHandler(SpielException):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownModeError(SpielException):
|
||||
pass
|
||||
|
||||
|
||||
class NoDeckFound(SpielException):
|
||||
pass
|
@ -0,0 +1,47 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from rich.console import ConsoleRenderable
|
||||
from rich.style import Style
|
||||
from rich.table import Column, Table
|
||||
|
||||
from spiel.modes import Mode
|
||||
from spiel.state import Stateful
|
||||
from spiel.utils import joinify
|
||||
|
||||
|
||||
@dataclass
|
||||
class Footer(Stateful):
|
||||
@property
|
||||
def longest_slide_number_length(self) -> int:
|
||||
num_slides = len(self.state.deck)
|
||||
return len(str(num_slides))
|
||||
|
||||
def __rich__(self) -> ConsoleRenderable:
|
||||
grid = Table.grid(
|
||||
Column(
|
||||
style=Style(dim=True),
|
||||
justify="left",
|
||||
),
|
||||
Column(
|
||||
style=Style(dim=True),
|
||||
justify="center",
|
||||
),
|
||||
Column(
|
||||
style=Style(dim=True),
|
||||
justify="right",
|
||||
),
|
||||
expand=True,
|
||||
)
|
||||
grid.add_row(
|
||||
joinify(
|
||||
" | ",
|
||||
[
|
||||
self.state.deck.name,
|
||||
self.state.current_slide.title if self.state.mode is Mode.SLIDE else None,
|
||||
],
|
||||
),
|
||||
date.today().isoformat(),
|
||||
f"[{self.state.current_slide_idx + 1:>0{self.longest_slide_number_length}d} / {len(self.state.deck)}]",
|
||||
)
|
||||
return grid
|
@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
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 .exceptions import DuplicateInputHandler
|
||||
from .modes import Mode
|
||||
from .state import State
|
||||
|
||||
IFLAG = 0
|
||||
OFLAG = 1
|
||||
CFLAG = 2
|
||||
LFLAG = 3
|
||||
ISPEED = 4
|
||||
OSPEED = 5
|
||||
CC = 6
|
||||
|
||||
|
||||
@contextmanager
|
||||
def no_echo() -> Iterator[None]:
|
||||
fd = sys.stdin.fileno()
|
||||
|
||||
old = termios.tcgetattr(fd)
|
||||
|
||||
mode = old.copy()
|
||||
mode[LFLAG] = mode[LFLAG] & ~(termios.ECHO | termios.ICANON)
|
||||
mode[CC][termios.VMIN] = 1
|
||||
mode[CC][termios.VTIME] = 0
|
||||
|
||||
try:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, mode)
|
||||
yield
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||
|
||||
|
||||
@unique
|
||||
class SpecialCharacters(Enum):
|
||||
Up = "up"
|
||||
Down = "down"
|
||||
Right = "right"
|
||||
Left = "left"
|
||||
CtrlUp = "ctrl-up"
|
||||
CtrlDown = "ctrl-down"
|
||||
CtrlRight = "ctrl-right"
|
||||
CtrlLeft = "ctrl-left"
|
||||
ShiftUp = "shift-up"
|
||||
ShiftDown = "shift-down"
|
||||
ShiftRight = "shift-right"
|
||||
ShiftLeft = "shift-left"
|
||||
CtrlShiftUp = "ctrl-shift-up"
|
||||
CtrlShiftDown = "ctrl-shift-down"
|
||||
CtrlShiftRight = "ctrl-shift-right"
|
||||
CtrlShiftLeft = "ctrl-shift-left"
|
||||
Backspace = "backspace"
|
||||
CtrlSpace = "ctrl-space"
|
||||
Enter = "enter"
|
||||
|
||||
@classmethod
|
||||
def from_character(cls, character: str) -> SpecialCharacters:
|
||||
return SPECIAL_CHARACTERS[character]
|
||||
|
||||
|
||||
SPECIAL_CHARACTERS = {
|
||||
"\x1b[A": SpecialCharacters.Up,
|
||||
"\x1b[B": SpecialCharacters.Down,
|
||||
"\x1b[C": SpecialCharacters.Right,
|
||||
"\x1b[D": SpecialCharacters.Left,
|
||||
"\x1b[1;5A": SpecialCharacters.CtrlUp,
|
||||
"\x1b[1;5B": SpecialCharacters.CtrlDown,
|
||||
"\x1b[1;5C": SpecialCharacters.CtrlRight,
|
||||
"\x1b[1;5D": SpecialCharacters.CtrlLeft,
|
||||
"\x1b[1;2A": SpecialCharacters.ShiftUp,
|
||||
"\x1b[1;2B": SpecialCharacters.ShiftDown,
|
||||
"\x1b[1;2C": SpecialCharacters.ShiftRight,
|
||||
"\x1b[1;2D": SpecialCharacters.ShiftLeft,
|
||||
"\x1b[1;6A": SpecialCharacters.CtrlShiftUp,
|
||||
"\x1b[1;6B": SpecialCharacters.CtrlShiftDown,
|
||||
"\x1b[1;6C": SpecialCharacters.CtrlShiftRight,
|
||||
"\x1b[1;6D": SpecialCharacters.CtrlShiftLeft,
|
||||
"\x7f": SpecialCharacters.Backspace,
|
||||
"\x00": SpecialCharacters.CtrlSpace,
|
||||
"\n": SpecialCharacters.Enter,
|
||||
}
|
||||
|
||||
ARROWS = [
|
||||
SpecialCharacters.Up,
|
||||
SpecialCharacters.Down,
|
||||
SpecialCharacters.Right,
|
||||
SpecialCharacters.Left,
|
||||
]
|
||||
|
||||
|
||||
def get_character(stream: TextIO) -> Union[str, SpecialCharacters]:
|
||||
result = stream.read(1)
|
||||
|
||||
if result[-1] == "\x1b":
|
||||
result += stream.read(2)
|
||||
|
||||
if result[-1] == "1":
|
||||
result += stream.read(3)
|
||||
|
||||
try:
|
||||
return SpecialCharacters.from_character(result)
|
||||
except KeyError:
|
||||
return result
|
||||
|
||||
|
||||
Character = Union[str, SpecialCharacters]
|
||||
InputHandler = Callable[[State], Optional[NoReturn]]
|
||||
InputHandlerKey = Tuple[Character, Mode]
|
||||
InputHandlerDecorator = Callable[[InputHandler], InputHandler]
|
||||
InputHandlers = MutableMapping[InputHandlerKey, InputHandler]
|
||||
INPUT_HANDLERS: InputHandlers = {} # type: ignore
|
||||
|
||||
|
||||
def handle_input(state: State, stream: TextIO) -> Optional[NoReturn]:
|
||||
character = get_character(stream)
|
||||
|
||||
try:
|
||||
handler = INPUT_HANDLERS[(character, state.mode)]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
return handler(state)
|
||||
|
||||
|
||||
def action(
|
||||
*characters: Character,
|
||||
modes: Optional[Iterator[Mode]] = None,
|
||||
handlers: InputHandlers = INPUT_HANDLERS,
|
||||
) -> InputHandlerDecorator:
|
||||
def decorator(func: InputHandler) -> InputHandler:
|
||||
for character, mode in product(characters, modes or list(Mode)):
|
||||
key: InputHandlerKey = (character, mode)
|
||||
if key in handlers:
|
||||
raise DuplicateInputHandler(
|
||||
f"{character} is already registered as an input handler for mode {mode}"
|
||||
)
|
||||
handlers[key] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@action(SpecialCharacters.Right)
|
||||
def next_slide(state: State) -> None:
|
||||
state.next_slide()
|
||||
|
||||
|
||||
@action(SpecialCharacters.Left)
|
||||
def previous_slide(state: State) -> None:
|
||||
state.previous_slide()
|
||||
|
||||
|
||||
@action("d")
|
||||
def deck_mode(state: State) -> None:
|
||||
state.mode = Mode.DECK
|
||||
|
||||
|
||||
@action("s")
|
||||
def slide_mode(state: State) -> None:
|
||||
state.mode = Mode.SLIDE
|
@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import TracebackType
|
||||
from typing import ContextManager, Optional, Type
|
||||
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.observers.polling import PollingObserver
|
||||
|
||||
from spiel.constants import DECK
|
||||
from spiel.exceptions import NoDeckFound
|
||||
from spiel.slides import Deck
|
||||
from spiel.state import State
|
||||
|
||||
|
||||
def load_deck(deck_path: Path) -> Deck:
|
||||
module_name = "__deck"
|
||||
spec = importlib.util.spec_from_file_location(module_name, deck_path)
|
||||
|
||||
if spec is None:
|
||||
raise FileNotFoundError(f"{deck_path} does not appear to be an importable Python module.")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module) # type: ignore
|
||||
|
||||
try:
|
||||
return getattr(module, DECK)
|
||||
except AttributeError as e:
|
||||
raise NoDeckFound(f"The module at {deck_path} does not have an attribute named {DECK}.")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeckReloader(FileSystemEventHandler):
|
||||
state: State
|
||||
deck_path: Path
|
||||
|
||||
def on_modified(self, event: FileSystemEvent) -> None:
|
||||
self.state.deck = load_deck(self.deck_path)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((type(self), id(self)))
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeckWatcher(ContextManager):
|
||||
event_handler: FileSystemEventHandler
|
||||
path: Path
|
||||
poll: bool = False
|
||||
observer: Optional[Observer] = None
|
||||
|
||||
def __enter__(self) -> DeckWatcher:
|
||||
self.observer = (PollingObserver if self.poll else Observer)(timeout=0.1)
|
||||
self.observer.schedule(self.event_handler, str(self.path))
|
||||
self.observer.start()
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[Type[BaseException]],
|
||||
exc_value: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> Optional[bool]:
|
||||
if self.observer is not None:
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
|
||||
return None
|
@ -0,0 +1,42 @@
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
from typer import Argument, Option, Typer
|
||||
|
||||
from spiel.constants import PACKAGE_NAME, __version__
|
||||
from spiel.load import DeckReloader, DeckWatcher, load_deck
|
||||
from spiel.present import present_deck
|
||||
from spiel.state import State
|
||||
|
||||
app = Typer()
|
||||
console = Console()
|
||||
|
||||
|
||||
@app.command()
|
||||
def present(
|
||||
path: Path = Argument(..., help="The path to the slide deck file."),
|
||||
watch: bool = Option(
|
||||
default=False, help="If enabled, reload the deck when the slide deck file changes."
|
||||
),
|
||||
poll: bool = Option(
|
||||
default=False,
|
||||
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))
|
||||
|
||||
watcher = (
|
||||
DeckWatcher(event_handler=DeckReloader(state, path), path=path, poll=poll)
|
||||
if (watch or poll)
|
||||
else nullcontext()
|
||||
)
|
||||
|
||||
with watcher:
|
||||
present_deck(console, state)
|
||||
|
||||
|
||||
@app.command()
|
||||
def version() -> None:
|
||||
console.print(Text(f"{PACKAGE_NAME} {__version__}"))
|
@ -0,0 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Mode(Enum):
|
||||
SLIDE = "slide"
|
||||
DECK = "deck"
|
@ -0,0 +1,80 @@
|
||||
import sys
|
||||
from itertools import islice
|
||||
|
||||
from rich.console import Console
|
||||
from rich.layout import Layout
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.style import Style
|
||||
|
||||
from .exceptions import UnknownModeError
|
||||
from .footer import Footer
|
||||
from .input import handle_input, no_echo
|
||||
from .modes import Mode
|
||||
from .state import State
|
||||
from .utils import joinify
|
||||
|
||||
|
||||
def present_deck(console: Console, state: State) -> None:
|
||||
def get_renderable() -> Layout:
|
||||
footer = Layout(Footer(state), name="footer", size=1)
|
||||
|
||||
current_slide = state.deck[state.current_slide_idx]
|
||||
|
||||
body = Layout(name="body", ratio=1)
|
||||
if state.mode is Mode.SLIDE:
|
||||
body.update(current_slide.content)
|
||||
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,
|
||||
)
|
||||
|
||||
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)]
|
||||
|
||||
body.split_column(*rows)
|
||||
for row, layouts in zip(rows, cols):
|
||||
for layout in layouts:
|
||||
slide_idx, slide = next(slides, (None, None))
|
||||
if slide is None:
|
||||
layout.update("")
|
||||
else:
|
||||
is_active_slide = slide is state.current_slide
|
||||
layout.update(
|
||||
Panel(
|
||||
slide.content,
|
||||
title=joinify(" | ", [slide_idx, slide.title]),
|
||||
border_style=Style(
|
||||
color="bright_cyan" if is_active_slide else None,
|
||||
dim=not is_active_slide,
|
||||
),
|
||||
)
|
||||
)
|
||||
row.split_row(*layouts)
|
||||
else:
|
||||
raise UnknownModeError(f"Unrecognized mode: {state.mode!r}")
|
||||
|
||||
root = Layout(name="root")
|
||||
root.split_column(body, footer)
|
||||
|
||||
return root
|
||||
|
||||
with no_echo(), Live(
|
||||
get_renderable=get_renderable,
|
||||
console=console,
|
||||
screen=True,
|
||||
auto_refresh=True,
|
||||
refresh_per_second=10,
|
||||
vertical_overflow="visible",
|
||||
) as live:
|
||||
try:
|
||||
while True:
|
||||
handle_input(state, sys.stdin)
|
||||
live.refresh()
|
||||
except Exception:
|
||||
live.stop()
|
||||
console.print_exception(show_locals=True)
|
@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Union
|
||||
|
||||
from rich.console import ConsoleRenderable, RichCast
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
@dataclass
|
||||
class Slide:
|
||||
content: Union[RichCast, ConsoleRenderable] = field(default_factory=Text)
|
||||
title: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Deck:
|
||||
name: str
|
||||
slides: List[Slide] = field(default_factory=list)
|
||||
|
||||
def __getitem__(self, idx: int) -> Slide:
|
||||
return self.slides[idx]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.slides)
|
||||
|
||||
def add_slide(self, slide: Slide) -> Deck:
|
||||
self.slides.append(slide)
|
||||
|
||||
return self
|
@ -0,0 +1,37 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .modes import Mode
|
||||
from .slides import Deck, Slide
|
||||
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
deck: Deck
|
||||
_current_slide_idx: int = 0
|
||||
mode: Mode = Mode.SLIDE
|
||||
|
||||
@property
|
||||
def current_slide_idx(self) -> int:
|
||||
return self._current_slide_idx
|
||||
|
||||
@current_slide_idx.setter
|
||||
def current_slide_idx(self, idx: int) -> None:
|
||||
self._current_slide_idx = max(0, min(len(self.deck) - 1, idx))
|
||||
|
||||
def next_slide(self, move: int = 1) -> None:
|
||||
self.current_slide_idx += move
|
||||
|
||||
def previous_slide(self, move: int = 1) -> None:
|
||||
self.current_slide_idx -= move
|
||||
|
||||
def jump_to_slide(self, idx: int) -> None:
|
||||
self.current_slide_idx = idx
|
||||
|
||||
@property
|
||||
def current_slide(self) -> Slide:
|
||||
return self.deck[self.current_slide_idx]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stateful:
|
||||
state: State
|
@ -0,0 +1,5 @@
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
|
||||
def joinify(joiner: str, items: Iterable[Optional[Any]]) -> str:
|
||||
return joiner.join(map(str, filter(None, items)))
|
@ -1,6 +1,22 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from spiel.constants import PACKAGE_NAME, __version__
|
||||
from tests.conftest import CLI
|
||||
|
||||
|
||||
def test_help(cli: CLI) -> None:
|
||||
result = cli(["--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_help_via_main() -> None:
|
||||
result = subprocess.run([sys.executable, "-m", PACKAGE_NAME, "--help"])
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
def test_version(cli: CLI) -> None:
|
||||
result = cli(["version"])
|
||||
assert result.exit_code == 0
|
||||
assert PACKAGE_NAME in result.stdout
|
||||
assert __version__ in result.stdout
|
||||
|
@ -0,0 +1,11 @@
|
||||
from spiel.slides import Deck, Slide
|
||||
|
||||
|
||||
def test_can_add_slide_to_deck(three_slide_deck: Deck) -> None:
|
||||
initial_len = len(three_slide_deck)
|
||||
new_slide = Slide()
|
||||
|
||||
three_slide_deck.add_slide(new_slide)
|
||||
|
||||
assert len(three_slide_deck) == initial_len + 1
|
||||
assert three_slide_deck[-1] is new_slide
|
@ -0,0 +1,29 @@
|
||||
from spiel.input import deck_mode, next_slide, previous_slide, slide_mode
|
||||
from spiel.modes import Mode
|
||||
from spiel.state import State
|
||||
|
||||
|
||||
def test_next_slide_goes_to_next_slide(three_slide_state: State) -> None:
|
||||
next_slide(three_slide_state)
|
||||
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[1]
|
||||
|
||||
|
||||
def test_previous_slide_goes_to_previous_slide(three_slide_state: State) -> None:
|
||||
three_slide_state.jump_to_slide(2)
|
||||
|
||||
previous_slide(three_slide_state)
|
||||
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[1]
|
||||
|
||||
|
||||
def test_enter_deck_mode(three_slide_state: State) -> None:
|
||||
deck_mode(three_slide_state)
|
||||
|
||||
assert three_slide_state.mode is Mode.DECK
|
||||
|
||||
|
||||
def test_enter_slide_mode(three_slide_state: State) -> None:
|
||||
slide_mode(three_slide_state)
|
||||
|
||||
assert three_slide_state.mode is Mode.SLIDE
|
@ -0,0 +1,22 @@
|
||||
import pytest
|
||||
|
||||
from spiel.exceptions import DuplicateInputHandler
|
||||
from spiel.input import InputHandlers, action
|
||||
from spiel.state import State
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handlers() -> InputHandlers:
|
||||
return {} # type: ignore
|
||||
|
||||
|
||||
def test_register_already_registered_raises_error(handlers: InputHandlers) -> None:
|
||||
@action("a")
|
||||
def a(state: State) -> None: # pragma: never runs
|
||||
pass
|
||||
|
||||
with pytest.raises(DuplicateInputHandler):
|
||||
|
||||
@action("a")
|
||||
def a(state: State) -> None: # pragma: never runs
|
||||
pass
|
@ -0,0 +1,19 @@
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from spiel.utils import joinify
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"joiner, items, expected",
|
||||
[
|
||||
(".", ["a", "b"], "a.b"),
|
||||
(".", ("a", "b"), "a.b"),
|
||||
(".", iter(["a", "b"]), "a.b"),
|
||||
(".", iter(["a", "", "b"]), "a.b"),
|
||||
(".", iter(["a", None, "b"]), "a.b"),
|
||||
],
|
||||
)
|
||||
def test_joinify(joiner: str, items: Iterable[Optional[Any]], expected: str) -> None:
|
||||
assert joinify(joiner, items) == expected
|
@ -0,0 +1,56 @@
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from time import sleep
|
||||
|
||||
import pytest
|
||||
|
||||
from spiel.constants import DECK
|
||||
from spiel.exceptions import NoDeckFound
|
||||
from spiel.load import DeckReloader, DeckWatcher, load_deck
|
||||
from spiel.slides import Deck
|
||||
from spiel.state import State
|
||||
|
||||
|
||||
def test_loading_from_empty_file_fails(empty_file: Path) -> None:
|
||||
with pytest.raises(NoDeckFound, match=DECK):
|
||||
load_deck(empty_file)
|
||||
|
||||
|
||||
def test_loading_from_missing_file_fails(tmp_path: Path) -> None:
|
||||
missing_file = tmp_path / "no-such-path"
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="no-such-path"):
|
||||
load_deck(missing_file)
|
||||
|
||||
|
||||
def test_can_load_deck_from_valid_file(file_with_empty_deck: Path) -> None:
|
||||
assert isinstance(load_deck(file_with_empty_deck), Deck)
|
||||
|
||||
|
||||
def test_reloader_triggers_when_file_modified(file_with_empty_deck: Path) -> None:
|
||||
state = State(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):
|
||||
sleep(0.01)
|
||||
|
||||
file_with_empty_deck.write_text(
|
||||
dedent(
|
||||
"""\
|
||||
from spiel import Deck
|
||||
|
||||
DECK = Deck(name="modified")
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
sleep(0.01)
|
||||
|
||||
for attempt in range(10):
|
||||
if state.deck.name == "modified":
|
||||
return # test succeeded
|
||||
sleep(0.1)
|
||||
|
||||
assert (
|
||||
False
|
||||
), f"Reloader never triggered, current file contents:\n{file_with_empty_deck.read_text()}" # pragma: debugging
|
@ -0,0 +1,43 @@
|
||||
from spiel.state import State
|
||||
|
||||
|
||||
def test_initial_state_has_first_slide_current(three_slide_state: State) -> None:
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[0]
|
||||
|
||||
|
||||
def test_next_from_first_to_second(three_slide_state: State) -> None:
|
||||
three_slide_state.next_slide()
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[1]
|
||||
|
||||
|
||||
def test_next_from_first_to_third(three_slide_state: State) -> None:
|
||||
three_slide_state.next_slide(move=2)
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[2]
|
||||
|
||||
|
||||
def test_jump_to_third_slide(three_slide_state: State) -> None:
|
||||
three_slide_state.jump_to_slide(2)
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[2]
|
||||
|
||||
|
||||
def test_jump_before_beginning_results_in_beginning(three_slide_state: State) -> None:
|
||||
three_slide_state.jump_to_slide(-5)
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[0]
|
||||
|
||||
|
||||
def test_jump_past_end_results_in_end(three_slide_state: State) -> None:
|
||||
three_slide_state.jump_to_slide(len(three_slide_state.deck) + 5)
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[-1]
|
||||
|
||||
|
||||
def test_next_from_last_slide_stays_put(three_slide_state: State) -> None:
|
||||
three_slide_state.jump_to_slide(2)
|
||||
|
||||
three_slide_state.next_slide()
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[2]
|
||||
|
||||
|
||||
def test_previous_from_first_slide_stays_put(three_slide_state: State) -> None:
|
||||
three_slide_state.previous_slide()
|
||||
|
||||
assert three_slide_state.current_slide is three_slide_state.deck[0]
|
Loading…
Reference in New Issue