more docs and compute example

This commit is contained in:
Will McGugan
2022-10-03 16:55:40 +01:00
parent 4abf70ca55
commit 9de1a87024
25 changed files with 549 additions and 66 deletions

View File

@@ -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

View File

@@ -0,0 +1,13 @@
#color-inputs {
dock: top;
height: auto;
}
Input {
width: 1fr;
}
#color {
height: 100%;
border: tall $secondary;
}

View 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()

View File

@@ -0,0 +1,9 @@
Input {
dock: top;
margin-top: 1;
}
Name {
height: 100%;
content-align: center middle;
}

View 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()

View File

@@ -0,0 +1,10 @@
Input {
dock: top;
margin-top: 1;
}
Name {
width: auto;
height: auto;
border: heavy $secondary;
}

View 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()

View File

@@ -0,0 +1,4 @@
#buttons {
dock: top;
height: auto;
}

View 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()

View 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;
}

View 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()

View File

@@ -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/).

View File

@@ -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.

View File

@@ -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.
&mdash; 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

View File

@@ -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)

View File

@@ -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 &mdash; 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"

View File

@@ -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

View File

@@ -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" }

View File

@@ -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.
"""

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)