-
Notifications
You must be signed in to change notification settings - Fork 826
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' into feat-collapsible-make-title-a-reactive-attri…
…bute
- Loading branch information
Showing
23 changed files
with
704 additions
and
152 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
A collection of Rich renderables which may be returned from a widget's `render()` method. | ||
|
||
::: textual.renderables.bar | ||
::: textual.renderables.blank | ||
::: textual.renderables.digits | ||
::: textual.renderables.gradient | ||
::: textual.renderables.sparkline |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
from time import time | ||
|
||
from textual.app import App, ComposeResult, RenderableType | ||
from textual.containers import Container | ||
from textual.renderables.gradient import LinearGradient | ||
from textual.widgets import Static | ||
|
||
COLORS = [ | ||
"#881177", | ||
"#aa3355", | ||
"#cc6666", | ||
"#ee9944", | ||
"#eedd00", | ||
"#99dd55", | ||
"#44dd88", | ||
"#22ccbb", | ||
"#00bbcc", | ||
"#0099cc", | ||
"#3366bb", | ||
"#663399", | ||
] | ||
STOPS = [(i / (len(COLORS) - 1), color) for i, color in enumerate(COLORS)] | ||
|
||
|
||
class Splash(Container): | ||
"""Custom widget that extends Container.""" | ||
|
||
DEFAULT_CSS = """ | ||
Splash { | ||
align: center middle; | ||
} | ||
Static { | ||
width: 40; | ||
padding: 2 4; | ||
} | ||
""" | ||
|
||
def on_mount(self) -> None: | ||
self.auto_refresh = 1 / 30 # (1)! | ||
|
||
def compose(self) -> ComposeResult: | ||
yield Static("Making a splash with Textual!") # (2)! | ||
|
||
def render(self) -> RenderableType: | ||
return LinearGradient(time() * 90, STOPS) # (3)! | ||
|
||
|
||
class SplashApp(App): | ||
"""Simple app to show our custom widget.""" | ||
|
||
def compose(self) -> ComposeResult: | ||
yield Splash() | ||
|
||
|
||
if __name__ == "__main__": | ||
app = SplashApp() | ||
app.run() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
# Render and compose | ||
|
||
A common question that comes up on the [Textual Discord server](https://discord.gg/Enf6Z3qhVr) is what is the difference between [`render`][textual.widget.Widget.render] and [`compose`][textual.widget.Widget.compose] methods on a widget? | ||
In this article we will clarify the differences, and use both these methods to build something fun. | ||
|
||
<div class="video-wrapper"> | ||
<iframe width="1280" height="922" src="https://www.youtube.com/embed/dYU7jHyabX8" title="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> | ||
</div> | ||
|
||
## Which method to use? | ||
|
||
Render and compose are easy to confuse because they both ultimately define what a widget will look like, but they have quite different uses. | ||
|
||
The `render` method on a widget returns a [Rich](https://rich.readthedocs.io/en/latest/) renderable, which is anything you could print with Rich. | ||
The simplest renderable is just text; so `render()` methods often return a string, but could equally return a [`Text`](https://rich.readthedocs.io/en/latest/text.html) instance, a [`Table`](https://rich.readthedocs.io/en/latest/tables.html), or anything else from Rich (or third party library). | ||
Whatever is returned from `render()` will be combined with any styles from CSS and displayed within the widget's borders. | ||
|
||
The `compose` method is used to build [*compound* widgets](../guide/widgets.md#compound-widgets) (widgets composed of other widgets). | ||
|
||
A general rule of thumb, is that if you implement a `compose` method, there is no need for a `render` method because it is the widgets yielded from `compose` which define how the custom widget will look. | ||
However, you *can* mix these two methods. | ||
If you implement both, the `render` method will set the custom widget's *background* and `compose` will add widgets on top of that background. | ||
|
||
## Combining render and compose | ||
|
||
Let's look at an example that combines both these methods. | ||
We will create a custom widget with a [linear gradient][textual.renderables.gradient.LinearGradient] as a background. | ||
The background will be animated (I did promise *fun*)! | ||
|
||
=== "render_compose.py" | ||
|
||
```python | ||
--8<-- "docs/examples/how-to/render_compose.py" | ||
``` | ||
|
||
1. Refresh the widget 30 times a second. | ||
2. Compose our compound widget, which contains a single Static. | ||
3. Render a linear gradient in the background. | ||
|
||
=== "Output" | ||
|
||
```{.textual path="docs/examples/how-to/render_compose.py" columns="100" lines="40"} | ||
``` | ||
|
||
The `Splash` custom widget has a `compose` method which adds a simple `Static` widget to display a message. | ||
Additionally there is a `render` method which returns a renderable to fill the background with a gradient. | ||
|
||
!!! tip | ||
|
||
As fun as this is, spinning animated gradients may be too distracting for most apps! | ||
|
||
## Summary | ||
|
||
Keep the following in mind when building [custom widgets](../guide/widgets.md). | ||
|
||
1. Use `render` to return simple text, or a Rich renderable. | ||
2. Use `compose` to create a widget out of other widgets. | ||
3. If you define both, then `render` will be used as a *background*. | ||
|
||
|
||
--- | ||
|
||
We are here to [help](../help.md)! |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
from __future__ import annotations | ||
|
||
import random | ||
from datetime import timedelta | ||
from time import monotonic | ||
|
||
from textual import events | ||
from textual.app import App, ComposeResult | ||
from textual.containers import Grid | ||
from textual.reactive import var | ||
from textual.renderables.gradient import LinearGradient | ||
from textual.widget import Widget | ||
from textual.widgets import Digits, Label, Switch | ||
|
||
# A nice rainbow of colors. | ||
COLORS = [ | ||
"#881177", | ||
"#aa3355", | ||
"#cc6666", | ||
"#ee9944", | ||
"#eedd00", | ||
"#99dd55", | ||
"#44dd88", | ||
"#22ccbb", | ||
"#00bbcc", | ||
"#0099cc", | ||
"#3366bb", | ||
"#663399", | ||
] | ||
|
||
|
||
# Maps a switch number on to other switch numbers, which should be toggled. | ||
TOGGLES: dict[int, tuple[int, ...]] = { | ||
1: (2, 4, 5), | ||
2: (1, 3), | ||
3: (2, 5, 6), | ||
4: (1, 7), | ||
5: (2, 4, 6, 8), | ||
6: (3, 9), | ||
7: (4, 5, 8), | ||
8: (7, 9), | ||
9: (5, 6, 8), | ||
} | ||
|
||
|
||
class LabelSwitch(Widget): | ||
"""Switch with a numeric label.""" | ||
|
||
DEFAULT_CSS = """ | ||
LabelSwitch Label { | ||
text-align: center; | ||
width: 1fr; | ||
text-style: bold; | ||
} | ||
LabelSwitch Label#label-5 { | ||
color: $text-disabled; | ||
} | ||
""" | ||
|
||
def __init__(self, switch_no: int) -> None: | ||
self.switch_no = switch_no | ||
super().__init__() | ||
|
||
def compose(self) -> ComposeResult: | ||
"""Compose the label and a switch.""" | ||
yield Label(str(self.switch_no), id=f"label-{self.switch_no}") | ||
yield Switch(id=f"switch-{self.switch_no}", name=str(self.switch_no)) | ||
|
||
|
||
class Timer(Digits): | ||
"""Displays a timer that stops when you win.""" | ||
|
||
DEFAULT_CSS = """ | ||
Timer { | ||
text-align: center; | ||
width: auto; | ||
margin: 2 8; | ||
color: $warning; | ||
} | ||
""" | ||
start_time = var(0.0) | ||
running = var(True) | ||
|
||
def on_mount(self) -> None: | ||
"""Start the timer on mount.""" | ||
self.start_time = monotonic() | ||
self.set_interval(1, self.tick) | ||
self.tick() | ||
|
||
def tick(self) -> None: | ||
"""Called from `set_interval` to update the clock.""" | ||
if self.start_time == 0 or not self.running: | ||
return | ||
time_elapsed = timedelta(seconds=int(monotonic() - self.start_time)) | ||
self.update(str(time_elapsed)) | ||
|
||
|
||
class MerlinApp(App): | ||
"""A simple reproduction of one game on the Merlin hand held console.""" | ||
|
||
CSS = """ | ||
Screen { | ||
align: center middle; | ||
} | ||
Screen.-win { | ||
background: transparent; | ||
} | ||
Screen.-win Timer { | ||
color: $success; | ||
} | ||
Grid { | ||
width: auto; | ||
height: auto; | ||
border: thick $primary; | ||
padding: 1 2; | ||
grid-size: 3 3; | ||
grid-rows: auto; | ||
grid-columns: auto; | ||
grid-gutter: 1 1; | ||
background: $surface; | ||
} | ||
""" | ||
|
||
def render(self) -> LinearGradient: | ||
"""Renders a gradient, when the background is transparent.""" | ||
stops = [(i / (len(COLORS) - 1), c) for i, c in enumerate(COLORS)] | ||
return LinearGradient(30.0, stops) | ||
|
||
def compose(self) -> ComposeResult: | ||
"""Compose a timer, and a grid of 9 switches.""" | ||
yield Timer() | ||
with Grid(): | ||
for switch in (7, 8, 9, 4, 5, 6, 1, 2, 3): | ||
yield LabelSwitch(switch) | ||
|
||
def on_mount(self) -> None: | ||
"""Randomize the switches on mount.""" | ||
for switch_no in range(1, 10): | ||
if random.randint(0, 1): | ||
self.query_one(f"#switch-{switch_no}", Switch).toggle() | ||
|
||
def check_win(self) -> bool: | ||
"""Check for a win.""" | ||
on_switches = { | ||
int(switch.name or "0") for switch in self.query(Switch) if switch.value | ||
} | ||
return on_switches == {1, 2, 3, 4, 6, 7, 8, 9} | ||
|
||
def on_switch_changed(self, event: Switch.Changed) -> None: | ||
"""Called when a switch is toggled.""" | ||
# The switch that was pressed | ||
switch_no = int(event.switch.name or "0") | ||
# Also toggle corresponding switches | ||
with self.prevent(Switch.Changed): | ||
for toggle_no in TOGGLES[switch_no]: | ||
self.query_one(f"#switch-{toggle_no}", Switch).toggle() | ||
# Check the win | ||
if self.check_win(): | ||
self.query_one("Screen").add_class("-win") | ||
self.query_one(Timer).running = False | ||
|
||
def on_key(self, event: events.Key) -> None: | ||
"""Maps switches to keys, so we can use the keyboard as well.""" | ||
if event.character and event.character.isdigit(): | ||
self.query_one(f"#switch-{event.character}", Switch).toggle() | ||
|
||
|
||
if __name__ == "__main__": | ||
MerlinApp().run() |
Oops, something went wrong.