From 9de1a87024ed22f9e46e52b6a8c3d822ac3064d9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Oct 2022 16:55:40 +0100 Subject: [PATCH 01/10] more docs and compute example --- README.md | 2 +- docs/examples/guide/reactivity/computed01.css | 13 + docs/examples/guide/reactivity/computed01.py | 47 ++++ docs/examples/guide/reactivity/refresh01.css | 9 + docs/examples/guide/reactivity/refresh01.py | 29 +++ docs/examples/guide/reactivity/refresh02.css | 10 + docs/examples/guide/reactivity/refresh02.py | 29 +++ docs/examples/guide/reactivity/validate01.css | 4 + docs/examples/guide/reactivity/validate01.py | 38 +++ docs/examples/guide/reactivity/watch01.css | 21 ++ docs/examples/guide/reactivity/watch01.py | 33 +++ docs/getting_started.md | 4 +- docs/guide/app.md | 2 +- docs/guide/reactivity.md | 228 +++++++++++++++++- docs/images/css_stopwatch.excalidraw.svg | 4 +- docs/index.md | 3 +- docs/tutorial.md | 66 ++--- mkdocs.yml | 6 +- pyproject.toml | 2 +- src/textual/reactive.py | 13 +- src/textual/richreadme.md | 2 +- src/textual/widget.py | 16 +- src/textual/widgets/_input.py | 11 +- src/textual/widgets/_static.py | 9 +- src/textual/widgets/_text_log.py | 14 +- 25 files changed, 549 insertions(+), 66 deletions(-) create mode 100644 docs/examples/guide/reactivity/computed01.css create mode 100644 docs/examples/guide/reactivity/computed01.py create mode 100644 docs/examples/guide/reactivity/refresh01.css create mode 100644 docs/examples/guide/reactivity/refresh01.py create mode 100644 docs/examples/guide/reactivity/refresh02.css create mode 100644 docs/examples/guide/reactivity/refresh02.py create mode 100644 docs/examples/guide/reactivity/validate01.css create mode 100644 docs/examples/guide/reactivity/validate01.py create mode 100644 docs/examples/guide/reactivity/watch01.css create mode 100644 docs/examples/guide/reactivity/watch01.py 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) From 65995c7fa2a987fb99865c80e570c9f54801e101 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Oct 2022 17:12:54 +0100 Subject: [PATCH 02/10] possible fix for exception on mount --- mkdocs.yml | 5 ++--- src/textual/widget.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 8dbc3ad38..1d609b587 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -178,9 +178,8 @@ plugins: default_handler: python handlers: python: - rendering: - show_source: false - selection: + options: + show_source: false filters: - "!^_" - "^__init__$" diff --git a/src/textual/widget.py b/src/textual/widget.py index c8efbe8a1..1a7171413 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -329,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. From 8589b90d9240bbfbe265471b373d383683d72e46 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Oct 2022 17:17:47 +0100 Subject: [PATCH 03/10] test fix --- tests/test_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_widget.py b/tests/test_widget.py index c2866e5f6..f5265d05b 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -39,7 +39,7 @@ def test_widget_content_width(): self.text = text super().__init__(id=id) - self.expand = False + self.expand = True def render(self) -> str: return self.text From f43366abfb32be8ffcecf77a7ace3aeaeef7f46a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Oct 2022 17:23:47 +0100 Subject: [PATCH 04/10] update dependancies --- docs/guide/reactivity.md | 1 + poetry.lock | 47 ++++++++++++++++++---------------------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 68e31617e..e414423c9 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -120,6 +120,7 @@ The following example modifies "refresh01.py" so that the greeting has an automa ```python hl_lines="10" --8<-- "docs/examples/guide/reactivity/refresh02.py" ``` + 1. This attribute will update the layout when changed. === "refresh02.css" diff --git a/poetry.lock b/poetry.lock index d4f9821f7..d6a6bf740 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, From 86a7bef71f1af49123c83eaca4da93c6cdc536a5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Oct 2022 17:38:10 +0100 Subject: [PATCH 05/10] replace references to TextInput --- docs/examples/events/dictionary.css | 12 +++--------- docs/guide/events.md | 4 ++-- docs/widgets/text_input.md | 1 - mkdocs.yml | 6 +++--- 4 files changed, 8 insertions(+), 15 deletions(-) delete mode 100644 docs/widgets/text_input.md diff --git a/docs/examples/events/dictionary.css b/docs/examples/events/dictionary.css index f0e46faa7..9b5e489ad 100644 --- a/docs/examples/events/dictionary.css +++ b/docs/examples/events/dictionary.css @@ -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 { diff --git a/docs/guide/events.md b/docs/guide/events.md index 242c34cfd..d5fb2fe69 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -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.
--8<-- "docs/images/events/queue.excalidraw.svg" diff --git a/docs/widgets/text_input.md b/docs/widgets/text_input.md deleted file mode 100644 index c55ce3dde..000000000 --- a/docs/widgets/text_input.md +++ /dev/null @@ -1 +0,0 @@ -# TextInput diff --git a/mkdocs.yml b/mkdocs.yml index 1d609b587..61470e8fd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,13 +86,13 @@ 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/text_input.md" - "widgets/tree_control.md" - Reference: - "reference/app.md" @@ -162,7 +162,7 @@ theme: 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 From 94ff417e1f95c7f88047affb3c9ac526614b24a3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Oct 2022 08:55:25 +0100 Subject: [PATCH 06/10] fix line highlight --- docs/guide/reactivity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index e414423c9..73edf48cb 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -200,7 +200,7 @@ The following example uses a computed attribute. It displays three inputs for th === "computed01.py" - ```python hl_lines="12-18 30 32" + ```python hl_lines="25-26 28-29" --8<-- "docs/examples/guide/reactivity/computed01.py" ``` From 38924b3143282bda8e836c8383ca810d89e3f781 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Oct 2022 09:53:48 +0100 Subject: [PATCH 07/10] fixes for table scroll --- docs/widgets/input.md | 1 + src/textual/scroll_view.py | 53 +++++------------------------- src/textual/scrollbar.py | 6 ++++ src/textual/widget.py | 6 +++- src/textual/widgets/_data_table.py | 3 +- 5 files changed, 23 insertions(+), 46 deletions(-) create mode 100644 docs/widgets/input.md diff --git a/docs/widgets/input.md b/docs/widgets/input.md new file mode 100644 index 000000000..135b6af33 --- /dev/null +++ b/docs/widgets/input.md @@ -0,0 +1 @@ +# Input diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 64373d72d..03b02a4c2 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -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) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 0d96304eb..04aa18ee6 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -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): diff --git a/src/textual/widget.py b/src/textual/widget.py index 1a7171413..9102784c2 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1666,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: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 2a865d8bc..c9c1d154f 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -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() From 3c33c7fee7d7f3c8973cd29d0e7d5d3eb06276d1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Oct 2022 09:55:34 +0100 Subject: [PATCH 08/10] fix get_Widgets_at --- src/textual/_compositor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3fe955270..c00a3d6a5 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -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 From 14e87b6c99b518e43c64ba63de5485e7bb7aaac2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Oct 2022 10:12:25 +0100 Subject: [PATCH 09/10] shrink expand --- examples/code_browser.css | 3 ++- src/textual/widget.py | 4 ++-- src/textual/widgets/_static.py | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/code_browser.css b/examples/code_browser.css index a95c820fb..2a58b68e1 100644 --- a/examples/code_browser.css +++ b/examples/code_browser.css @@ -21,7 +21,8 @@ DirectoryTree { } #code-view { - overflow: auto scroll; + overflow: auto scroll; + min-width: 100%; } #code { width: auto; diff --git a/src/textual/widget.py b/src/textual/widget.py index 9102784c2..4dd7089d6 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -407,9 +407,9 @@ class Widget(DOMNode): renderable = self._render() width = measure(console, renderable, container.width) - if not self.expand: + if self.expand: width = max(container.width, width) - if not self.shrink: + if self.shrink: width = min(width, container.width) self._content_width_cache = (cache_key, width) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index bbf50dfee..91ef56119 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -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 optional. 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. name (str | None, optional): Name of widget. Defaults to None. id (str | None, optional): ID of Widget. Defaults to None. classes (str | None, optional): Space separated list of class names. Defaults to None. @@ -49,8 +49,8 @@ class Static(Widget): self, renderable: RenderableType = "", *, - expand: bool = True, - shrink: bool = True, + expand: bool = False, + shrink: bool = False, markup: bool = True, name: str | None = None, id: str | None = None, From fdc549a66c1a872314e9188cee242f6499f85255 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Oct 2022 10:15:09 +0100 Subject: [PATCH 10/10] fix test --- tests/test_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_widget.py b/tests/test_widget.py index f5265d05b..bd06d4c22 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -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 = True + self.expand = False + self.shrink = True def render(self) -> str: return self.text