flesh out tutorial

This commit is contained in:
Will McGugan
2024-11-19 11:41:26 +00:00
parent f3a98d1419
commit 0f4f423514
5 changed files with 50 additions and 38 deletions

View File

@@ -1,9 +1,9 @@
from time import monotonic
from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual.containers import HorizontalGroup, VerticalScroll
from textual.reactive import reactive
from textual.widgets import Button, Digits, Footer, Header, Static
from textual.widgets import Button, Digits, Footer, Header
class TimeDisplay(Digits):
@@ -44,7 +44,7 @@ class TimeDisplay(Digits):
self.time = 0
class Stopwatch(Static):
class Stopwatch(HorizontalGroup):
"""A stopwatch widget."""
def on_button_pressed(self, event: Button.Pressed) -> None:
@@ -83,7 +83,7 @@ class StopwatchApp(App):
"""Called to add widgets to the app."""
yield Header()
yield Footer()
yield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")
yield VerticalScroll(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")
def action_add_stopwatch(self) -> None:
"""An action to add a timer."""

View File

@@ -1,5 +1,4 @@
Stopwatch {
layout: horizontal;
Stopwatch {
background: $boost;
height: 5;
margin: 1;

View File

@@ -1,5 +1,4 @@
Stopwatch {
layout: horizontal;
background: $boost;
height: 5;
margin: 1;

View File

@@ -161,7 +161,11 @@ The final three lines create an instance of the app and calls the [run()][textua
## Designing a UI with widgets
Textual comes with a number of builtin widgets, like Header and Footer, which are versatile and re-usable. We will need to build some custom widgets for the stopwatch. Before we dive in to that, let's first sketch a design for the app — so we know what we're aiming for.
Textual has a large number of [builtin widgets](./widget_gallery.md).
For our app we will need new widgets, which we can create by extending and combining the builtin widgets.
Before we dive in to building widgets, let's first sketch a design for the app — so we know what we're aiming for.
<div class="excalidraw">
--8<-- "docs/images/stopwatch.excalidraw.svg"
@@ -184,13 +188,13 @@ Just a skeleton for now, we will add the rest of the features as we go.
```
We've imported two new widgets in this code: [`Button`](widgets/button.md) for the buttons and [`Digits`](widgets/digits.md) for the time display.
Additionally, we've imported `Horizontal` and `VerticalScroll` from `textual.containers` (as the name of the module suggests, *containers* are widgets which contain other widgets).
Additionally, we've imported [`Horizontal`][textual.containers.Horizontal] and [`VerticalScroll`][textual.containers.VerticalScroll] from `textual.containers` (as the name of the module suggests, *containers* are widgets which contain other widgets).
We will use these container widgets to define the general layout of our interface.
The `TimeDisplay` is currently very simple, all it does is extend `Digits` without defining any methods of its own. We will flesh this out later.
The `TimeDisplay` is currently very simple, all it does is extend `Digits` without adding any new. We will flesh this out later.
The `Stopwatch` widget class extends the `HorizontalGroup` container class, which will arrange its children into a horizontal row. The Stopwatch's `compose()` adds those children, which correspond to the components from the sketch above.
The `Stopwatch` widget class extends the `HorizontalGroup` container class, which will arrange its children into a horizontal row.
It's children are yielded from the Stopwatch's `compose()` method, and define the components from the sketch above.
#### The buttons
@@ -201,9 +205,10 @@ The Button constructor takes a label to be displayed in the button (`"Start"`, `
### Composing the widgets
To add widgets to our application we first need to yield them from the app's `compose()` method:
The new line in `StopwatchApp.compose()` yields a single `VerticalScroll` which will scroll if the contents don't quite fit. This widget also takes care of key bindings required for scrolling, like ++up++, ++down++, ++pgdn++, ++pgup++, ++home++, ++end++, etc.
The new line in `StopwatchApp.compose()` yields a single `ScrollableContainer` object which will create a scrolling list of stopwatches. When classes contain other widgets (like `ScrollableContainer`) they will typically accept their child widgets as positional arguments. We want to start the app with three stopwatches, so we construct three `Stopwatch` instances and pass them to the container's constructor.
When widgets contain other widgets (like `VerticalScroll`) they will typically accept their child widgets as positional arguments.
So the line `yield VerticalScroll(Stopwatch(), Stopwatch(), Stopwatch())` creates a `VerticalScroll` containing three `Stopwatch` widgets.
### The unstyled app
@@ -213,7 +218,7 @@ Let's see what happens when we run `stopwatch02.py`.
```{.textual path="docs/examples/tutorial/stopwatch02.py" title="stopwatch02.py"}
```
The elements of the stopwatch application are there. The buttons are clickable and you can scroll the container but it doesn't look like the sketch. This is because we have yet to apply any _styles_ to our new widgets.
The elements of the stopwatch application are there, but it doesn't look much like the sketch. This is because we have yet to apply any _styles_ to our new widgets.
## Writing Textual CSS
@@ -228,7 +233,7 @@ While it's possible to set all styles for an app this way, it is rarely necessar
!!! info
The dialect of CSS used in Textual is greatly simplified over web based CSS and much easier to learn.
The dialect of CSS used in Textual is greatly simplified over web based CSS and easier to learn.
CSS makes it easy to iterate on the design of your app and enables [live-editing](./guide/devtools.md#live-editing) &mdash; you can edit CSS and see the changes without restarting the app!
@@ -259,7 +264,6 @@ CSS files contain a number of _declaration blocks_. Here's the first such block
```css
Stopwatch {
layout: horizontal;
background: $boost;
height: 5;
margin: 1;
@@ -276,7 +280,6 @@ Here's how this CSS code changes how the `Stopwatch` widget is displayed.
--8<-- "docs/images/stopwatch_widgets.excalidraw.svg"
</div>
- `layout: horizontal` aligns child widgets horizontally from left to right.
- `background: $boost` sets the background color to `$boost`. The `$` prefix picks a pre-defined color from the builtin theme. There are other ways to specify colors such as `"blue"` or `rgb(20,46,210)`.
- `height: 5` sets the height of our widget to 5 lines of text.
- `margin: 1` sets a margin of 1 cell around the `Stopwatch` widget to create a little space between widgets in the list.
@@ -287,9 +290,9 @@ Here's how this CSS code changes how the `Stopwatch` widget is displayed.
Here's the rest of `stopwatch03.tcss` which contains further declaration blocks:
```css
TimeDisplay {
content-align: center middle;
opacity: 60%;
TimeDisplay {
text-align: center;
color: $foreground-muted;
height: 3;
}
@@ -311,9 +314,9 @@ Button {
}
```
The `TimeDisplay` block aligns text to the center (`content-align`), fades it slightly (`opacity`), and sets its height (`height`) to 3 lines.
The `TimeDisplay` block aligns text to the center (`text-align:`), sets its color (`color:`), and sets its height (`height:`) to 3 lines.
The `Button` block sets the width (`width`) of buttons to 16 cells (character widths).
The `Button` block sets the width (`width:`) of buttons to 16 cells (character widths).
The last 3 blocks have a slightly different format. When the declaration begins with a `#` then the styles will be applied to widgets with a matching "id" attribute. We've set an ID on the `Button` widgets we yielded in `compose`. For instance the first button has `id="start"` which matches `#start` in the CSS.
@@ -330,11 +333,11 @@ We want our `Stopwatch` widget to have two states: a default state with a Start
</div>
We can accomplish this with a CSS _class_. Not to be confused with a Python class, a CSS class is like a tag you can add to a widget to modify its styles.
We can accomplish this with a CSS _class_. Not to be confused with a Python class, a CSS class is like a tag you can add to a widget to modify its styles. A widget may have any number of CSS classes, which may be added and removes to change the style of your widget.
Here's the new CSS:
```css title="stopwatch04.tcss" hl_lines="33-53"
```css title="stopwatch04.tcss" hl_lines="32-52"
--8<-- "docs/examples/tutorial/stopwatch04.tcss"
```
@@ -371,23 +374,31 @@ If you run `stopwatch04.py` now you will be able to toggle between the two state
```{.textual path="docs/examples/tutorial/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,enter"}
```
When the button event handler adds or removes the `"started"` CSS class, Textual will reapply the CSS and make the required visual changes.
This keeps our Python code of potentially messy display logic.
## Reactive attributes
A recurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call [refresh()][textual.widget.Widget.refresh] to display new data. However, Textual prefers to do this automatically via _reactive_ attributes.
A recurring theme in Textual is that you rarely need to explicitly update a widget's visuals.
It is possible: you can call [refresh()][textual.widget.Widget.refresh] to display new data.
However, Textual prefers to do this automatically via _reactive_ attributes.
You can declare a reactive attribute with [reactive][textual.reactive.reactive]. Let's use this feature to create a timer that displays elapsed time and keeps it updated.
You can declare a reactive attribute with [reactive][textual.reactive.reactive].
Let's use this feature to create a timer that displays elapsed time and keeps it updated.
```python title="stopwatch05.py" hl_lines="1 5 12-27 45"
--8<-- "docs/examples/tutorial/stopwatch05.py"
```
We have added two reactive attributes to the `TimeDisplay` widget: `start_time` will contain the time (in seconds) the stopwatch was started, and `time` will contain the time to be displayed on the `Stopwatch`.
We have added two reactive attributes to the `TimeDisplay` widget: `start_time` will contain the time the stopwatch was started (in seconds), and `time` will contain the time to be displayed in the `Stopwatch` widget.
Both attributes will be available on `self` as if you had assigned them in `__init__`. If you write to either of these attributes the widget will update automatically.
Both attributes will be available on `self` as if you had assigned them in `__init__`.
If you write to either of these attributes the widget will update automatically.
!!! info
The `monotonic` function in this example is imported from the standard library `time` module. It is similar to `time.time` but won't go backwards if the system clock is changed.
The `monotonic` function in this example is imported from the standard library `time` module.
It is similar to `time.time` but won't go backwards if the system clock is changed.
The first argument to `reactive` may be a default value for the attribute or a callable that returns a default value.
We set the default for `start_time` to the `monotonic` function which will be called to initialize the attribute with the current time when the `TimeDisplay` is added to the app.
@@ -399,7 +410,8 @@ The `on_mount` method is an event handler called when the widget is first added
If you implement a method that begins with `watch_` followed by the name of a reactive attribute, then the method will be called when the attribute is modified.
Such methods are known as *watch methods*.
Because `watch_time` watches the `time` attribute, when we update `self.time` 60 times a second we also implicitly call `watch_time` which converts the elapsed time to a string and updates the widget with a call to `self.update`. Because this happens automatically, we don't need to pass in an initial argument to `TimeDisplay`.
Because `watch_time` watches the `time` attribute, when we update `self.time` 60 times a second we also implicitly call `watch_time` which converts the elapsed time to a string and updates the widget with a call to `self.update`.
Because this happens automatically, we don't need to pass in an initial argument to `TimeDisplay`.
The end result is that the `Stopwatch` widgets show the time elapsed since the widget was created:
@@ -422,7 +434,7 @@ Here's a summary of the changes made to `TimeDisplay`.
- We've added a `total` reactive attribute to store the total time elapsed between clicking the start and stop buttons.
- The call to `set_interval` has grown a `pause=True` argument which starts the timer in pause mode (when a timer is paused it won't run until [resume()][textual.timer.Timer.resume] is called). This is because we don't want the time to update until the user hits the start button.
- The `update_time` method now adds `total` to the current time to account for the time between any previous clicks of the start and stop buttons.
- We've stored the result of `set_interval` which returns a Timer object. We will use this later to _resume_ the timer when we start the Stopwatch.
- We've stored the result of `set_interval` which returns a [Timer](textual.timer.Timer) object. We will use this to _resume_ the timer when we start the Stopwatch.
- We've added `start()`, `stop()`, and `reset()` methods.
In addition, the `on_button_pressed` method on `Stopwatch` has grown some code to manage the time display when the user clicks a button. Let's look at that in detail:
@@ -445,7 +457,7 @@ In addition, the `on_button_pressed` method on `Stopwatch` has grown some code t
This code supplies missing features and makes our app useful. We've made the following changes.
- The first line retrieves `id` attribute of the button that was pressed. We can use this to decide what to do in response.
- The second line calls `query_one` to get a reference to the `TimeDisplay` widget.
- The second line calls [`query_one`][textual.dom.DOMNode.query_one] to get a reference to the `TimeDisplay` widget.
- We call the method on `TimeDisplay` that matches the pressed button.
- We add the `"started"` class when the Stopwatch is started (`self.add_class("started")`), and remove it (`self.remove_class("started")`) when it is stopped. This will update the Stopwatch visuals via CSS.
@@ -468,14 +480,16 @@ Let's use these methods to implement adding and removing stopwatches to our app.
Here's a summary of the changes:
- The `ScrollableContainer` object in `StopWatchApp` grew a `"timers"` ID.
- The `VerticalScroll` object in `StopWatchApp` grew a `"timers"` ID.
- Added `action_add_stopwatch` to add a new stopwatch.
- Added `action_remove_stopwatch` to remove a stopwatch.
- Added keybindings for the actions.
The `action_add_stopwatch` method creates and mounts a new stopwatch. Note the call to [query_one()][textual.dom.DOMNode.query_one] with a CSS selector of `"#timers"` which gets the timer's container via its ID. Once mounted, the new Stopwatch will appear in the terminal. That last line in `action_add_stopwatch` calls [scroll_visible()][textual.widget.Widget.scroll_visible] which will scroll the container to make the new `Stopwatch` visible (if required).
The `action_add_stopwatch` method creates and mounts a new stopwatch. Note the call to [query_one()][textual.dom.DOMNode.query_one] with a CSS selector of `"#timers"` which gets the timer's container via its ID.
Once mounted, the new Stopwatch will appear in the terminal. That last line in `action_add_stopwatch` calls [scroll_visible()][textual.widget.Widget.scroll_visible] which will scroll the container to make the new `Stopwatch` visible (if required).
The `action_remove_stopwatch` function calls [query()][textual.dom.DOMNode.query] with a CSS selector of `"Stopwatch"` which gets all the `Stopwatch` widgets. If there are stopwatches then the action calls [last()][textual.css.query.DOMQuery.last] to get the last stopwatch, and [remove()][textual.css.query.DOMQuery.remove] to remove it.
The `action_remove_stopwatch` function calls [query()][textual.dom.DOMNode.query] with a CSS selector of `"Stopwatch"` which gets all the `Stopwatch` widgets.
If there are stopwatches then the action calls [last()][textual.css.query.DOMQuery.last] to get the last stopwatch, and [remove()][textual.css.query.DOMQuery.remove] to remove it.
If you run `stopwatch.py` now you can add a new stopwatch with the ++a++ key and remove a stopwatch with ++r++.

View File

@@ -3319,9 +3319,9 @@ class Widget(DOMNode):
immediate=immediate,
)
else:
# self.region is falsey which may indicate the widget hasn't been through a layout operation
# self.region is falsy which may indicate the widget hasn't been through a layout operation
# We can potentially make it do the right thing by postponing the scroll to after a refresh
self.call_after_refresh(
parent.call_after_refresh(
self.screen.scroll_to_widget,
self,
animate=animate,