diff --git a/README.md b/README.md
index a1144eea6..16a5c89eb 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/examples/guide/reactivity/computed01.css b/docs/examples/guide/reactivity/computed01.css
new file mode 100644
index 000000000..7d1ca2740
--- /dev/null
+++ b/docs/examples/guide/reactivity/computed01.css
@@ -0,0 +1,13 @@
+#color-inputs {
+ dock: top;
+ height: auto;
+}
+
+Input {
+ width: 1fr;
+}
+
+#color {
+ height: 100%;
+ border: tall $secondary;
+}
diff --git a/docs/examples/guide/reactivity/computed01.py b/docs/examples/guide/reactivity/computed01.py
new file mode 100644
index 000000000..dcef731ff
--- /dev/null
+++ b/docs/examples/guide/reactivity/computed01.py
@@ -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()
diff --git a/docs/examples/guide/reactivity/refresh01.css b/docs/examples/guide/reactivity/refresh01.css
new file mode 100644
index 000000000..08cd17d91
--- /dev/null
+++ b/docs/examples/guide/reactivity/refresh01.css
@@ -0,0 +1,9 @@
+Input {
+ dock: top;
+ margin-top: 1;
+}
+
+Name {
+ height: 100%;
+ content-align: center middle;
+}
diff --git a/docs/examples/guide/reactivity/refresh01.py b/docs/examples/guide/reactivity/refresh01.py
new file mode 100644
index 000000000..d01e1031c
--- /dev/null
+++ b/docs/examples/guide/reactivity/refresh01.py
@@ -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()
diff --git a/docs/examples/guide/reactivity/refresh02.css b/docs/examples/guide/reactivity/refresh02.css
new file mode 100644
index 000000000..55ba7f0f8
--- /dev/null
+++ b/docs/examples/guide/reactivity/refresh02.css
@@ -0,0 +1,10 @@
+Input {
+ dock: top;
+ margin-top: 1;
+}
+
+Name {
+ width: auto;
+ height: auto;
+ border: heavy $secondary;
+}
diff --git a/docs/examples/guide/reactivity/refresh02.py b/docs/examples/guide/reactivity/refresh02.py
new file mode 100644
index 000000000..28da2549c
--- /dev/null
+++ b/docs/examples/guide/reactivity/refresh02.py
@@ -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()
diff --git a/docs/examples/guide/reactivity/validate01.css b/docs/examples/guide/reactivity/validate01.css
new file mode 100644
index 000000000..5bb65cbc8
--- /dev/null
+++ b/docs/examples/guide/reactivity/validate01.css
@@ -0,0 +1,4 @@
+#buttons {
+ dock: top;
+ height: auto;
+}
diff --git a/docs/examples/guide/reactivity/validate01.py b/docs/examples/guide/reactivity/validate01.py
new file mode 100644
index 000000000..348a9f1d8
--- /dev/null
+++ b/docs/examples/guide/reactivity/validate01.py
@@ -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()
diff --git a/docs/examples/guide/reactivity/watch01.css b/docs/examples/guide/reactivity/watch01.css
new file mode 100644
index 000000000..1b1fce667
--- /dev/null
+++ b/docs/examples/guide/reactivity/watch01.css
@@ -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;
+}
diff --git a/docs/examples/guide/reactivity/watch01.py b/docs/examples/guide/reactivity/watch01.py
new file mode 100644
index 000000000..5d2cacffd
--- /dev/null
+++ b/docs/examples/guide/reactivity/watch01.py
@@ -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()
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 2ea567083..776df6cc2 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -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/).
diff --git a/docs/guide/app.md b/docs/guide/app.md
index 8038e387c..b1edc3ae2 100644
--- a/docs/guide/app.md
+++ b/docs/guide/app.md
@@ -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.
diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md
index b9918c259..68e31617e 100644
--- a/docs/guide/reactivity.md
+++ b/docs/guide/reactivity.md
@@ -1,10 +1,224 @@
# Reactivity
-TODO: Reactivity docs
+Textual's reactive attributes are attributes _with superpowers_. In this chapter we will look at how reactive attributes can simplify your apps.
-- What is reactivity
-- Reactive variables
- - Demo
- - repaint vs layout
-- Validation
-- Watch methods
+!!! quote
+
+ With great power comes great responsibility.
+
+ — Uncle Ben
+
+## Reactive attributes
+
+Textual provides an alternative way of adding attributes to your widget or App, which doesn't require adding them to your class constructor (`__init__`). To create these attributes import [reactive][textual.reactive.reactive] from `textual.reactive`, and assign them in the class scope.
+
+The following code illustrates how to create reactive attributes:
+
+```python
+from textual.reactive import reactive
+from textual.widget import Widget
+
+class Reactive(Widget):
+
+ name = reactive("Paul") # (1)!
+ count = reactive(0) # (2)!
+ is_cool = reactive(True) # (3)!
+```
+
+1. Create a string attribute with a default of `"Paul"`
+2. Creates an integer attribute with a default of `0`.
+3. Creates a boolean attribute with a default of `True`.
+
+The `reactive` constructor accepts a default value as the first positional argument.
+
+!!! information
+
+ Textual uses Python's _descriptor protocol_ to create reactive attributes, which is the same protocol used by the builtin `property` decorator.
+
+You can get and set these attributes in the same way as if you had assigned them in a `__init__` method. For instance `self.name = "Jessica"`, `self.count += 1`, or `print(self.is_cool)`.
+
+### Dynamic defaults
+
+You can also set the default to a function (or other callable). Textual will call this function to get the default value. The following code illustrates a reactive value which will be automatically assigned the current time when the widget is created:
+
+```python
+from time import time
+from textual.reactive import reactive
+from textual.widget import Widget
+
+class Timer(Widget):
+
+ start_time = reactive(time) # (1)!
+```
+
+1. The `time` function returns the current time in seconds.
+
+### Typing reactive attributes
+
+There is no need to specify a type hint if a reactive attribute has a default value, as type checkers will assume the attribute is the same type as the default.
+
+You may want to add explicit type hints if the attribute type is a superset of the default type. For instance if you want to make an attribute optional. Here's how you would create a reactive string attribute which may be `None`:
+
+```python
+ name: reactive[str | None] = reactive("Paul")
+```
+
+## Smart refresh
+
+The first superpower we will look at is "smart refresh". When you modify a reactive attribute, Textual will make note of the fact that it has changed and refresh automatically.
+
+!!! information
+
+ If you modify multiple reactive attribute, Textual will only do a single refresh to minimize updates.
+
+Let's look at an example which illustrates this. In the following app, the value of an input is used to update a "Hello, World!" type greeting.
+
+=== "refresh01.py"
+
+ ```python hl_lines="7-13 24"
+ --8<-- "docs/examples/guide/reactivity/refresh01.py"
+ ```
+
+=== "refresh01.css"
+
+ ```sass
+ --8<-- "docs/examples/guide/reactivity/refresh01.css"
+ ```
+
+=== "Output"
+
+ ```{.textual path="docs/examples/guide/reactivity/refresh01.py" press="tab,T,e,x,t,u,a,l"}
+ ```
+
+The `Name` widget has a reactive `who` attribute. When the app modifies that attribute, a refresh happens automatically.
+
+!!! information
+
+ Textual will check if a value has really changed, so assigning the same value wont prompt an unnecessary refresh.
+
+### Disabling refresh
+
+If you *don't* want an attribute to prompt a refresh or layout but you still want other reactive superpowers, you can use [var][textual.reactive.var] to create an attribute. You can import `var` from `textual.reactive`.
+
+The following code illustrates how you create non-refreshing reactive attributes.
+
+```python
+class MyWidget(Widget):
+ count = var(0) # (1)!
+```
+
+1. Changing `self.count` wont cause a refresh or layout.
+
+### Layout
+
+The smart refresh feature will update the content area of a widget, but will not change its size. If modifying an attribute should change the size of the widget, you should set `layout=True` on the reactive attribute. This ensures that your CSS layout will update accordingly.
+
+The following example modifies "refresh01.py" so that the greeting has an automatic width.
+
+=== "refresh02.py"
+
+ ```python hl_lines="10"
+ --8<-- "docs/examples/guide/reactivity/refresh02.py"
+ ```
+ 1. This attribute will update the layout when changed.
+
+=== "refresh02.css"
+
+ ```sass hl_lines="7-9"
+ --8<-- "docs/examples/guide/reactivity/refresh02.css"
+ ```
+
+=== "Output"
+
+ ```{.textual path="docs/examples/guide/reactivity/refresh02.py" press="tab,n,a,m,e"}
+ ```
+
+If you type in to the input now, the greeting will expand to fit the content. If you were to set `layout=False` on the reactive attribute, you should see that the box remains the same size when you type.
+
+## Validation
+
+The next superpower we will look at is _validation_. If you add a method that begins with `validate_` followed by the name of your attribute, it will be called when you assign a value to that attribute. This method should accept the incoming value as a positional argument, and return the value to set (which may be the same or a different value).
+
+A common use for this is to restrict numbers to a given range. The following example keeps a count. There is a button to increase the count, and a button to decrease it.
+
+=== "validate01.py"
+
+ ```python hl_lines="12-18 30 32"
+ --8<-- "docs/examples/guide/reactivity/validate01.py"
+ ```
+
+=== "validate01.css"
+
+ ```sass
+ --8<-- "docs/examples/guide/reactivity/validate01.css"
+ ```
+
+=== "Output"
+
+ ```{.textual path="docs/examples/guide/reactivity/validate01.py"}
+ ```
+
+If you click the buttons in the above example it will show the current count. When `self.count` is modified in the button handler, Textual runs `validate_count` which limits self.count` to a maximum of 10, and stops it going below zero.
+
+## Watch methods
+
+Watch methods are another superpower. Textual will call watch methods when reactive attributes are modified. Watch methods begin with `watch_` followed by the name of the attribute. If the watch method accepts a positional argument, it will be called with the new assigned value. If the watch method accepts *two* positional arguments, it will be called with both the *old* value and the *new* value.
+
+The follow app will display any color you type in to the input. Try it with a valid color in Textual CSS. For example `"darkorchid"` or `"#52de44".
+
+=== "watch01.py"
+
+ ```python hl_lines="17-19 28"
+ --8<-- "docs/examples/guide/reactivity/watch01.py"
+ ```
+
+ 1. Creates a reactive [color][textual.color.Color] attribute.
+ 2. Called when `self.color` is changed.
+ 3. New color is assigned here.
+
+=== "watch01.css"
+
+ ```sass
+ --8<-- "docs/examples/guide/reactivity/watch01.css"
+ ```
+
+=== "Output"
+
+ ```{.textual path="docs/examples/guide/reactivity/watch01.py" press="tab,d,a,r,k,o,r,c,h,i,d"}
+ ```
+
+The color is parsed in `on_input_submitted` and assigned to `self.color`. Because `color` is reactive, Textual also calls `watch_color` with the old and new values.
+
+## Compute methods
+
+Compute methods are the final superpower offered by the `reactive` descriptor. Textual runs compute methods to calculate the value of a reactive attribute. Compute methods begin with `compute_` followed by the name of the reactive value.
+
+You could be forgiven in thinking this sounds a lot like Python's property decorator. The difference is that Textual will cache the value of compute methods, and update them when any other reactive attribute changes.
+
+The following example uses a computed attribute. It displays three inputs for the each color component (red, green, and blue). If you enter numbers in to these inputs, the background color of another widget changes.
+
+=== "computed01.py"
+
+ ```python hl_lines="12-18 30 32"
+ --8<-- "docs/examples/guide/reactivity/computed01.py"
+ ```
+
+ 1. Combines color components in to a Color object.
+ 2. The compute method is called when the _result_ of `compute_color` changes.
+
+=== "computed01.css"
+
+ ```sass
+ --8<-- "docs/examples/guide/reactivity/computed01.css"
+ ```
+
+=== "Output"
+
+ ```{.textual path="docs/examples/guide/reactivity/computed01.py"}
+ ```
+
+Note the `compute_color` method which combines the color components into a [Color][textual.color.Color] object. When the _result_ of this method changes, Textual calls `watch_color` which uses the new color as a background.
+
+!!! note
+
+ You should avoid doing anything slow or cpu-intensive in a compute method. Textual calls compute methods on an object when _any_ reactive attribute changes, so it can known when it changes.
diff --git a/docs/images/css_stopwatch.excalidraw.svg b/docs/images/css_stopwatch.excalidraw.svg
index e647262ae..931702ccf 100644
--- a/docs/images/css_stopwatch.excalidraw.svg
+++ b/docs/images/css_stopwatch.excalidraw.svg
@@ -1,6 +1,6 @@
\ No newline at end of file
+ Stop00:00:00.00ResetStart00:00:00.00StopwatchStarted Stopwatch
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
index cd7842ab6..66e83adb7 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -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)
diff --git a/docs/tutorial.md b/docs/tutorial.md
index e0822b1e9..4ee874e27 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -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.
--8<-- "docs/images/stopwatch.excalidraw.svg"
-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.
+
+
+
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.
-
-
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"
diff --git a/mkdocs.yml b/mkdocs.yml
index 2a9a020a8..8dbc3ad38 100644
--- a/mkdocs.yml
+++ b/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"
@@ -154,6 +155,7 @@ theme:
- navigation.tabs
- navigation.indexes
- navigation.tabs.sticky
+ - content.code.annotate
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
diff --git a/pyproject.toml b/pyproject.toml
index b8a1499bf..6c4fe8886 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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" }
diff --git a/src/textual/reactive.py b/src/textual/reactive.py
index a1317a4cb..5a1dcfcaf 100644
--- a/src/textual/reactive.py
+++ b/src/textual/reactive.py
@@ -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.
"""
diff --git a/src/textual/richreadme.md b/src/textual/richreadme.md
index 880d2544d..090795a07 100644
--- a/src/textual/richreadme.md
+++ b/src/textual/richreadme.md
@@ -315,7 +315,7 @@ See the [tree.py](https://github.com/willmcgugan/rich/blob/master/examples/tree.
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:
+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
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 83c549f62..c8efbe8a1 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -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)
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
index bf3db3725..695288d58 100644
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -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()
diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py
index 7e10a8a1b..bbf50dfee 100644
--- a/src/textual/widgets/_static.py
+++ b/src/textual/widgets/_static.py
@@ -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)
diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py
index 090bd965c..d471bb5b2 100644
--- a/src/textual/widgets/_text_log.py
+++ b/src/textual/widgets/_text_log.py
@@ -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)