Skip to content

Commit

Permalink
Implement image support and ImageStep
Browse files Browse the repository at this point in the history
  • Loading branch information
basnijholt committed Dec 26, 2024
1 parent 47e09a4 commit 45f4ff5
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 17 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies = [
"pyyaml>=6.0.2",
"rich",
"textual",
"textual-image[textual]>=0.6.6",
]

[dependency-groups]
Expand Down
65 changes: 64 additions & 1 deletion tests/test_app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# tests/test_app.py
from pathlib import Path

import PIL
import pytest

from tuitorial.app import Chapter, Step, TutorialApp
from tuitorial.app import Chapter, ImageStep, Step, TutorialApp
from tuitorial.highlighting import Focus


Expand Down Expand Up @@ -149,3 +152,63 @@ async def test_toggle_dim(chapter) -> None:
assert not app.current_chapter.code_display.dim_background
await pilot.press("d")
assert app.current_chapter.code_display.dim_background


@pytest.fixture
def image_path(tmp_path: Path) -> Path:
im = PIL.Image.new(mode="RGB", size=(200, 200))
filename = tmp_path / "test_image.png"
im.save(filename)
return filename


@pytest.mark.asyncio
async def test_image_step(example_code, image_path: Path):
"""Test ImageStep functionality."""
steps: list[ImageStep | Step] = [
ImageStep("Image Step", str(image_path)),
Step("Code Step", [Focus.literal("def")]),
]
chapter = Chapter("Test Chapter", example_code, steps)
app = TutorialApp([chapter])

async with app.run_test() as pilot:
# Initial state should be ImageStep
assert isinstance(app.current_chapter.current_step, ImageStep)
assert app.current_chapter.image_display.visible
assert not app.current_chapter.code_display.visible

# Move to next step (Code Step)
await pilot.press("down")
assert isinstance(app.current_chapter.current_step, Step)
assert not app.current_chapter.image_display.visible
assert app.current_chapter.code_display.visible

# Move back to ImageStep
await pilot.press("up")
assert isinstance(app.current_chapter.current_step, ImageStep)
assert app.current_chapter.image_display.visible
assert not app.current_chapter.code_display.visible


@pytest.mark.asyncio
async def test_toggle_dim_image_step(example_code, image_path: Path):
"""Test that toggle_dim doesn't affect ImageStep."""
steps: list[ImageStep | Step] = [
ImageStep("Image Step", str(image_path)),
Step("Code Step", [Focus.literal("def")]),
]
chapter = Chapter("Test Chapter", example_code, steps)
app = TutorialApp([chapter])

async with app.run_test() as pilot:
# Initial state should be ImageStep
assert app.current_chapter.image_display.visible
assert not app.current_chapter.code_display.visible

# Press toggle_dim key
await pilot.press("d")

# Ensure toggle_dim didn't affect ImageStep and code display is still not visible
assert app.current_chapter.image_display.visible
assert not app.current_chapter.code_display.visible
3 changes: 2 additions & 1 deletion tuitorial/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""Top-level package for tuitorial."""

from ._version import __version__
from .app import Chapter, Step, TutorialApp
from .app import Chapter, ImageStep, Step, TutorialApp
from .highlighting import Focus

__all__ = [
"Chapter",
"Focus",
"ImageStep",
"Step",
"TutorialApp",
"__version__",
Expand Down
43 changes: 32 additions & 11 deletions tuitorial/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from textual.binding import Binding
from textual.containers import Container
from textual.widgets import Footer, Header, Static, TabbedContent, TabPane, Tabs
from textual_image.widget import Image

from .highlighting import Focus
from .widgets import CodeDisplay
Expand All @@ -19,34 +20,53 @@ class Step(NamedTuple):
focuses: list[Focus]


class ImageStep(NamedTuple):
"""A step that displays an image."""

description: str
image_path: str


class Chapter:
"""A chapter of a tutorial, containing multiple steps."""

def __init__(self, title: str, code: str, steps: list[Step]) -> None:
def __init__(self, title: str, code: str, steps: list[Step | ImageStep]) -> None:
self.title = title or f"Untitled {id(self)}"
self.code = code
self.steps = steps
self.current_index = 0
self.code_display = CodeDisplay(
self.code,
self.current_step.focuses,
[], # Initialize with empty focuses
dim_background=True,
)
self.image_display = Image() # Widget to display images
self.image_display.visible = False # Initially hide the image widget
self.description = Static("", id="description")
self.update_display()

@property
def current_step(self) -> Step:
def current_step(self) -> Step | ImageStep:
"""Get the current step."""
if not self.steps:
return Step("", []) # Return an empty Step object
return Step("", []) # Return an empty Step object if no steps
return self.steps[self.current_index]

def update_display(self) -> None:
"""Update the display with current focus."""
self.code_display.update_focuses(self.current_step.focuses)
"""Update the display with current focus or image."""
step = self.current_step
if isinstance(step, Step):
self.code_display.visible = True
self.image_display.visible = False
self.code_display.update_focuses(step.focuses)
elif isinstance(step, ImageStep):
self.code_display.visible = False
self.image_display.visible = True
self.image_display.image = step.image_path
# No focuses to update for ImageStep

self.description.update(
f"Step {self.current_index + 1}/{len(self.steps)}\n{self.current_step.description}",
f"Step {self.current_index + 1}/{len(self.steps)}\n{step.description}",
)

def next_step(self) -> None:
Expand All @@ -66,13 +86,14 @@ def reset_step(self) -> None:

def toggle_dim(self) -> None:
"""Toggle dim background."""
self.code_display.dim_background = not self.code_display.dim_background
self.code_display.refresh()
self.update_display()
if isinstance(self.current_step, Step):
self.code_display.dim_background = not self.code_display.dim_background
self.code_display.refresh()
self.update_display()

def compose(self) -> ComposeResult:
"""Compose the chapter display."""
yield Container(self.description, self.code_display)
yield Container(self.description, self.code_display, self.image_display)


class TutorialApp(App):
Expand Down
6 changes: 5 additions & 1 deletion tuitorial/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,8 @@ def create_bullet_point_chapter(
# Create steps, each highlighting one bullet point
steps = [Step(extra, [Focus.line(i, style=style)]) for i, extra in enumerate(extras)]

return Chapter(title, code, steps)
return Chapter(
title,
code,
steps, # type: ignore[arg-type]
)
10 changes: 8 additions & 2 deletions tuitorial/parse_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import yaml
from rich.style import Style

from tuitorial import Chapter, Focus, Step, TutorialApp
from tuitorial import Chapter, Focus, ImageStep, Step, TutorialApp
from tuitorial.helpers import create_bullet_point_chapter


Expand Down Expand Up @@ -64,9 +64,15 @@ def _parse_focus(focus_data: dict) -> Focus: # noqa: PLR0911
raise ValueError(msg)


def _parse_step(step_data: dict) -> Step:
def _parse_step(step_data: dict) -> Step | ImageStep:
"""Parses a single step from the YAML data."""
description = step_data["description"]

if "image_path" in step_data:
# It's an ImageStep
image_path = step_data["image_path"]
return ImageStep(description, image_path)
# It's a regular Step
focus_list = [_parse_focus(focus_data) for focus_data in step_data.get("focus", [])]
return Step(description, focus_list)

Expand Down
Loading

0 comments on commit 45f4ff5

Please sign in to comment.