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 @@ - eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVabVPaSlx1MDAxNP7ur2Do15ru+0tn7tyxKra3ioptbXvvXHUwMDFkJyRcdTAwMGKJXHUwMDA0gkkoYKf//W6CV1x1MDAxMkhcdTAwMDBcdTAwMTEwdZSR3c2ek93z7PPs2f25V6lUo3FfVd9WqmpkmZ5rXHUwMDA35rD6Oi7/oYLQ9Xu6XG4l30N/XHUwMDEwWElLJ4r64ds3b7pm0FFR3zMtZfxww4HphdHAdn3D8rtv3Eh1wz/jz7rZVX/0/a5cdTAwMWRcdTAwMDXG1Mi+st3IXHUwMDBmJraUp7qqXHUwMDE3hbr3v/X3SuVn8pnyLlBWZPbankpcdTAwMWVIqqZcdTAwMGViymZL634vcVx1MDAxNmIoIMZYoMdcdTAwMTZueKTtRcrW1S3ts5rWxEVVtO/21NA7XHUwMDE2NX469ulYXHUwMDFjy3Fzarblet5VNPZcdTAwMTK3Ql+/zbQujFx1MDAwMr+jrl07cmLbM+VFT1x1MDAwNf6g7fRUXHUwMDE4Zp7x+6blRuO4XGaAx9LJXHUwMDE4vK1MS0bxXGZRYCAokX5NXHUwMDAxXHUwMDExfayLn0ZIXHUwMDFhXHUwMDE0USxcdTAwMDFL1Uw8OvQ9PVx1MDAwNdqjVyD5mfrUNK1OWzvWs6dtXGKwXHUwMDAw5dM2w4f3pFxmXHUwMDE5WFx1MDAxMorTplx1MDAxZOW2nWjivcGh4CnbKlx1MDAxOXuIgOBcYlwiMp2a2GL/g53Ewb+zo+eYQf9hlKph/CXlbezo8WxcdTAwMTClXHUwMDAzKTW/70hcYsLhqHVw2r47kLiN//IuPz72lYk6M1xi/GH1sebXw39T11x1MDAwNn3bnERcdTAwMTJkXGZxyKCk+u+x3nN7XHUwMDFkXdlcdTAwMWJ43rTMtzrT4EtKf71eI+pcdFx1MDAwN4VRLzmQVFIuVo76oN5EXHUwMDA3+6f49sSun1/UXHUwMDFi38LvjXclj3pcdTAwMDZcZlx0kMBcYuC5qCfUIJBcdTAwMDFIn1x1MDAxYvUtU6NcdTAwMDfNR73ufD7YXHUwMDE5m4tyKKUgXHUwMDAwpCZrJ1F+fHB8XHUwMDEznbRDVe/Y3+9ax1x1MDAwMN7sXHUwMDBm8qM8UqMoXHUwMDE15K/zu820fr2qwZfDTsbPXGZZkFwi2CBcdTAwMDQ51uslWVx1MDAxOTWLRzmLXHUwMDFhx7ScQaDKgFx1MDAxYlqMXHUwMDFixlxmuVx03ESB2Vx1MDAwYvtmoGM1XHUwMDA3OzRcdTAwMDc7XGLPYUdKTLUzVGxcdTAwMWU7m4zD6Xz7vejKvU9iXHRkSmtm1/XGmSlLXHUwMDAyVHt6XHUwMDE1+WlHzVBpi8kqzjNtXHUwMDBmPLdcdTAwMWRcdTAwMDdw1dLvoIJMbEeu1lWPXHK6rm2nOcPSXHUwMDBlmLrP4MMqS71cdTAwMWa4bbdnep/S/q1PUyj1XHUwMDFhszQloFx1MDAxNiWArK7NXHUwMDA2x9eB88l9L8nwQvSa8uxrR7wvN0tcdTAwMTFKXHJcYjnjmo3n0EalXHUwMDAxJOSSZfTROiyV/OQhXHJcdTAwMWKQXHUwMDE0aTOsXHUwMDE3XHUwMDAyQXCONmNcdTAwMWNcdTAwMTO9XHUwMDEwXHUwMDEytnnkLWKtzk372Ox2XHUwMDFkJG/IfVxyX4bS+1xcoM02w1r5XHUwMDA2y8dakMhCXHUwMDE0SUxcdTAwMDSjLKUwlsFo8TCXlLaIlnuFQNKURlx1MDAwNZ5ccuZN01x1MDAxNsHzXHUwMDE4mqctqkHHXHUwMDA03lx1MDAwNnbKw1pcdTAwMDC8TX6N7IBunbyWMMAseaXdXFyfw7gs3mpcdTAwMTHBiVx1MDAxZXi4umiEw/OL0zBqXHUwMDFlNFx1MDAwZeyju1x1MDAxYetbYbNRblx1MDAxMmNYXHUwMDFhXFxkU1xi8ZP73NCRTuUzUffKUjaxzVU3WWJcdTAwMTZxTFDMJdr1XHUwMDFl65Pn3973/NpNo3nRO6x3ry/qh83nsNXv1e0ybs03+Fx1MDAwNG6lnEmZUodb4lbGilx1MDAxNSpcdTAwMTWIS/FcdTAwMDRwL1x1MDAxZeWSUisjJFx1MDAxN96QXHUwMDE4XHUwMDAyMvLczOFm9oKUayHL8Vx1MDAxNjC+yVxifFx1MDAxZas2VKiinfLpXHUwMDEyMprl04mD6zMpXHUwMDA1olxiazxGmmD4XHRYXHUwMDFiwfe3N7chXHTVNy1iz4LRt/7+S1x1MDAxMileKVFPJWd0jklcdTAwMTE0NiFgXHUwMDBiqZQyZnCut3VpXHUwMDAzmSy9oDNZn//z9Fx1MDAxOGmBXHUwMDAzUovgTvRsvPLAJyBv/aBkXHUwMDFjXHUwMDE1XHUwMDA1JcaQIYSfkKK47F6e1/tOjVx1MDAxZd58bvWRV8e+dVJudZck0jGlJJOGSKIy3lbNpFx0N3l0tGpcdTAwMTKdcqCXflxmdpyOeNfq1EZcdTAwMDdcdTAwMWY/yNPvZ91PZyPw1azXnq/Efpdul1x0vHyD5Vx1MDAxM3i0OOWvV1xcSVx1MDAwNZGr505cdTAwMTaPcklcdTAwMDVekvLPRTjUXHUwMDE1XHUwMDFiSEBuQuIhre+0K1x1MDAxNO00cbJriXdcdTAwMTWZwW4l3lx1MDAxMkaaz/fHXHUwMDBlrs+mhFx1MDAxNLIpZFhiqeNtdY33pbF/LZ1G4+xCkJr//cg5umy3X1x1MDAxNm58pZQ/XHUwMDA1eH5DJVxmJulz2XRcdNbWS/hTJrRdirdw1LaIs66su+tcdTAwMWL/8Iszat5/XHUwMDFlX7as9ig4eT5cdTAwMTX+Lt0uY9h8g+VjWMxcdTAwMGJvYDFGQXyOuzrkXHUwMDE3j3JJIVx1MDAxZlx1MDAxZk7kQl5cdTAwMTMs3Cq5rnYoIaTeXjOwXHJ8l4dbX+pQYlx0Sa19KFFcdTAwMDQ3XHUwMDAyiu+wXHUwMDEwTCSidHVB+/VcXFx1MDAxZHktdnvojEHD63BX9Fx1MDAwM1p2QUviREpOXHUwMDE2hUtjVuVuXHUwMDFjcFx1MDAwMOVcdTAwMDBunlFcdGBUMol3mzZBXHUwMDEw8dSmelx1MDAxN5dXhmZkOfl4XHUwMDEz+XjzVCtagLbMhZgs1DIvkndTZeLMeqjChblJKClEevlMJSCWwqrdvKOfW6dH7sWH869cdTAwMTckPGFOUHZYYS1cdTAwMTBJTnZcdTAwMTJyzWJbvlx1MDAxNIZT17xcdTAwMTbhilx0XHUwMDA0JFx1MDAxMGK3x+tcYlx1MDAwMin47nFVXHUwMDE5upFTOby6qlieXHUwMDE5hpV/9Hvp/Zmy/6mWXG5yi/ycoHHvQadWzX5f7zGj2NtcdDb1XHUwMDA0uvbDXHUwMDEwTa1Vf7hq+K74rtPeXHUwMDAzwmMoqXj6fv7a+/VcdTAwMWZcdTAwMTejLMsifQ== + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVaa1PiSFx1MDAxNP3ur7CYr5rp98OqrS1fOO4oXCLq6ri1ZYWkIZFAMFx0XHUwMDAzOOV/305wJYFEXHUwMDEwXHUwMDExXHUwMDE5Synp7vS96b6nz+nb/Wtjc7NcdTAwMTRccruqtLNZUlx1MDAwM8v0XFw7MPulrbj8p1xuQtfv6CqUfFx1MDAwZv1eYCUtnSjqhjtfv7bNoKWirmdayvjphj3TXHUwMDBio57t+oblt7+6kWqHf8afXHUwMDE1s63+6PptO1xujLGRbWW7kVx1MDAxZoxsKU+1VSdcbnXv/+jvm5u/ks+Ud4GyXCKz0/RU8kBSNXZcdTAwMTBTNlla8TuJs1x1MDAxMENcdTAwMDExxlx1MDAwMr20cMNcdTAwMDNtL1K2rm5on9W4Ji4qoW23o/reoSjzk6FPh+JQXHUwMDBl62OzXHLX8y6ioZe4XHUwMDE1+vptxnVhXHUwMDE0+C117dqRXHUwMDEz254oL3oq8HtNp6PCMPOM3zUtN1x1MDAxYcZlXHUwMDAwvJSOxmBnc1xcMohniFx1MDAwMlx1MDAwM0GJ9GtcbojoS138NELSoIhiXHRYqmbk0b7v6SnQXHUwMDFlfVx1MDAwMcnP2Ke6abWa2rGOPW5DgFx1MDAwNShcdTAwMWa36T+/J2XIwJJQnDbtKLfpRCPvXHJcdTAwMGVcdTAwMDVP2VbJ2ENcdTAwMDRcdTAwMDRHXHUwMDEwkfHUxFx1MDAxNrvHdlx1MDAxMlx1MDAwN/9Ojp5jXHUwMDA23edRKoXxl5S3saOHk0GUXHUwMDBlpNT87pFcdTAwMTCE/UFj96T5sCtxXHUwMDEz/+Wdf3/pK1x1MDAxM3VmXHUwMDEw+P3SS83T839j13pd21x1MDAxY0VcdTAwMTJkXGZxyKCk+u+l3nM7LV3Z6XneuMy3WuPgS0qftlx1MDAxNoh6wkFh1EtcdTAwMGUklZSLuaM+qNTR7vZcdL4/sitn1UrtR3hb21vzqGfAkFx1MDAwMFx0jFx1MDAwMJ6KekJcclx1MDAwMlx1MDAxOYD0vVHfMDV60HTU686ng52xqSiHUlxuXHUwMDAyQGqyVlx1MDAxMuWHu4d30VEzVJWWffvQOFx1MDAwNPBuu5dcdTAwMWblkVx1MDAxYUSpIN/K7zbTemteg5+HnYyfXHUwMDE5siBFsEFcYnKs10syN2peXHUwMDFm5SxqXHUwMDFj03J6gVpcdTAwMDfc0GLcMGbIZeAmXG7MTtg1XHUwMDAzXHUwMDFkqznYoTnYQXhcbjtSYqqdoWL52FlmXHUwMDFjjufb70RcdTAwMTfuY1x1MDAxMksgU1o22643zExZXHUwMDEyoNrTi8hPO2qGSltMVnGeabvruc04gEuWflx1MDAwN1x1MDAxNWRiO3K1rnpp0HZtO81cdTAwMTmWdsDUfVx1MDAwNsfzLPV+4Dbdjuldpv1bnKZQ6jUmaUpALUpcdTAwMDCZX5v1XHUwMDBlr1x1MDAwM+fS/SZJvyo6dXl601x1MDAxMt/Wm6VcYqVcdTAwMDaEnHHNxlNoo9JcdTAwMDBcdTAwMTJyyTL6aFx1MDAxMZZKfvKQhlxySIq0XHUwMDE51lx1MDAwYoEgOEebMY6JXlxiXHRbPvJeY63WXfPQbLdcdTAwMWQk78hjXHUwMDE5n4fSuyrQZsthrXyD68dakMhCXHUwMDE0SUxcdTAwMDSjLKUwZsHo9WFeU9pcIlruXHUwMDE1XHUwMDAySVNcdTAwMWFcdTAwMTV4MpiXTVtcdTAwMDRPY2iatqhcdTAwMDZcdTAwMWRcdTAwMTP4I7CzPqxcdTAwMDXATvJrZFx1MDAwN/TDyWtcdTAwMDZcdTAwMDNMklfazcU5jMvirVx1MDAxNlx1MDAxMZzogYfzi0bYP6uehFF9t7ZrXHUwMDFmPJRZ11xu67X1JjGGpcFFNoVcdTAwMTA/uc1cclx1MDAxZOlUvlx1MDAxM3VfLGVcdTAwMTPbnHeTJSZcdTAwMTHHXHUwMDA0xVxcolXvsS49//6x45fvavVqZ7/Svq5W9uvvYavfq9tZ3Jpv8Fxy3Eo5kzKlXHUwMDBlP4hbXHUwMDE5K1aoVCAuxVx1MDAxYsD9+iivKbUyQnLhXHKJISAj781cdTAwMWMuZy9IuVx1MDAxNrJcdTAwMWN/XHUwMDAwxpdcdTAwMTmB72PVmlxuVbRSPp1BRpN8OnJwcSalQFx1MDAxNGGNx0hcdTAwMTNcZr9cdTAwMDFrXHUwMDAz+O3+7j4kofqhRexpMPjR3f5MXCLFcyXqqeSMTjEpgsYyXHUwMDA0bCGVUsZcZs71ti5tIJOlXHUwMDE3dFwi6/N/nlx1MDAxZSMtcEBqXHUwMDExXFyJno1XXHUwMDFl+Fx1MDAwNuQtXHUwMDFllIyjoqDEXHUwMDE4MoTwXHUwMDFiUlx1MDAxNOft87NK1ynT/burRlx1MDAxN3lcdTAwMTXsW0frre6SRDqmlGTSXHUwMDEwSVTG26qJNOEyj47mTaJTXHUwMDBl9NKPwYrTXHUwMDExe41WebD7/Vie3J62L09cdTAwMDfgxqyU36/EfpduZ1x0vHyD6yfwaHHKX6+4klxuXCLnz528PsprKvCSlH8uwqGuWEJcdTAwMDJyXHUwMDE5XHUwMDEyXHUwMDBmaX2nXaFopYmTVUu8i8hcZlYr8WYw0nS+P3ZwcTYlpJBNIcNcdTAwMTJLXHUwMDFkb/NrvL9r29fSqdVOq4KU/dtcdTAwMDPn4LzZ/FxcuPG5Uv5cdTAwMTTg6VxylTCYpO9l01x1MDAxOVhbLOFPmdB2Kf6Ao7bXOOvCeri+8/f/dlx1MDAwNvXHq+F5w2pcdTAwMGWCo/dT4e/S7SyGzTe4flxmi3nhXHIsxiiIz3Hnh/zro7ymkI9cdTAwMGYnciGvXHRcdTAwMTZ+KLnOdyghpN5eM/BcdTAwMTH4Xlx1MDAxZm79rEOJXHUwMDE5JLXwoURcdTAwMTHcXGIovsNCMJGI0vlcdTAwMDXtzZk68Fx1MDAxYex+31x1MDAxOYKa1+Ku6Fx1MDAwNnTdXHUwMDA1LYlcdTAwMTMpOVlcdTAwMTQujUmVu3TAXHUwMDAxlFx1MDAwM7hpRiWAUckkXm3aXHUwMDA0QcRTm+pVXFxe6ZuR5eTjTeTjzVON6Fx1MDAxNbRlLsRkoZZ5kbybKiNnXHUwMDE2Qlx1MDAxNS1GXHUwMDE1RHqXKKVEb4BVs/5Ar1x1MDAxYSdcdTAwMDdu9fjspkrCI+ZcdTAwMDRrXHUwMDBmK6hcdTAwMTUqZtn7usmjnFx1MDAxOIxcIvyx5+tcdTAwMTLOhSwmXHUwMDEwkECI1e1cdTAwMTOJ5jLGqcSrRJbehil7czGEzWS0xTE26dZcYmtcdTAwMWLPKrRkdru6TVx1MDAxNDs3Qp6eXHUwMDFj135+/XHXpZ+u6u9cdTAwMTXfZNp4xm9cZlx1MDAxNFx1MDAxNU/Nr6eNp/9cdTAwMDDD6SGzIn0= - Stop00:00:00.00ResetStart00:00:00.00StopwatchStopwatch with CSS class "started" \ 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. +
+--8<-- "docs/images/css_stopwatch.excalidraw.svg" +
+ + 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. -
---8<-- "docs/images/css_stopwatch.excalidraw.svg" -
- 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)