Skip to content

Widgets

In this chapter we will explore widgets in more detail, and how you can create custom widgets of your own.

What is a widget?

A widget is a component of your UI responsible for managing a rectangular region of the screen. Widgets may respond to events in much the same way as an app. In many respects, widgets are like mini-apps.

Information

Every widget runs in its own asyncio task.

Custom widgets

There is a growing collection of builtin widgets in Textual, but you can build entirely custom widgets that work in the same way.

The first step in building a widget is to import and extend a widget class. This can either be Widget which is the base class of all widgets, or one of its subclasses.

Let's create a simple custom widget to display a greeting.

hello01.py
from textual.app import App, ComposeResult, RenderResult
from textual.widget import Widget


class Hello(Widget):
    """Display a greeting."""

    def render(self) -> RenderResult:
        return "Hello, [b]World[/b]!"


class CustomApp(App):
    def compose(self) -> ComposeResult:
        yield Hello()


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

The highlighted lines define a custom widget class with just a render() method. Textual will display whatever is returned from render in the content area of your widget. We have returned a string in the code above, but there are other possible return types which we will cover later.

Note that the text contains tags in square brackets, i.e. [b]. This is console markup which allows you to embed various styles within your content. If you run this you will find that World is in bold.

CustomApp Hello, World!

This (very simple) custom widget may be styled in the same way as builtin widgets, and targeted with CSS. Let's add some CSS to this app.

hello02.py
from textual.app import App, ComposeResult, RenderResult
from textual.widget import Widget


class Hello(Widget):
    """Display a greeting."""

    def render(self) -> RenderResult:
        return "Hello, [b]World[/b]!"


class CustomApp(App):
    CSS_PATH = "hello02.tcss"

    def compose(self) -> ComposeResult:
        yield Hello()


if __name__ == "__main__":
    app = CustomApp()
    app.run()
hello02.tcss
Screen {
    align: center middle;
}

Hello {
    width: 40;
    height: 9;
    padding: 1 2;
    background: $panel;
    color: $text;
    border: $secondary tall;
    content-align: center middle;
}

The addition of the CSS has completely transformed our custom widget.

CustomApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ Hello, World! ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

Static widget

While you can extend the Widget class, a subclass will typically be a better starting point. The Static class is a widget subclass which caches the result of render, and provides an update() method to update the content area.

Let's use Static to create a widget which cycles through "hello" in various languages.

hello03.py
from itertools import cycle

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

hellos = cycle(
    [
        "Hola",
        "Bonjour",
        "Guten tag",
        "Salve",
        "Nǐn hǎo",
        "Olá",
        "Asalaam alaikum",
        "Konnichiwa",
        "Anyoung haseyo",
        "Zdravstvuyte",
        "Hello",
    ]
)


class Hello(Static):
    """Display a greeting."""

    def on_mount(self) -> None:
        self.next_word()

    def on_click(self) -> None:
        self.next_word()

    def next_word(self) -> None:
        """Get a new hello and update the content area."""
        hello = next(hellos)
        self.update(f"{hello}, [b]World[/b]!")


class CustomApp(App):
    CSS_PATH = "hello03.tcss"

    def compose(self) -> ComposeResult:
        yield Hello()


if __name__ == "__main__":
    app = CustomApp()
    app.run()
hello03.tcss
Screen {
    align: center middle;
}

Hello {
    width: 40;
    height: 9;
    padding: 1 2;
    background: $panel;
    border: $secondary tall;
    content-align: center middle;
}

CustomApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ Hola, World! ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

Note that there is no render() method on this widget. The Static class is handling the render for us. Instead we call update() when we want to update the content within the widget.

The next_word method updates the greeting. We call this method from the mount handler to get the first word, and from a click handler to cycle through the greetings when we click the widget.

Default CSS

When building an app it is best to keep your CSS in an external file. This allows you to see all your CSS in one place, and to enable live editing. However if you intend to distribute a widget (via PyPI for instance) it can be convenient to bundle the code and CSS together. You can do this by adding a DEFAULT_CSS class variable inside your widget class.

Textual's builtin widgets bundle CSS in this way, which is why you can see nicely styled widgets without having to copy any CSS code.

Here's the Hello example again, this time the widget has embedded default CSS:

hello04.py
from itertools import cycle

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

hellos = cycle(
    [
        "Hola",
        "Bonjour",
        "Guten tag",
        "Salve",
        "Nǐn hǎo",
        "Olá",
        "Asalaam alaikum",
        "Konnichiwa",
        "Anyoung haseyo",
        "Zdravstvuyte",
        "Hello",
    ]
)


class Hello(Static):
    """Display a greeting."""

    DEFAULT_CSS = """
    Hello {
        width: 40;
        height: 9;
        padding: 1 2;
        background: $panel;
        border: $secondary tall;
        content-align: center middle;
    }
    """

    def on_mount(self) -> None:
        self.next_word()

    def on_click(self) -> None:
        self.next_word()

    def next_word(self) -> None:
        """Get a new hello and update the content area."""
        hello = next(hellos)
        self.update(f"{hello}, [b]World[/b]!")


class CustomApp(App):
    CSS_PATH = "hello04.tcss"

    def compose(self) -> ComposeResult:
        yield Hello()


if __name__ == "__main__":
    app = CustomApp()
    app.run()
hello04.tcss
Screen {
    align: center middle;
}

CustomApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ Hola, World! ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

Scoped CSS

Default CSS is scoped by default. All this means is that CSS defined in DEFAULT_CSS will affect the widget and potentially its children only. This is to prevent you from inadvertently breaking an unrelated widget.

You can disable scoped CSS by setting the class var SCOPED_CSS to False.

Default specificity

CSS defined within DEFAULT_CSS has an automatically lower specificity than CSS read from either the App's CSS class variable or an external stylesheet. In practice this means that your app's CSS will take precedence over any CSS bundled with widgets.

Text in a widget may be marked up with links which perform an action when clicked. Links in console markup use the following format:

"Click [@click='app.bell']Me[/]"

The @click tag introduces a click handler, which runs the app.bell action.

Let's use markup links in the hello example so that the greeting becomes a link which updates the widget.

hello05.py
from itertools import cycle

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

hellos = cycle(
    [
        "Hola",
        "Bonjour",
        "Guten tag",
        "Salve",
        "Nǐn hǎo",
        "Olá",
        "Asalaam alaikum",
        "Konnichiwa",
        "Anyoung haseyo",
        "Zdravstvuyte",
        "Hello",
    ]
)


class Hello(Static):
    """Display a greeting."""

    def on_mount(self) -> None:
        self.action_next_word()

    def action_next_word(self) -> None:
        """Get a new hello and update the content area."""
        hello = next(hellos)
        self.update(f"[@click='next_word']{hello}[/], [b]World[/b]!")


class CustomApp(App):
    CSS_PATH = "hello05.tcss"

    def compose(self) -> ComposeResult:
        yield Hello()


if __name__ == "__main__":
    app = CustomApp()
    app.run()
hello05.tcss
Screen {
    align: center middle;
}

Hello {
    width: 40;
    height: 9;
    padding: 1 2;
    background: $panel;
    border: $secondary tall;
    content-align: center middle;
}

CustomApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ HolaWorld! ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

If you run this example you will see that the greeting has been underlined, which indicates it is clickable. If you click on the greeting it will run the next_word action which updates the next word.

Border titles

Every widget has a border_title and border_subtitle attribute. Setting border_title will display text within the top border, and setting border_subtitle will display text within the bottom border.

Note

Border titles will only display if the widget has a border enabled.

The default value for these attributes is empty string, which disables the title. You can change the default value for the title attributes with the BORDER_TITLE and BORDER_SUBTITLE class variables.

Let's demonstrate setting a title, both as a class variable and a instance variable:

hello06.py
from itertools import cycle

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

hellos = cycle(
    [
        "Hola",
        "Bonjour",
        "Guten tag",
        "Salve",
        "Nǐn hǎo",
        "Olá",
        "Asalaam alaikum",
        "Konnichiwa",
        "Anyoung haseyo",
        "Zdravstvuyte",
        "Hello",
    ]
)


class Hello(Static):
    """Display a greeting."""

    BORDER_TITLE = "Hello Widget"  # (1)!

    def on_mount(self) -> None:
        self.action_next_word()
        self.border_subtitle = "Click for next hello"  # (2)!

    def action_next_word(self) -> None:
        """Get a new hello and update the content area."""
        hello = next(hellos)
        self.update(f"[@click='next_word']{hello}[/], [b]World[/b]!")


class CustomApp(App):
    CSS_PATH = "hello05.tcss"

    def compose(self) -> ComposeResult:
        yield Hello()


if __name__ == "__main__":
    app = CustomApp()
    app.run()
  1. Setting the default for the title attribute via class variable.
  2. Setting subtitle via an instance attribute.
hello06.tcss
Screen {
    align: center middle;
}

Hello {
    width: 40;
    height: 9;
    padding: 1 2;
    background: $panel;
    border: $secondary tall;
    content-align: center middle;
}

CustomApp  Hello Widget ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ HolaWorld! ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ Click for next hello 

Note that titles are limited to a single line of text. If the supplied text is too long to fit within the widget, it will be cropped (and an ellipsis added).

There are a number of styles that influence how titles are displayed (color and alignment). See the style reference for details.

Focus & keybindings

Widgets can have a list of associated key bindings, which let them call actions in response to key presses.

A widget is able to handle key presses if it or one of its descendants has focus.

Widgets aren't focusable by default. To allow a widget to be focused, we need to set can_focus=True when defining a widget subclass. Here's an example of a simple focusable widget:

counter01.py
from textual.app import App, ComposeResult, RenderResult
from textual.reactive import reactive
from textual.widgets import Footer, Static


class Counter(Static, can_focus=True):  # (1)!
    """A counter that can be incremented and decremented by pressing keys."""

    count = reactive(0)

    def render(self) -> RenderResult:
        return f"Count: {self.count}"


class CounterApp(App[None]):
    CSS_PATH = "counter.tcss"

    def compose(self) -> ComposeResult:
        yield Counter()
        yield Counter()
        yield Counter()
        yield Footer()


if __name__ == "__main__":
    app = CounterApp()
    app.run()
  1. Allow the widget to receive input focus.
counter.tcss
Counter {
    background: $panel-darken-1;
    padding: 1 2;
    color: $text-muted;

    &:focus {  /* (1)! */
        background: $primary;
        color: $text;
        text-style: bold;
        outline-left: thick $accent;
    }
}
  1. These styles are applied only when the widget has focus.

CounterApp Count: 0 Count: 0 Count: 0 ^p palette

The app above contains three Counter widgets, which we can focus by clicking or using Tab and Shift+Tab.

Now that our counter is focusable, let's add some keybindings to it to allow us to change the count using the keyboard. To do this, we add a BINDINGS class variable to Counter, with bindings for Up and Down. These new bindings are linked to the change_count action, which updates the count reactive attribute.

With our bindings in place, we can now change the count of the currently focused counter using Up and Down.

counter02.py
from textual.app import App, ComposeResult, RenderResult
from textual.reactive import reactive
from textual.widgets import Footer, Static


class Counter(Static, can_focus=True):
    """A counter that can be incremented and decremented by pressing keys."""

    BINDINGS = [
        ("up,k", "change_count(1)", "Increment"),  # (1)!
        ("down,j", "change_count(-1)", "Decrement"),
    ]

    count = reactive(0)

    def render(self) -> RenderResult:
        return f"Count: {self.count}"

    def action_change_count(self, amount: int) -> None:  # (2)!
        self.count += amount


class CounterApp(App[None]):
    CSS_PATH = "counter.tcss"

    def compose(self) -> ComposeResult:
        yield Counter()
        yield Counter()
        yield Counter()
        yield Footer()


if __name__ == "__main__":
    app = CounterApp()
    app.run()
  1. Associates presses of Up or K with the change_count action, passing 1 as the argument to increment the count. The final argument ("Increment") is a user-facing label displayed in the footer when this binding is active.
  2. Called when the binding is triggered. Take care to add the action_ prefix to the method name.
counter.tcss
Counter {
    background: $panel-darken-1;
    padding: 1 2;
    color: $text-muted;

    &:focus {  /* (1)! */
        background: $primary;
        color: $text;
        text-style: bold;
        outline-left: thick $accent;
    }
}
  1. These styles are applied only when the widget has focus.

CounterApp Count: 1 Count: -2 Count: 0  ↑ Increment  ↓ Decrement ^p palette

Rich renderables

In previous examples we've set strings as content for Widgets. You can also use special objects called renderables for advanced visuals. You can use any renderable defined in Rich or third party libraries.

Lets make a widget that uses a Rich table for its content. The following app is a solution to the classic fizzbuzz problem often used to screen software engineers in job interviews. The problem is this: Count up from 1 to 100, when the number is divisible by 3, output "fizz"; when the number is divisible by 5, output "buzz"; and when the number is divisible by both 3 and 5 output "fizzbuzz".

This app will "play" fizz buzz by displaying a table of the first 15 numbers and columns for fizz and buzz.

fizzbuzz01.py
from rich.table import Table

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


class FizzBuzz(Static):
    def on_mount(self) -> None:
        table = Table("Number", "Fizz?", "Buzz?")
        for n in range(1, 16):
            fizz = not n % 3
            buzz = not n % 5
            table.add_row(
                str(n),
                "fizz" if fizz else "",
                "buzz" if buzz else "",
            )
        self.update(table)


class FizzBuzzApp(App):
    CSS_PATH = "fizzbuzz01.tcss"

    def compose(self) -> ComposeResult:
        yield FizzBuzz()


if __name__ == "__main__":
    app = FizzBuzzApp()
    app.run()
fizzbuzz01.tcss
Screen {
    align: center middle;
}

FizzBuzz {
    width: auto;
    height: auto;
    background: $primary;
    color: $text;
}

FizzBuzzApp ┏━━━━━━━━┳━━━━━━━┳━━━━━━━┓ NumberFizz?Buzz? ┡━━━━━━━━╇━━━━━━━╇━━━━━━━┩ 1      2      3     fizz  4      5     buzz  6     fizz  7      8      9     fizz  10    buzz  11     12    fizz  13     14     15    fizz buzz  └────────┴───────┴───────┘

Content size

Textual will auto-detect the dimensions of the content area from rich renderables if width or height is set to auto. You can override auto dimensions by implementing get_content_width() or get_content_height().

Let's modify the default width for the fizzbuzz example. By default, the table will be just wide enough to fix the columns. Let's force it to be 50 characters wide.

