CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/pypi-textual

Modern Text User Interface framework for building cross-platform terminal and web applications with Python

Overall
score

93%

Overview
Eval results
Files

testing.mddocs/

Testing and Development

Programmatic testing framework with the Pilot class for automated UI testing, command palette system for built-in development tools, logging utilities for debugging applications, and background task management.

Capabilities

Programmatic Testing with Pilot

The Pilot class enables automated testing of Textual applications by simulating user interactions and verifying application state.

class Pilot:
    """Programmatic controller for testing Textual applications."""
    
    def __init__(self, app: App):
        """
        Initialize pilot for an application.
        
        Parameters:
        - app: Textual application to control
        """
    
    async def press(self, *keys: str) -> None:
        """
        Simulate key presses.
        
        Parameters:
        - *keys: Key names to press (e.g., "enter", "ctrl+c", "a")
        """
    
    async def click(
        self,
        selector: str | None = None,
        *,
        offset: tuple[int, int] = (0, 0)
    ) -> None:
        """
        Simulate mouse click.
        
        Parameters:
        - selector: CSS selector for target widget (None for current focus)
        - offset: X,Y offset from widget origin
        """
    
    async def hover(
        self,
        selector: str | None = None,
        *,
        offset: tuple[int, int] = (0, 0)
    ) -> None:
        """
        Simulate mouse hover.
        
        Parameters:
        - selector: CSS selector for target widget
        - offset: X,Y offset from widget origin
        """
    
    async def scroll_down(self, selector: str | None = None) -> None:
        """
        Simulate scroll down.
        
        Parameters:
        - selector: CSS selector for target widget
        """
    
    async def scroll_up(self, selector: str | None = None) -> None:
        """
        Simulate scroll up.
        
        Parameters:
        - selector: CSS selector for target widget
        """
    
    async def wait_for_screen(self, screen: type[Screen] | str | None = None) -> None:
        """
        Wait for a specific screen to be active.
        
        Parameters:
        - screen: Screen class, name, or None for any screen change
        """
    
    async def wait_for_animation(self) -> None:
        """Wait for all animations to complete."""
    
    async def pause(self, delay: float = 0.1) -> None:
        """
        Pause execution.
        
        Parameters:
        - delay: Pause duration in seconds
        """
    
    async def exit(self, result: Any = None) -> None:
        """
        Exit the application.
        
        Parameters:
        - result: Exit result value
        """
    
    # Properties
    app: App  # The controlled application

class OutOfBounds(Exception):
    """Raised when pilot operation is out of bounds."""

class WaitForScreenTimeout(Exception):
    """Raised when waiting for screen times out."""

Command Palette System

Built-in command palette for development tools and application commands.

class CommandPalette(Widget):
    """Built-in command palette widget."""
    
    class Opened(Message):
        """Sent when command palette opens."""
    
    class Closed(Message):
        """Sent when command palette closes."""
    
    class OptionSelected(Message):
        """Sent when command is selected."""
        def __init__(self, option: Hit): ...
    
    def __init__(self, **kwargs):
        """Initialize command palette."""
    
    def search(self, query: str) -> None:
        """
        Search for commands.
        
        Parameters:
        - query: Search query string
        """

class Provider:
    """Base class for command providers."""
    
    async def search(self, query: str) -> Iterable[Hit]:
        """
        Search for commands matching query.
        
        Parameters:
        - query: Search query string
        
        Returns:
        Iterable of matching command hits
        """
    
    async def discover(self) -> Iterable[Hit]:
        """
        Discover all available commands.
        
        Returns:
        Iterable of all command hits
        """

class Hit:
    """Command search result."""
    
    def __init__(
        self,
        match_score: int,
        renderable: RenderableType,
        *,
        command: Callable | None = None,
        help_text: str | None = None,
        id: str | None = None
    ):
        """
        Initialize command hit.
        
        Parameters:
        - match_score: Relevance score for search
        - renderable: Display representation
        - command: Callable to execute
        - help_text: Help description
        - id: Unique identifier
        """
    
    # Properties
    match_score: int
    renderable: RenderableType
    command: Callable | None
    help_text: str | None
    id: str | None

CommandList = list[Hit]
"""Type alias for list of command hits."""

Logging System

Comprehensive logging utilities for debugging and development.

