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.
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.
Note that the text contains tags in square brackets, i.e. [b].
This is content markup which allows you to embed various styles within your content.
If you run this you will find that World is in bold.
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.
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()
The addition of the CSS has completely transformed our custom widget.
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.
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()
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:
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()
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 links¶
Text in a widget may be marked up with links which perform an action when clicked. Links in markup use the following format:
The @click tag introduces a click handler, which runs the app.bell action.
Let's use links in the hello example so that the greeting becomes a link which updates the widget.
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()
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:
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()
- Setting the default for the
titleattribute via class variable. - Setting
subtitlevia an instance attribute.
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:
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()
- Allow the widget to receive input focus.
Counter {
background: $panel-darken-1;
padding: 1 2;
color: $text-muted;
&:focus { /* (1)! */
background: $primary;
color: $text;
text-style: bold;
outline-left: thick $accent;
}
}
- These styles are applied only when the widget has focus.
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.
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]):