fizzbuzz02.py
from rich.table import Table

from textual.app import App, ComposeResult
from textual.geometry import Size
from textual.widgets import Static


class FizzBuzz(Static):
    def on_mount(self) -> None:
        table = Table("Number", "Fizz?", "Buzz?", expand=True)
        for n in range(1, 16):
            fizz = not n % 3
            buzz = not n % 5
            table.add_row(
                str(n),
                "fizz" if fizz else "",
                "buzz" if buzz else "",
            )
        self.update(table)

    def get_content_width(self, container: Size, viewport: Size) -> int:
        """Force content width size."""
        return 50


class FizzBuzzApp(App):
    CSS_PATH = "fizzbuzz02.tcss"

    def compose(self) -> ComposeResult:
        yield FizzBuzz()


if __name__ == "__main__":
    app = FizzBuzzApp()
    app.run()
fizzbuzz02.tcss
Screen {
    align: center middle;
}

FizzBuzz {
    width: auto;
    height: auto;
    background: $primary;
    color: $text;
}

FizzBuzzApp ┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓ Number         Fizz?        Buzz?        ┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩ 1               2               3              fizz          4               5              buzz         6              fizz          7               8               9              fizz          10             buzz         11              12             fizz          13              14              15             fizz         buzz         └─────────────────┴───────────────┴──────────────┘

Note that we've added expand=True to tell the Table to expand beyond the optimal width, so that it fills the 50 characters returned by get_content_width.

Tooltips

Widgets can have tooltips which is content displayed when the user hovers the mouse over the widget. You can use tooltips to add supplementary information or help messages.

Tip

It is best not to rely on tooltips for essential information. Some users prefer to use the keyboard exclusively and may never see tooltips.

To add a tooltip, assign to the widget's tooltip property. You can set text or any other Rich renderable.

The following example adds a tooltip to a button:

tooltip01.py
from textual.app import App, ComposeResult
from textual.widgets import Button

TEXT = """I must not fear.
Fear is the mind-killer.
Fear is the little-death that brings total obliteration.
I will face my fear."""


class TooltipApp(App):
    CSS = """
    Screen {
        align: center middle;
    }
    """

    def compose(self) -> ComposeResult:
        yield Button("Click me", variant="success")

    def on_mount(self) -> None:
        self.query_one(Button).tooltip = TEXT


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

TooltipApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ Click me ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

TooltipApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. I will face my fear.

Customizing the tooltip

If you don't like the default look of the tooltips, you can customize them to your liking with CSS. Add a rule to your CSS that targets Tooltip. Here's an example:

tooltip02.py
from textual.app import App, ComposeResult
from textual.widgets import Button

TEXT = """I must not fear.
Fear is the mind-killer.
Fear is the little-death that brings total obliteration.
I will face my fear."""


class TooltipApp(App):
    CSS = """
    Screen {
        align: center middle;
    }
    Tooltip {
        padding: 2 4;
        background: $primary;
        color: auto 90%;
    }
    """

    def compose(self) -> ComposeResult:
        yield Button("Click me", variant="success")

    def on_mount(self) -> None:
        self.query_one(Button).tooltip = TEXT


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

TooltipApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ Click me ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

TooltipApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ I must not fear. Fear is the mind-killer. Fear is the little-death that  brings total obliteration. I will face my fear.

Loading indicator

Widgets have a loading reactive which when set to True will temporarily replace your widget with a LoadingIndicator.

You can use this to indicate to the user that the app is currently working on getting data, and there will be content when that data is available. Let's look at an example of this.

loading01.py
from asyncio import sleep
from random import randint

from textual import work
from textual.app import App, ComposeResult
from textual.widgets import DataTable

ROWS = [
    ("lane", "swimmer", "country", "time"),
    (4, "Joseph Schooling", "Singapore", 50.39),
    (2, "Michael Phelps", "United States", 51.14),
    (5, "Chad le Clos", "South Africa", 51.14),
    (6, "László Cseh", "Hungary", 51.14),
    (3, "Li Zhuhao", "China", 51.26),
    (8, "Mehdy Metella", "France", 51.58),
    (7, "Tom Shields", "United States", 51.73),
    (1, "Aleksandr Sadovnikov", "Russia", 51.84),
    (10, "Darren Burns", "Scotland", 51.84),
]


class DataApp(App):
    CSS = """
    Screen {
        layout: grid;
        grid-size: 2;
    }
    DataTable {
        height: 1fr;
    }
    """

    def compose(self) -> ComposeResult:
        yield DataTable()
        yield DataTable()
        yield DataTable()
        yield DataTable()

    def on_mount(self) -> None:
        for data_table in self.query(DataTable):
            data_table.loading = True  # (1)!
            self.load_data(data_table)

    @work
    async def load_data(self, data_table: DataTable) -> None:
        await sleep(randint(2, 10))  # (2)!
        data_table.add_columns(*ROWS[0])
        data_table.add_rows(ROWS[1:])
        data_table.loading = False  # (3)!


if __name__ == "__main__":
    app = DataApp()
    app.run()
  1. Shows the loading indicator in place of the data table.
  2. Insert a random sleep to simulate a network request.
  3. Show the new data.

DataApp ● ● ● ● ● ● ● ●  ● ● ● ● ● ● ● ● 

In this example we have four DataTable widgets, which we put into a loading state by setting the widget's loading property to True. This will temporarily replace the widget with a loading indicator animation. When the (simulated) data has been retrieved, we reset the loading property to show the new data.

Tip

See the guide on Workers if you want to know more about the @work decorator.

Line API

A downside of widgets that return Rich renderables is that Textual will redraw the entire widget when its state is updated or it changes size. If a widget is large enough to require scrolling, or updates frequently, then this redrawing can make your app feel less responsive. Textual offers an alternative API which reduces the amount of work required to refresh a widget, and makes it possible to update portions of a widget (as small as a single character) without a full redraw. This is known as the line API.

Note

The Line API requires a little more work that typical Rich renderables, but can produce powerful widgets such as the builtin DataTable which can handle thousands or even millions of rows.

Render Line method

To build a widget with the line API, implement a render_line method rather than a render method. The render_line method takes a single integer argument y which is an offset from the top of the widget, and should return a Strip object containing that line's content. Textual will call this method as required to get content for every row of characters in the widget.

eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT28hcdTAwMTL9nl9BsV/2Vlx1MDAwNe109zxTtXVcdTAwMGJIIISER4BcdTAwMTBya4tcdTAwMTK2bFx1MDAxNORcdTAwMDe2eG7lv99cdTAwMWWHWPJDxjY2ce5d71x1MDAwNowk2+OZc7pPP0Z/v1haWk7vmtHyq6Xl6LZcdTAwMTQmcblcdTAwMTXeLL/0x6+jVjtu1PlcdTAwMTR2/m43rlqlzpXnadpsv/rjj1rYuojSZlx1MDAxMpai4DpuX4VJO70qx42g1Kj9XHUwMDExp1Gt/W//cyesRX82XHUwMDFitXLaXG6yXHUwMDBmWYnKcdpoff+sKIlqUT1t87v/h/9eWvq78zM3ulZUSsN6NYk6L+icylx1MDAwNqjR9Vx1MDAxZt1p1DuDtWhJkJa6e0Hcfs1cdTAwMWaXRmU+W+EhR9lcdTAwMTl/aFndXHUwMDFlnVx1MDAxY16fNnfM0d398cmhkqdlzD61XHUwMDEyJ8lBepd8n4mwdH7Vyo2pnbZcdTAwMWFcdTAwMTfRcVxcTs/5vOw73n1du8GTkL2q1biqntejtv/+0D3aaIalOL3zX0J0XHUwMDBmfp+DV0vZkVv+a0VcdTAwMDVOklx1MDAwMuuUMMZcdJd9sn89KFxurFJGgUGhrXR941pvJLxcdTAwMTI8rt9UhcolmY3sLCxdVHl49XL3mrRcdTAwMTXW282wxeuVXXfz8I3JUICCXHUwMDAwVffUeVx1MDAxNFfPU1x1MDAwZiPUgVLOKtLKOevQZMOIOstcdTAwMDFaXHUwMDFhRONM9u38hze3ylx1MDAxZGT8lZ+wevlhwupXSZKN159404+mPKJyK920Z9VduFx1MDAxMVxmiFx1MDAxOGpn764qm4er3e/UXHUwMDAzv7DVatwsd898e3iWjeiqWVx1MDAwZdOHL2GURGLgWYvd80lcXL/oXHUwMDFmbNIoXWQw7Fx1MDAxY/32clxu+IOAQvzzSljhQMmx8Z9cXLy7PD1vbev9N3uJhlx1MDAwYrmLd1x1MDAxYlx1MDAwNfgvtVx1MDAxYe32ynmYls6LOIAz4lx1MDAwMIhHSeBE4LRgiFx1MDAxYqFcdTAwMDXwXHUwMDFh9JDAuMA5XHUwMDA3vCokrdVAhSRcdTAwMTCdx/QkUFJcdTAwMDaakCRcdFx1MDAwNVx1MDAwMEOoQFpcdTAwMDRcdTAwMTaYplKTpy3qXHUwMDAxKpBTXGYkUnZmVFx1MDAxOFx1MDAwMVaDRln9LGA1aFx1MDAwYrHqnCG2XGZkx1x1MDAwNqvaxvVSZecmxNXjk1x1MDAxYqrCadIuMtZ9gPt5MIWA0JA2glx1MDAxNFx0hFx1MDAwMZhK6yRcdTAwMWFUxlhw84QpXHUwMDA1XHUwMDAytEKrXGaw0bWDOEVcdTAwMWJoLZgvWqAmxvQgTlx1MDAxZCn2KFx1MDAxYWZnslx1MDAxZsOpmVx1MDAxNU6jJImb7eGKQkNcdTAwMTFKnbcsykpcdTAwMWNcdTAwMWKk+1uV6+279t37xsV++nqrtd3ePL+cXHUwMDA2pPB8IPUwJJBsw7RcdTAwMDX2IL0gtS5QhtFAiEJYJfqFzkQg/a1cdTAwMTIqVDhcYlCgQFxuROU08i822nJcdTAwMTChgIHSWjJcbkEgXHUwMDExylx1MDAwMVEh2bxaySrwf1xuoCbnVvpcdTAwMDCK1pFGY8dcdTAwMDcolurV++Rz+aB+bj/o9kH49cRcdTAwMTS5/EVcdTAwMDGo4oVcdTAwMTdCXHUwMDFha0AjsLPsXHUwMDA1qFxyXHUwMDE4XHUwMDExvFx1MDAxNk4oyVx1MDAwMNZPXHUwMDA06JlcdTAwMTBqXlx1MDAwMCW2v0Yw1Z5ccqD2OVx1MDAwMKpcdTAwMGI1qXKs0YhyjHxcZqDHe1x1MDAxN/d7n87XXHUwMDEy8UnvbO2+PvhcdTAwMWNcdTAwMDItOEBRXHUwMDA20kipXHUwMDE0aFx1MDAwMM1cdTAwMTBQvVxiNYFcdTAwMTGCueqIp4qVwJNcdTAwMTBcbnjGmnZeXGLl8TlhrNC/XHUwMDFlQkdqUetcbr08XHUwMDAwSZBKuPFBWj7Y3CuX3lx1MDAxZlx1MDAxZX01N1dcdTAwMWZut1Vauf70XFwgXHUwMDE1U4FUXHUwMDA0QILYO3JULpSPm7BcdTAwMDekoFxcXHUwMDAw0jlEXHUwMDA2MVx1MDAxOYbIk1BqnFx1MDAxMlx1MDAxNcQhrt5cdTAwMDdEikNcdTAwMDNyUqNcdTAwMWOePtBcdTAwMWO/SZSKjFx1MDAxNPwzXHUwMDA35Vx1MDAxZoZUasPhnZ5cdTAwMDSmWVrgXHUwMDA3ZOjhyLdi9HZfMySp8PHy+uaotH178WXjrb7/fLe+d4234yVcdTAwMTVejnrfuSYrXGZcdTAwMWJkSbPiXFxcdTAwMWHdpsPohrYw9EMjrVx1MDAwNDOBSzhxXHUwMDFm31x1MDAxZidcdTAwMDdvjqp2rXK1trL1QZVcdTAwMGWnS9M9o1Mgli0sSViyoPWhXHUwMDE39NFcclx1MDAwMz5swElcdTAwMDFI4Pqj0tlcdTAwMDV/mMuOZFx1MDAxNFP9lGLyK+/DZmj650SeR0HOVoVtuppcdTAwMDDkXHUwMDE5mFx1MDAxYfX0IL7vXHUwMDAwVfRcdTAwMWPdXGJrcXLXg4dcdTAwMGX6X3Vmulx1MDAxYaVcdTAwMDFPfzlqnfKnRb/f/Sn+lV+yduRcdTAwMGb7V9uel68mcdVcdTAwMTNmOYkqvUxK41KYdE+njWZ2tsTDXHT57Vpb5f6v1WjF1bhcdTAwMWUmh49cZm0qVlNxqFxmXHUwMDFjXHUwMDBikdMwfvJRXHUwMDFklsT2znFsWnt2o11cdTAwMGJ33ea7rV+C1Up4SrNzXCKle4NlNq1cdTAwMDEosFx1MDAxNkmwi2NcdTAwMTU2P1ZcdTAwMGZLNlx1MDAwZbKa1Vx1MDAxY0p2o5l5mSepL0vr7urg8qDkbPJ2O6xcXGxdfjibXHUwMDE5qVnTimzZflxuqWFxSVxyU5JajojehHLkI5qxSZ2YKt6n+9H1p9219fhzJV7Zw5tfgNQqQMGKmONcdTAwMTFcdTAwMTamprekhopcdTAwMDJhQLP168R4pm9gs/TUQ2K2IZ6anOPh2GdcInWj9CVsnVTfXHUwMDFlxXs7d+5g/8Jd7u/OjtSan/1cXFLj4pJcdTAwMWFcdTAwMWYh9fdcdFx1MDAxZsJqsLk0Wb+vJiU5XG5cdTAwMTg/aThaqy0qq1lWM29cdTAwMWRcdTAwMWFAzf6YSd6rwDWfVtrn45RcdTAwMDUgnJ9cdTAwMDJcdTAwMDewgVWGPOJRO8rlKLPUXGZcdTAwMDRoXHUwMDA1x2eWQ3Difznp8KNkzr5cdTAwMWOJ9CS0XHUwMDFmXGZ68eHIiKB3XHUwMDE0X0lLmspcdLfTsJWuxfVyXFyv9lx1MDAwZeyhJWRrjGCvw/DSlVx1MDAxZuWKXGKUlZJNtjI8JG0zseVnJmzyNTqwqKzzVXOSzFxioVx1MDAwNr480+3xQX1U5eZXiCvq6HiXPtRcdTAwMGWqq9VwtWBQXHUwMDA2wPB/1pLjNVdcdTAwMDNjXHUwMDAyXG5cYqWUwvjAz6EzXHUwMDAzY0rCdrreqNXilOd+r1x1MDAxMdfT/jnuTOaqJ/95XHUwMDE0XHUwMDBlmFx1MDAxNv5O+XP9VqLp37HX7GfPljJcdTAwMWF1/ug+/+vl0KuLsf39bD+qs/d7kf89uYFcdTAwMTNS9Fx1MDAxZv5h4CRcdTAwMThfUp+guDxauC6uhbNcdTAwMDFLe9ZcdTAwMDO+PFwiOf7qXHUwMDE1LiBcdTAwMDOn0WhpNIl8d8LMXHJcdTAwMWPKwFdg0DphhVOZQ8/6IJitvCp8gXS+ijOsJUghaWEmalx0mrl94zHgJL08k9q30WFvzpSIXHUwMDAwhFx1MDAwNuGs9Wl9XHUwMDBlrVx1MDAxNeSu+m5LePk77S+KULMxXHUwMDE5/OpjWbeTnd23qzWx6u7bZ7T1+vBetdebw4eEmlx1MDAxOExaW7Ik+HPNwJBAXHUwMDA0JFx1MDAxNS8w+1XprFx1MDAwNPdrm7dcImT7x8ogqGdm3vKxwEDzXGbrXHUwMDE29M1EY9u30Vx1MDAxYX5x7Vx1MDAxYrJEQ1x1MDAwNGRWSqFtb1x1MDAwZVx1MDAxNTXbN+NcdTAwMWJalCAgkvOLzNin+5KEVk5LadlMXHLpoFx1MDAwMVx1MDAxNVx1MDAwMFx1MDAwM1x1MDAwNSQrXHUwMDEydJZoiIWz1lxuMmaSTq+ZW7ipI64xLdzoXHUwMDFjQI85YelGzCpjkWWEM4NcdTAwMDbO16TYhnhcdTAwMDPoW+zUYMVmLFx1MDAwYrd1e5rcrVSPcUW3PsLnzXfNK7k3fEhsctF6YUasZyzpIUNcIoau0lxuoNP++ovbt0Jg+8fKIKYnNHCFWSco7lxyRI7IWDVOXHUwMDEyoI7U51x1MDAxM5m3wu7Actg+j2bZyc3YXHUwMDE2qHjWmY/ev/dFp8h+lGkhnWKZl/dcdTAwMDYzzzrl2oeyrFM21q7xXHUwMDEyXGZcdTAwMDRHM+yu6mJptkXQl6Ped751J2JdlKVTp8tmge452s1mZaWGXHUwMDFm2ayDtFx1MDAxNTd//087qvopfbnUfVx1MDAxMlx1MDAwNMFfXHUwMDA1SS3d8y7zTmo9MsKRpmNkP4fLtSn221x1MDAwZtZGPvKewH7EryVtv0nCS6rJXHUwMDBioq9cdTAwMWI3t7VcInm0IP1cdTAwMWMrXCKQaIT1qlx1MDAxM1xmWtvfXHUwMDE3XHUwMDA3Tlx1MDAwN1xuXHUwMDA0XHUwMDBiVO8/zdOa4Ofdz+FNvlx1MDAwNtLuacroiVxyXHUwMDFk8yxfkW9cdTAwMDM3z9PsxIFjYfLXi+lOz8H4RZ1K+0vdnF9cXH84PLtf+1irt+tuM1l4dqBwznI4L1x1MDAxOXhGqL6aXHUwMDBlSfa+TivB/6N4Ws/onLnB4ouv6Nm18Vx1MDAxM7gxzypcdTAwMTD5lqBcXNpnTk1JJFx1MDAwYiud2vq9RGqCRurRSZNcdTAwMDWVnDogjUL4rVNWXHSw/d1cdTAwMGJcIjDo87iW8WjNXHUwMDFjXHUwMDBinWNKTuLoz29cdTAwMTN6XHUwMDE2xTlX62+F31fzjzKciTIs7k8q7jpcdTAwMTSKpFx1MDAxNdaNnzNcdTAwMWKdNFhQhqtcdTAwMDBcdTAwMTUq0dnAxXymfrdnXHUwMDAyXHJW++SF1ajnV1x1MDAxMVx1MDAxOJPhljQoXprniSnn6sNcdTAwMThcXEr/XHUwMDEz+82X4a44Ka6N76PNV8dcdTAwMWYjOJhcdTAwMWS92fq0cXK11npT3r9d/bJn381U146i9zBh+yi9/T5O5dCXu5Umlvn9/Fx1MDAwZVgtXHUwMDEyKVx1MDAwZfeU0UZcdTAwMTdcdTAwMGLbMbb/j1x1MDAxMLaghm0hXHUwMDFk7FRija2cmSysW9Rd/lJcdTAwMTgzVWVwqk6l90yypdW9raXjTl/QXCJ0KPVcdTAwMGZpJINcdTAwMGIrW7r4XHUwMDBlXHUwMDA2KKRcdTAwMDSfa1x1MDAxZb+LePSSz3lX+FRcdTAwMTT2XHJcdTAwMTOGY0Gyxlx1MDAxN4Ogd1x1MDAxYlx1MDAwZVx1MDAwN4zOXHUwMDE3iDhe1Fx1MDAxMmGEh35cdTAwMWGBpVx1MDAwYoS0vqeRQ1x1MDAwMVx1MDAxNlx1MDAwNEPobGWgrZagJZtcdTAwMTOgXFz+6MF3I1x1MDAwN21mlvtcdTAwMDVGsk/lXHUwMDBiXHUwMDAys69ajXZcdTAwMDZLPUVwYVx00Vx1MDAxOFx1MDAxMKT81FHuqodcIjhcdTAwMDbGMZitXHUwMDAzniRp7WCPz1h1q9H7zHpcdTAwMDYloNM6gdJcdTAwMDdcdTAwMWHo01x1MDAxZYOjooC1oUFeclx1MDAwNpiU4tcuXVx1MDAxNULYP/rBm73Zi/zv6dJrVHxjXHUwMDBi35BmnZ5AhVT3bk72t1blUUlhfFgtl0qfP5ZnqkImtGDqUVx1MDAwYlx1MDAxNiAozfAxvu8kn9TsaFx1MDAxMCNcdTAwMDLpXHUwMDA0XHUwMDBiXHUwMDE0R8hRRv+wZpZbU96OknWK/OpcdTAwMDNcZuudfjS5ZslqY1xyPq2ncpFza7xcdTAwMDIzvOFLkTBXuW6HPkpcdTAwMTAvgGOvP75T39i5rq2vJPK02Tyrne5v7X51Z5s/OfKWj0rzwJBjny4sXHUwMDE4Ry5/XHUwMDAzle+0kFx1MDAwMet2sEjOXGIj5rjfb9x6rtTkd8o9z81cYizHXHUwMDAyk2w7/Sc6ntY7gSws/liw3v+Pn1x1MDAwM/uyefYubFx1MDAxZdy//Xik11x1MDAwZeMvd9vVm2e7XHUwMDFiw1TOybeFS/Y9yMFcdTAwMDSgXHUwMDEykEv6d7BcInRgWFx1MDAxM1xinlx1MDAwN806ySx0XVx1MDAxNFhnWpA0/43uI6sz/j4z8/YgUlx1MDAxNXpcdTAwMTDQYNCx3lx1MDAxON+FvK9cdTAwMWbbo1J8vH5v65XD083W12azseguhFx1MDAwMoaLUuyzfVecXHUwMDEzvVx1MDAxYlZcYjj08Dd11PyDr/rpXHUwMDBlRFx1MDAxOVL+plGz24Y20oFImKhE+I9cdTAwMDOZ2oFcdTAwMTTfY1x1MDAxNaThZfftx2MzcVVUrrd0RUNyrfZKXHUwMDFm4q9n9rqojPIsLuRRXHUwMDFh+lx1MDAxNFxmXGJGtkO/P6xv31x1MDAxOElcdTAwMGWjjXaC40q0+Z7kXHUwMDA19Fx1MDAxZlx1MDAxY4uSr3z9XFz34UvN83dcdTAwMWaFXHJhKCx6XHUwMDFkYMbXPXftjav7hjsyNqkmZXO6oe7fvVl098FoIHKotZ9vwVx1MDAxZaJcdTAwMWa4Olx1MDAxMP5mR75Q5XdcdTAwMGL99Po+XG7lt1SoXHUwMDE5llx1MDAwN0Z6XHUwMDEwjrqeelOQ/ztcdTAwMGby4iG5sFx1MDAxYzabXHUwMDA3Kc9o10LwWsXlh2nJPmf5Oo5u1obdWa/z8O/aobbnUNQxN99efPsvXHUwMDFht7uQIn0= widget.render_line(y=0)widget.render_line(y=1)widget.render_line(y=2)Strip([segment, segment, ...])Strip([segment, segment, ...])Strip([segment, segment, ...])Line API WidgetStrip([segment, segment, ...])Strip([segment, segment, ...])Strip([segment, segment, ...])

Let's look at an example before we go in to the details. The following Textual app implements a widget with the line API that renders a checkerboard pattern. This might form the basis of a chess / checkers game. Here's the code:

checker01.py
from rich.segment import Segment
from rich.style import Style

from textual.app import App, ComposeResult
from textual.strip import Strip
from textual.widget import Widget


class CheckerBoard(Widget):
    """Render an 8x8 checkerboard."""

    def render_line(self, y: int) -> Strip:
        """Render a line of the widget. y is relative to the top of the widget."""

        row_index = y // 4  # A checkerboard square consists of 4 rows

        if row_index >= 8:  # Generate blank lines when we reach the end
            return Strip.blank(self.size.width)

        is_odd = row_index % 2  # Used to alternate the starting square on each row

        white = Style.parse("on white")  # Get a style object for a white background
        black = Style.parse("on black")  # Get a style object for a black background

        # Generate a list of segments with alternating black and white space characters
        segments = [
            Segment(" " * 8, black if (column + is_odd) % 2 else white)
            for column in range(8)
        ]
        strip = Strip(segments, 8 * 8)
        return strip


class BoardApp(App):
    """A simple app to show our widget."""

    def compose(self) -> ComposeResult:
        yield CheckerBoard()


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

BoardApp

The render_line method above calculates a Strip for every row of characters in the widget. Each strip contains alternating black and white space characters which form the squares in the checkerboard.

You may have noticed that the checkerboard widget makes use of some objects we haven't covered before. Let's explore those.

Segment and Style

A Segment is a class borrowed from the Rich project. It is small object (actually a named tuple) which bundles a string to be displayed and a Style which tells Textual how the text should look (color, bold, italic etc).

Let's look at a simple segment which would produce the text "Hello, World!" in bold.

greeting = Segment("Hello, World!", Style(bold=True))

This would create the following object:

eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1aW1PbRlx1MDAxOH3nVzDuSztcdTAwMTM2e790ptOhJFx1MDAxNEIuUEJoaTuMsNa2gixcdTAwMTlJ5pJO/nu/XHUwMDE1RpIl7CBjUqd+XHUwMDAwe3el/Xb3nPNdpH/W1tc72c3Idn5cXO/Y665cdTAwMTdcdTAwMDZ+4l11nrn2S5ukQVx1MDAxY0FcdTAwMTfNf6fxOOnmI1x1MDAwN1k2Sn98/nzoJec2XHUwMDFihV7XossgXHUwMDFke2Gajf0gRt14+DzI7DD92f1961xy7U+jeOhnXHQqJ9mwfpDFye1cXDa0Q1x1MDAxYmUp3P1P+L2+/k/+t2JdYruZXHUwMDE39UObX5B3lVx1MDAwNirJ661v4yg3lkhFhGRcdTAwMWHLYkSQvoD5MutDd1x1MDAwZmy2ZY9r6vAjuvlhK+7ffPQvtlx1MDAwZcJNSo7CP8ppe0FcdTAwMThcdTAwMWVmN+HtVnjdwTipXHUwMDE4lWZJfG6PXHUwMDAzP1x1MDAxYrjZa+3Fdb6XXHUwMDBlwICiO4nH/UFkU7dcdTAwMDO4aI1HXjfIbtyNcNl6u1xy1XHX7pBcZkZSca41Zsatueh11zNKXHUwMDExx0ZLyZRcdTAwMTCMypphW3FcYmdcdTAwMDGGfYfzT2nZmdc974N5kV+MyVx1MDAxMi9KR15cdTAwMDInVo67miyZXHUwMDBiiqQglCqMXHKunszAXHUwMDA2/UHmjosgho1SynDMsJCiNMbmp0JcZnN2XHUwMDEyo4tcdTAwMWVnwmjXz1x1MDAxMfJ3ddtcIn+ybXeQKUHDJi2fy8W48S/rYKtcdTAwMDKugoN3e0dXxmNDtn/iXHUwMDFk7uyeblx1MDAxZqTvVLHgKXR6SVx1MDAxMl91ip7Pk2+loeOR791cIlx1MDAwZVx1MDAwMCk4XHUwMDExWlFDTdFcdTAwMWZcdTAwMDbROXRG4zAs2+LueVx00rXKStqxg1a2scZcdTAwMGVcdTAwMDBcdTAwMGLRilx1MDAxOf5gclxcnrw7vd5S9rV93dvjsYjt/lx0WYxcdTAwMWN0XHUwMDE2OdJcdTAwMTgk4n5ukFx1MDAwNblhkFwiXFxzXHUwMDAw1TQtXHUwMDE4QVhSMZNcclpSa7qLs4FiiaQhXHUwMDFjJuHGfVSTXHUwMDBlgiFtpm2744HEmlxuzivn80Q8mIdUXHUwMDAxu8SWhdTMXmf3gnQmRlx1MDAwNTVcZnSLPlxcwI9Aen57b7btx4R9XHUwMDE0Nt7ZXHUwMDBlg8GSXHUwMDA1fOlcdTAwMThlRFwirakmtFx1MDAwNlH+dFpNKtJbwJHyOlxmXHKmXHUwMDA0XHUwMDBiptqgcFxuXHUwMDFmrWT3evx7cPkq+nBp9/ax2Lu+Or5cdTAwMWPsLUl2OWfaXHUwMDE40lx1MDAwMswlauIoO1xmPtmc1FOt294wXGLzoyqac5SDgX91dmxcdTAwMTjGz9aP4yT0/+pUjyq1MHvtdu66zTDoO0Z0QtubpkpcdTAwMTZA8FR0Z/Go7O2CXHUwMDFkXHUwMDFl3C7Z9evriZOgXHUwMDFmRF74fpZNi3tcdTAwMTbGVL31jrWGXHUwMDEwIK3hXHUwMDBmZ+3u3ubbbSrVxYuD991cdTAwMWXTn3x/J5jB2lx1MDAxYfv+K79cIihGVIBCmqZjwVxiT7XWucsk73XVIzxcdTAwMGJhSNx5ldaeRTpBxZqV6/m/XHUwMDA1WMYwgkvFelwit0VmM4AoyjGRpEVwdfM2ftU/O9u/XHUwMDEx4uJ0g1x1MDAxZI9e71x1MDAxZmyuuuNcdTAwMTJMI6yVXHUwMDEyhlPnKNg0XHUwMDExuECSc8HInCjr0X5Mmyb4m35MSaG5Ulx1MDAxNU16Sj/2q/fJo3tcdTAwMDO6uSuOduTxsWTnLy+Wlj5cdTAwMTjNOG2B7sf5sVx1MDAxYz3fn8Wh/9P7ZGx/WFx1MDAwNT/WsGmxuLNidZ3AnEHKXHUwMDAwaWhcdTAwMTmafonAL0/UwcvRfnK+8bt9dbQpe1x1MDAxYofh6apcdTAwMTOYUYYoJNzwMVxuXHUwMDEy93K57nouOVx1MDAwMvpSXHUwMDE3PHHYjbp3XVwii+k9Low2XFyXkka6QK6N61rNaFRcdTAwMTBcdTAwMTBF9lQsJlNsdFx1MDAwNvZcdTAwMTNrsyDqo2kyVChcXIH616DwtEGL8Vx1MDAxN8/kL/gjXHR7rMpcdTAwMDFfou9V/8Ppm1x1MDAxNyPpXHUwMDFmvtl68UZEoThcdTAwMWOfrDp9hYJA0HBiZO6AWZ2+XG5RIDV0XHUwMDAwezWresVl87dCyTn8hWReSY1L2H+zTphcdTAwMTCBK1x1MDAxOfRXo2+aY2mV+Htr0VxcXHUwMDAy327vfUlkpVxmWvfAWlx1MDAwYlx1MDAwMVx1MDAxOU5cdTAwMGJcdTAwMGY8X69XlMJMXHUwMDBiJFx1MDAwNGR04Fx1MDAwZaRcdTAwMDF/PFx1MDAxZENzSZCmmFNtnthcdTAwMDVcdTAwMWKkQElcXKFEaqOwvqc8pDRSICXmLlx1MDAxY6hcdTAwMThzV7yXcFx1MDAwM6ZcdTAwMWVXu6eTlsVqlos71jTzkuyXIPJcdTAwMDHW04ZNnlHtPiDQy7ncXHUwMDFkOys3MMJcXFx1MDAxMVc9g3CSUWp4+WjG7Y03cqtFkuWlv8aqbeR/2Zr5XHUwMDA1z4o1YFxmwVx1MDAwNsPeMEyoNIRy1TCGMMjn8pJGw5rQS7OteDhcZjLY7v04iLL6tub7t+m4PrBeQ0BgNdW+uiiM3Fx1MDAxZKc1vfy2XpIm/1F8//vZvaNnYtl9Nlx1MDAxYTAub7dW/d9azqiaWclcdTAwMDYh4EJcYqVcdTAwMWUuZ/P914rKmWRcdTAwMWNcdTAwMTmqMVMu6IDMoaFmsFx1MDAwNdJhzEUkrGbX8tRcZuJcIsyNUkZcdTAwMGLIb4SsJP6lnCmEXHUwMDFkXHUwMDE1INOrXHUwMDE5M1EzJjXRXHUwMDFjyzZFg2XL2eLZ/lx1MDAwM+VsfuBblzNcdTAwMDaUYiBoXHUwMDEwVzqK8cq4W1x1MDAwNVx1MDAxMYgzdr+CPEjP5tfBpvWMXG7KNVx1MDAwMVx1MDAwNitOsKaGNawhXHUwMDAykVx1MDAxOer6LenZxmw45911JLdcdTAwMTS0WVx0lmGi3lokWFxmpuK0zZO5M9zt9a8hXHUwMDFhXHUwMDFkXHUwMDFmXnw8+qN38mv8epFcdTAwMWH/XCJitth7XHUwMDE1Tq5cdTAwMDTPdUxgXHUwMDAzwVlZpHA3oJQh8KJGXHUwMDEy6TA/O7tcdTAwMDLn7/H5YvZdr9drqph6UFWEQOKHtWStdGrxvOqJS/dSV6pQXyuvWqWMasFcXIpSQurNRfRcdTAwMDFbXHUwMDBiQCUtkqn5p7ySj+S4XHUwMDExiCqmlVCUS1x1MDAxMMKSkTldhUBaQSxuXHUwMDA0eHZiKq/GLI2wXHUwMDEwP3NcZiqtJOdcdTAwMWFCoEq4Vz6ZI65mg4VDPDZcdTAwMTJX9GzygFx1MDAwZbSVske+XHUwMDAw9aioQ2JcdTAwMTeutnlFqW3UMd9cdTAwMWJMuXnKMVx1MDAwM99GKTbCbUwz5lBIuUhAaYWpXHUwMDE0RuNcdTAwMDVjj/lv/9ViXHUwMDBmLMDlau5cdTAwMWVwSaFpwyhcdTAwMDKBMNPgrImGMFx1MDAxMoY0n5h+SyHIbGS7T1x1MDAwM9OzXCKQtclcdTAwMDRcdTAwMWRvNDrMXHUwMDAwdMVxXHUwMDAwylx1MDAwM39cIuDlKjuXgb365X76OVx1MDAwNq5N9tNcdJLNmfB57fO/MTl+mCJ9 "Hello, World"Style(bold=True)greeting.textgreeting.stylegreeting

Both Rich and Textual work with segments to generate content. A Textual app is the result of combining hundreds, or perhaps thousands, of segments,

Strips

A Strip is a container for a number of segments covering a single line (or row) in the Widget. A Strip will contain at least one segment, but often many more.

A Strip is constructed from a list of Segment objects. Here's now you might construct a strip that displays the text "Hello, World!", but with the second word in bold:

segments = [
    Segment("Hello, "),
    Segment("World", Style(bold=True)),
    Segment("!")
]
strip = Strip(segments)

The first and third Segment omit a style, which results in the widget's default style being used. The second segment has a style object which applies bold to the text "World". If this were part of a widget it would produce the text: Hello, World!

The Strip constructor has an optional second parameter, which should be the cell length of the strip. The strip above has a length of 13, so we could have constructed it like this:

strip = Strip(segments, 13)

Note that the cell length parameter is not the total number of characters in the string. It is the number of terminal "cells". Some characters (such as Asian language characters and certain emoji) take up the space of two Western alphabet characters. If you don't know in advance the number of cells your segments will occupy, it is best to omit the length parameter so that Textual calculates it automatically.

Component classes

When applying styles to widgets we can use CSS to select the child widgets. Widgets rendered with the line API don't have children per-se, but we can still use CSS to apply styles to parts of our widget by defining component classes. Component classes are associated with a widget by defining a COMPONENT_CLASSES class variable which should be a set of strings containing CSS class names.

In the checkerboard example above we hard-coded the color of the squares to "white" and "black". But what if we want to create a checkerboard with different colors? We can do this by defining two component classes, one for the "white" squares and one for the "dark" squares. This will allow us to change the colors with CSS.

The following example replaces our hard-coded colors with component classes.

checker02.py
from rich.segment import Segment

from textual.app import App, ComposeResult
from textual.strip import Strip
from textual.widget import Widget


class CheckerBoard(Widget):
    """Render an 8x8 checkerboard."""

    COMPONENT_CLASSES = {
        "checkerboard--white-square",
        "checkerboard--black-square",
    }

    DEFAULT_CSS = """
    CheckerBoard .checkerboard--white-square {
        background: #A5BAC9;
    }
    CheckerBoard .checkerboard--black-square {
        background: #004578;
    }
    """

    def render_line(self, y: int) -> Strip:
        """Render a line of the widget. y is relative to the top of the widget."""

        row_index = y // 4  # four lines per row

        if row_index >= 8:
            return Strip.blank(self.size.width)

        is_odd = row_index % 2

        white = self.get_component_rich_style("checkerboard--white-square")
        black = self.get_component_rich_style("checkerboard--black-square")

        segments = [
            Segment(" " * 8, black if (column + is_odd) % 2 else white)
            for column in range(8)
        ]
        strip = Strip(segments, 8 * 8)
        return strip


class BoardApp(App):
    """A simple app to show our widget."""

    def compose(self) -> ComposeResult:
        yield CheckerBoard()


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

BoardApp

The COMPONENT_CLASSES class variable above adds two class names: checkerboard--white-square and checkerboard--black-square. These are set in the DEFAULT_CSS but can modified in the app's CSS class variable or external CSS.

Tip

Component classes typically begin with the name of the widget followed by two hyphens. This is a convention to avoid potential name clashes.

The render_line method calls get_component_rich_style to get Style objects from the CSS, which we apply to the segments to create a more colorful looking checkerboard.

Scrolling

A Line API widget can be made to scroll by extending the ScrollView class (rather than Widget). The ScrollView class will do most of the work, but we will need to manage the following details:

  1. The ScrollView class requires a virtual size, which is the size of the scrollable content and should be set via the virtual_size property. If this is larger than the widget then Textual will add scrollbars.
  2. We need to update the render_line method to generate strips for the visible area of the widget, taking into account the current position of the scrollbars.

Let's add scrolling to our checkerboard example. A standard 8 x 8 board isn't sufficient to demonstrate scrolling so we will make the size of the board configurable and set it to 100 x 100, for a total of 10,000 squares.

checker03.py
from __future__ import annotations

from textual.app import App, ComposeResult
from textual.geometry import Size
from textual.strip import Strip
from textual.scroll_view import ScrollView

from rich.segment import Segment


class CheckerBoard(ScrollView):
    COMPONENT_CLASSES = {
        "checkerboard--white-square",
        "checkerboard--black-square",
    }

    DEFAULT_CSS = """
    CheckerBoard .checkerboard--white-square {
        background: #A5BAC9;
    }
    CheckerBoard .checkerboard--black-square {
        background: #004578;
    }
    """

    def __init__(self, board_size: int) -> None:
        super().__init__()
        self.board_size = board_size
        # Each square is 4 rows and 8 columns
        self.virtual_size = Size(board_size * 8, board_size * 4)

    def render_line(self, y: int) -> Strip:
        """Render a line of the widget. y is relative to the top of the widget."""

        scroll_x, scroll_y = self.scroll_offset  # The current scroll position
        y += scroll_y  # The line at the top of the widget is now `scroll_y`, not zero!
        row_index = y // 4  # four lines per row

        white = self.get_component_rich_style("checkerboard--white-square")
        black = self.get_component_rich_style("checkerboard--black-square")

        if row_index >= self.board_size:
            return Strip.blank(self.size.width)

        is_odd = row_index % 2

        segments = [
            Segment(" " * 8, black if (column + is_odd) % 2 else white)
            for column in range(self.board_size)
        ]
        strip = Strip(segments, self.board_size * 8)
        # Crop the strip so that is covers the visible area
        strip = strip.crop(scroll_x, scroll_x + self.size.width)
        return strip


class BoardApp(App):
    def compose(self) -> ComposeResult:
        yield CheckerBoard(100)


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

BoardApp ▅▅

The virtual size is set in the constructor to match the total size of the board, which will enable scrollbars (unless you have your terminal zoomed out very far). You can update the virtual_size attribute dynamically as required, but our checkerboard isn't going to change size so we only need to set it once.

The render_line method gets the scroll offset which is an Offset containing the current position of the scrollbars. We add scroll_offset.y to the y argument because y is relative to the top of the widget, and we need a Y coordinate relative to the scrollable content.

We also need to compensate for the position of the horizontal scrollbar. This is done in the call to strip.crop which crops the strip to the visible area between scroll_x and scroll_x + self.size.width.

Tip

Strip objects are immutable, so methods will return a new Strip rather than modifying the original.

eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVdaXNcIkmS/d6/QlbzcZuccI97zMbWdKFcdTAwMTPd6GB3TMYpkFKAOCSktv7v645UXCI5XHUwMDEyoYvKYtVtqiqSI4h87v6eh4fHX38sLf3oPDbLP/619KPcK+bDWqmVf/jxJz9+X261a406XcL+v9uNbqvYf2a102m2//XPf97mWzflTjPMXHUwMDE3y8F9rd3Nh+1Ot1RrXHUwMDA0xcbtP2ud8m37v/n3Xv62/O9m47bUaVx1MDAwNYNcdTAwMGZJlUu1TqP1/FnlsHxbrnfa9O7/Q/9eWvqr/zsyula52MnXr8Jy/1x1MDAwNf1Lg1x1MDAwMVx1MDAxYWVHXHUwMDFm3WvU+4NcdTAwMDVcdTAwMTRotdBavj6j1l6jz+uUS3S5QmMuXHUwMDBmrvBDP453K1AvX/dcdTAwMGWLK6nWib/e1M3LjcHHVmpheNx5XGafp1wiX6x2W5FBtTutxk35rFbqVPnTR1x1MDAxZX99XbtBszB4VavRvarWy+320GtcdTAwMWHNfLHWeaTHpHh98HlcdTAwMTL+tTR4pEf/SjlcZjRcdTAwMDJ4kFx1MDAwZZXE14v8auVcXKC0tVJIROE1yJFhrTZCulx1MDAxMzSsf4j+z2BghXzx5opGVy9ccp6D6FxuZTN4zsPLlzUmXHUwMDEwylqtUGlnhFevz6iWa1fVXHUwMDBlPcVi4KQ2wlx1MDAxOIHCOTdcdTAwMThnu9y/XHUwMDFmXGJSKbruXHUwMDA3N5Q/vrlV6mPjP9FcdTAwMTmrl15mrN5ccsPBiPnCelx1MDAwNE+D13SbpfzzbVx1MDAwN2O1Rk9DoLl5vVx1MDAxZdbqN6NvXHUwMDE3Noo3XHUwMDAzpPRcdTAwMWb9+89cdTAwMGZAlO5KXHUwMDFjRK1cdTAwMDaNXHUwMDAy3OxcYl3LVm6ue8t7d1x1MDAxOXl2eFxcObnPZdeWXHUwMDEzjlBtXHUwMDAy0MpcdTAwMWGp0ErrnDfjXHUwMDE4ldJrXHRcdTAwMDRTacEkXHUwMDE2ozR85b0wesEgaoWLgyhISSZcdORDZsZo+WRzm1x1MDAwNl8olNTRQWbL7excdTAwMWPcqIRjNFx1MDAwNTJcdTAwMTDgnfZgwVx1MDAxOedgXHUwMDE4pNrIQFx1MDAxOSfoXHUwMDEyoFOj40pcdTAwMGVEQUvp6D9YOIjqOIhaa5SQxsLMXGI93y/s7VxcaFdoXHUwMDFjXHUwMDFj3ubP7zeJ37R/LUJBvOlGVWC1oWl3XHUwMDA2vFFgh1x1MDAwMGqkXHUwMDBmiFx1MDAwM3hPXHUwMDFlSmilMbFcYkWwVtFQXHUwMDE3XHUwMDBloVx1MDAxMEtFUSjiNqD97IH+NpXLy3RxOXucq96U16pcdTAwMDfrXHUwMDFiISTdiTpcYoiFKidccjqg71xmw1x1MDAxMFVcIiBcdTAwMGaqtFx1MDAwMkVE1erEQlx1MDAxNCyRXHUwMDE0TTFg0bio9bFcXFx1MDAxNFx1MDAxY01cdTAwMTdFNz27XHUwMDFi9TZvdNi4vu5cdTAwMTazcFx1MDAxOXZLjX27l3CMXHUwMDAy6sBcdTAwMWKhKcxcdTAwMTNcdTAwMDR8RFx1MDAxMD2HeU1cXNRcYutQ9mNKciHafy25lPm5UT9cdTAwMWa5JHxcdTAwMWNEtfDOXHUwMDAy+plcdTAwMDHaetquLtv1PVxym5uynFp7Slx1MDAxZlRPk1x1MDAxZefpzlKgJ1x1MDAwNmdR05/aXHJLesJtgI6UvDGoPGKSqahw4KxcdTAwMTBcdTAwMGJcdTAwMDdRXHUwMDE5n3RykmKbfIdcdTAwMTNdllx1MDAwNd+53Xpyjerm8Xr2XGbyp/WkK/pcdTAwMTRgIDmvRLLeoSBHaUYwqomMXG5E68lFXHUwMDE5n9ysXHUwMDEzgDWk6oWaX6SfXHUwMDBmRq0wcVx1MDAxOPXgPOiogHhcdTAwMGKjK141fKdzs1Jo3+CtKUH7ot5MOEbBXHUwMDEzXHUwMDA2lVx1MDAwNVx1MDAwMUpcdTAwMDLFe1x1MDAxY470ylx1MDAxOYKGoD+EQYr3oJKLUUnwcdZEXHUwMDAy42JglD4pXHUwMDBlo8zMlNJmdozuhL3qWlXmMlx1MDAxZFFcdTAwMGXXzcNj2OydJFx1MDAxY6NSyoCYptBcdTAwMTTKhTM4mrwniFxuY+mOWCFRJDgxSjZGMUBoOb/M6JzcqME4iFwiKE30Rjo9M0b1zXpYKFXu08f3WpRy5+FlfT1MOEbRqEA68j9cdTAwMWWU0TKShntcdTAwMTZMXHUwMDEwOMKuQk5MYYKdKPlcdTAwMTJA4qNcdTAwMGKHUFx1MDAxZJu7d2hQ0q2ZPeu0eqMuOnBdusruXHUwMDE1W6dcdTAwMGYtma3m80lcdTAwMTdM7EWV8kQzXHRcdTAwMDFcdTAwMDCRtYyfWSdA4ywvjoIzXHSGqJVcdTAwMTL418JBVMk4iKKj2dL+XHUwMDFkq0v6+mJze9dm95dVuYHb+6enzZxNOkTBm4Cw4STxXHUwMDFhXHUwMDAxUo5hXHUwMDE0XHUwMDAzLSjWkGJcImbuklx1MDAwYlGyMbpjOMdF+jlRUVx1MDAxZFx1MDAwYlFtke5cdTAwMWH42SU9bi7bo3C7lrtfbV5f7eNtRVxc51x1MDAxMlx1MDAxZeaVkIEha1x1MDAxNN4rXHUwMDBmVo2GeVwiXHUwMDAxSMhcdTAwMTDSeUy0oDfaWWW1XTQn6lxcbF6UvIvxRkaXSN+C6F7n8r6mbnqnzZW73HLnMXN8VF3/XHUwMDFkIGqEJS6qnZMjqXvOOVx1MDAxMVd1gqhcdTAwMDCr/eSKJS6FsVx1MDAxNtTCIVTGMlFcdTAwMDM0a1rI2X3oxe7DRqHaq4Rdd7ZV92erjfr+RdLDPNHMwFiHJJPAOI3KjkDUXHUwMDA2XHUwMDAyXHUwMDFk31x1MDAxOGe4jiixXHUwMDEwReEleVHQi5ZysjLWiyryoUJqNbtcdTAwMTNcci+ecvumqFx1MDAwZm/NQ72zv3N73To/TLhcdTAwMTNNOZJD9D3BSclcdJtcdTAwMTGEelx1MDAxMVgwSoNcdTAwMDaLTiVXLCktkWJeRFksXHUwMDA2QJ1cdTAwMTdxXHUwMDAwXHUwMDA1Vlx1MDAxMHTv9OxcYr16KKZPa21wl9nH7VpVmePc+i9Ois5Q6GRJXGZJ+qaEQZr3KD6eMVxugdJkrFZcdTAwMDOp/YiLSlx1MDAxYUbpXCLxXHUwMDE05Vx1MDAxNm3908ZjlL+wgWhZxZvVou2KKp6c7Oid5mrT5DLd6nphM+lOXHUwMDE0REA6wztcdTAwMDOoud4uUpPAb+BcYj2kokjqk2Am8CS3jMRcdTAwMTlnhV84JlxusVFcdTAwMWWFXHUwMDE2Xoj3rCx5XHUwMDExVjvdwoYt75ZqXCJzXuymdlx1MDAxYlx0Ryj5UGk8ek1cXFR7gcNy3itcdTAwMTF4hSRDXHUwMDE0XG6Z4Fx1MDAxMlx1MDAxMpLxxEXkwiXtnYivXHUwMDE1JXySdphcdTAwMWSehUZcdTAwMTWuWts7t08rXHUwMDA3t1f3ze398/VMwuGZsi6w1pIs1I6IJrnRXHUwMDExfMpAopTOoqbJwORWiqIgLi1cdTAwMTDEwvFQXHUwMDFiW2+PLJSMtbPT0OPMRerk5nxv67540d3aL4dcdTAwMGZcdTAwMGb791x0hyigXHJcdTAwMTR7SFCS5LBcdTAwMWGN8Fx1MDAwNGAgnU86RHlcdTAwMGLJLbe3aLVfQFx1MDAxN+ohNtukpLXo4Vx1MDAxZEp+fcs9XFyd7tQz9+XD7PLKSemyW096OpRcdTAwMDHKy1x1MDAxMp5cdTAwMWOQ5FxyIUNcdTAwMDAl7qlcdTAwMDMhNTlZrZXxNsGLSob/035cdTAwMDG1fHwps1x1MDAwNE9cIvZcdTAwMWRL8/dcdTAwMWJcdTAwMGad86Otq8xarry6fX2xXazmz1x1MDAxM1x1MDAwZdFcdTAwMTS5UMF1MsJpY6xcdTAwMWYpZSaM2sAp+lx1MDAxMUT0SEol14tcdTAwMDJdsDSChcuHmtgw7zQ4XHUwMDE33Y79pk7avYHbTmpzt3l3XHUwMDEwbuyk6j19eZD0ZFx1MDAxM3jL2+eFQCSkRpcwnnNNXHUwMDE4KND9XHJ3hGKdXFylXHUwMDA0QCYk1OIlRH188VxiOO2dXHUwMDE3XHUwMDA2Zlx1MDAwZvS5Wlx1MDAxNcqNVLpWz2RrOrO50fBcdTAwMGa/WMvPUuBkXHUwMDAyQqDXllx1MDAxM0p+dFWJMCqJXHIxepBcdTAwMTdtXHUwMDEyi1FjUJOkc4vmRJ2MdaIgOIXt5DtcbpzOT1x1MDAwZotccn9YujqrXHUwMDFkZU+0bj21Vs5cdTAwMTJcdTAwMWXo0fDKJil2pYiWXHUwMDEy3Vx1MDAxY07ZO7qsSMqT4JdcdTAwMWGT7EXRWuJcIkYvmlpyJjbhpFhcdTAwMWPCe7YsZc+3m1x1MDAxYrWtjaNMa2tXn3SOz07bSW+TI6VcdTAwMGVcdTAwMWNcYqKhxEhp1ofVkldcdTAwMTA4a/t1I+RpXHUwMDEznFx1MDAxMVx1MDAwNcnKwZk59iCZV1x1MDAwNV5spT2QNDBWKJw9zvuV8/rpffWyU8BcdTAwMTW3WXkqrJVcdTAwMWbuXHUwMDEyXHUwMDBlUfAuIDFvhffaaFx1MDAwMGlGMKpcdTAwMDKKn2iIqYJEmVxcQe/J0Kywi0dFMX7fp1x1MDAxMZr3jNvZNydcdTAwMWbs7dhuSp7ByeN1/cGmsVPYLCdcdTAwMWOiSlx1MDAxMDxIXHTT/UUhhVx1MDAxZNlcckJyMTCaVD1xVMNro8mFqDZcbr3Ri1bH7OPT9oCO63tRzVx1MDAwZdH1Qlhv3e1cdTAwMTfSXHUwMDBltleObjZuXHUwMDFl1sKjxEOUXHUwMDAzPVFRYclcdTAwMGaBXHUwMDE4dqL9tKi3gvyr8kbK5Fx1MDAxNo9cdTAwMDCS/Vxit4iCPt6LUtBcdTAwMTDc9WB2Lrpmd+5ztfW1p/AqbNbq4DauNm5cdTAwMTNcdTAwMGVR8p+BIMmOdPspkFx1MDAwYmdHMcqro16C1nR7olQvcSBVvDuVSfOCgVSa+I54zit05Edmz91X7/Nb6V1cdTAwMTf2Llxua2m7nXdHO+W4XHUwMDFhp1x1MDAxMbBcckN0tFx1MDAxZfP1VaV8u1p+XHUwMDA3Ru0sbUVZXHRpZTn1NJq6573JXHUwMDA2eEcl31x1MDAxOTDuUyVOnVa+3m7mW4SBcZxqrVx1MDAwMmOs05ZmXurIkskrTlx0oUG/o5TWRJ9hUs8xxWu5qL5cdTAwMGWnL1x1MDAxN1x1MDAwNsCK3O+Gvdjcv8Dl5ulx+7RSa/m6yVxmmndccqEw32o1XHUwMDFlfrxe+fvPae+bOd+9XHUwMDE2+TOzVz0uioNmoVx1MDAxM+7ordne9+VvybAucDZ+M4vgLXJcdTAwMTbf0c5v+bKS71xcXHUwMDFk7Fx1MDAxZjdSVVkopo+PN1RcXD5iqnmNrj99azM/bblcdTAwMTZNXHUwMDExTyalN8yjjXRcdTAwMDFcdTAwMDV+0CwriG/Hl2HTs/LqjVx1MDAwMFCpVMatSipHI1BI4ZY+30VcdTAwMTbTX62KXCJT4HkvXHUwMDAzyU2DUX3+umHVaMlcdTAwMWJWv45FT7Wq89LpKt5V7vaq+96Yxoo5yJz8juhcdTAwMTdcdTAwMTCf6rA0o85F6/LfQr8tbu7Ui4VO53Hn3pnlp9Ku6vaSvqShdSB5r1x1MDAwNVx1MDAxMoSUVsRyhlxyQEEgyd+DI1x1MDAwNjy0pTtpXGbIglRCLdy6MFxijMcoXHRJ40HNzn+2lrP3XHUwMDBm4U1W5S5219IrWbVij4pJhyh4XHUwMDE1oNFWcl9cdTAwMGIyWTWGUFx1MDAwMVx1MDAwNlx1MDAwNXlp3tGdZI7OXHUwMDA1XHUwMDE4Xi9cdTAwMWNH91x1MDAxMYowuqbRryrRZvZUhzvdyJz27uzmyurxzr06XHUwMDBm5ermL27DNsvCMFx1MDAwNqSYvSdcdTAwMDLurbejPYFcdTAwMTVcdTAwMDaSN1x1MDAxYzpP0TvJzVm80t5TXHUwMDE0WDg3qlxcrFx1MDAxYjVWSVx1MDAxMlc4e6TvPS7fhPlwfTt31MvlTn0h3HWtxPNcXCG04jVGr7mkeyTOXHUwMDEz0SWKS1x1MDAxNJIgqpz93I7YXHUwMDE4omtcdTAwMDOtaFx1MDAxMJKbwGk1QT7CM9tGYl7cKFx1MDAwNqL7dV52XHUwMDFiXG6Dyir4ukW3aURcdTAwMTex97jTbsPxg6rD9uPBtj+utWcjulPl429EoMthWGu2J9tcdTAwMTTxxjibQsKIXHUwMDA2Zdzs4nE1tfm0dbyXlb1cdTAwMDPZvs4101x1MDAwZvthXFxcdTAwMDJxqlHNz+1cdTAwMWKiXHUwMDFlfI5cdTAwMDa3XHUwMDFmXHUwMDEz0c6Fz05fXHUwMDEzMUHJus1+cot5Ja9R47hNsXS0xtHryb+p6IlcdTAwMWSvNuUoKqDtb9Hs96yXoyZcdTAwMDVcdTAwMWVcdTAwMTVyt/D5iMdP2FSSsK+mtPXkXHLsxsvZXHUwMDE3yVx1MDAxZkXtonq/bVx1MDAwMVx1MDAxZXZ6XHUwMDE1n013u+eYbOhbctVAmDFeSEUqeVQ2qn5iXVvlUVxu87l64kq+QN7ke9BvKfJcdTAwMTn9lfnIZMBTx+f1nOZ2rO9pUrPSOzg9Or9q1zOt+onRqdst7dLJxqc3XHUwMDAxeUyJwnpBXHUwMDE4XHUwMDFjQadcZlx1MDAxY0hcdTAwMDOWa+GI9Hxcbp2AXHUwMDA15ybw8a9AJyrlXHUwMDAwo0dcdTAwMDH8Nuh8K/NcdTAwMTa79qiNJOeJcnbJeLeVrm0yc7BcdTAwMTdP9e4y3jzI9C9u8zVcdTAwMDMhN4RBL/uiUWvrRurd+SwuuiOSdJxcIsKb3I3rXHUwMDAwaHjBZ+G6KHlcdTAwMTWfeFPsRH20XHUwMDEx9FtcdTAwMThN27ua9duVxure6nlnuXZb3ln7xZ27Z0trXHUwMDEwj9GOlCNnf4drNblHXHLvXHUwMDE1XCKhJrj1QoJrNWl00jrxO0b56aduxm9sXHUwMDAzT1x1MDAxOFx1MDAxNVx1MDAxNFneUU58mOnKXZu9W1x1MDAxM+srOzv32Vx1MDAxNYzd2fblYV58XGKh2lx1MDAwNki+UlxuirROR49cdTAwMTjtl8FpRVxi9U5JQb/d51LDxXJJlfLjXHUwMDAwlcR1pVx1MDAxNUSB0ViylkhkXHUwMDFiJDYwMF4765ltSHKYalx1MDAxNKLIXUeV/Uo3+nJholxuM2m9d5KRT1ukXdcvU3p143LvcDZcdTAwMTX257T33czVTHhSf+q08+Ji9XBv/ab31PgqdUdwILo+XHUwMDFm51x1MDAxZp/aIOZsXHUwMDE0yvdcdTAwMWPEuFJO5eHo2nWaZ49yZb1X2c2sJZ6gXHUwMDEwOVx0LFx1MDAxYc3HL1x07q0w3v1JXGLD59xcdTAwMTlF/jW5PfRcXL9kxSyc85+6Ic9xu1x1MDAwZfOOKuiLsL0s072101x1MDAwYpHN71x1MDAxYtvJp5dlsp0/qdxcdTAwMDClXHUwMDA04tDcjlSPIJS9v6BcdTAwMTBoiK55Jz/ZcFxcXHUwMDE1TbkyIVx0XHUwMDAx3lx1MDAwN9JbXHUwMDAwj9yHSkyo3yDvT+BFQzpUOOWjNVx1MDAwNT8hXG5cXH6iXHUwMDE27oCmaD/xXHUwMDExiEq2SVx1MDAxZF1cZn1cdTAwMTOhd729w4vl/HZnZ2VHnl2J1XVI/uI16lx1MDAwMKxcInhwP3mMXHUwMDFlY/HcidRcdTAwMDSSXHUwMDBiMFx1MDAxNOk8XHUwMDFmLeBJmlx1MDAxN1x1MDAwNU6kWLt4zSHIaOMwalx1MDAxNUU4mozZI31ntbZXrbWrdrmi0o3Udc2ellx1MDAwYknHKFx1MDAxYVx1MDAxOWiB5JpcZoAzY/2cTUBcdTAwMTSagihywjfBzXKl5lx1MDAxM7VcZs4tXHUwMDEzXHUwMDExab/4rdkygFg2yrkh3nw/O0Srayd+67GweplXXHUwMDA3T83zfPmweFx1MDAxOXcqeEJcdTAwMDK9dC6g6GmRmDc5Slx1MDAxY85DXHUwMDE4TVx1MDAxMVihUJI7zmvxPTJcdTAwMGZkoIhE8mJcdTAwMDFZgvGRj1x1MDAxOVx1MDAwNHqv+Cwzy1x1MDAwZV8hUZLxxTbB8mHo1d8p875Vjs3TXHUwMDAyplfBWURcdTAwMDPvqDG6gHSx2Mvu11x1MDAwZdPiuuuf0ie4XHUwMDFld1x1MDAxY2lC1jPYXHUwMDA0LHeC9rxcdTAwMWRFXHUwMDFhXHUwMDFjWXAjscY9KZSxRDC9/tySRizXhcCQtCBkU5RcdTAwMTAmmltcdTAwMWGsaSA3vFx1MDAxNMJb0d8zM8Z1uaU198iYz3Lz91pcdTAwMDBxh1xiw/2kXHUwMDA1dMq9zkTw2/hcXFx1MDAwNDvDfmO5mbF/WMrc7V9cdTAwMWTpjebVQ+8207kut+1jsrfAXHUwMDEw5lx1MDAwM97agqiJXHUwMDE1Kj16yKly/eI7XHUwMDE0ktgpfvKs6Im1S6gn8JHoyVx1MDAxNi+bWYWWnjuKzVx1MDAwNdrftbnlNH1oiqliPsy1ZK+3msvmunj8y4LGXHUwMDAwmY1657j21K8uckOPpvO3tfBxXGJdfVuiXHUwMDAx3tdanW4+vGzTXHUwMDBig5c7XHUwMDE3uf3tMlxyoP+Oeuily2Htik3vR1iuXGbbZKdGXHUwMDEz83q504is4Vx1MDAxNGkoeXq71lZp9Cs1WrWrWj1cdTAwMWaeTFx1MDAxOdZU3/A80Vx1MDAxM5yDjO9cdTAwMWLG68rkqmdcdTAwMTfY01x1MDAwMfVcdTAwMGLKXHUwMDFh3/RcZlrYgMNcdJLxK1x1MDAxN21o1H+51IHU5Fx1MDAxYVR/Z4P83Fx1MDAwMtXkosZAcDrfSevpfy0mXHUwMDE0NVwi14EpxedcdTAwMWIrjUpGT1x1MDAxNXshhY6rXHUwMDAwXFy0XHUwMDE3cZKES2TW8q3OSq1eqtWv6OLAdfwoP3/01lxmXHUwMDExpm+zxS6PMiVcdTAwMDLPRf2aj6ZWpP5cdTAwMDbHXFzwXHUwMDE05JvPtFuS6lx1MDAwM+2lN8R7lHt5xqtcdTAwMGb7Ua6X3lx1MDAxZdT0nZ/RQUFAZkM3RIO1Rlx1MDAxYTM+Jkk0qK+6pNbWXHUwMDFib8aGXHUwMDE05tud1cbtba1DU3/QqNU7o1Pcn8tltupqOT/mL+grRa+Nmn+T33E4Plxm/rY0sJH+P17//p8/Jz47XHUwMDE1i+H+1XH4XHUwMDBl3vCP6J/vdl3g4lx1MDAwZsxcdTAwMTQ0rzp6XHUwMDE4+Fu+a3rQSqTvXHUwMDAyXGacMoq4hTPK4OjiOlx1MDAwMZ9cdTAwMTNcIkKTojTuk1x1MDAxNXRcdTAwMTNJTUBKQTjuXHUwMDEyQoJcdTAwMTYnkXn0JvBoXHUwMDE0N1j2Q2vJr9VcdTAwMWbgSVx1MDAxMkj8f+a6ROCclyTDvFx1MDAwM7qFkSbdr16CXHUwMDBi6lxy3TuCOadcdTAwMDOMn+64hr/F7+Q/YnHEP2NcYnqn94hcdTAwMTNFXHUwMDE4aVx1MDAxYzbqPKRA8lTvKM5pZFx1MDAxZs9ubaPcLGbO2/uq53Xq9EOFXHUwMDBm89NEzlx1MDAwNzTd5LWVdlx1MDAwZYRcdTAwMTjO2fJOzUDxjk3pnJRcbj/XXmWy+1AzaVwiXHUwMDEwXHUwMDEyrHZfeLTJK4K+dmP+VE10eUfM/na1dLV2SVx1MDAxYXPz5GB/q1hdXHUwMDA0TfR8N5MmiZ5H9TFagT5eXHUwMDExkSP22pnZN8xOx1NcdTAwMTJphVTE5/hcdTAwMDRuzV1cdTAwMDEs4qhnQKJcdTAwMWRcdTAwMTSUXHUwMDE09y6Unz1cdTAwMTJpomdcdTAwMDBhXHUwMDAyR1x1MDAxZu1IgFx1MDAxMmsk/TXuKIhCoyT2Yzw3K3U+kjP5mUyxzlxuObfVxm/lXHUwMDE101x1MDAwM8wrZfBcdTAwMDF6a9FYllx1MDAxZdxcdTAwMDd7smhcdTAwMTKcXHUwMDA146JcdTAwMWPBZbdK/oypXy2Jnlx1MDAwN4UuXHUwMDAwzjtznLGSXHUwMDBmMpqsmoRcdTAwMTe8QEKciIZlzLhO+51ITTyE+Sc1jt530pp474XxR7p5x4fcvKPubHrUSqL3sjJwJHZI+TG43UhCXHUwMDA3+KhccuuE1EZ5bsvyXHLbVEFDXHUwMDAwXHUwMDE0XCJcYsaOTFxm3KR1voC7oDrSqJJbbMrIdtSfzotgQaFmfodf/1r3XHUwMDE1cU5AhFx1MDAxNEi1XCJcdTAwMGIk7uw35lC48Vx1MDAxZlqFkmjhc9Ob6f7r91VG8Vh6vjxcdTAwMDajd3qROHFkpm3MRXDeR1Olb/ZHrW489MJcXObx8WlVy+WM0vXK2q/0XCL+LS+S0nyomXL9XHUwMDAzd4zGkcMktFS8nmRRXHUwMDBiukNexi+WXHUwMDE2PeYx/43iqH/ajv9KdTStfppl+mBcZt+tM0hJVIJ2sdVcYsPLRqXSLidi6WXCqD5cdTAwMTapdXzrNG1cdTAwMDRZdZS8vmVj03dFJzFSp6znXHUwMDEzqsnXW66vJa8/ZGOqf4C1JetcdTAwMTJGO4VTemd+2MhcdTAwMDBVYJXnXHUwMDE0XHUwMDFjSC8hsmFrXHUwMDEwqo1cZri3XHUwMDA3elSWrHIsg8l1Q4pcdTAwMGL95tLyXHUwMDA0rDPqK0L1aFB7O4hPb1oywuWl53RcdTAwMWVqcpXCXHJK/pZcdTAwMDbJTU/S0mrF7W608vL3ZvuxQOpfXHUwMDFkhdBcdTAwMTdF6eh+37H6fetcdTAwMWRzg9mj9NHZzX796v60dt29yOS1PCnBdpjsKI3OkGrkQ1x1MDAxNLRA6e1o3SmRJ6GF8WSeXHUwMDE433huXHUwMDA29zG1rSmYXHRcdTAwMWK2JoRqkoOMdfi6XHUwMDFh/VdcdTAwMWP9ilx1MDAxNjFzTDg+Lv176SXiPiaBXHUwMDAyXGaN52PB30yR6aRrjFx1MDAxNPJcdTAwMWR1XHUwMDE3U+91XCJNXHUwMDE3hFxulCNpY6Xpn1x1MDAxYTmyOYyrZT2FXGLe4m7UlMqLT1uv8oHzvHvGcz9cdTAwMTIhJ3BcdTAwMDBSYVx1MDAwZcBcdHR82ICa0Fx1MDAwMseK/smyZi6rmGA1XHUwMDA1ky/gXHUwMDAwcZF+eihYiq5iUpwnXHTCfcOF9laPXHUwMDA3elBcdTAwMDFdI3fBrWelNm682OFr2Vx1MDAwN9FGQYzCsjDiqr5J3IN71HE9oHRa0vB/b+5cdTAwMTFcdTAwMGJg/kmNYfeLyIeRscVcdTAwMTc0XHUwMDBloKDrZ+9cdTAwMGaDXHUwMDA3qdZaeLjse2F2I1u73c6Bc8l2YOSZXHUwMDAydl7kw4zhxZAh/+UlXHUwMDA2SE5cXDllLZeifJ//slx1MDAxM1x1MDAxY9Yk9tE/1O1cdTAwMGKXQ6aRj0/sXHUwMDBlf5t8XHUwMDEwnfaRpv7fTT56g2DfS1x1MDAwMvlcdTAwMThcdTAwMWHPx8hcdTAwMDeXp8VcdTAwMTkvzbAw7zr+aPrNTqTxXHUwMDAy9yVBpygmeTJgXHUwMDFjyT2QTlx1MDAwYlx1MDAxMNGT/Vx1MDAxYe99fO1DibSHqHzCeJ1cdTAwMGZQsCZUxnKX7HFTNuTdjdJeKvLfWsFY41x1MDAwN7DAgfUri8a/xfhm5Fx1MDAxZdNDQSSGXHUwMDBiJTiuXHRyr9xBYFx1MDAwMlx1MDAxM+CVTor0XHUwMDFj4ZWy3HOByPHH2Mf0ziZcdTAwMDOyM2FcdTAwMTTc5Vx1MDAwMFx1MDAwNaBzgLxs/nuTjTjA8k9qXGarX8Q1tI7lXHUwMDFhUvSLe9/RwnzdbKz2uqc5dyGOb25qVX1cdTAwMWFmk801NK9cdTAwMDKR7XnBp11ZO9xcdTAwMWKaZjvgynVNXCKK7suUXHUwMDBl+5+lXHUwMDFhOKk63Y4lRHmbL1x1MDAxOdxcdTAwMTdcdTAwMWVcdTAwMGU8jWp8b8NcdTAwMThUVnxIaX2aaiz91//Wn5dcdTAwMWGmV1mZ4beZJ/uYNMRcdTAwMGZcdTAwMTJcdTAwMTLw8VvUKOKAtO85pWA6Jlx1MDAxMmni3lx1MDAwN55z4ZJcdTAwMWKB2JGaK1wiXCKBRd7VzkdcdTAwMTR4XHUwMDFiT0g+rSbIv5NIXHUwMDA3329RJGDSKqTl4lx1MDAxNW208dKSjejx7Shk/PRiO5+OfsinWOiP2OiMjGR6wFiKZlx1MDAxZZDPylx1MDAwND6uwFC8x/GdXHUwMDFmKuBSW3IqgMhV31x1MDAxZt2MMnVcdTAwMGL+0JCI8lx1MDAxMNEl5Vx1MDAwZp6X7u3YkFxcoPlwXGbNjVx1MDAwZpif/N51V6l4XHUwMDA09y+PgfeLKFxuQPwxcyRcInjh/Fx1MDAxZOdg1dxGXHUwMDBmM+kw18hgunS0JY5cbvvbiXZg4HSgNVx1MDAxZj/FvJDsYPR4XHUwMDAwXHUwMDEzOEt6SkpyXHUwMDBicko70lx1MDAxOc7BmurB7IQ2uZFcXPvPTlx1MDAxZlxc1+Hn2C/p06slkfa/QyxiXHUwMDEwKX6yiJVGvlVabjYn0oXI28yDLryO5dmo/nix21x1MDAxZvlm87hDs/Tq42j+a6WXrzp4x1x1MDAxZve18sPK5OV8XtH/48VQ2VwiynxcdTAwMWL++vuPv/9cdTAwMGZcYuGAVyJ9 virtual_size.heightvirtual_size.widthself.scroll_offsety = scroll_yx = scroll_xx = scroll_x +self.size.widthBoardApp

