Files
2025-11-30 09:08:16 +08:00

4.0 KiB

Textual Basics

App Structure

Every Textual app follows this pattern:

from textual.app import App, ComposeResult
from textual.widgets import Widget

class MyApp(App):
    """Docstring describing the app."""

    # Optional: Link to external CSS file
    CSS_PATH = "app.tcss"

    # Optional: Inline CSS
    CSS = """
    Screen {
        align: center middle;
    }
    """

    def compose(self) -> ComposeResult:
        """Create child widgets."""
        yield Widget()

    def on_mount(self) -> None:
        """Called when app is mounted and ready."""
        pass

if __name__ == "__main__":
    app = MyApp()
    app.run()

Compose Method

The compose() method yields widgets to add to the app. It's called once during initialization:

def compose(self) -> ComposeResult:
    yield Header()
    yield ContentWidget()
    yield Footer()

Mounting

  • on_mount(): Called when the app/widget is fully mounted and ready
  • mount(): Dynamically add widgets after app starts (returns a coroutine)
async def on_key(self) -> None:
    # Must await when modifying mounted widgets
    await self.mount(NewWidget())
    self.query_one(Button).label = "Modified!"

Reactive Attributes

Reactive attributes automatically update the UI when changed:

from textual.reactive import reactive

class Counter(Widget):
    count = reactive(0)  # Initial value

    def watch_count(self, new_value: int) -> None:
        """Called automatically when count changes."""
        self.query_one(Label).update(f"Count: {new_value}")

    def increment(self) -> None:
        self.count += 1  # Triggers watch_count

Reactive with Bindings

Set bindings=True to auto-refresh footer bindings when reactive changes:

class MyApp(App):
    page = reactive(0, bindings=True)

    def check_action(self, action: str, parameters) -> bool | None:
        """Return None to disable action."""
        if action == "next" and self.page == MAX_PAGES:
            return None  # Dims the key in footer
        return True

Querying Widgets

Find widgets in the DOM:

# Get one widget (raises if not found)
button = self.query_one(Button)
button = self.query_one("#my-id")

# Get multiple widgets
all_buttons = self.query(Button)
for button in all_buttons:
    pass

# Get with CSS selector
widget = self.query_one("#container .special-class")

Messages and Events

Built-in Events

Handle with on_<event> methods:

def on_mount(self) -> None:
    """When mounted."""
    pass

def on_key(self, event: events.Key) -> None:
    """Key pressed."""
    if event.key == "escape":
        self.exit()

Widget Messages

Handle messages from child widgets:

def on_button_pressed(self, event: Button.Pressed) -> None:
    """Button was clicked."""
    self.notify(f"Button {event.button.id} clicked!")

def on_input_changed(self, event: Input.Changed) -> None:
    """Input text changed."""
    self.value = event.value

Custom Messages

Define custom messages in your widgets:

from textual.message import Message

class MyWidget(Widget):
    class ValueChanged(Message):
        """Posted when value changes."""
        def __init__(self, value: int) -> None:
            super().__init__()
            self.value = value

    def update_value(self, new_value: int) -> None:
        self.value = new_value
        self.post_message(self.ValueChanged(new_value))

# Handle in parent
def on_my_widget_value_changed(self, event: MyWidget.ValueChanged) -> None:
    self.notify(f"New value: {event.value}")

Preventing Message Propagation

Stop messages from bubbling to parent:

def on_switch_changed(self, event: Switch.Changed) -> None:
    event.stop()  # Don't propagate to parent
    # Handle here

Preventing Reactive Watchers

Temporarily prevent reactive watchers from firing:

with self.prevent(MyWidget.ValueChanged):
    self.value = new_value  # Won't trigger watch_value or post message