mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
more docs and compute example
This commit is contained in:
@@ -23,7 +23,7 @@ On modern terminal software (installed by default on most systems), Textual apps
|
||||
|
||||
## Compatibility
|
||||
|
||||
Textual runs on Linux, MacOS, and Windows. Textual requires Python 3.7 or above.
|
||||
Textual runs on Linux, macOS, and Windows. Textual requires Python 3.7 or above.
|
||||
|
||||
## Installing
|
||||
|
||||
|
||||
13
docs/examples/guide/reactivity/computed01.css
Normal file
13
docs/examples/guide/reactivity/computed01.css
Normal file
@@ -0,0 +1,13 @@
|
||||
#color-inputs {
|
||||
dock: top;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
Input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
#color {
|
||||
height: 100%;
|
||||
border: tall $secondary;
|
||||
}
|
||||
47
docs/examples/guide/reactivity/computed01.py
Normal file
47
docs/examples/guide/reactivity/computed01.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.color import Color
|
||||
from textual.containers import Horizontal
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
|
||||
class ComputedApp(App):
|
||||
CSS_PATH = "computed01.css"
|
||||
|
||||
red = reactive(0)
|
||||
green = reactive(0)
|
||||
blue = reactive(0)
|
||||
color = reactive(Color.parse("transparent"))
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Horizontal(
|
||||
Input("0", placeholder="Enter red 0-255", id="red"),
|
||||
Input("0", placeholder="Enter green 0-255", id="green"),
|
||||
Input("0", placeholder="Enter blue 0-255", id="blue"),
|
||||
id="color-inputs",
|
||||
)
|
||||
yield Static(id="color")
|
||||
|
||||
def compute_color(self) -> Color: # (1)!
|
||||
return Color(self.red, self.green, self.blue).clamped
|
||||
|
||||
def watch_color(self, color: Color) -> None: # (2)
|
||||
self.query_one("#color").styles.background = color
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
try:
|
||||
component = int(event.value)
|
||||
except ValueError:
|
||||
self.bell()
|
||||
else:
|
||||
if event.input.id == "red":
|
||||
self.red = component
|
||||
elif event.input.id == "green":
|
||||
self.green = component
|
||||
else:
|
||||
self.blue = component
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = ComputedApp()
|
||||
app.run()
|
||||
9
docs/examples/guide/reactivity/refresh01.css
Normal file
9
docs/examples/guide/reactivity/refresh01.css
Normal file
@@ -0,0 +1,9 @@
|
||||
Input {
|
||||
dock: top;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
Name {
|
||||
height: 100%;
|
||||
content-align: center middle;
|
||||
}
|
||||
29
docs/examples/guide/reactivity/refresh01.py
Normal file
29
docs/examples/guide/reactivity/refresh01.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input
|
||||
|
||||
|
||||
class Name(Widget):
|
||||
"""Generates a greeting."""
|
||||
|
||||
who = reactive("name")
|
||||
|
||||
def render(self) -> str:
|
||||
return f"Hello, {self.who}!"
|
||||
|
||||
|
||||
class WatchApp(App):
|
||||
CSS_PATH = "refresh01.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(placeholder="Enter your name")
|
||||
yield Name()
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
self.query_one(Name).who = event.value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = WatchApp()
|
||||
app.run()
|
||||
10
docs/examples/guide/reactivity/refresh02.css
Normal file
10
docs/examples/guide/reactivity/refresh02.css
Normal file
@@ -0,0 +1,10 @@
|
||||
Input {
|
||||
dock: top;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
Name {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: heavy $secondary;
|
||||
}
|
||||
29
docs/examples/guide/reactivity/refresh02.py
Normal file
29
docs/examples/guide/reactivity/refresh02.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input
|
||||
|
||||
|
||||
class Name(Widget):
|
||||
"""Generates a greeting."""
|
||||
|
||||
who = reactive("name", layout=True) # (1)!
|
||||
|
||||
def render(self) -> str:
|
||||
return f"Hello, {self.who}!"
|
||||
|
||||
|
||||
class WatchApp(App):
|
||||
CSS_PATH = "refresh02.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(placeholder="Enter your name")
|
||||
yield Name()
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
self.query_one(Name).who = event.value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = WatchApp()
|
||||
app.run()
|
||||
4
docs/examples/guide/reactivity/validate01.css
Normal file
4
docs/examples/guide/reactivity/validate01.css
Normal file
@@ -0,0 +1,4 @@
|
||||
#buttons {
|
||||
dock: top;
|
||||
height: auto;
|
||||
}
|
||||
38
docs/examples/guide/reactivity/validate01.py
Normal file
38
docs/examples/guide/reactivity/validate01.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Button, TextLog
|
||||
|
||||
|
||||
class ValidateApp(App):
|
||||
CSS_PATH = "validate01.css"
|
||||
|
||||
count = reactive(0)
|
||||
|
||||
def validate_count(self, count: int) -> int:
|
||||
"""Validate value."""
|
||||
if count < 0:
|
||||
count = 0
|
||||
elif count > 10:
|
||||
count = 10
|
||||
return count
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Horizontal(
|
||||
Button("+1", id="plus", variant="success"),
|
||||
Button("-1", id="minus", variant="error"),
|
||||
id="buttons",
|
||||
)
|
||||
yield TextLog(highlight=True)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "plus":
|
||||
self.count += 1
|
||||
else:
|
||||
self.count -= 1
|
||||
self.query_one(TextLog).write(f"{self.count=}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = ValidateApp()
|
||||
app.run()
|
||||
21
docs/examples/guide/reactivity/watch01.css
Normal file
21
docs/examples/guide/reactivity/watch01.css
Normal file
@@ -0,0 +1,21 @@
|
||||
Input {
|
||||
dock: top;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#colors {
|
||||
grid-size: 2 1;
|
||||
grid-gutter: 2 4;
|
||||
grid-columns: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#old {
|
||||
height: 100%;
|
||||
border: wide $secondary;
|
||||
}
|
||||
|
||||
#new {
|
||||
height: 100%;
|
||||
border: wide $secondary;
|
||||
}
|
||||
33
docs/examples/guide/reactivity/watch01.py
Normal file
33
docs/examples/guide/reactivity/watch01.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.color import Color, ColorParseError
|
||||
from textual.containers import Grid
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
|
||||
class WatchApp(App):
|
||||
CSS_PATH = "watch01.css"
|
||||
|
||||
color = reactive(Color.parse("transparent")) # (1)!
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(placeholder="Enter a color")
|
||||
yield Grid(Static(id="old"), Static(id="new"), id="colors")
|
||||
|
||||
def watch_color(self, old_color: Color, new_color: Color) -> None: # (2)!
|
||||
self.query_one("#old").styles.background = old_color
|
||||
self.query_one("#new").styles.background = new_color
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
try:
|
||||
input_color = Color.parse(event.value)
|
||||
except ColorParseError:
|
||||
pass
|
||||
else:
|
||||
self.query_one(Input).value = ""
|
||||
self.color = input_color # (3)!
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = WatchApp()
|
||||
app.run()
|
||||
@@ -2,7 +2,7 @@ All you need to get started building Textual apps.
|
||||
|
||||
## Requirements
|
||||
|
||||
Textual requires Python 3.7 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, MacOS, Windows and probably any OS where Python also runs.
|
||||
Textual requires Python 3.7 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs.
|
||||
|
||||
!!! info inline end "Your platform"
|
||||
|
||||
@@ -10,7 +10,7 @@ Textual requires Python 3.7 or later (if you have a choice, pick the most recent
|
||||
|
||||
All Linux distros come with a terminal emulator that can run Textual apps.
|
||||
|
||||
### :material-apple: MacOS
|
||||
### :material-apple: macOS
|
||||
|
||||
The default terminal app is limited to 256 colors. We recommend installing a newer terminal such as [iterm2](https://iterm2.com/), [Kitty](https://sw.kovidgoyal.net/kitty/), or [WezTerm](https://wezfurlong.org/wezterm/).
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ Widgets can be as simple as a piece of text, a button, or a fully-fledge compone
|
||||
|
||||
### Composing
|
||||
|
||||
To add widgets to your app implement a [`compose()`][textual.app.App.compose] method which should return a iterable of Widget instances. A list would work, but it is convenient to yield widgets, making the method a *generator*.
|
||||
To add widgets to your app implement a [`compose()`][textual.app.App.compose] method which should return an iterable of Widget instances. A list would work, but it is convenient to yield widgets, making the method a *generator*.
|
||||
|
||||
The following example imports a builtin Welcome widget and yields it from compose.
|
||||
|
||||
|
||||
@@ -1,10 +1,224 @@
|
||||
# Reactivity
|
||||
|
||||
TODO: Reactivity docs
|
||||
Textual's reactive attributes are attributes _with superpowers_. In this chapter we will look at how reactive attributes can simplify your apps.
|
||||
|
||||
- What is reactivity
|
||||
- Reactive variables
|
||||
- Demo
|
||||
- repaint vs layout
|
||||
- Validation
|
||||
- Watch methods
|
||||
!!! quote
|
||||
|
||||
With great power comes great responsibility.
|
||||
|
||||
— Uncle Ben
|
||||
|
||||
## Reactive attributes
|
||||
|
||||
Textual provides an alternative way of adding attributes to your widget or App, which doesn't require adding them to your class constructor (`__init__`). To create these attributes import [reactive][textual.reactive.reactive] from `textual.reactive`, and assign them in the class scope.
|
||||
|
||||
The following code illustrates how to create reactive attributes:
|
||||
|
||||
```python
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
|
||||
class Reactive(Widget):
|
||||
|
||||
name = reactive("Paul") # (1)!
|
||||
count = reactive(0) # (2)!
|
||||
is_cool = reactive(True) # (3)!
|
||||
```
|
||||
|
||||
1. Create a string attribute with a default of `"Paul"`
|
||||
2. Creates an integer attribute with a default of `0`.
|
||||
3. Creates a boolean attribute with a default of `True`.
|
||||
|
||||
The `reactive` constructor accepts a default value as the first positional argument.
|
||||
|
||||
!!! information
|
||||
|
||||
Textual uses Python's _descriptor protocol_ to create reactive attributes, which is the same protocol used by the builtin `property` decorator.
|
||||
|
||||
You can get and set these attributes in the same way as if you had assigned them in a `__init__` method. For instance `self.name = "Jessica"`, `self.count += 1`, or `print(self.is_cool)`.
|
||||
|
||||
### Dynamic defaults
|
||||
|
||||
You can also set the default to a function (or other callable). Textual will call this function to get the default value. The following code illustrates a reactive value which will be automatically assigned the current time when the widget is created:
|
||||
|
||||
```python
|
||||
from time import time
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
|
||||
class Timer(Widget):
|
||||
|
||||
start_time = reactive(time) # (1)!
|
||||
```
|
||||
|
||||
1. The `time` function returns the current time in seconds.
|
||||
|
||||
### Typing reactive attributes
|
||||
|
||||
There is no need to specify a type hint if a reactive attribute has a default value, as type checkers will assume the attribute is the same type as the default.
|
||||
|
||||
You may want to add explicit type hints if the attribute type is a superset of the default type. For instance if you want to make an attribute optional. Here's how you would create a reactive string attribute which may be `None`:
|
||||
|
||||
```python
|
||||
name: reactive[str | None] = reactive("Paul")
|
||||
```
|
||||
|
||||
## Smart refresh
|
||||
|
||||
The first superpower we will look at is "smart refresh". When you modify a reactive attribute, Textual will make note of the fact that it has changed and refresh automatically.
|
||||
|
||||
!!! information
|
||||
|
||||
If you modify multiple reactive attribute, Textual will only do a single refresh to minimize updates.
|
||||
|
||||
Let's look at an example which illustrates this. In the following app, the value of an input is used to update a "Hello, World!" type greeting.
|
||||
|
||||
=== "refresh01.py"
|
||||
|
||||
```python hl_lines="7-13 24"
|
||||
--8<-- "docs/examples/guide/reactivity/refresh01.py"
|
||||
```
|
||||
|
||||
=== "refresh01.css"
|
||||
|
||||
```sass
|
||||
--8<-- "docs/examples/guide/reactivity/refresh01.css"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/guide/reactivity/refresh01.py" press="tab,T,e,x,t,u,a,l"}
|
||||
```
|
||||
|
||||
The `Name` widget has a reactive `who` attribute. When the app modifies that attribute, a refresh happens automatically.
|
||||
|
||||
!!! information
|
||||
|
||||
Textual will check if a value has really changed, so assigning the same value wont prompt an unnecessary refresh.
|
||||
|
||||
### Disabling refresh
|
||||
|
||||
If you *don't* want an attribute to prompt a refresh or layout but you still want other reactive superpowers, you can use [var][textual.reactive.var] to create an attribute. You can import `var` from `textual.reactive`.
|
||||
|
||||
The following code illustrates how you create non-refreshing reactive attributes.
|
||||
|
||||
```python
|
||||
class MyWidget(Widget):
|
||||
count = var(0) # (1)!
|
||||
```
|
||||
|
||||
1. Changing `self.count` wont cause a refresh or layout.
|
||||
|
||||
### Layout
|
||||
|
||||
The smart refresh feature will update the content area of a widget, but will not change its size. If modifying an attribute should change the size of the widget, you should set `layout=True` on the reactive attribute. This ensures that your CSS layout will update accordingly.
|
||||
|
||||
The following example modifies "refresh01.py" so that the greeting has an automatic width.
|
||||
|
||||
=== "refresh02.py"
|
||||
|
||||
```python hl_lines="10"
|
||||
--8<-- "docs/examples/guide/reactivity/refresh02.py"
|
||||
```
|
||||
1. This attribute will update the layout when changed.
|
||||
|
||||
=== "refresh02.css"
|
||||
|
||||
```sass hl_lines="7-9"
|
||||
--8<-- "docs/examples/guide/reactivity/refresh02.css"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/guide/reactivity/refresh02.py" press="tab,n,a,m,e"}
|
||||
```
|
||||
|
||||
If you type in to the input now, the greeting will expand to fit the content. If you were to set `layout=False` on the reactive attribute, you should see that the box remains the same size when you type.
|
||||
|
||||
## Validation
|
||||
|
||||
The next superpower we will look at is _validation_. If you add a method that begins with `validate_` followed by the name of your attribute, it will be called when you assign a value to that attribute. This method should accept the incoming value as a positional argument, and return the value to set (which may be the same or a different value).
|
||||
|
||||
A common use for this is to restrict numbers to a given range. The following example keeps a count. There is a button to increase the count, and a button to decrease it.
|
||||
|
||||
=== "validate01.py"
|
||||
|
||||
```python hl_lines="12-18 30 32"
|
||||
--8<-- "docs/examples/guide/reactivity/validate01.py"
|
||||
```
|
||||
|
||||
=== "validate01.css"
|
||||
|
||||
```sass
|
||||
--8<-- "docs/examples/guide/reactivity/validate01.css"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/guide/reactivity/validate01.py"}
|
||||
```
|
||||
|
||||
If you click the buttons in the above example it will show the current count. When `self.count` is modified in the button handler, Textual runs `validate_count` which limits self.count` to a maximum of 10, and stops it going below zero.
|
||||
|
||||
## Watch methods
|
||||
|
||||
Watch methods are another superpower. Textual will call watch methods when reactive attributes are modified. Watch methods begin with `watch_` followed by the name of the attribute. If the watch method accepts a positional argument, it will be called with the new assigned value. If the watch method accepts *two* positional arguments, it will be called with both the *old* value and the *new* value.
|
||||
|
||||
The follow app will display any color you type in to the input. Try it with a valid color in Textual CSS. For example `"darkorchid"` or `"#52de44".
|
||||
|
||||
=== "watch01.py"
|
||||
|
||||
```python hl_lines="17-19 28"
|
||||
--8<-- "docs/examples/guide/reactivity/watch01.py"
|
||||
```
|
||||
|
||||
1. Creates a reactive [color][textual.color.Color] attribute.
|
||||
2. Called when `self.color` is changed.
|
||||
3. New color is assigned here.
|
||||
|
||||
=== "watch01.css"
|
||||
|
||||
```sass
|
||||
--8<-- "docs/examples/guide/reactivity/watch01.css"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/guide/reactivity/watch01.py" press="tab,d,a,r,k,o,r,c,h,i,d"}
|
||||
```
|
||||
|
||||
The color is parsed in `on_input_submitted` and assigned to `self.color`. Because `color` is reactive, Textual also calls `watch_color` with the old and new values.
|
||||
|
||||
## Compute methods
|
||||
|
||||
Compute methods are the final superpower offered by the `reactive` descriptor. Textual runs compute methods to calculate the value of a reactive attribute. Compute methods begin with `compute_` followed by the name of the reactive value.
|
||||
|
||||
You could be forgiven in thinking this sounds a lot like Python's property decorator. The difference is that Textual will cache the value of compute methods, and update them when any other reactive attribute changes.
|
||||
|
||||
The following example uses a computed attribute. It displays three inputs for the each color component (red, green, and blue). If you enter numbers in to these inputs, the background color of another widget changes.
|
||||
|
||||
=== "computed01.py"
|
||||
|
||||
```python hl_lines="12-18 30 32"
|
||||
--8<-- "docs/examples/guide/reactivity/computed01.py"
|
||||
```
|
||||
|
||||
1. Combines color components in to a Color object.
|
||||
2. The compute method is called when the _result_ of `compute_color` changes.
|
||||
|
||||
=== "computed01.css"
|
||||
|
||||
```sass
|
||||
--8<-- "docs/examples/guide/reactivity/computed01.css"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/guide/reactivity/computed01.py"}
|
||||
```
|
||||
|
||||
Note the `compute_color` method which combines the color components into a [Color][textual.color.Color] object. When the _result_ of this method changes, Textual calls `watch_color` which uses the new color as a background.
|
||||
|
||||
!!! note
|
||||
|
||||
You should avoid doing anything slow or cpu-intensive in a compute method. Textual calls compute methods on an object when _any_ reactive attribute changes, so it can known when it changes.
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,4 +1,5 @@
|
||||
# Welcome
|
||||
|
||||
# Introduction
|
||||
|
||||
Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation. Built with ❤️ by [Textualize.io](https://www.textualize.io)
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
# Tutorial
|
||||
|
||||
Welcome to the Textual Tutorial!
|
||||
@@ -102,7 +107,7 @@ Let's examine stopwatch01.py in more detail.
|
||||
--8<-- "docs/examples/tutorial/stopwatch01.py"
|
||||
```
|
||||
|
||||
The first line imports the Textual `App` class. The second line imports two builtin widgets: `Footer` which shows available keys and `Header` which shows a title and the current time. Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build such widgets in this tutorial.
|
||||
The first line imports the Textual `App` class, which we will use as the base class for our App. The second line imports two builtin widgets: `Footer` which shows a bar at the bottom of the screen with current keys, and `Header` which shows a title and the current time at the top of the screen. Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build widgets in this tutorial.
|
||||
|
||||
The following lines define the app itself:
|
||||
|
||||
@@ -114,29 +119,29 @@ The App class is where most of the logic of Textual apps is written. It is respo
|
||||
|
||||
Here's what the above app defines:
|
||||
|
||||
- `BINDINGS` is a list of tuples that maps (or *binds*) keys to actions in your app. The first value in the tuple is the key; the second value is the name of the action; the final value is a short description. We have a single binding which maps the ++d++ key on to the "toggle_dark" action.
|
||||
- `BINDINGS` is a list of tuples that maps (or *binds*) keys to actions in your app. The first value in the tuple is the key; the second value is the name of the action; the final value is a short description. We have a single binding which maps the ++d++ key on to the "toggle_dark" action. See [key bindings](./guide/input.md#bindings) in the guide for details.
|
||||
|
||||
- `compose()` is where we construct a user interface with widgets. The `compose()` method may return a list of widgets, but it is generally easier to _yield_ them (making this method a generator). In the example code we yield an instance of each of the widget classes we imported, i.e. `Header()` and `Footer()`.
|
||||
|
||||
- `action_toggle_dark()` defines an _action_ method. Actions are methods beginning with `action_` followed by the name of the action. The `BINDINGS` list above tells Textual to run this action when the user hits the ++d++ key.
|
||||
- `action_toggle_dark()` defines an _action_ method. Actions are methods beginning with `action_` followed by the name of the action. The `BINDINGS` list above tells Textual to run this action when the user hits the ++d++ key. See [actions](./guide/actions.md) in the guide for details.
|
||||
|
||||
```python title="stopwatch01.py" hl_lines="20-22"
|
||||
--8<-- "docs/examples/tutorial/stopwatch01.py"
|
||||
```
|
||||
|
||||
The final three lines create an instance of the app and call [run()][textual.app.App.run] which puts your terminal in to *application mode* and runs the app until you exit with ++ctrl+c++. This happens within a `__name__ == "__main__"` block so we could run the app with `python stopwatch01.py` or import it as part of a larger project.
|
||||
The final three lines create an instance of the app and calls the [run()][textual.app.App.run] method which puts your terminal in to *application mode* and runs the app until you exit with ++ctrl+c++. This happens within a `__name__ == "__main__"` block so we could run the app with `python stopwatch01.py` or import it as part of a larger project.
|
||||
|
||||
## Designing a UI with widgets
|
||||
|
||||
The header and footer are builtin widgets. For our Stopwatch application we will need to build custom widgets.
|
||||
|
||||
Let's sketch out a design for our app:
|
||||
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.
|
||||
|
||||
<div class="excalidraw">
|
||||
--8<-- "docs/images/stopwatch.excalidraw.svg"
|
||||
</div>
|
||||
|
||||
We will need to build a `Stopwatch` widget composed of the following _child_ widgets:
|
||||
### Custom widgets
|
||||
|
||||
We need a `Stopwatch` widget composed of the following _child_ widgets:
|
||||
|
||||
- A "Start" button
|
||||
- A "Stop" button
|
||||
@@ -151,15 +156,15 @@ Let's add those to the app. Just a skeleton for now, we will add the rest of the
|
||||
--8<-- "docs/examples/tutorial/stopwatch02.py"
|
||||
```
|
||||
|
||||
### Extending widget classes
|
||||
We've imported two new widgets in this code: `Button`, which creates a clickable button, and `Static` which is a base class for a simple control. We've also imported `Container` from `textual.containers` which (as the name suggests) is a Widget which contains other widgets.
|
||||
|
||||
We've imported two new widgets in this code: `Button`, which creates a clickable button, and `Static` which is a base class for a simple control. We've also imported `Container` from `textual.layout`. As the name suggests, `Container` is a Widget which contains other widgets. We will use this container to create a scrolling list of stopwatches.
|
||||
We've defined an empty `TimeDisplay` widget by extending `Static`. We will flesh this out later.
|
||||
|
||||
We're extending Static as a foundation for our `TimeDisplay` widget. There are no methods on this class yet.
|
||||
The Stopwatch widget also class extends `Static`. This class has a `compose()` method which yields child widgets, consisting of three `Button` objects and a single `TimeDisplay`. These widgets will form the stopwatch in our sketch.
|
||||
|
||||
The Stopwatch class extends Static to define a new widget. This class has a `compose()` method which yields its child widgets, consisting of three `Button` objects and a single `TimeDisplay`. These are all we need to build a stopwatch as in the sketch.
|
||||
#### The buttons
|
||||
|
||||
The Button constructor takes a label to be displayed in the button ("Start", "Stop", or "Reset"). Additionally some of the buttons set the following parameters:
|
||||
The Button constructor takes a label to be displayed in the button (`"Start"`, `"Stop"`, or `"Reset"`). Additionally some of the buttons set the following parameters:
|
||||
|
||||
- `id` is an identifier we can use to tell the buttons apart in code and apply styles. More on that later.
|
||||
- `variant` is a string which selects a default style. The "success" variant makes the button green, and the "error" variant makes it red.
|
||||
@@ -189,20 +194,20 @@ self.styles.background = "blue"
|
||||
self.styles.color = "white"
|
||||
```
|
||||
|
||||
!!! info inline end
|
||||
|
||||
Don't worry if you have never worked with CSS before. The dialect of CSS we use is greatly simplified over web based CSS and easy to learn!
|
||||
|
||||
|
||||
While it's possible to set all styles for an app this way, it is rarely necessary. Textual has support for CSS (Cascading Style Sheets), a technology used by web browsers. CSS files are data files loaded by your app which contain information about styles to apply to your widgets.
|
||||
|
||||
!!! info
|
||||
|
||||
The dialect of CSS used in Textual is greatly simplified over web based CSS and much easier to learn!
|
||||
|
||||
|
||||
Let's add a CSS file to our application.
|
||||
|
||||
```python title="stopwatch03.py" hl_lines="24"
|
||||
--8<-- "docs/examples/tutorial/stopwatch03.py"
|
||||
```
|
||||
|
||||
Adding the `CSS_PATH` class variable tells Textual to load the following file when it starts the app:
|
||||
Adding the `CSS_PATH` class variable tells Textual to load the following file when the app starts:
|
||||
|
||||
```sass title="stopwatch03.css"
|
||||
--8<-- "docs/examples/tutorial/stopwatch03.css"
|
||||
@@ -213,7 +218,7 @@ If we run the app now, it will look *very* different.
|
||||
```{.textual path="docs/examples/tutorial/stopwatch03.py" title="stopwatch03.py"}
|
||||
```
|
||||
|
||||
This app looks much more like our sketch. Textual has read style information from `stopwatch03.css` and applied it to the widgets.
|
||||
This app looks much more like our sketch. let's look at how the Textual uses `stopwatch03.css` to apply styles.
|
||||
|
||||
### CSS basics
|
||||
|
||||
@@ -285,6 +290,11 @@ You may have noticed that the stop button (`#stop` in the CSS) has `display: non
|
||||
|
||||
We want our Stopwatch widget to have two states: a default state with a Start and Reset button; and a _started_ state with a Stop button. When a stopwatch is started it should also have a green background and bold text.
|
||||
|
||||
<div class="excalidraw">
|
||||
--8<-- "docs/images/css_stopwatch.excalidraw.svg"
|
||||
</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.
|
||||
|
||||
Here's the new CSS:
|
||||
@@ -295,10 +305,6 @@ Here's the new CSS:
|
||||
|
||||
These new rules are prefixed with `.started`. The `.` indicates that `.started` refers to a CSS class called "started". The new styles will be applied only to widgets that have this CSS class.
|
||||
|
||||
<div class="excalidraw">
|
||||
--8<-- "docs/images/css_stopwatch.excalidraw.svg"
|
||||
</div>
|
||||
|
||||
Some of the new styles have more than one selector separated by a space. The space indicates that the rule should match the second selector if it is a child of the first. Let's look at one of these styles:
|
||||
|
||||
```sass
|
||||
@@ -353,7 +359,7 @@ The first argument to `reactive` may be a default value or a callable that retur
|
||||
The `time` attribute has a simple float as the default value, so `self.time` will be `0` on start.
|
||||
|
||||
|
||||
The `on_mount` method is an event handler which is called then the widget is first added to the application (or _mounted_). In this method we call [set_interval()][textual.message_pump.MessagePump.set_interval] to create a timer which calls `self.update_time` sixty times a second. This `update_time` method calculates the time elapsed since the widget started and assigns it to `self.time`. Which brings us to one of Reactive's super-powers.
|
||||
The `on_mount` method is an event handler called then the widget is first added to the application (or _mounted_). In this method we call [set_interval()][textual.message_pump.MessagePump.set_interval] to create a timer which calls `self.update_time` sixty times a second. This `update_time` method calculates the time elapsed since the widget started and assigns it to `self.time`. Which brings us to one of Reactive's super-powers.
|
||||
|
||||
If you implement a method that begins with `watch_` followed by the name of a reactive attribute (making it a _watch method_), that method will be called when the attribute is modified.
|
||||
|
||||
@@ -401,7 +407,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 the button's ID, which we will use to decide what to do in response.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -411,15 +417,13 @@ If you run stopwatch06.py you will be able to use the stopwatches independently.
|
||||
```{.textual path="docs/examples/tutorial/stopwatch06.py" title="stopwatch06.py" press="tab,enter,_,_,tab,enter,_,tab"}
|
||||
```
|
||||
|
||||
The only remaining feature of the Stopwatch app left to implement is the ability to add and remove timers.
|
||||
The only remaining feature of the Stopwatch app left to implement is the ability to add and remove stopwatches.
|
||||
|
||||
## Dynamic widgets
|
||||
|
||||
It's convenient to build a user interface with the `compose` method. We may also want to add or remove widgets while the app is running.
|
||||
The Stopwatch app creates widgets when it starts via the `compose` method. We will also need to create new widgets while the app is running, and remove widgets we no longer need. We can do this by calling [mount()][textual.widget.Widget.mount] to add a widget, and [remove()][textual.widget.Widget.remove] to remove a widget.
|
||||
|
||||
To add a new child widget call `mount()` on the parent. To remove a widget, call its `remove()` method.
|
||||
|
||||
Let's use these to implement adding and removing stopwatches to our app.
|
||||
Let's use these methods to implement adding and removing stopwatches to our app.
|
||||
|
||||
```python title="stopwatch.py" hl_lines="78-79 88-92 94-98"
|
||||
--8<-- "docs/examples/tutorial/stopwatch.py"
|
||||
|
||||
@@ -3,8 +3,9 @@ site_url: https://textual.textualize.io/
|
||||
repo_url: https://github.com/textualize/textual/
|
||||
|
||||
nav:
|
||||
- "index.md"
|
||||
- "getting_started.md"
|
||||
- Introduction:
|
||||
- "index.md"
|
||||
- "getting_started.md"
|
||||
- "tutorial.md"
|
||||
- Guide:
|
||||
- "guide/index.md"
|
||||
@@ -154,6 +155,7 @@ theme:
|
||||
- navigation.tabs
|
||||
- navigation.indexes
|
||||
- navigation.tabs.sticky
|
||||
- content.code.annotate
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
|
||||
@@ -22,7 +22,7 @@ textual = "textual.cli.cli:run"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.7"
|
||||
rich = "^12.6.0a2"
|
||||
rich = "^12.6.0"
|
||||
#rich = {path="../rich", develop=true}
|
||||
importlib-metadata = "^4.11.3"
|
||||
typing-extensions = { version = "^4.0.0", python = "<3.8" }
|
||||
|
||||
@@ -132,12 +132,14 @@ class Reactive(Generic[ReactiveType]):
|
||||
if current_value != value or first_set:
|
||||
setattr(obj, f"__first_set_{self.internal_name}", False)
|
||||
setattr(obj, self.internal_name, value)
|
||||
self._check_watchers(obj, name, current_value)
|
||||
self._check_watchers(obj, name, current_value, first_set=first_set)
|
||||
if self._layout or self._repaint:
|
||||
obj.refresh(repaint=self._repaint, layout=self._layout)
|
||||
|
||||
@classmethod
|
||||
def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None:
|
||||
def _check_watchers(
|
||||
cls, obj: Reactable, name: str, old_value: Any, first_set: bool = False
|
||||
) -> None:
|
||||
|
||||
internal_name = f"_reactive_{name}"
|
||||
value = getattr(obj, internal_name)
|
||||
@@ -175,6 +177,11 @@ class Reactive(Generic[ReactiveType]):
|
||||
)
|
||||
)
|
||||
|
||||
if not first_set:
|
||||
obj.post_message_no_wait(
|
||||
events.Callback(obj, callback=partial(Reactive._compute, obj))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _compute(cls, obj: Reactable) -> None:
|
||||
_rich_traceback_guard = True
|
||||
@@ -196,7 +203,7 @@ class reactive(Reactive[ReactiveType]):
|
||||
default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default.
|
||||
layout (bool, optional): Perform a layout on change. Defaults to False.
|
||||
repaint (bool, optional): Perform a repaint on change. Defaults to True.
|
||||
init (bool, optional): Call watchers on initialize (post mount). Defaults to False.
|
||||
init (bool, optional): Call watchers on initialize (post mount). Defaults to True.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ See the [tree.py](https://github.com/willmcgugan/rich/blob/master/examples/tree.
|
||||
<details>
|
||||
<summary>Columns</summary>
|
||||
|
||||
Rich can render content in neat [columns](https://rich.readthedocs.io/en/latest/columns.html) with equal or optimal width. Here's a very basic clone of the (MacOS / Linux) `ls` command which displays a directory listing in columns:
|
||||
Rich can render content in neat [columns](https://rich.readthedocs.io/en/latest/columns.html) with equal or optimal width. Here's a very basic clone of the (macOS / Linux) `ls` command which displays a directory listing in columns:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
@@ -9,13 +9,14 @@ from typing import TYPE_CHECKING, ClassVar, Collection, Iterable, NamedTuple, ca
|
||||
import rich.repr
|
||||
from rich.console import (
|
||||
Console,
|
||||
ConsoleRenderable,
|
||||
ConsoleOptions,
|
||||
RichCast,
|
||||
ConsoleRenderable,
|
||||
JustifyMethod,
|
||||
RenderableType,
|
||||
RenderResult,
|
||||
RichCast,
|
||||
)
|
||||
from rich.measure import Measurement
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
@@ -28,9 +29,9 @@ from ._layout import Layout
|
||||
from ._segment_tools import align_lines
|
||||
from ._styles_cache import StylesCache
|
||||
from ._types import Lines
|
||||
from .css.scalar import ScalarOffset
|
||||
from .binding import NoBinding
|
||||
from .box_model import BoxModel, get_box_model
|
||||
from .css.scalar import ScalarOffset
|
||||
from .dom import DOMNode, NoScreen
|
||||
from .geometry import Offset, Region, Size, Spacing, clamp
|
||||
from .layouts.vertical import VerticalLayout
|
||||
@@ -100,6 +101,11 @@ class _Styled:
|
||||
)
|
||||
return result_segments
|
||||
|
||||
def __rich_measure__(
|
||||
self, console: "Console", options: "ConsoleOptions"
|
||||
) -> Measurement:
|
||||
return self.renderable.__rich_measure__(console, options)
|
||||
|
||||
|
||||
class RenderCache(NamedTuple):
|
||||
"""Stores results of a previous render."""
|
||||
@@ -401,9 +407,9 @@ class Widget(DOMNode):
|
||||
renderable = self._render()
|
||||
|
||||
width = measure(console, renderable, container.width)
|
||||
if self.expand:
|
||||
if not self.expand:
|
||||
width = max(container.width, width)
|
||||
if self.shrink:
|
||||
if not self.shrink:
|
||||
width = min(width, container.width)
|
||||
|
||||
self._content_width_cache = (cache_key, width)
|
||||
|
||||
@@ -85,6 +85,7 @@ class Input(Widget, can_focus=True):
|
||||
Binding("home", "home", "Home"),
|
||||
Binding("end", "end", "Home"),
|
||||
Binding("ctrl+d", "delete_right", "Delete"),
|
||||
Binding("enter", "submit", "Submit"),
|
||||
]
|
||||
|
||||
COMPONENT_CLASSES = {"input--cursor", "input--placeholder"}
|
||||
@@ -154,6 +155,8 @@ class Input(Widget, can_focus=True):
|
||||
self.view_position = self.view_position
|
||||
|
||||
async def watch_value(self, value: str) -> None:
|
||||
if self.styles.auto_dimensions:
|
||||
self.refresh(layout=True)
|
||||
await self.emit(self.Changed(self, value))
|
||||
|
||||
@property
|
||||
@@ -178,16 +181,18 @@ class Input(Widget, can_focus=True):
|
||||
class Changed(Message, bubble=True):
|
||||
"""Value was changed."""
|
||||
|
||||
def __init__(self, sender: MessageTarget, value: str) -> None:
|
||||
def __init__(self, sender: Input, value: str) -> None:
|
||||
super().__init__(sender)
|
||||
self.value = value
|
||||
self.input = sender
|
||||
|
||||
class Submitted(Message, bubble=True):
|
||||
"""Value was updated via enter key or blur."""
|
||||
|
||||
def __init__(self, sender: MessageTarget, value: str) -> None:
|
||||
def __init__(self, sender: Input, value: str) -> None:
|
||||
super().__init__(sender)
|
||||
self.value = value
|
||||
self.input = sender
|
||||
|
||||
@property
|
||||
def _value(self) -> Text:
|
||||
@@ -233,7 +238,7 @@ class Input(Widget, can_focus=True):
|
||||
# Do key bindings first
|
||||
if await self.handle_key(event):
|
||||
event.stop()
|
||||
elif event.key == "tab":
|
||||
elif event.key in ("tab", "shift+tab"):
|
||||
return
|
||||
elif event.is_printable:
|
||||
event.stop()
|
||||
|
||||
@@ -49,8 +49,8 @@ class Static(Widget):
|
||||
self,
|
||||
renderable: RenderableType = "",
|
||||
*,
|
||||
expand: bool = False,
|
||||
shrink: bool = False,
|
||||
expand: bool = True,
|
||||
shrink: bool = True,
|
||||
markup: bool = True,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
@@ -86,13 +86,12 @@ class Static(Widget):
|
||||
"""
|
||||
return self._renderable
|
||||
|
||||
def update(self, renderable: RenderableType = "", *, layout: bool = True) -> None:
|
||||
def update(self, renderable: RenderableType = "") -> None:
|
||||
"""Update the widget's content area with new text or Rich renderable.
|
||||
|
||||
Args:
|
||||
renderable (RenderableType, optional): A new rich renderable. Defaults to empty renderable;
|
||||
layout (bool, optional): Perform a layout. Defaults to True.
|
||||
"""
|
||||
_check_renderable(renderable)
|
||||
self.renderable = renderable
|
||||
self.refresh(layout=layout)
|
||||
self.refresh(layout=True)
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
from typing import cast
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.highlighter import ReprHighlighter
|
||||
from rich.pretty import Pretty
|
||||
from rich.protocol import is_renderable
|
||||
from rich.segment import Segment
|
||||
from rich.text import Text
|
||||
|
||||
from ..reactive import var
|
||||
from ..geometry import Size, Region
|
||||
@@ -27,6 +29,7 @@ class TextLog(ScrollView, can_focus=True):
|
||||
max_lines: var[int | None] = var(None)
|
||||
min_width: var[int] = var(78)
|
||||
wrap: var[bool] = var(False)
|
||||
highlight: var[bool] = var(False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -34,6 +37,7 @@ class TextLog(ScrollView, can_focus=True):
|
||||
max_lines: int | None = None,
|
||||
min_width: int = 78,
|
||||
wrap: bool = False,
|
||||
highlight: bool = False,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
@@ -45,6 +49,8 @@ class TextLog(ScrollView, can_focus=True):
|
||||
self.max_width: int = 0
|
||||
self.min_width = min_width
|
||||
self.wrap = wrap
|
||||
self.highlight = highlight
|
||||
self.highlighter = ReprHighlighter()
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
|
||||
def _on_styles_updated(self) -> None:
|
||||
@@ -61,7 +67,13 @@ class TextLog(ScrollView, can_focus=True):
|
||||
if not is_renderable(content):
|
||||
renderable = Pretty(content)
|
||||
else:
|
||||
renderable = cast(RenderableType, content)
|
||||
if isinstance(content, str):
|
||||
if self.highlight:
|
||||
renderable = self.highlighter(content)
|
||||
else:
|
||||
renderable = Text(content)
|
||||
else:
|
||||
renderable = cast(RenderableType, content)
|
||||
|
||||
console = self.app.console
|
||||
width = max(self.min_width, self.size.width or self.min_width)
|
||||
|
||||
Reference in New Issue
Block a user