Region updates

The Line API makes it possible to refresh parts of a widget, as small as a single character. Refreshing smaller regions makes updates more efficient, and keeps your widget feeling responsive.

To demonstrate this we will update the checkerboard to highlight the square under the mouse pointer. Here's the code:

checker04.py
from __future__ import annotations

from textual import events
from textual.app import App, ComposeResult
from textual.geometry import Offset, Region, Size
from textual.reactive import var
from textual.strip import Strip
from textual.scroll_view import ScrollView

from rich.segment import Segment
from rich.style import Style


class CheckerBoard(ScrollView):
    COMPONENT_CLASSES = {
        "checkerboard--white-square",
        "checkerboard--black-square",
        "checkerboard--cursor-square",
    }

    DEFAULT_CSS = """
    CheckerBoard > .checkerboard--white-square {
        background: #A5BAC9;
    }
    CheckerBoard > .checkerboard--black-square {
        background: #004578;
    }
    CheckerBoard > .checkerboard--cursor-square {
        background: darkred;
    }
    """

    cursor_square = var(Offset(0, 0))

    def __init__(self, board_size: int) -> None:
        super().__init__()
        self.board_size = board_size
        # Each square is 4 rows and 8 columns
        self.virtual_size = Size(board_size * 8, board_size * 4)

    def on_mouse_move(self, event: events.MouseMove) -> None:
        """Called when the user moves the mouse over the widget."""
        mouse_position = event.offset + self.scroll_offset
        self.cursor_square = Offset(mouse_position.x // 8, mouse_position.y // 4)

    def watch_cursor_square(
        self, previous_square: Offset, cursor_square: Offset
    ) -> None:
        """Called when the cursor square changes."""

        def get_square_region(square_offset: Offset) -> Region:
            """Get region relative to widget from square coordinate."""
            x, y = square_offset
            region = Region(x * 8, y * 4, 8, 4)
            # Move the region in to the widgets frame of reference
            region = region.translate(-self.scroll_offset)
            return region

        # Refresh the previous cursor square
        self.refresh(get_square_region(previous_square))

        # Refresh the new cursor square
        self.refresh(get_square_region(cursor_square))

    def render_line(self, y: int) -> Strip:
        """Render a line of the widget. y is relative to the top of the widget."""

        scroll_x, scroll_y = self.scroll_offset  # The current scroll position
        y += scroll_y  # The line at the top of the widget is now `scroll_y`, not zero!
        row_index = y // 4  # four lines per row

        white = self.get_component_rich_style("checkerboard--white-square")
        black = self.get_component_rich_style("checkerboard--black-square")
        cursor = self.get_component_rich_style("checkerboard--cursor-square")

        if row_index >= self.board_size:
            return Strip.blank(self.size.width)

        is_odd = row_index % 2

        def get_square_style(column: int, row: int) -> Style:
            """Get the cursor style at the given position on the checkerboard."""
            if self.cursor_square == Offset(column, row):
                square_style = cursor
            else:
                square_style = black if (column + is_odd) % 2 else white
            return square_style

        segments = [
            Segment(" " * 8, get_square_style(column, row_index))
            for column in range(self.board_size)
        ]
        strip = Strip(segments, self.board_size * 8)
        # Crop the strip so that is covers the visible area
        strip = strip.crop(scroll_x, scroll_x + self.size.width)
        return strip


class BoardApp(App):
    def compose(self) -> ComposeResult:
        yield CheckerBoard(100)


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

BoardApp ▅▅

We've added a style to the checkerboard which is the color of the highlighted square, with a default of "darkred". We will need this when we come to render the highlighted square.

We've also added a reactive variable called cursor_square which will hold the coordinate of the square underneath the mouse. Note that we have used var which gives us reactive superpowers but won't automatically refresh the whole widget, because we want to update only the squares under the cursor.

The on_mouse_move handler takes the mouse coordinates from the MouseMove object and calculates the coordinate of the square underneath the mouse. There's a little math here, so let's break it down.

  • The event contains the coordinates of the mouse relative to the top left of the widget, but we need the coordinate relative to the top left of board which depends on the position of the scrollbars. We can perform this conversion by adding self.scroll_offset to event.offset.
  • Once we have the board coordinate underneath the mouse we divide the x coordinate by 8 and divide the y coordinate by 4 to give us the coordinate of a square.

If the cursor square coordinate calculated in on_mouse_move changes, Textual will call watch_cursor_square with the previous coordinate and new coordinate of the square. This method works out the regions of the widget to update and essentially does the reverse of the steps we took to go from mouse coordinates to square coordinates. The get_square_region function calculates a Region object for each square and uses them as a positional argument in a call to refresh. Passing Region objects to refresh tells Textual to update only the cells underneath those regions, and not the entire widget.

Note

Textual is smart about performing updates. If you refresh multiple regions, Textual will combine them into as few non-overlapping regions as possible.

The final step is to update the render_line method to use the cursor style when rendering the square underneath the mouse.

You should find that if you move the mouse over the widget now, it will highlight the square underneath the mouse pointer in red.

Line API examples

The following builtin widgets use the Line API. If you are building advanced widgets, it may be worth looking through the code for inspiration!

Compound widgets

Widgets may be combined to create new widgets with additional features. Such widgets are known as compound widgets. The stopwatch in the tutorial is an example of a compound widget.

A compound widget can be used like any other widget. The only thing that differs is that when you build a compound widget, you write a compose() method which yields child widgets, rather than implement a render or render_line method.

The following is an example of a compound widget.

compound01.py
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.widgets import Input, Label


class InputWithLabel(Widget):
    """An input with a label."""

    DEFAULT_CSS = """
    InputWithLabel {
        layout: horizontal;
        height: auto;
    }
    InputWithLabel Label {
        padding: 1;
        width: 12;
        text-align: right;
    }
    InputWithLabel Input {
        width: 1fr;
    }
    """

    def __init__(self, input_label: str) -> None:
        self.input_label = input_label
        super().__init__()

    def compose(self) -> ComposeResult:  # (1)!
        yield Label(self.input_label)
        yield Input()


class CompoundApp(App):
    CSS = """
    Screen {
        align: center middle;
    }
    InputWithLabel {
        width: 80%;
        margin: 1;
    }
    """

    def compose(self) -> ComposeResult:
        yield InputWithLabel("First Name")
        yield InputWithLabel("Last Name")
        yield InputWithLabel("Email")


if __name__ == "__main__":
    app = CompoundApp()
    app.run()
  1. The compose method makes this widget a compound widget.

CompoundApp ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ First Name ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Last Name ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔      Email ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

The InputWithLabel class bundles an Input with a Label to create a new widget that displays a right-aligned label next to an input control. You can re-use this InputWithLabel class anywhere in a Textual app, including in other widgets.

Coordinating widgets

Widgets rarely exist in isolation, and often need to communicate or exchange data with other parts of your app. This is not difficult to do, but there is a risk that widgets can become dependant on each other, making it impossible to reuse a widget without copying a lot of dependant code.

In this section we will show how to design and build a fully-working app, while keeping widgets reusable.

Designing the app

We are going to build a byte editor which allows you to enter a number in both decimal and binary. You could use this as a teaching aid for binary numbers.

Here's a sketch of what the app should ultimately look like:

Tip

There are plenty of resources on the web, such as this excellent video from Khan Academy if you want to brush up on binary numbers.

eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVdaU/jyFx1MDAxNv3ev1x1MDAwMjFf3pMmnlpvVY309MTSQCCsYe2nXHUwMDExMomTmCTOYidcdTAwMDRG/d/frUBcdTAwMTNncchcdTAwMDKM0400PVx1MDAxMNtJueqcU+de36r8/WVtbT16bHrrf66te72CW/OLbfdh/Xf7etdrh34jwEOs/3fY6LRcdTAwMGL9MytR1Fxm//zjj7rbrnpRs+ZcdTAwMTY8p+uHXHUwMDFkt1x1MDAxNkadot9wXG6N+lx1MDAxZn7k1cP/2n+P3Lr3n2ajXozazuBDMl7Rj1x1MDAxYe3nz/JqXt1cdTAwMGKiXHUwMDEw3/1/+Pfa2t/9f2Ota3uFyFxyyjWvf0H/UKyBko++etRcYvqNpVx1MDAwNDQ1lHP6eoZcdTAwMWZu4+dFXlx1MDAxMVx1MDAwZpewzd7giH1pXHUwMDFkzppcdTAwMTdnwVOnSW865dxWZn9jc/dm8LElv1bLR4+1frNcbu1GXHUwMDE4ZipuVKhcZs5cYqN2o+pd+cWoYlsw8vrrtWFcdTAwMDN7YnBVu9EpV1x1MDAwMi9cZoeuaTTdglx1MDAxZj3amySvLz53xJ9rg1d6+Jek2lFEU05cdTAwMTRcdTAwMDHDxetBe7XQ4GggWjNcdTAwMTCEMdAjrdpq1HAwsFW/kf7PoF13bqFaxsZcdTAwMDXFwTmiXHUwMDAwXklcdTAwMGXOeXi5VymoQzhjnFHNKSHy9YyK55crkb01RVx1MDAxZInNUJJcdTAwMTEmqVx1MDAxMmzQXHUwMDEyrz8mXHUwMDE0NKGcXHUwMDExMlx1MDAxOFT7+c1ssY+Pv+I9XHUwMDE2XHUwMDE0X3os6NRqgybbXHUwMDAzX2OYXHUwMDFhXFzTaVx1MDAxNt3o5WOUUoZQrYlcdTAwMTn0Rs1cdTAwMGaqo29Xa1x1MDAxNKpcdTAwMDO09F/9/vtcdTAwMDIwpYQn41RcdTAwMTNcclx1MDAwMsDMjtM9dpJxm72oVT047Fx1MDAxYz1cdTAwMTGze8C/Lo5T9k44xWF/XHUwMDEzqMrBW+VcZlxiUcA4XGYjVVx1MDAxYYdcdTAwMDHiR1HBXHJ2i1pcdTAwMDaqUdtccsKm20YkTIKrcLBcdTAwMTlcXHBcIimlXHUwMDEz0MqBOJpcdTAwMTIjXHUwMDA0cI5cdTAwMDRjMIpWMJJIzpn8NLCaT1x1MDAwMWucmKNY5VpQI1x1MDAwNZiZsZrb2bvZolx1MDAwZlutWq8rRHGDkqfrIFx1MDAwMasjePsnUUqIXHUwMDAxhVomULFGQKpcdTAwMWSthcbxXHUwMDAw0Fxu4CNBylx1MDAxZEJBMi1cdTAwMTVVRulxlDLtXHUwMDAwXHUwMDEwjlx1MDAxYU9cdTAwMThwS6xRlCrNSJ9Sq4dSr1bzm+FEjIJcdTAwMTKJXHUwMDE41YIowbWSM2PU69ZPN77dXHUwMDFmXFzlstmb6kaudbW3XV5cdTAwMDSj7zXjz4BRXHUwMDFjeUMkXHUwMDEwoVx1MDAxMa7EsGGQXHUwMDAygpRLg8rGjIXOUnN+yZVMsnF8Uu5YRyFccuCEzpBcdTAwMTJiXHUwMDFjoJQ5XHUwMDEyQFx1MDAxOJRQwlAshVx1MDAxYVx1MDAwNShcdTAwMTeUMWpibfxcdTAwMTlcdTAwMDCqmE5cdTAwMDIo+jBcdTAwMWMxodTM+DSsV7nNRlx1MDAwN7nuUfd8v51vcdVopFx1MDAxY59SXCJcdTAwMDJcdTAwMDFcdTAwMDEqXHRcdTAwMDe0pSP4VFx1MDAwZVCBRGVUSFx1MDAwNVIsXHTQO3ScXHUwMDFmXHUwMDA1UIZyYlx1MDAxOFx1MDAwMbWCXHUwMDEz/TSE6uRpXlKMXCJcdTAwMDSfXHUwMDE5oHv5ulxmy2euOLmqXHUwMDFmh8F5buN6p5NygGqOYVx1MDAxMZM4W0itXHUwMDE4lyNcdTAwMDBcdTAwMDVcdTAwMDdRY7hmiFWtR83HfPik7E5r+Dh8XHUwMDEynFx1MDAwNriWn1x1MDAxNzV9jlx1MDAxMdUyXHUwMDExoFx1MDAxOERcbowk+excdTAwMTBccjJnV0FwuXta7mZ36MZGePh0t5duiCqG8ZA0SmBgLVx1MDAxMYxmXGKiUjJcdTAwMDSwZIDgXHUwMDExeFAuXHUwMDA10VKpNFx0n1x1MDAxM1x1MDAwMFx1MDAxOWPKj1x1MDAxOVxccpzCcVx1MDAxMp9cdTAwMDN/P5AwwFx1MDAwMn955XsyLF+vXHUwMDE5XFxcdTAwMWTDUuT1XHUwMDA2Jjo28s2Th92jTKV0obY297fJ6WbLO9Drr+d9/33y2z5f/PW47rmt/Xz1kGarXHUwMDE33lGzenFcdTAwMTJcdTAwMGV/yo/Pd9vtxkPsfV9+S+KSxrBSsHgotSSXhu4/RiNhXHUwMDEyaaRBgVx1MDAxYVxuYd+i0eTOnEyjiluodNpe+omk3o9IU1x1MDAwMzrGx+nExuiEXHUwMDAxNjZCXHUwMDEzmc6QbTDWjSDK+099S0uGXt1x637tcWi4+uDE/lkz8e5cdTAwMGI9/LxnJFx1MDAwZZ25UfPLXHUwMDE2uus1rzSM6cgvuLXXw3W/WIzPXHUwMDFkXHUwMDA1/HBcdTAwMTffsZ2dRfNcdTAwMWJtv+xcdTAwMDdu7XzQtsUnK5qciTZCXHUwMDEzYSPSmVnmV0/Oj2vlzMP+9l6tK+tHJrpkqWeZXHUwMDExyDLGNNpcdTAwMTFcZnHYsOVHmXE4xnnKXHUwMDE4jFx1MDAwYthcdTAwMDfm9lx1MDAwNHdsXHUwMDFhXHUwMDFjuKVcdTAwMTBIOSm7x5hcdTAwMDPKSGyyTeRQYsbzJngpXHUwMDFlg3nC0oUmtXR4LWqSXHUwMDEzKqibIONcdTAwMDH6W/DduoON6FwiMsXb++vTXFzwcHK2XHUwMDE1h9o/9Vx1MDAxY+VtXGJrKlx1MDAxZI6ux3CDs7PUZlx1MDAwNMLCMTgmXHUwMDE4XHUwMDFhUUYwMljKcVx0UiBSTZokXHUwMDFj++Rcbj9cdTAwMDQ7XWpKJ6CXOlJcdTAwMTJF7dRtjb9cdTAwMWVcdTAwMDUvN4JK8vGGLFx1MDAxZNhlsXFcdTAwMWRPWHOFgVx1MDAxM5394cp+b/eCXHUwMDA2N1t1cXpPj8J6JnNZ99OuvUZcdTAwMDFcblx1MDAxYdpw9DFCXHUwMDFiMVx1MDAwZVxcITHg11x1MDAwMiNdJZeKZj9Fe5F7XHUwMDEy47t/XHUwMDE2v/Fu/1D8isRAVzFcdTAwMTQhXHUwMDE5XHUwMDFmz7fgXHUwMDFifVx1MDAxNU2/c1jyXHUwMDAytl1RXHUwMDE5kTO79HZcdTAwMTW0XHUwMDE3J1x1MDAxOFx1MDAwN4HKudFcdTAwMTKNOGUjXHUwMDEwXHUwMDA2h0hcdTAwMDGaXHUwMDE5gtJrlkHwh0qvfZzLmFwiep5s4TtGw88oyOe/XHUwMDFkdO/Pe3dunlSOXHUwMDAyUz2JVP3dwlb0d7FHt1x1MDAxZkpccpmYRqdAUTw4mUPaN/d6XHUwMDA10T5Sgp1vfDvdr9Jet1dLu7RjbzuUY8dcdTAwMTNcdTAwMDFExaPCV1ttn1NcdTAwMTP0JahasFxmMT5B2pmy8bdcdTAwMTK/iLRzQVx1MDAxM/HLJEhOyVx1MDAxY88p71xcssd446RVydz1mt6J37qst1ZB2y2G0XegfFNcdTAwMTR3zSeEhuhaKMNAg/LlQsPfNGjPTEi2v4e4o61cdTAwMDai8YRfXHUwMDA0vclVS1x1MDAxOP9cdTAwMTjCXHUwMDE5mT1cdTAwMDHf2rlcdTAwMTf+dnBcdTAwMWJcXJU293dP+d7N8U1SIUhqxFx1MDAxNyiaXHUwMDEyXGb4lFbow1x1MDAwMMRcYnCFI21lndBcblx1MDAwNZGnPadhn2VRXHUwMDFlL877qeGLTE9cdTAwMTRfyYnSqFx1MDAwNLPj9+yqVbx42pG128JZVVx1MDAxNKNm7f6gsFxu4lx1MDAwYlQ7XHUwMDE0XHJcdTAwMWJojFx1MDAwZlxy3vN4bKhcdTAwMTDZwI1SiObUii9lXHUwMDFh8Dao+UXgy03yXHUwMDAzepuZxLBRzVx1MDAxZVx1MDAxNz5cXFb35bedyq6f9erB8VngPVx1MDAxY6Q+rYF65lx1MDAxMCptvVxmXGLsj9G0XHUwMDA2c9D14pQsXHUwMDE1pfFAIJ3ySzlGK4LJX1x1MDAwNcBCJeY1NKP2QVx1MDAwMZ1dfm9Ubkfz4lG1ZIr5Mjts1VtVs1x1MDAxMvKr7MNHNPpoXCK0UWN5XHLEsCRGM2LQbi1ZaPKh8quFXCJGxiOan1x1MDAxYr06Oatsi4Ml9sbsqYf7w1x1MDAxM9K4PLy99MONh43QXFxH+fOktFxcauRXceUwjNgwNiNcdTAwMDTFS41AlztSoCnGIaGGkbRnle1iIFxmt3+Z3IOEZP+A3Vx1MDAwNMJQmD33kMtcdTAwMTauvcr1TeusXrjNPmRcdTAwMGav+fHxKuivXHUwMDA1sTRacS2YfVx1MDAxNqTHQUxt9lx1MDAwMf1cdTAwMTSVii9cdTAwMDPij9VfyVx1MDAxOcM2/ir6K6eU+INcdTAwMTbGMJgjdVbee3wqXHUwMDExt37VaoS3hzdcdTAwMWI8W8Omp1xcf7VE4bOVSURb68BGrUNfXHUwMDE2gSphUcGXeibyXHT6y4kkiH4+T5nqXG7jXHUwMDE3ZGL2gffLis1cdTAwMWNld1ftzVMhzEGvfXfwKFx1MDAxYrVcdTAwMWJcdTAwMWFcdTAwMTb2V0F90Tw4VKBNsE/0uIxVwP2AMChbXHUwMDFiheBcdTAwMDK0walVX4bmXFxY+/CLgJcmi68xVGCwMrv3velcdTAwMTVOdffkXHUwMDAyrlx1MDAwZnfunlqd/cvk1Vx1MDAwManRXjSLqL3CYM8zNbR+91V7cUKWiFrN4s44ndqL5uHZ//xcIvCNXHUwMDE1q43AXHUwMDE3XHUwMDE1XHUwMDAwea7nSPxcdTAwMTa7VffxdrtcdTAwMDS3j3fu08XxiV9/zK6C9lwiT1x1MDAxZEJcdTAwMThcdTAwMDDKL1omOlx1MDAxZb4pZp8/UvtoWaU48Vx1MDAwYii9gPj+eZxvUq0+Jcm6S23yXGKHi8yM3MKW2cs8VkpcdTAwMTc7ep9lg9Pc0Vx1MDAwZZUrgVxcw1x1MDAxY3RcdTAwMGKEXG4lXHUwMDEwejCCXFxcdTAwMDKOXHUwMDEyNppcdTAwMDctUZ2TXHUwMDBi9lx1MDAxMTeuWFx1MDAxMLmxRN1cdTAwMDCqZFx1MDAxNJv9dYHA4lx1MDAwNYbLXHUwMDE2679cdTAwMDJ3Ql1P7mJcdTAwMGZuu7RLOlx1MDAwN/eiXFwq34WN2FQ6XHUwMDA0sbnrelx1MDAwNKNcdTAwMWFiOYPFllx1MDAwMsQqUN5YXG4wNC6DlVx1MDAwMGLoxFlXXHUwMDAyRI1m0jKAoVx1MDAxYlx1MDAxOK35J9NL/pN4XG7JZUmKISbto6qZaXr+UKrlNk0+XGLK7u5Fdvss4zG6XHUwMDEyNLXPXHUwMDBlKWFcdTAwMTjWac3VyMNxXCLt7MPxP1xmX5VKtkjLsHSSXHUwMDFiXHUwMDFhI6mdgDBcdTAwMTD5+PU0n09cIppcdTAwMDZcdTAwMTLRxUhERTKLXHUwMDA0XHUwMDAyRtrtNWZmUY5+bfaK1b2C2JK1m8JJt83yuVVgkVbELoanXHUwMDA2JzOiOZ1AI0BJsfGzkmy0XZ862VEmXHJX8bTxz8Mkllx1MDAwNiaxXHUwMDA1mVx1MDAwNIllhuhcdTAwMTjR/+NcdHpmJu2dbXSzl5fhrp/fyarr6Lq3ebGzXG5MwlDGYVLYXHUwMDFiRnuoKYwxSYKtz5bckPhjoHdl0qRcYmeMSZRcdTAwMTOpOPC5kqGrwiSRXHUwMDA2JolcdTAwMDWZlFxcs6BcZlx1MDAwN61cdTAwMDWfPf66XHUwMDBmz13aOFx1MDAwN5/Ip8fbsOh+O6+vRNZcdTAwMTZcZjhcdTAwMDLjL4M84dyIcVwiXHUwMDAxKENcdTAwMTkwW8r9z1x1MDAxMkmjs/uUzS8+n0gyXHJEkouau+RtXHUwMDA3XGLaXGKGnTk7k85uy3tQb3qV7Y2n3YjSXG5/MKvBJC5cdTAwMWRFtX28rFx1MDAxNWej1T9cdTAwMDRcdTAwMWPWL4RcIooyPeVcdTAwMTHexzOJc6FcdTAwMTVTn7BR3OdcdTAwMTNcdNJAJFiQSDzR22HQIKyhmWNKOi+b/MmFyLiFfWRJJLdcdTAwMWW71XBcdTAwMTWIJFx1MDAxNbo3YiTBXHUwMDE5WFx1MDAwYi1GyjhwSuKcUlx1MDAxMFx1MDAxY12VgOQyjqWIRGYhkt1yz65cbvhcdTAwMTlnJJVcdTAwMDZcIqlcdTAwMDWJZJL34Fx1MDAxNVx1MDAxYeNaXHUwMDEy31XyLVwi8XLQpHukWij1NiumSup1fytpOUuqiKQpdZRdUkw44lxcqdFcdTAwMTlJONTuzStcZmg2bUnLx6dcdTAwMWJcZlx1MDAxMcauqvlcdTAwMTmJxNNAJL4gkZLTdlx1MDAxMuyihDm2XG7ptIS/V2td3T1G3V3voFx1MDAwYse3Okqg0WLbslx1MDAxNd2w4r3zdlLaIVx1MDAwNqdcdTAwMTljjJB0JDrCOdlcdTAwMTFcdTAwMDKhi7GRYYpM2XyVgyhcdTAwMTXUdFxuTdyYTc6U9pbcSPGu+1a+XHUwMDFjWIGd0pZjZzZodqJ//Ttcclx1MDAxY/3RlKlMfe7QXHRUTa5BXHUwMDEz9lx1MDAwYlx1MDAwM0Do2esgplx1MDAwZnA6d1CU4IDk6Fx0cUJDYzi8fkhcYuZQTe3G2VxiMymSXHUwMDBi2Fx1MDAxNyYqY1x1MDAwZfpVQFx1MDAxOVDcME5iNVx1MDAxOINcdTAwMWQ+ibGb4jNb1Gp3XHUwMDAxZWPfi1x1MDAwMFx1MDAxYVBr9Fxc+/ksOlx1MDAxMVwi1WQ8mzJcdTAwMDfVwshtR5t+UPSD8vpQXHUwMDA1xsu3fGRnkPw+OVx1MDAwYlx1MDAxZNtK4lx1MDAxMG1QSXH0XHUwMDAwhUzSwY5Ltlx1MDAwYtymXHUwMDFkYUdcdKLQunGFWGaoxi9nXGaqPLyg+Hajpm9cdTAwMTVcdTAwMTdrVIZYzFx1MDAxOIyUXHUwMDE5XG68xMbJsUZRh1GluMBwXHUwMDA1zyGcwVijam5cdTAwMThtNdA0Rtj5J1xyP4hGO7nfm1x1MDAxYpbYXHUwMDE1z1x1MDAxZJNcdLyp+LFRXHUwMDA1aNp3XHUwMDFjluzBb2tcdTAwMDOW9P94/f2v3yeenVxmYvszXHUwMDBl38H7fYn/f26bkVx1MDAxOPditCdcdTAwMTiXc+z+ynVR+81vN6Ql/bPcXHUwMDE27+RPL5LyRynRLiPAkYaAfC4vieVl7PVcdTAwMWGYY5fzckSixDebkjlaVLtgtlx1MDAwNKyiknE6X1n34i4jXVx1MDAxYtss5zLyXHUwMDBmPkaE6bBcdTAwMTmvbVnMZ0Dy5pdGg/0yhtlr3adcdTAwMGZxOrmqwG4wK5GLz1x1MDAxYt5cdTAwMGZzVWpHKk5sZa4ynCfv0bMwVzGmt3swo1ZcdTAwMTiceDSZQF1cdTAwMDNcdTAwMGVcdTAwMTdcdTAwMTLDfi2pxKhg/NuXXHUwMDA04OVcdTAwMTBflPuRNmNhrs1oM6ZL/vCMjlx1MDAwZVx1MDAwMnWMK+A4hDqW9Xid0rmDc5/hKLtcbm9cdTAwMDAnw8VsxvTd2WKNsjWxXFzbXHUwMDFkXHUwMDE57JdcZlHB2ViTKDpcdTAwMTFA8ae2JsNcdTAwMTiQK20yXHUwMDEyXHUwMDExbH8yY+B9L4+RuMaXXHQjQZHZM1x1MDAxObXC3XaVX3tX8p7C42XuW/787C7dskWtdzOGXHUwMDExm1BcdTAwMTdGjqyNXHUwMDA0YVVNc1uCi1xu9/6qJSdsLz8xXHKo3u/LYaaZi4+srkVlofEt8j/aXFzk3Duvllx1MDAwZW/xoylcdTAwMGJaiymlTVx1MDAwNsNcdTAwMTJcdTAwMTLfjuvNXCLBqSOcVpIyW9XE7ZbwXHUwMDA02Ti8f1x1MDAwZqC5QFVcdTAwMTRMKrvNLUwphl+UpoY7dr9jw6xcIlxuPcFbyH5cdTAwMGLt14xxobiKnfLDW6CEXGJit8H4XHUwMDE0b7Ew1Wb0XHUwMDE207V+LZ7CwFHhxm6/ZPc+l3w8WWB3PVWKgTJcdTAwMDKHj6nFjMX0pVx1MDAxZSNuXHUwMDA3m4KjiZG60XbhsFx1MDAxZW+UcLTRXHUwMDAynaxcdTAwMTREYYipV9pbZJJcdTAwMTBsf8awm2Qtvry8/7rbbOYjRNzrcCCW/eKLQlx1MDAwZm5yvet7XHUwMDBmm5NJZnn25aU7rep4/SVL3798/z/RhT5iIn0= 9