"""App for presenting code tutorials."""
import asyncio
import concurrent.futures
import os
import time
from typing import Any, ClassVar
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.events import MouseScrollDown, MouseScrollUp
from textual.widgets import Footer, Header, TabbedContent, TabPane, Tabs
from .widgets import Chapter, ImageStep, TitleSlide
[docs]
class TuitorialApp(App):
"""A Textual app for presenting code tutorials."""
CSS = """
Tabs {
dock: top;
}
TabPane {
padding: 1 1;
}
CodeDisplay {
height: auto;
margin: 1;
background: $surface;
color: $text;
border: solid $primary;
padding: 1;
}
#description {
height: auto;
margin: 1;
background: $surface-darken-1;
color: $text;
border: solid $primary;
padding: 1;
}
ContentContainer {
height: auto;
}
#image-container {
align: center middle;
height: auto;
}
#image {
width: auto;
height: auto;
}
#title-container {
align: center middle;
}
#title-rich-log {
overflow-y: auto;
background: black 0%;
width: auto;
height: auto;
/* When removing the border, the whole thing is gone? */
border: solid green 0%;
}
#markdown-container {
height: 1fr;
}
"""
BINDINGS: ClassVar[list[Binding]] = [
Binding("q", "quit", "Quit"),
Binding("down", "next_focus", "Next Focus"),
Binding("up", "previous_focus", "Previous Focus"),
Binding("d", "toggle_dim", "Toggle Dim"),
("r", "reset_focus", "Reset Focus"),
]
def __init__(
self,
chapters: list[Chapter],
title_slide: TitleSlide | None = None,
initial_chapter: int | None = None,
initial_step: int = 0,
) -> None:
super().__init__()
self.chapters: list[Chapter] = chapters
if initial_chapter is None:
initial_chapter = 0 if title_slide is None else -1
self.current_chapter_index: int = initial_chapter
self.initial_chapter: int = initial_chapter
self.initial_step: int = initial_step
self.title_slide = title_slide
self.is_scrolling: bool = False # Flag to track if a scroll action is in progress
self.last_scroll_time: float = 0.0 # Initialize the time of the last scroll event
self.scroll_debounce_time: float = 0.1 # Minimum time between scroll events in seconds
self._predownload_images()
[docs]
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header(show_clock=True)
with TabbedContent():
if self.title_slide:
with TabPane("Title Slide", id="title-slide-tab"):
yield self.title_slide
for i, chapter in enumerate(self.chapters):
with TabPane(chapter.title, id=f"chapter_{i}"):
yield chapter
yield Footer()
[docs]
async def set_chapter(self, chapter_index: int, *, nearest: bool = False) -> None:
"""Set the current chapter and update the display."""
if chapter_index == -1 and self.title_slide:
self.current_chapter_index = -1
self.query_one(TabbedContent).active = "title-slide-tab"
elif 0 <= chapter_index < len(self.chapters):
self.current_chapter_index = chapter_index
self.query_one(TabbedContent).active = f"chapter_{chapter_index}"
await self.current_chapter.update_display()
elif nearest:
chapter_index = max(0, min(len(self.chapters) - 1, chapter_index))
await self.set_chapter(chapter_index)
else:
msg = f"Invalid chapter index: {chapter_index}"
raise ValueError(msg)
[docs]
async def set_step(self, step_index: int) -> None:
"""Set the current step and update the display."""
if self.current_chapter_index >= 0:
n_steps = len(self.current_chapter.steps) - 1
self.current_chapter.current_index = max(0, min(step_index, n_steps))
await self.update_display()
[docs]
async def on_mount(self) -> None:
"""Set initial chapter and step."""
if 0 <= self.initial_chapter < len(self.chapters):
await self.set_chapter(self.initial_chapter)
await self.set_step(self.initial_step)
elif self.title_slide:
await self.set_chapter(-1)
@property
def current_chapter(self) -> Chapter:
"""Get the current chapter."""
return self.chapters[self.current_chapter_index]
[docs]
@on(TabbedContent.TabActivated)
@on(Tabs.TabActivated)
def on_change(self, event: TabbedContent.TabActivated | Tabs.TabActivated) -> None:
"""Handle tab change event and set the current chapter index."""
tab_id = event.pane.id
if tab_id == "title-slide-tab":
self.current_chapter_index = -1
return
assert tab_id.startswith("chapter_")
index = int(tab_id.split("_")[-1])
self.current_chapter_index = int(index)
[docs]
def current_tab_pane(self) -> TabPane:
"""Get the current tab id."""
tab_id = self.query_one(TabbedContent).active
return self.query_one(f"#{tab_id}")
[docs]
async def update_display(self) -> None:
"""Update the display with current focus."""
await self.current_chapter.update_display()
[docs]
async def action_next_focus(self) -> None:
"""Handle next focus action."""
if self.current_chapter_index >= 0:
await self.current_chapter.next_step()
await self.update_display()
[docs]
async def action_previous_focus(self) -> None:
"""Handle previous focus action."""
if self.current_chapter_index >= 0:
await self.current_chapter.previous_step()
await self.update_display()
[docs]
async def action_reset_focus(self) -> None:
"""Reset to first focus pattern."""
if self.current_chapter_index >= 0:
await self.current_chapter.reset_step()
[docs]
async def action_toggle_dim(self) -> None:
"""Toggle dim background."""
await self.current_chapter.toggle_dim()
await self.update_display()
def _predownload_images(self) -> None:
"""Preload images in a thread pool."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
executor = concurrent.futures.ThreadPoolExecutor()
for chapter in self.chapters:
for step in chapter.steps:
if isinstance(step, ImageStep):
loop.run_in_executor(executor, step._maybe_download_image)
if os.getenv("MARKDOWN_CODE_RUNNER"):
def mock_run(*args: Any, **kwargs: Any) -> None:
"""Mock the run method to prevent the app from running."""
TuitorialApp.run = mock_run