"""Custom widgets for the Tuitorial application."""
from __future__ import annotations
import itertools
import os.path
import re
import shutil
import tempfile
import urllib.request
from contextlib import suppress
from dataclasses import dataclass
from io import StringIO
from pathlib import Path
from re import Pattern
from typing import TYPE_CHECKING, Literal, NamedTuple
from PIL import Image as PILImage
from pyfiglet import Figlet
from rich.console import Console
from rich.markdown import Markdown as RichMarkdown
from rich.style import Style
from rich.syntax import Syntax
from rich.text import Text
from textual.containers import Container
from textual.css.scalar import Scalar
from textual.widgets import Markdown, RichLog, Static
from .highlighting import Focus, FocusType, _BetweenTuple, _RangeTuple, _StartsWithTuple
if TYPE_CHECKING:
import textual_image.widget
from textual.app import ComposeResult
[docs]
class Step(NamedTuple):
"""A single step in a tutorial, containing a description and focus patterns."""
description: str
focuses: list[Focus]
[docs]
@dataclass
class ImageStep:
"""A step that displays an image."""
description: str
image: str | Path | PILImage.Image
width: int | str | None = None
height: int | str | None = None
halign: Literal["left", "center", "right"] | None = None
def _maybe_download_image(self) -> None:
"""Download the image to the specified path."""
if os.environ.get("APP_ENV") == "TUITORIAL_DOCKER_WEBAPP":
# Disable image download in the Docker webapp environment
self.image = "'Image download disabled in Docker webapp environment'"
return
if isinstance(self.image, str) and self.image.startswith("http"):
with suppress(Exception):
self.image = _download_image(self.image)
def _download_image(url: str) -> PILImage:
with (
urllib.request.urlopen(url) as response, # noqa: S310
tempfile.NamedTemporaryFile(delete=False) as tmp_file,
):
tmp_file.write(response.read())
return PILImage.open(tmp_file.name)
[docs]
class TitleSlide(Container):
"""A title slide with ASCII art and centered text."""
def __init__(
self,
title: str,
subtitle: str | None = None,
font: str = "ansi_shadow",
gradient: str = "lava",
) -> None:
super().__init__(id="title-slide")
self.title = title
self.subtitle = subtitle or ""
self.font = font
self.ascii_art, self.gradient = _ascii_art(self.title, self.font, gradient_name=gradient)
[docs]
def compose(self) -> ComposeResult:
"""Compose the title slide."""
yield Container(RichLog(id="title-rich-log"), id="title-container")
[docs]
def on_mount(self) -> None:
"""Create and display the ASCII art."""
# Create ASCII art
rich_log = self.query_one("#title-rich-log", RichLog)
for line, color in zip(self.ascii_art, itertools.cycle(self.gradient)):
text = Text.from_markup(f"[{color}]{line}[/]")
rich_log.write(text)
# Center the subtitle
if self.subtitle:
rich_log.write("\n") # Add some spacing
rich_log.write(self.subtitle)
self.refresh()
GRADIENTS = {
"lava": [
"#FF4500", # Red-orange
"#FF6B00", # Orange
"#FF8C00", # Dark orange
"#FFA500", # Orange
"#FF4500", # Back to red-orange
],
"blue": [
"#000080", # Navy
"#0000FF", # Blue
"#1E90FF", # Dodger Blue
"#00BFFF", # Deep Sky Blue
"#87CEEB", # Sky Blue
],
"green": [
"#006400", # Dark Green
"#228B22", # Forest Green
"#32CD32", # Lime Green
"#90EE90", # Light Green
"#98FB98", # Pale Green
],
"rainbow": [
"#FF0000", # Red
"#FFA500", # Orange
"#FFFF00", # Yellow
"#008000", # Green
"#0000FF", # Blue
"#4B0082", # Indigo
"#9400D3", # Violet
],
"pink": [
"#FF1493", # Deep Pink
"#FF69B4", # Hot Pink
"#FFB6C1", # Light Pink
"#FFC0CB", # Pink
"#FF69B4", # Hot Pink
],
"ocean": [
"#000080", # Navy
"#0077BE", # Ocean Blue
"#20B2AA", # Light Sea Green
"#48D1CC", # Medium Turquoise
"#40E0D0", # Turquoise
],
}
def _get_gradient(name: str) -> list[str]:
"""Get a predefined gradient color scheme.
Parameters
----------
name
Name of the gradient to use
"""
if name not in GRADIENTS:
msg = f"Gradient '{name}' not found. Available gradients: `{', '.join(GRADIENTS)}`"
raise ValueError(
msg,
)
return GRADIENTS[name]
def _ascii_art(text: str, font: str, gradient_name: str) -> tuple[list[str], list[str]]:
"""Create ASCII art with the specified gradient.
Parameters
----------
text
Text to convert to ASCII art
font
Font to use for ASCII art
gradient_name
Name of the gradient to use
"""
f = Figlet(font=font)
ascii_text = f.renderText(text)
gradient = _get_gradient(gradient_name)
lines = ascii_text.rstrip().split("\n")
return lines, gradient
[docs]
class Chapter(Container):
"""A chapter of a tutorial, containing multiple steps."""
def __init__(self, title: str, code: str, steps: list[Step | ImageStep]) -> None:
super().__init__()
self.title = title or f"Untitled {id(self)}"
self.code = code
self.steps = steps
self.current_index = 0
self.content = ContentContainer(self.code)
self.description = Static("", id="description")
@property
def current_step(self) -> Step | ImageStep:
"""Get the current step."""
if not self.steps:
return Step("", []) # Return an empty Step object if no steps
return self.steps[self.current_index]
[docs]
async def on_mount(self) -> None:
"""Mount the chapter."""
await self.update_display()
[docs]
async def on_resize(self) -> None:
"""Called when the app is resized."""
await self.update_display()
def _set_description_height(self) -> None:
"""Set the height of the description."""
padding = 4 # For widget padding/borders
height_description = _calculate_heights_of_steps(self.steps, self.description.size.width)
max_description_height = height_description + padding
self.description.styles.height = Scalar.from_number(max_description_height)
[docs]
async def update_display(self) -> None:
"""Update the display with current focus or image."""
step = self.current_step
await self.content.update_display(step)
step_counter = f"**Step {self.current_index + 1}/{len(self.steps)}**\n\n"
markdown_text = _render_markdown(step_counter + step.description)
self.description.update(markdown_text)
self._set_description_height()
[docs]
async def next_step(self) -> None:
"""Handle next focus action."""
self.current_index = (self.current_index + 1) % len(self.steps)
await self.update_display()
[docs]
async def previous_step(self) -> None:
"""Handle previous focus action."""
self.current_index = (self.current_index - 1) % len(self.steps)
await self.update_display()
[docs]
async def reset_step(self) -> None:
"""Reset to first focus pattern."""
self.current_index = 0
await self.update_display()
[docs]
async def toggle_dim(self) -> None:
"""Toggle dim background."""
if isinstance(self.current_step, Step):
code_display = self.content.code_display
code_display.dim_background = not code_display.dim_background
code_display.refresh()
await self.update_display()
[docs]
def compose(self) -> ComposeResult:
"""Compose the chapter display."""
yield Container(self.description, self.content)
def _maybe_image(widget_id: str) -> textual_image.widget.Image:
"""Create an Image widget with optional ID."""
# ref: https://github.com/basnijholt/tuitorial/issues/34
try:
from textual_image.widget import Image
return Image(id=widget_id)
except Exception as e: # noqa: BLE001
msg = (
"Image widget not available, it is likely not supported for your terminal,"
f" see https://github.com/lnqs/textual-image for supported terminals: {e}"
)
return Static(msg, id=widget_id)
class ContentContainer(Container):
"""A container that can display either code, markdown, or image content."""
def __init__(self, code: str) -> None:
"""Initialize the container with a code display widget."""
super().__init__()
self.code_display = CodeDisplay(code, [], dim_background=True)
self.markdown = Markdown(code, id="markdown")
image = _maybe_image(widget_id="image")
image_text = Static("Image not available", id="image-text")
image_text.styles.display = "none"
self.image_container = Container(image, image_text, id="image-container")
def compose(self) -> ComposeResult:
"""Compose the container with both widgets."""
yield self.code_display
yield self.markdown
yield self.image_container
async def show_code(self, focuses: list[Focus]) -> None:
"""Show code content."""
self.code_display.styles.display = "block"
self.markdown.styles.display = "none"
self.image_container.styles.display = "none"
self.code_display.update_focuses(focuses)
async def show_markdown(self) -> None:
"""Show markdown content."""
self.code_display.styles.display = "none"
self.markdown.styles.display = "block"
self.image_container.styles.display = "none"
async def show_image(self, step: ImageStep) -> None:
"""Show image content."""
image_widget = self.query_one("#image")
self.code_display.styles.display = "none"
self.markdown.styles.display = "none"
self.image_container.styles.display = "block"
if not isinstance(image_widget, Static):
image_msg = self.query_one("#image-text", Static)
step._maybe_download_image()
if isinstance(step.image, str | Path) and not os.path.exists(step.image): # noqa: PTH110
image_msg.update(
f"[red bold]Image file not found: {step.image}",
)
image_msg.styles.display = "block"
return
image_msg.styles.display = "none"
image_widget.image = step.image
# Set the image size using styles
if step.width is not None:
width = f"{step.width}" if isinstance(step.width, int) else step.width
image_widget.styles.width = Scalar.parse(width)
if step.height is not None:
height = f"{step.height}" if isinstance(step.height, int) else step.height
image_widget.styles.height = Scalar.parse(height)
if step.halign is not None:
image_widget.styles.align_horizontal = step.halign
async def update_display(self, step: Step | ImageStep) -> None:
"""Update the display based on the step type."""
if isinstance(step, ImageStep):
await self.show_image(step)
return
assert isinstance(step, Step)
markdown = any(f.type == FocusType.MARKDOWN for f in step.focuses)
if markdown:
await self.show_markdown()
else:
await self.show_code(step.focuses)
class CodeDisplay(Static):
"""A widget to display code with highlighting.
Parameters
----------
code
The code to display
focuses
List of Focus objects to apply
dim_background
Whether to dim the non-highlighted text
"""
def __init__(
self,
code: str,
focuses: list[Focus] | None = None,
*,
dim_background: bool = True,
) -> None:
super().__init__(id="code-display")
self.code = code
self.focuses = focuses or []
self.dim_background = dim_background
def update_focuses(self, focuses: list[Focus]) -> None:
"""Update the focuses and refresh the display."""
self.focuses = focuses
self.refresh() # Tell Textual to refresh this widget
def highlight_code(self) -> Text:
"""Apply highlighting to the code."""
# Check if we have a syntax focus
syntax_focuses = [f for f in self.focuses if f.type == FocusType.SYNTAX]
if syntax_focuses:
return _highlight_with_syntax(self.code, syntax_focuses[0])
text = Text(self.code)
ranges = _collect_highlight_ranges(self.code, self.focuses)
sorted_ranges = _sort_ranges(ranges)
_apply_highlights(text, self.code, sorted_ranges, self.dim_background)
return text
def render(self) -> Text:
"""Render the widget content."""
return self.highlight_code()
def _collect_literal_ranges(code: str, focus: Focus) -> set[tuple[int, int, Style]]:
"""Collect ranges for literal focus type."""
ranges = set()
pattern = re.escape(str(focus.pattern))
if getattr(focus, "word_boundary", False) and str(focus.pattern).isalnum():
pattern = rf"\b{pattern}\b"
matches = list(re.finditer(pattern, code))
match_index = focus.extra.get("match_index") if focus.extra else None
if match_index is not None:
if isinstance(match_index, int):
match_indices = [match_index]
elif isinstance(match_index, list):
match_indices = match_index
else:
match_indices = []
for index in match_indices:
if 0 <= index < len(matches):
match = matches[index]
ranges.add((match.start(), match.end(), focus.style))
else:
for match in matches:
ranges.add((match.start(), match.end(), focus.style))
return ranges
def _collect_regex_ranges(code: str, focus: Focus) -> set[tuple[int, int, Style]]:
"""Collect ranges for regex focus type."""
ranges = set()
pattern = (
focus.pattern # type: ignore[assignment]
if isinstance(focus.pattern, Pattern)
else re.compile(focus.pattern) # type: ignore[type-var]
)
assert isinstance(pattern, Pattern)
for match in pattern.finditer(code):
ranges.add((match.start(), match.end(), focus.style))
return ranges
def _collect_line_ranges(code: str, focus: Focus) -> set[tuple[int, int, Style]]:
"""Collect ranges for line focus type."""
ranges = set()
assert isinstance(focus.pattern, int)
line_number = int(focus.pattern)
lines = code.split("\n")
if 0 <= line_number < len(lines):
start = sum(len(line) + 1 for line in lines[:line_number])
end = start + len(lines[line_number])
ranges.add((start, end, focus.style))
return ranges
def _collect_range_ranges(_: str, focus: Focus) -> set[tuple[int, int, Style]]:
"""Collect ranges for range focus type."""
assert isinstance(focus.pattern, _RangeTuple)
start, end = focus.pattern
assert isinstance(start, int)
return {(start, end, focus.style)}
def _collect_highlight_ranges(
code: str,
focuses: list[Focus],
) -> set[tuple[int, int, Style]]:
"""Collect all ranges that need highlighting with their styles."""
ranges = set()
for focus in focuses:
match focus.type:
case FocusType.LITERAL:
ranges.update(_collect_literal_ranges(code, focus))
case FocusType.REGEX:
ranges.update(_collect_regex_ranges(code, focus))
case FocusType.LINE:
ranges.update(_collect_line_ranges(code, focus))
case FocusType.RANGE:
ranges.update(_collect_range_ranges(code, focus))
case FocusType.STARTSWITH:
ranges.update(_collect_startswith_ranges(code, focus))
case FocusType.BETWEEN:
ranges.update(_collect_between_ranges(code, focus))
case FocusType.LINE_CONTAINING | FocusType.LINE_CONTAINING_REGEX:
assert isinstance(focus.extra, dict)
matches = _get_line_containing_matches(
code,
str(focus.pattern),
lines_before=focus.extra.get("lines_before", 0),
lines_after=focus.extra.get("lines_after", 0),
regex=focus.type == FocusType.LINE_CONTAINING_REGEX,
match_index=focus.extra.get("match_index"),
)
ranges.update((start, end, focus.style) for start, end in matches)
case _: # pragma: no cover
msg = f"Unsupported focus type: {focus.type}"
raise ValueError(msg)
return ranges
def _sort_ranges(
ranges: set[tuple[int, int, Style]],
) -> list[tuple[int, int, Style]]:
"""Sort ranges by position and length (longer matches first)."""
return sorted(ranges, key=lambda x: (x[0], -(x[1] - x[0])))
def _is_overlapping(
start: int,
end: int,
processed_ranges: set[tuple[int, int]],
) -> bool:
"""Check if a range overlaps with any processed ranges in an invalid way.
Allows partial overlaps but prevents:
1. Complete containment of the new range
2. Complete containment of an existing range
"""
for p_start, p_end in processed_ranges:
# Skip if either range completely contains the other
if (p_start <= start and p_end >= end) or (start <= p_start and end >= p_end):
return True
# Allow partial overlaps
continue
return False
def _apply_highlights(
text: Text,
code: str,
sorted_ranges: list[tuple[int, int, Style]],
dim_background: bool, # noqa: FBT001
) -> None:
"""Apply highlights without overlaps and dim the background."""
current_pos = 0
processed_ranges: set[tuple[int, int]] = set()
for start, end, style in sorted_ranges:
# Skip if this range overlaps with an already processed range
if _is_overlapping(start, end, processed_ranges):
continue
# Add dim style to gap before this highlight if needed
if dim_background and current_pos < start:
text.stylize(Style(dim=True), current_pos, start)
# Add the highlight style
text.stylize(style, start, end)
processed_ranges.add((start, end))
current_pos = max(current_pos, end)
# Dim any remaining text
if dim_background and current_pos < len(code):
text.stylize(Style(dim=True), current_pos, len(code))
def _collect_startswith_ranges(code: str, focus: Focus) -> set[tuple[int, int, Style]]:
"""Collect ranges for startswith focus type.
Matches and highlights entire lines that start with the pattern
(ignoring leading whitespace) or from the pattern to the end of line.
Parameters
----------
code
The code to search
focus
Focus object containing the pattern to match and whether to match from line starts
If from_start_of_line is True, matches the pattern at the start of any line
(ignoring leading whitespace) and highlights the entire line.
If from_start_of_line is False, finds all occurrences of the pattern anywhere
and highlights from each occurrence to the end of its line.
"""
ranges = set()
assert isinstance(focus.pattern, _StartsWithTuple)
text, from_start_of_line = focus.pattern
assert isinstance(text, str)
assert isinstance(from_start_of_line, bool)
if from_start_of_line:
# Process each line, keeping track of position
pos = 0
for line in code.splitlines(keepends=True):
stripped = line.lstrip()
if stripped.startswith(text):
# Find start of the actual text in the original line
start = pos + line.find(text)
end = pos + len(line.rstrip("\n"))
ranges.add((start, end, focus.style))
pos += len(line)
else:
# Find all occurrences
pos = 0
while True:
# Find next occurrence of pattern
start = code.find(text, pos)
if start == -1:
break
# Find the end of the line containing this occurrence
end = code.find("\n", start)
if end == -1:
end = len(code)
ranges.add((start, end, focus.style))
pos = start + 1
return ranges
def _collect_between_ranges(code: str, focus: Focus) -> set[tuple[int, int, Style]]:
"""Collect ranges for between focus type."""
ranges = set()
assert isinstance(focus.pattern, _BetweenTuple)
start_pattern, end_pattern, inclusive, multiline, match_index, greedy = focus.pattern
# Escape special characters if they're not already regex patterns
if not any(c in start_pattern for c in ".^$*+?{}[]\\|()"):
start_pattern = re.escape(start_pattern)
if not any(c in end_pattern for c in ".^$*+?{}[]\\|()"):
end_pattern = re.escape(end_pattern)
# Create the regex pattern
flags = re.MULTILINE | re.DOTALL if multiline else 0
if inclusive:
# Include the patterns in the match
quantifier = ".*" if greedy else ".*?"
pattern = f"({start_pattern})({quantifier})({end_pattern})"
else:
# Use positive lookbehind/ahead to match between patterns
quantifier = ".*" if greedy else ".*?"
pattern = f"(?<={start_pattern})({quantifier})(?={end_pattern})"
matches = list(re.finditer(pattern, code, flags=flags))
if match_index is not None:
# Only include the specified match
if 0 <= match_index < len(matches):
match = matches[match_index]
if inclusive:
ranges.add((match.start(), match.end(), focus.style))
else:
ranges.add((match.start(1), match.end(1), focus.style))
else:
# Include all matches
for match in matches:
if inclusive:
ranges.add((match.start(), match.end(), focus.style))
else:
ranges.add((match.start(1), match.end(1), focus.style))
return ranges
def _get_line_containing_matches(
text: str,
pattern: str,
*,
lines_before: int = 0,
lines_after: int = 0,
regex: bool = False,
match_index: int | None = None,
) -> list[tuple[int, int]]:
"""Get the start and end positions of lines containing pattern.
Parameters
----------
text
The text to search in
pattern
The pattern to search for
lines_before
Number of lines to include before the matched line
lines_after
Number of lines to include after the matched line
regex
If True, treat pattern as a regular expression
match_index
If provided, only return the nth match (0-based).
If None, return all matches.
"""
lines = text.splitlines(keepends=True)
matches = []
# Find all matches first
for i, line in enumerate(lines):
if regex:
if re.search(pattern, line):
start_idx = max(0, i - lines_before)
end_idx = min(len(lines), i + lines_after + 1)
start_pos = sum(len(l) for l in lines[:start_idx])
end_pos = sum(len(l) for l in lines[:end_idx])
matches.append((start_pos, end_pos))
elif pattern in line:
start_idx = max(0, i - lines_before)
end_idx = min(len(lines), i + lines_after + 1)
start_pos = sum(len(l) for l in lines[:start_idx])
end_pos = sum(len(l) for l in lines[:end_idx])
matches.append((start_pos, end_pos))
# Return specific match if match_index is provided
if match_index is not None:
if 0 <= match_index < len(matches):
return [matches[match_index]]
return []
return matches
def _highlight_with_syntax(code: str, focus: Focus) -> Text:
"""Apply syntax highlighting using Rich's Syntax class.
Parameters
----------
code
The code to highlight
focus
The syntax focus object containing highlighting options
"""
assert isinstance(focus.extra, dict)
# Get the line range
start_line = focus.extra.get("start_line")
end_line = focus.extra.get("end_line")
if start_line is not None or end_line is not None:
lines = code.splitlines()
code = "\n".join(lines[start_line:end_line])
# Create syntax object and get Text
syntax = Syntax(
code,
lexer=focus.extra.get("lexer", "python"),
theme="default" if focus.extra.get("theme") is None else focus.extra["theme"],
line_numbers=focus.extra.get("line_numbers", False),
)
return syntax.highlight(code)
def _render_markdown(text: str, width: int | None = None) -> Text:
"""Render Markdown text to a Rich Text object."""
if width is None or width == 0:
width = shutil.get_terminal_size().columns - 8
string_io = StringIO()
console = Console(file=string_io, width=width, force_terminal=True)
console.print(RichMarkdown(text))
output = string_io.getvalue()
return Text.from_ansi(output)
def _calculate_height(
text: str,
width: int | None = None,
) -> int:
"""Calculate the height of the Markdown content."""
if width is None or width == 0:
width = shutil.get_terminal_size().columns - 8
string_io = StringIO()
console = Console(file=string_io, width=width, force_terminal=True)
console.print(RichMarkdown(text))
output = string_io.getvalue()
return output.count("\n")
def _calculate_heights_of_steps(
steps: list[Step | ImageStep],
width: int | None = None,
) -> int:
"""Calculate the max height across all steps.
Includes the step counter prefix that gets added during rendering.
"""
height = 0
n_steps = len(steps)
for i, step in enumerate(steps):
if isinstance(step, Step):
# Include step counter prefix as it appears in actual rendering
step_counter = f"**Step {i + 1}/{n_steps}**\n\n"
full_content = step_counter + step.description
h_step = _calculate_height(full_content, width)
height = max(height, h_step)
return height