def log(*args, **kwargs) -> None:
    """
    Global logging function.
    
    Parameters:
    - *args: Values to log
    - **kwargs: Keyword arguments to log
    
    Note:
    Logs to the currently active app or debug output if no app.
    """

class Logger:
    """Logger class that logs to the Textual console."""
    
    def __init__(
        self,
        log_callable: LogCallable | None,
        group: LogGroup = LogGroup.INFO,
        verbosity: LogVerbosity = LogVerbosity.NORMAL,
        app: App | None = None,
    ):
        """
        Initialize logger.
        
        Parameters:
        - log_callable: Function to handle log calls
        - group: Log group classification
        - verbosity: Logging verbosity level
        - app: Associated application
        """
    
    def __call__(self, *args: object, **kwargs) -> None:
        """Log values and keyword arguments."""
    
    def verbosity(self, verbose: bool) -> Logger:
        """
        Get logger with different verbosity.
        
        Parameters:
        - verbose: True for high verbosity, False for normal
        
        Returns:
        New logger instance
        """
    
    # Property loggers for different categories
    @property
    def verbose(self) -> Logger:
        """High verbosity logger."""
    
    @property
    def event(self) -> Logger:
        """Event logging."""
    
    @property
    def debug(self) -> Logger:
        """Debug message logging."""
    
    @property
    def info(self) -> Logger:
        """Information logging."""
    
    @property
    def warning(self) -> Logger:
        """Warning message logging."""
    
    @property
    def error(self) -> Logger:
        """Error message logging."""
    
    @property
    def system(self) -> Logger:
        """System information logging."""
    
    @property
    def logging(self) -> Logger:
        """Standard library logging integration."""
    
    @property
    def worker(self) -> Logger:
        """Worker/background task logging."""

class LogGroup(Enum):
    """Log message categories."""
    INFO = "INFO"
    DEBUG = "DEBUG"
    WARNING = "WARNING"
    ERROR = "ERROR"
    EVENT = "EVENT"
    SYSTEM = "SYSTEM"
    LOGGING = "LOGGING"
    WORKER = "WORKER"

class LogVerbosity(Enum):
    """Log verbosity levels."""
    NORMAL = "NORMAL"
    HIGH = "HIGH"

class LoggerError(Exception):
    """Raised when logger operation fails."""

Background Task Management

System for managing background tasks and workers in Textual applications.

class Worker:
    """Background worker for running tasks."""
    
    def __init__(
        self,
        target: Callable,
        *,
        name: str | None = None,
        group: str = "default",
        description: str = "",
        exit_on_error: bool = True,
        start: bool = True
    ):
        """
        Initialize worker.
        
        Parameters:
        - target: Function/coroutine to run
        - name: Worker name
        - group: Worker group name
        - description: Worker description
        - exit_on_error: Whether to exit app on worker error
        - start: Whether to start immediately
        """
    
    def start(self) -> None:
        """Start the worker."""
    
    def cancel(self) -> None:
        """Cancel the worker."""
    
    async def wait(self) -> Any:
        """Wait for worker completion and return result."""
    
    # Properties
    is_cancelled: bool  # Whether worker is cancelled
    is_finished: bool  # Whether worker completed
    state: WorkerState  # Current worker state
    result: Any  # Worker result (if completed)
    error: Exception | None  # Worker error (if failed)

class WorkerState(Enum):
    """Worker execution states."""
    PENDING = "PENDING"
    RUNNING = "RUNNING"
    CANCELLED = "CANCELLED"
    ERROR = "ERROR"
    SUCCESS = "SUCCESS"

class WorkerError(Exception):
    """Base worker error."""

class WorkerFailed(WorkerError):
    """Raised when worker fails."""

def work(exclusive: bool = False, thread: bool = False):
    """
    Decorator for creating background workers.
    
    Parameters:
    - exclusive: Cancel other workers in same group when starting
    - thread: Run in separate thread instead of async task
    
    Usage:
    @work
    async def background_task(self):
        # Background work here
        pass
    """

Timer System

Scheduling and timing utilities for delayed execution and periodic tasks.

