mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' of github.com:willmcgugan/textual into docs-widgets
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
|
||||
|
||||
|
||||
@@ -2,18 +2,12 @@ Screen {
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
TextInput {
|
||||
dock: top;
|
||||
border: tall $background;
|
||||
Input {
|
||||
dock: top;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
margin: 1 1 0 1;
|
||||
background: $boost;
|
||||
}
|
||||
|
||||
TextInput:focus {
|
||||
border: tall $accent;
|
||||
margin: 1 1 0 1;
|
||||
}
|
||||
|
||||
#results {
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ This processing of messages is done within an asyncio Task which is started when
|
||||
|
||||
The FastAPI docs have an [excellent introduction](https://fastapi.tiangolo.com/async/) to Python async programming.
|
||||
|
||||
By way of an example, let's consider what happens if you were to type "Text" in to a `TextInput` widget. When you hit the ++t++ key, Textual creates a [key][textual.events.Key] event and sends it to the widget's message queue. Ditto for ++e++, ++x++, and ++t++.
|
||||
By way of an example, let's consider what happens if you were to type "Text" in to a `Input` widget. When you hit the ++t++ key, Textual creates a [key][textual.events.Key] event and sends it to the widget's message queue. Ditto for ++e++, ++x++, and ++t++.
|
||||
|
||||
The widget's task will pick the first message from the queue (a key event for the ++t++ key) and call the `on_key` method with the event as the first argument. In other words it will call `TextInput.on_key(event)`, which updates the display to show the new letter.
|
||||
The widget's task will pick the first message from the queue (a key event for the ++t++ key) and call the `on_key` method with the event as the first argument. In other words it will call `Input.on_key(event)`, which updates the display to show the new letter.
|
||||
|
||||
<div class="excalidraw">
|
||||
--8<-- "docs/images/events/queue.excalidraw.svg"
|
||||
|
||||
@@ -1,10 +1,225 @@
|
||||
# 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="25-26 28-29"
|
||||
--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.
|
||||
@@ -188,21 +193,21 @@ Every widget has a `styles` object with a number of attributes that impact how t
|
||||
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"
|
||||
|
||||
@@ -21,7 +21,8 @@ DirectoryTree {
|
||||
}
|
||||
|
||||
#code-view {
|
||||
overflow: auto scroll;
|
||||
overflow: auto scroll;
|
||||
min-width: 100%;
|
||||
}
|
||||
#code {
|
||||
width: auto;
|
||||
|
||||
14
mkdocs.yml
14
mkdocs.yml
@@ -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"
|
||||
@@ -85,11 +86,12 @@ nav:
|
||||
- "styles/visibility.md"
|
||||
- "styles/width.md"
|
||||
- Widgets:
|
||||
- "widgets/index.md"
|
||||
- "widgets/button.md"
|
||||
- "widgets/data_table.md"
|
||||
- "widgets/footer.md"
|
||||
- "widgets/header.md"
|
||||
- "widgets/index.md"
|
||||
- "widgets/input.md"
|
||||
- "widgets/static.md"
|
||||
- "widgets/input.md"
|
||||
- "widgets/tree_control.md"
|
||||
@@ -156,13 +158,14 @@ theme:
|
||||
- navigation.tabs
|
||||
- navigation.indexes
|
||||
- navigation.tabs.sticky
|
||||
- content.code.annotate
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
accent: purple
|
||||
toggle:
|
||||
icon: material/weather-sunny
|
||||
name: Switch to dark mode
|
||||
name: Switch to dark modeTask was destroyed but it is pending!
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: black
|
||||
@@ -178,9 +181,8 @@ plugins:
|
||||
default_handler: python
|
||||
handlers:
|
||||
python:
|
||||
rendering:
|
||||
options:
|
||||
show_source: false
|
||||
selection:
|
||||
filters:
|
||||
- "!^_"
|
||||
- "^__init__$"
|
||||
|
||||
47
poetry.lock
generated
47
poetry.lock
generated
@@ -97,7 +97,7 @@ python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2022.9.14"
|
||||
version = "2022.9.24"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -163,7 +163,7 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "6.4.4"
|
||||
version = "6.5.0"
|
||||
description = "Code coverage measurement for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -216,7 +216,7 @@ dev = ["twine", "markdown", "flake8", "wheel"]
|
||||
|
||||
[[package]]
|
||||
name = "griffe"
|
||||
version = "0.22.1"
|
||||
version = "0.22.2"
|
||||
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -249,7 +249,7 @@ python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "4.12.0"
|
||||
version = "4.13.0"
|
||||
description = "Read metadata from Python packages"
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -260,9 +260,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
|
||||
zipp = ">=0.5"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
|
||||
docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"]
|
||||
perf = ["ipython"]
|
||||
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
|
||||
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
@@ -318,22 +318,23 @@ python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs"
|
||||
version = "1.3.1"
|
||||
version = "1.4.0"
|
||||
description = "Project documentation with Markdown."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=3.3"
|
||||
click = ">=7.0"
|
||||
ghp-import = ">=1.0"
|
||||
importlib-metadata = ">=4.3"
|
||||
Jinja2 = ">=2.10.2"
|
||||
importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""}
|
||||
Jinja2 = ">=2.11.1"
|
||||
Markdown = ">=3.2.1,<3.4"
|
||||
mergedeep = ">=1.3.4"
|
||||
packaging = ">=20.5"
|
||||
PyYAML = ">=3.10"
|
||||
PyYAML = ">=5.1"
|
||||
pyyaml-env-tag = ">=0.1"
|
||||
typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""}
|
||||
watchdog = ">=2.0"
|
||||
|
||||
[package.extras]
|
||||
@@ -353,7 +354,7 @@ mkdocs = ">=1.1"
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material"
|
||||
version = "8.5.3"
|
||||
version = "8.5.6"
|
||||
description = "Documentation that simply works"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -362,7 +363,7 @@ python-versions = ">=3.7"
|
||||
[package.dependencies]
|
||||
jinja2 = ">=3.0.2"
|
||||
markdown = ">=3.2"
|
||||
mkdocs = ">=1.3.0"
|
||||
mkdocs = ">=1.4.0"
|
||||
mkdocs-material-extensions = ">=1.0.3"
|
||||
pygments = ">=2.12"
|
||||
pymdown-extensions = ">=9.4"
|
||||
@@ -553,7 +554,7 @@ plugins = ["importlib-metadata"]
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "9.5"
|
||||
version = "9.6"
|
||||
description = "Extension pack for Python Markdown."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -691,7 +692,7 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "12.6.0a2"
|
||||
version = "12.6.0"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -727,7 +728,7 @@ pytest = ">=5.1.0,<8.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "time-machine"
|
||||
version = "2.8.1"
|
||||
version = "2.8.2"
|
||||
description = "Travel through time in your tests."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -841,7 +842,7 @@ dev = ["aiohttp", "click", "msgpack"]
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "b1ad14eaa7ccba5153501b10981f6910409ea084e6e6ab66364bfe362c66ae90"
|
||||
content-hash = "d976cf5f1d28001eecafb074b02e644f16c971828be5a115d1c3b9b2db49fc70"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = []
|
||||
@@ -893,10 +894,7 @@ ghp-import = [
|
||||
griffe = []
|
||||
identify = []
|
||||
idna = []
|
||||
importlib-metadata = [
|
||||
{file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"},
|
||||
{file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
|
||||
]
|
||||
importlib-metadata = []
|
||||
iniconfig = [
|
||||
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
|
||||
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
|
||||
@@ -1141,10 +1139,7 @@ py = [
|
||||
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
|
||||
]
|
||||
pygments = []
|
||||
pymdown-extensions = [
|
||||
{file = "pymdown_extensions-9.5-py3-none-any.whl", hash = "sha256:ec141c0f4983755349f0c8710416348d1a13753976c028186ed14f190c8061c4"},
|
||||
{file = "pymdown_extensions-9.5.tar.gz", hash = "sha256:3ef2d998c0d5fa7eb09291926d90d69391283561cf6306f85cd588a5eb5befa0"},
|
||||
]
|
||||
pymdown-extensions = []
|
||||
pyparsing = [
|
||||
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
|
||||
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -553,7 +553,7 @@ class Compositor:
|
||||
Iterable[tuple[Widget, Region]]: Sequence of (WIDGET, REGION) tuples.
|
||||
"""
|
||||
contains = Region.contains
|
||||
for widget, cropped_region, region in self.layers_visible.get(y, []):
|
||||
for widget, cropped_region, region in self.layers_visible[y]:
|
||||
if contains(cropped_region, x, y) and widget.visible:
|
||||
yield widget, region
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Collection
|
||||
|
||||
from rich.console import RenderableType
|
||||
|
||||
|
||||
from .geometry import Region, Size
|
||||
from .geometry import Size
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
@@ -17,19 +14,12 @@ class ScrollView(Widget):
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
|
||||
ScrollView {
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str | None = None, id: str | None = None, classes: str | None = None
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
|
||||
@property
|
||||
def is_scrollable(self) -> bool:
|
||||
"""Always scrollable."""
|
||||
@@ -68,25 +58,6 @@ class ScrollView(Widget):
|
||||
"""
|
||||
return self.virtual_size.height
|
||||
|
||||
def watch_virtual_size(self, virtual_size: Size) -> None:
|
||||
self._scroll_update(virtual_size)
|
||||
|
||||
def watch_show_horizontal_scrollbar(self, value: bool) -> None:
|
||||
"""Watch function for show_horizontal_scrollbar attribute.
|
||||
|
||||
Args:
|
||||
value (bool): Show horizontal scrollbar flag.
|
||||
"""
|
||||
self.refresh(layout=True)
|
||||
|
||||
def watch_show_vertical_scrollbar(self, value: bool) -> None:
|
||||
"""Watch function for show_vertical_scrollbar attribute.
|
||||
|
||||
Args:
|
||||
value (bool): Show vertical scrollbar flag.
|
||||
"""
|
||||
self.refresh(layout=True)
|
||||
|
||||
def _size_updated(
|
||||
self, size: Size, virtual_size: Size, container_size: Size
|
||||
) -> None:
|
||||
@@ -97,12 +68,16 @@ class ScrollView(Widget):
|
||||
virtual_size (Size): New virtual size.
|
||||
container_size (Size): New container size.
|
||||
"""
|
||||
if self._size != size or virtual_size != self.virtual_size:
|
||||
if (
|
||||
self._size != size
|
||||
or virtual_size != self.virtual_size
|
||||
or container_size != self.container_size
|
||||
):
|
||||
self._size = size
|
||||
virtual_size = self.virtual_size
|
||||
self._container_size = size - self.gutter.totals
|
||||
self._scroll_update(virtual_size)
|
||||
self.scroll_to(self.scroll_x, self.scroll_y)
|
||||
self._container_size = size - self.gutter.totals
|
||||
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
@@ -114,13 +89,3 @@ class ScrollView(Widget):
|
||||
from rich.panel import Panel
|
||||
|
||||
return Panel(f"{self.scroll_offset} {self.show_vertical_scrollbar}")
|
||||
|
||||
def watch_scroll_x(self, new_value: float) -> None:
|
||||
"""Called when horizontal bar is scrolled."""
|
||||
self.horizontal_scrollbar.position = int(new_value)
|
||||
self.refresh(layout=False)
|
||||
|
||||
def watch_scroll_y(self, new_value: float) -> None:
|
||||
"""Called when vertical bar is scrolled."""
|
||||
self.vertical_scrollbar.position = int(new_value)
|
||||
self.refresh(layout=False)
|
||||
|
||||
@@ -271,6 +271,7 @@ class ScrollBar(Widget):
|
||||
async def _on_mouse_up(self, event: events.MouseUp) -> None:
|
||||
if self.grabbed:
|
||||
self.release_mouse()
|
||||
event.stop()
|
||||
|
||||
def _on_mouse_capture(self, event: events.MouseCapture) -> None:
|
||||
self.grabbed = event.mouse_position
|
||||
@@ -278,6 +279,7 @@ class ScrollBar(Widget):
|
||||
|
||||
def _on_mouse_release(self, event: events.MouseRelease) -> None:
|
||||
self.grabbed = None
|
||||
event.stop()
|
||||
|
||||
async def _on_mouse_move(self, event: events.MouseMove) -> None:
|
||||
if self.grabbed and self.window_size:
|
||||
@@ -300,6 +302,10 @@ class ScrollBar(Widget):
|
||||
)
|
||||
)
|
||||
await self.emit(ScrollTo(self, x=x, y=y))
|
||||
event.stop()
|
||||
|
||||
async def _on_click(self, event: events.Click) -> None:
|
||||
event.stop()
|
||||
|
||||
|
||||
class ScrollBarCorner(Widget):
|
||||
|
||||
@@ -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."""
|
||||
@@ -323,7 +329,7 @@ class Widget(DOMNode):
|
||||
|
||||
"""
|
||||
self.app._register(self, *anon_widgets, **widgets)
|
||||
self.screen.refresh(layout=True)
|
||||
self.app.screen.refresh(layout=True)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Called by Textual to create child widgets.
|
||||
@@ -1660,7 +1666,11 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
Style: A rich Style object.
|
||||
"""
|
||||
offset_x, offset_y = self.screen.get_offset(self)
|
||||
widget, region = self.screen.get_widget_at(x, y)
|
||||
if widget is not self:
|
||||
return Style()
|
||||
offset_x, offset_y = region.offset
|
||||
# offset_x, offset_y = self.screen.get_offset(self)
|
||||
return self.screen.get_style_at(x + offset_x, y + offset_y)
|
||||
|
||||
async def _forward_event(self, event: events.Event) -> None:
|
||||
|
||||
@@ -256,7 +256,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
total_width = sum(column.width for column in self.columns)
|
||||
self.virtual_size = Size(
|
||||
total_width,
|
||||
len(self._y_offsets) + (self.header_height if self.show_header else 0),
|
||||
max(len(self._y_offsets), (self.header_height if self.show_header else 0)),
|
||||
)
|
||||
|
||||
def _get_cell_region(self, row_index: int, column_index: int) -> Region:
|
||||
@@ -557,6 +557,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
if meta:
|
||||
self.cursor_cell = Coord(meta["row"], meta["column"])
|
||||
self._scroll_cursor_in_to_view()
|
||||
event.stop()
|
||||
|
||||
def key_down(self, event: events.Key):
|
||||
self.cursor_cell = self.cursor_cell.down()
|
||||
|
||||
@@ -85,6 +85,7 @@ class Input(Widget, can_focus=True):
|
||||
Binding("home", "home", "home"),
|
||||
Binding("end", "end", "end"),
|
||||
Binding("ctrl+d", "delete_right", "delete right"),
|
||||
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()
|
||||
|
||||
@@ -30,8 +30,8 @@ class Static(Widget):
|
||||
Args:
|
||||
renderable (RenderableType, optional): A Rich renderable, or string containing console markup.
|
||||
Defaults to "".
|
||||
expand (bool, optional): Rich renderable may expand beyond optimal. Defaults to False.
|
||||
shrink (bool, optional): Rich renderable may shrink below optimal. Defaults to False.
|
||||
expand (bool, optional): Expand content if required to fill container. Defaults to False.
|
||||
shrink (bool, optional): Shrink content if required to fill container. Defaults to False.
|
||||
markup (bool, optional): True if markup should be parsed and rendered. Defaults to True.
|
||||
name (str | None, optional): Name of widget. Defaults to None.
|
||||
id (str | None, optional): ID of Widget. Defaults to None.
|
||||
@@ -87,13 +87,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)
|
||||
|
||||
@@ -37,9 +37,9 @@ def test_widget_content_width():
|
||||
class TextWidget(Widget):
|
||||
def __init__(self, text: str, id: str) -> None:
|
||||
self.text = text
|
||||
|
||||
super().__init__(id=id)
|
||||
self.expand = False
|
||||
self.shrink = True
|
||||
|
||||
def render(self) -> str:
|
||||
return self.text
|
||||
|
||||
Reference in New Issue
Block a user