Compound docs (#1952)

* compound example

* update bit switch

* prevent

* no css

* compound widget example

* more diagrams

* more diagrams

* diagrams

* words

* words

* remove sender

* removed priority post

* timer fix

* test fixes

* drop async version of post_message

* extended docs

* fix no app

* Added control properties

* changelog

* changelog

* changelog

* fix for stopping timers

* changelog

* docs update

* last byte example

* new section

* update of byte03

* updae to docs

* Added compound examples

* Rewording

* Use set sender

* don't need this

* hyphens

* Update docs/guide/widgets.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/guide/widgets.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/guide/widgets.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/guide/widgets.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/guide/widgets.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/guide/widgets.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/guide/widgets.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update docs/guide/widgets.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update docs/guide/widgets.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update docs/guide/widgets.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* parenthesis

* stack diagram

---------

Co-authored-by: Dave Pearson <davep@davep.org>
Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
Will McGugan
2023-03-06 16:56:24 +00:00
committed by GitHub
parent fd0e0d9983
commit 864931e94b
13 changed files with 643 additions and 6 deletions

View File

@@ -13,7 +13,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Breaking change: The Timer class now has just one method to stop it, `Timer.stop` which is non sync https://github.com/Textualize/textual/pull/1940
- Breaking change: Messages don't require a `sender` in their constructor https://github.com/Textualize/textual/pull/1940
- Many messages have grown a `control` property which returns the control they relate to. https://github.com/Textualize/textual/pull/1940
- Dropped `time` attribute from Messages https://github.com/Textualize/textual/pull/1940
- Updated styling to make it clear DataTable grows horizontally https://github.com/Textualize/textual/pull/1946
- Changed the `Checkbox` character due to issues with Windows Terminal and Windows 10 https://github.com/Textualize/textual/issues/1934
- Changed the `RadioButton` character due to issues with Windows Terminal and Windows 10 and 11 https://github.com/Textualize/textual/issues/1934
@@ -236,6 +235,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed issue with TextLog not writing anything before layout https://github.com/Textualize/textual/issues/1498
- Fixed an exception when populating a child class of `ListView` purely from `compose` https://github.com/Textualize/textual/issues/1588
- Fixed freeze in tests https://github.com/Textualize/textual/issues/1608
- Fixed minus not displaying as symbol https://github.com/Textualize/textual/issues/1482
## [0.9.1] - 2022-12-30

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widget import Widget
from textual.widgets import Input, Label, Switch
class BitSwitch(Widget):
"""A Switch with a numeric label above it."""
DEFAULT_CSS = """
BitSwitch {
layout: vertical;
width: auto;
height: auto;
}
BitSwitch > Label {
text-align: center;
width: 1fr;
}
"""
def __init__(self, bit: int) -> None:
self.bit = bit
super().__init__()
def compose(self) -> ComposeResult:
yield Label(str(self.bit))
yield Switch()
class ByteInput(Widget):
"""A compound widget with 8 switches."""
DEFAULT_CSS = """
ByteInput {
width: auto;
height: auto;
border: blank;
layout: horizontal;
}
ByteInput:focus-within {
border: heavy $secondary;
}
"""
def compose(self) -> ComposeResult:
for bit in reversed(range(8)):
yield BitSwitch(bit)
class ByteEditor(Widget):
DEFAULT_CSS = """
ByteEditor > Container {
height: 1fr;
align: center middle;
}
ByteEditor > Container.top {
background: $boost;
}
ByteEditor Input {
width: 16;
}
"""
def compose(self) -> ComposeResult:
with Container(classes="top"):
yield Input(placeholder="byte")
with Container():
yield ByteInput()
class ByteInputApp(App):
def compose(self) -> ComposeResult:
yield ByteEditor()
if __name__ == "__main__":
app = ByteInputApp()
app.run()

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.messages import Message
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Input, Label, Switch
class BitSwitch(Widget):
"""A Switch with a numeric label above it."""
DEFAULT_CSS = """
BitSwitch {
layout: vertical;
width: auto;
height: auto;
}
BitSwitch > Label {
text-align: center;
width: 1fr;
}
"""
class BitChanged(Message):
"""Sent when the 'bit' changes."""
def __init__(self, bit: int, value: bool) -> None:
super().__init__()
self.bit = bit
self.value = value
value = reactive(0) # (1)!
def __init__(self, bit: int) -> None:
self.bit = bit
super().__init__()
def compose(self) -> ComposeResult:
yield Label(str(self.bit))
yield Switch()
def on_switch_changed(self, event: Switch.Changed) -> None: # (2)!
"""When the switch changes, notify the parent via a message."""
event.stop() # (3)!
self.value = event.value # (4)!
self.post_message(self.BitChanged(self.bit, event.value))
class ByteInput(Widget):
"""A compound widget with 8 switches."""
DEFAULT_CSS = """
ByteInput {
width: auto;
height: auto;
border: blank;
layout: horizontal;
}
ByteInput:focus-within {
border: heavy $secondary;
}
"""
def compose(self) -> ComposeResult:
for bit in reversed(range(8)):
yield BitSwitch(bit)
class ByteEditor(Widget):
DEFAULT_CSS = """
ByteEditor > Container {
height: 1fr;
align: center middle;
}
ByteEditor > Container.top {
background: $boost;
}
ByteEditor Input {
width: 16;
}
"""
def compose(self) -> ComposeResult:
with Container(classes="top"):
yield Input(placeholder="byte")
with Container():
yield ByteInput()
def on_bit_switch_bit_changed(self, event: BitSwitch.BitChanged) -> None:
"""When a switch changes, update the value."""
value = 0
for switch in self.query(BitSwitch):
value |= switch.value << switch.bit
self.query_one(Input).value = str(value)
class ByteInputApp(App):
def compose(self) -> ComposeResult:
yield ByteEditor()
if __name__ == "__main__":
app = ByteInputApp()
app.run()

View File

@@ -0,0 +1,130 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.geometry import clamp
from textual.messages import Message
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Input, Label, Switch
class BitSwitch(Widget):
"""A Switch with a numeric label above it."""
DEFAULT_CSS = """
BitSwitch {
layout: vertical;
width: auto;
height: auto;
}
BitSwitch > Label {
text-align: center;
width: 1fr;
}
"""
class BitChanged(Message):
"""Sent when the 'bit' changes."""
def __init__(self, bit: int, value: bool) -> None:
super().__init__()
self.bit = bit
self.value = value
value = reactive(0)
def __init__(self, bit: int) -> None:
self.bit = bit
super().__init__()
def compose(self) -> ComposeResult:
yield Label(str(self.bit))
yield Switch()
def watch_value(self, value: bool) -> None: # (1)!
"""When the value changes we want to set the switch accordingly."""
self.query_one(Switch).value = value
def on_switch_changed(self, event: Switch.Changed) -> None:
"""When the switch changes, notify the parent via a message."""
event.stop()
self.value = event.value
self.post_message(self.BitChanged(self.bit, event.value))
class ByteInput(Widget):
"""A compound widget with 8 switches."""
DEFAULT_CSS = """
ByteInput {
width: auto;
height: auto;
border: blank;
layout: horizontal;
}
ByteInput:focus-within {
border: heavy $secondary;
}
"""
def compose(self) -> ComposeResult:
for bit in reversed(range(8)):
yield BitSwitch(bit)
class ByteEditor(Widget):
DEFAULT_CSS = """
ByteEditor > Container {
height: 1fr;
align: center middle;
}
ByteEditor > Container.top {
background: $boost;
}
ByteEditor Input {
width: 16;
}
"""
value = reactive(0)
def validate_value(self, value: int) -> int: # (2)!
"""Ensure value is between 0 and 255."""
return clamp(value, 0, 255)
def compose(self) -> ComposeResult:
with Container(classes="top"):
yield Input(placeholder="byte")
with Container():
yield ByteInput()
def on_bit_switch_bit_changed(self, event: BitSwitch.BitChanged) -> None:
"""When a switch changes, update the value."""
value = 0
for switch in self.query(BitSwitch):
value |= switch.value << switch.bit
self.query_one(Input).value = str(value)
def on_input_changed(self, event: Input.Changed) -> None: # (3)!
"""When the text changes, set the value of the byte."""
try:
self.value = int(event.value or "0")
except ValueError:
pass
def watch_value(self, value: int) -> None: # (4)!
"""When self.value changes, update switches."""
for switch in self.query(BitSwitch):
with switch.prevent(BitSwitch.BitChanged): # (5)!
switch.value = bool(value & (1 << switch.bit)) # (6)!
class ByteInputApp(App):
def compose(self) -> ComposeResult:
yield ByteEditor()
if __name__ == "__main__":
app = ByteInputApp()
app.run()

View File

@@ -0,0 +1,52 @@
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.widgets import Input, Label
class InputWithLabel(Widget):
"""An input with a label."""
DEFAULT_CSS = """
InputWithLabel {
layout: horizontal;
height: auto;
}
InputWithLabel Label {
padding: 1;
width: 12;
text-align: right;
}
InputWithLabel Input {
width: 1fr;
}
"""
def __init__(self, input_label: str) -> None:
self.input_label = input_label
super().__init__()
def compose(self) -> ComposeResult: # (1)!
yield Label(self.input_label)
yield Input()
class CompoundApp(App):
CSS = """
Screen {
align: center middle;
}
InputWithLabel {
width: 80%;
margin: 1;
}
"""
def compose(self) -> ComposeResult:
yield InputWithLabel("Fist Name")
yield InputWithLabel("Last Name")
yield InputWithLabel("Email")
if __name__ == "__main__":
app = CompoundApp()
app.run()

View File

@@ -194,10 +194,6 @@ Let's modify the default width for the fizzbuzz example. By default, the table w
Note that we've added `expand=True` to tell the `Table` to expand beyond the optimal width, so that it fills the 50 characters returned by `get_content_width`.
## Compound widgets
TODO: Explanation of compound widgets
## Line API
A downside of widgets that return Rich renderables is that Textual will redraw the entire widget when its state is updated or it changes size.
@@ -388,3 +384,191 @@ The following builtin widgets use the Line API. If you are building advanced wid
- [DataTable](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_data_table.py)
- [TextLog](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_text_log.py)
- [Tree](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_tree.py)
## Compound widgets
Widgets may be combined to create new widgets with additional features.
Such widgets are known as *compound widgets*.
The stopwatch in the [tutorial](./../tutorial.md) is an example of a compound widget.
A compound widget can be used like any other widget.
The only thing that differs is that when you build a compound widget, you write a `compose()` method which yields *child* widgets, rather than implement a `render` or `render_line` method.
The following is an example of a compound widget.
=== "compound01.py"
```python title="compound01.py" hl_lines="28-30 44-47"
--8<-- "docs/examples/guide/compound/compound01.py"
```
1. The `compose` method makes this widget a *compound* widget.
=== "Output"
```{.textual path="docs/examples/guide/compound/compound01.py"}
```
The `InputWithLabel` class bundles an [Input](../widgets/input.md) with a [Label](../widgets/label.md) to create a new widget that displays a right-aligned label next to an input control. You can re-use this `InputWithLabel` class anywhere in a Textual app, including in other widgets.
## Coordinating widgets
Widgets rarely exist in isolation, and often need to communicate or exchange data with other parts of your app.
This is not difficult to do, but there is a risk that widgets can become dependant on each other, making it impossible to reuse a widget without copying a lot of dependant code.
In this section we will show how to design and build a fully-working app, while keeping widgets reusable.
### Designing the app
We are going to build a *byte editor* which allows you to enter a number in both decimal and binary. You could use this a teaching aid for binary numbers.
Here's a sketch of what the app should ultimately look like:
!!! tip
There are plenty of resources on the web, such as this [excellent video from Khan Academy](https://www.khanacademy.org/math/algebra-home/alg-intro-to-algebra/algebra-alternate-number-bases/v/number-systems-introduction) if you want to brush up on binary numbers.
<div class="excalidraw">
--8<-- "docs/images/byte01.excalidraw.svg"
</div>
There are three types of built-in widget in the sketch, namely ([Input](../widgets/input.md), [Label](../widgets/label.md), and [Switch](../widgets/switch.md)). Rather than manage these as a single collection of widgets, we can arrange them in to logical groups with compound widgets. This will make our app easier to work with.
### Identifying components
We will divide this UI into three compound widgets:
1. `BitSwitch` for a switch with a numeric label.
2. `ByteInput` which contains 8 `BitSwitch` widgets.
3. `ByteEditor` which contains a `ByteInput` and an [Input](../widgets/input.md) to show the decimal value.
This is not the only way we could implement our design with compound widgets.
So why these three widgets?
As a rule of thumb, a widget should handle one piece of data, which is why we have an independent widget for a bit, a byte, and the decimal value.
<div class="excalidraw">
--8<-- "docs/images/byte02.excalidraw.svg"
</div>
In the following code we will implement the three widgets. There will be no functionality yet, but it should look like our design.
=== "byte01.py"
```python title="byte01.py" hl_lines="28-30 48-50 67-71"
--8<-- "docs/examples/guide/compound/byte01.py"
```
=== "Output"
```{.textual path="docs/examples/guide/compound/byte01.py" columns="90" line="30"}
```
Note the `compose()` methods of each of the widgets.
- The `BitSwitch` yields a [Label](../widgets/label.md) which displays the bit number, and a [Switch](../widgets/switch.md) control for that bit. The default CSS for `BitSwitch` aligns its children vertically, and sets the label's [text-align](../styles/text_align.md) to center.
- The `ByteInput` yields 8 `BitSwitch` widgets and arranges them horizontally. It also adds a `focus-within` style in its CSS to draw an accent border when any of the switches are focused.
- The `ByteEditor` yields a `ByteInput` and an `Input` control. The default CSS stacks the two controls on top of each other to divide the screen in to two parts.
With these three widgets, the [DOM](CSS.md#the-dom) for our app will look like this:
<div class="excalidraw">
--8<-- "docs/images/byte_input_dom.excalidraw.svg"
</div>
Now that we have the design in place, we can implement the behavior.
### Data flow
We want to ensure that our widgets are re-usable, which we can do by following the guideline of "attributes down, messages up". This means that a widget can update a child by setting its attributes or calling its methods, but widgets should only ever send [messages](./events.md) to their *parent* (or other ancestors).
!!! info
This pattern of only setting attributes in one direction and using messages for the opposite direction is known as *uni-directional data flow*.
In practice, this means that to update a child widget you get a reference to it and use it like any other Python object. Here's an example of an [action](actions.md) that updates a child widget:
```python
def action_set_true(self):
self.query_one(Switch).value = 1
```
If a child needs to update a parent, it should send a message with [post_message][textual.message_pump.MessagePump.post_message].
Here's an example of posting message:
```python
def on_click(self):
self.post_message(MyWidget.Change(active=True))
```
Note that *attributes down and messages up* means that you can't modify widgets on the same level directly. If you want to modify a *sibling*, you will need to send a message to the parent, and the parent would make the changes.
The following diagram illustrates this concept:
<div class="excalidraw">
--8<-- "docs/images/attributes_messages.excalidraw.svg"
</div>
### Messages up
Let's extend the `ByteEditor` so that clicking any of the 8 `BitSwitch` widgets updates the decimal value. To do this we will add a custom message to `BitSwitch` that we catch in the `ByteEditor`.
=== "byte02.py"
```python title="byte02.py" hl_lines="5-6 26-32 34 44-48 91-96"
--8<-- "docs/examples/guide/compound/byte02.py"
```
1. This will store the value of the "bit".
2. This is sent by the builtin `Switch` widgets, when it changes state.
3. Stop the event, because we don't want it to go to the parent.
4. Store the new value of the "bit".
=== "Output"
```{.textual path="docs/examples/guide/compound/byte02.py" columns="90" line="30", press="tab,tab,tab,tab,enter"}
```
- The `BitSwitch` widget now has an `on_switch_changed` method which will handle a [`Switch.Changed`][textual.widgets.Switch.Changed] message, sent when the user clicks a switch. We use this to store the new value of the bit, and sent a new custom message, `BitSwitch.BitChanged`.
- The `ByteEditor` widget handles the `BitSwitch.Changed` message by calculating the decimal value and setting it on the input.
The following is a (simplified) DOM diagram to show how the new message is processed:
<div class="excalidraw">
--8<-- "docs/images/bit_switch_message.excalidraw.svg"
</div>
### Attributes down
We also want the switches to update if the user edits the decimal value.
Since the switches are children of `ByteEditor` we can update them by setting their attributes directly.
This is an example of "attributes down".
=== "byte02.py"
```python title="byte03.py" hl_lines="5 45-47 90 92-94 109-114 116-120"
--8<-- "docs/examples/guide/compound/byte03.py"
```
1. When the `BitSwitch`'s value changed, we want to update the builtin `Switch` to match.
2. Ensure the value is in a the range of a byte.
3. Handle the `Input.Changed` event when the user modified the value in the input.
4. When the `ByteEditor` value changes, update all the switches to match.
5. Prevent the `BitChanged` message from being sent.
6. Because `switch` is a child, we can set its attributes directly.
=== "Output"
```{.textual path="docs/examples/guide/compound/byte03.py" columns="90" line="30", press="tab,1,0,0"}
```
- When the user edits the input, the [Input](../widgets/input.md) widget sends a `Changed` event, which we handle with `on_input_changed` by setting `self.value`, which is a reactive value we added to `ByteEditor`.
- If the value has changed, Textual will call `watch_value` which sets the value of each of the eight switches. Because we are working with children of the `ByteEditor`, we can set attributes directly without going via a message.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 76 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 77 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -879,7 +879,7 @@ class App(Generic[ReturnType], DOMNode):
char = key if len(key) == 1 else None
print(f"press {key!r} (char={char!r})")
key_event = events.Key(key, char)
key_event._sender = app
key_event._set_sender(app)
driver.send_event(key_event)
await wait_for_idle(0)

View File

@@ -84,6 +84,10 @@ class Message:
"""Mark this event as being forwarded."""
self._forwarded = True
def _set_sender(self, sender: MessageTarget) -> None:
"""Set the sender."""
self._sender = sender
def can_replace(self, message: "Message") -> bool:
"""Check if another message may supersede this one.