class Timer:
    """Scheduled callback timer."""
    
    def __init__(
        self,
        name: str,
        interval: float,
        callback: TimerCallback,
        *,
        repeat: int | None = None,
        pause: bool = False
    ):
        """
        Initialize timer.
        
        Parameters:
        - name: Timer identifier
        - interval: Callback interval in seconds
        - callback: Function to call
        - repeat: Number of times to repeat (None for infinite)
        - pause: Start timer paused
        """
    
    def start(self) -> None:
        """Start the timer."""
    
    def stop(self) -> None:
        """Stop the timer."""
    
    def pause(self) -> None:
        """Pause the timer."""
    
    def resume(self) -> None:
        """Resume paused timer."""
    
    def reset(self) -> None:
        """Reset timer to initial state."""
    
    # Properties
    time: float  # Current time
    next_fire: float  # Time of next callback
    is_active: bool  # Whether timer is running

TimerCallback = Callable[[Timer], None]
"""Type alias for timer callback functions."""

Development Utilities

def get_app() -> App:
    """
    Get the currently active Textual application.
    
    Returns:
    Active App instance
    
    Raises:
    RuntimeError: If no app is active
    """

def set_title(title: str) -> None:
    """
    Set terminal window title.
    
    Parameters:
    - title: Window title text
    """

class DevConsole:
    """Development console for runtime debugging."""
    
    def __init__(self, app: App):
        """
        Initialize dev console.
        
        Parameters:
        - app: Application to debug
        """
    
    def run_code(self, code: str) -> Any:
        """
        Execute Python code in app context.
        
        Parameters:
        - code: Python code to execute
        
        Returns:
        Execution result
        """
    
    def inspect_widget(self, selector: str) -> dict:
        """
        Inspect widget properties.
        
        Parameters:
        - selector: CSS selector for widget
        
        Returns:
        Widget property dictionary
        """

Usage Examples

Basic Application Testing

import pytest
from textual.app import App
from textual.widgets import Button, Input

class TestApp(App):
    def compose(self):
        yield Input(placeholder="Enter text", id="input")
        yield Button("Submit", id="submit")
        yield Button("Clear", id="clear")
    
    def on_button_pressed(self, event: Button.Pressed):
        if event.button.id == "submit":
            input_widget = self.query_one("#input", Input)
            self.log(f"Submitted: {input_widget.value}")
        elif event.button.id == "clear":
            input_widget = self.query_one("#input", Input)
            input_widget.value = ""

@pytest.mark.asyncio
async def test_app_interaction():
    """Test basic app interaction with Pilot."""
    app = TestApp()
    
    async with app.run_test() as pilot:
        # Type in input field
        await pilot.click("#input")
        await pilot.press("h", "e", "l", "l", "o")
        
        # Verify input value
        input_widget = app.query_one("#input", Input)
        assert input_widget.value == "hello"
        
        # Click submit button
        await pilot.click("#submit") 
        
        # Click clear button and verify input is cleared
        await pilot.click("#clear")
        assert input_widget.value == ""

@pytest.mark.asyncio
async def test_keyboard_shortcuts():
    """Test keyboard shortcuts."""
    app = TestApp()
    
    async with app.run_test() as pilot:
        # Enter text and use keyboard shortcut
        await pilot.press("tab")  # Focus input
        await pilot.press("w", "o", "r", "l", "d")
        await pilot.press("enter")  # Should trigger submit
        
        input_widget = app.query_one("#input", Input)
        assert input_widget.value == "world"

Screen Navigation Testing

from textual.app import App
from textual.screen import Screen
from textual.widgets import Button, Static

class SecondScreen(Screen):
    def compose(self):
        yield Static("Second Screen")
        yield Button("Go Back", id="back")
    
    def on_button_pressed(self, event: Button.Pressed):
        if event.button.id == "back":
            self.dismiss("result from second screen")

class NavigationApp(App):
    def compose(self):
        yield Static("Main Screen")
        yield Button("Go to Second", id="goto")
    
    def on_button_pressed(self, event: Button.Pressed):
        if event.button.id == "goto":
            self.push_screen(SecondScreen())

@pytest.mark.asyncio
async def test_screen_navigation():
    """Test screen stack navigation."""
    app = NavigationApp()
    
    async with app.run_test() as pilot:
        # Start on main screen
        assert len(app.screen_stack) == 1
        
        # Navigate to second screen
        await pilot.click("#goto")
        await pilot.wait_for_screen(SecondScreen)
        
        assert len(app.screen_stack) == 2
        assert isinstance(app.screen, SecondScreen)
        
        # Go back to main screen
        await pilot.click("#back")
        
        # Should be back on main screen
        assert len(app.screen_stack) == 1

Custom Command Provider

from textual.app import App
from textual.command import Provider, Hit
from textual.widgets import Static

