diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c4c528ee..4296bb28c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/examples/guide/compound/byte01.py b/docs/examples/guide/compound/byte01.py
new file mode 100644
index 000000000..76f6ee69c
--- /dev/null
+++ b/docs/examples/guide/compound/byte01.py
@@ -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()
diff --git a/docs/examples/guide/compound/byte02.py b/docs/examples/guide/compound/byte02.py
new file mode 100644
index 000000000..c9732b499
--- /dev/null
+++ b/docs/examples/guide/compound/byte02.py
@@ -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()
diff --git a/docs/examples/guide/compound/byte03.py b/docs/examples/guide/compound/byte03.py
new file mode 100644
index 000000000..829252a35
--- /dev/null
+++ b/docs/examples/guide/compound/byte03.py
@@ -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()
diff --git a/docs/examples/guide/compound/compound01.py b/docs/examples/guide/compound/compound01.py
new file mode 100644
index 000000000..66d8745ea
--- /dev/null
+++ b/docs/examples/guide/compound/compound01.py
@@ -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()
diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md
index 70d7f55e8..78e222894 100644
--- a/docs/guide/widgets.md
+++ b/docs/guide/widgets.md
@@ -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.
+
+
+
+--8<-- "docs/images/byte01.excalidraw.svg"
+
+
+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.
+
+
+--8<-- "docs/images/byte02.excalidraw.svg"
+
+
+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:
+
+
+
+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:
+
+
+
+
+### 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:
+
+
+
+
+### 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.
diff --git a/docs/images/attributes_messages.excalidraw.svg b/docs/images/attributes_messages.excalidraw.svg
new file mode 100644
index 000000000..7a750a148
--- /dev/null
+++ b/docs/images/attributes_messages.excalidraw.svg
@@ -0,0 +1,16 @@
+
diff --git a/docs/images/bit_switch_message.excalidraw.svg b/docs/images/bit_switch_message.excalidraw.svg
new file mode 100644
index 000000000..c777903e0
--- /dev/null
+++ b/docs/images/bit_switch_message.excalidraw.svg
@@ -0,0 +1,16 @@
+
diff --git a/docs/images/byte01.excalidraw.svg b/docs/images/byte01.excalidraw.svg
new file mode 100644
index 000000000..4d502f4f7
--- /dev/null
+++ b/docs/images/byte01.excalidraw.svg
@@ -0,0 +1,16 @@
+
diff --git a/docs/images/byte02.excalidraw.svg b/docs/images/byte02.excalidraw.svg
new file mode 100644
index 000000000..0bd554cfc
--- /dev/null
+++ b/docs/images/byte02.excalidraw.svg
@@ -0,0 +1,16 @@
+
diff --git a/docs/images/byte_input_dom.excalidraw.svg b/docs/images/byte_input_dom.excalidraw.svg
new file mode 100644
index 000000000..d6fe3dd90
--- /dev/null
+++ b/docs/images/byte_input_dom.excalidraw.svg
@@ -0,0 +1,16 @@
+
diff --git a/src/textual/app.py b/src/textual/app.py
index fa6814933..b387a718a 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -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)
diff --git a/src/textual/message.py b/src/textual/message.py
index 22e877a18..7b8b3befe 100644
--- a/src/textual/message.py
+++ b/src/textual/message.py
@@ -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.