class CustomCommands(Provider):
    """Custom command provider."""
    
    async def search(self, query: str) -> list[Hit]:
        """Search for custom commands."""
        commands = [
            ("hello", "Say hello", self.say_hello),
            ("goodbye", "Say goodbye", self.say_goodbye),
            ("time", "Show current time", self.show_time),
        ]
        
        # Filter commands by query
        matches = []
        for name, description, callback in commands:
            if query.lower() in name.lower():
                score = 100 - len(name)  # Shorter names score higher
                hit = Hit(
                    score,
                    f"[bold]{name}[/bold] - {description}",
                    command=callback,
                    help_text=description
                )
                matches.append(hit)
        
        return matches
    
    async def say_hello(self) -> None:
        """Say hello command."""
        app = self.app
        status = app.query_one("#status", Static)
        status.update("Hello from command palette!")
    
    async def say_goodbye(self) -> None:
        """Say goodbye command."""
        app = self.app
        status = app.query_one("#status", Static)
        status.update("Goodbye from command palette!")
    
    async def show_time(self) -> None:
        """Show time command."""
        import datetime
        app = self.app
        status = app.query_one("#status", Static)
        current_time = datetime.datetime.now().strftime("%H:%M:%S")
        status.update(f"Current time: {current_time}")

class CommandApp(App):
    COMMANDS = {CustomCommands}  # Register command provider
    
    def compose(self):
        yield Static("Press Ctrl+P to open command palette", id="status")
    
    def on_mount(self):
        # Command palette is automatically available with Ctrl+P
        pass

Background Worker Usage

from textual.app import App
from textual.widgets import Button, ProgressBar, Static
from textual import work
import asyncio

class WorkerApp(App):
    def compose(self):
        yield Static("Click button to start background task")
        yield Button("Start Task", id="start")
        yield Button("Cancel Task", id="cancel")
        yield ProgressBar(id="progress")
    
    @work(exclusive=True)  # Cancel previous workers
    async def long_running_task(self):
        """Background task that updates progress."""
        progress_bar = self.query_one("#progress", ProgressBar)
        
        for i in range(100):
            # Check if cancelled
            if self.workers.get("long_running_task").is_cancelled:
                break
                
            # Update progress
            progress_bar.progress = i + 1
            
            # Simulate work
            await asyncio.sleep(0.1)
        
        # Task completed
        self.log("Background task completed!")
    
    def on_button_pressed(self, event: Button.Pressed):
        if event.button.id == "start":
            # Start background worker
            self.long_running_task()
            
        elif event.button.id == "cancel":
            # Cancel running workers
            worker = self.workers.get("long_running_task")
            if worker and not worker.is_finished:
                worker.cancel()
                self.log("Task cancelled")

Advanced Logging

from textual.app import App
from textual.widgets import Button
from textual import log

class LoggingApp(App):
    def compose(self):
        yield Button("Info", id="info")
        yield Button("Warning", id="warning")
        yield Button("Error", id="error")
        yield Button("Debug", id="debug")
    
    def on_button_pressed(self, event: Button.Pressed):
        button_id = event.button.id
        
        if button_id == "info":
            log.info("This is an info message")
            
        elif button_id == "warning":
            log.warning("This is a warning message")
            
        elif button_id == "error":
            log.error("This is an error message")
            
        elif button_id == "debug":
            log.debug("This is a debug message")
            
        # Log with structured data
        log("Button pressed", button=button_id, timestamp=time.time())
        
        # Verbose logging
        log.verbose("Detailed information about button press", 
                   widget=event.button, 
                   coordinates=(event.button.region.x, event.button.region.y))

Timer-based Updates

from textual.app import App
from textual.widgets import Static
from textual.timer import Timer
import datetime

class ClockApp(App):
    def compose(self):
        yield Static("", id="clock")
    
    def on_mount(self):
        # Update clock every second
        self.set_interval(1.0, self.update_clock)
        
        # Initial update
        self.update_clock()
    
    def update_clock(self):
        """Update the clock display."""
        current_time = datetime.datetime.now().strftime("%H:%M:%S")
        clock = self.query_one("#clock", Static)
        clock.update(f"Current time: {current_time}")

Install with Tessl CLI

npx tessl i tessl/pypi-textual

docs

content.md

core-framework.md

events.md

index.md

styling.md

testing.md

widgets.md

tile.json