Skip to content

Commit

Permalink
Merge branch 'main' into feat-collapsible-make-title-a-reactive-attri…
Browse files Browse the repository at this point in the history
…bute
  • Loading branch information
TomJGooding committed Dec 13, 2023
2 parents 7b2389b + c6aef4b commit fe91408
Show file tree
Hide file tree
Showing 23 changed files with 704 additions and 152 deletions.
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

## [0.45.1] - 2023-12-12

### Fixed

- Fixed issues were styles wouldn't update if changed in mount. https://github.com/Textualize/textual/pull/3860

## [0.45.0] - 2023-12-12

### Fixed

Expand All @@ -15,12 +22,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

### Removed

- Removed renderables/align.py which was no longer used
- Removed renderables/align.py which was no longer used.

### Changed

- Dropped ALLOW_CHILDREN flag introduced in 0.43.0 https://github.com/Textualize/textual/pull/3814
- Widgets with an auto height in an auto height container will now expand if they have no siblings https://github.com/Textualize/textual/pull/3814
- Breaking change: Removed `limit_rules` from Stylesheet.apply https://github.com/Textualize/textual/pull/3844

### Added

Expand Down Expand Up @@ -1525,6 +1533,8 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling

[0.45.1]: https://github.com/Textualize/textual/compare/v0.45.0...v0.45.1
[0.45.0]: https://github.com/Textualize/textual/compare/v0.44.1...v0.45.0
[0.44.1]: https://github.com/Textualize/textual/compare/v0.44.0...v0.44.1
[0.44.0]: https://github.com/Textualize/textual/compare/v0.43.2...v0.44.0
[0.43.2]: https://github.com/Textualize/textual/compare/v0.43.1...v0.43.2
Expand Down
7 changes: 7 additions & 0 deletions docs/api/renderables.md
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
2 changes: 1 addition & 1 deletion docs/custom_theme/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<!-- / Fathom -->


<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:card" content="summary">
<meta name="twitter:site" content="@textualizeio">
<meta name="twitter:creator" content="@willmcgugan">
<meta property="og:title" content="Textual - {{ page.title }}">
Expand Down
57 changes: 57 additions & 0 deletions docs/examples/how-to/render_compose.py
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()
2 changes: 1 addition & 1 deletion docs/guide/styles.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ When you set padding or border it reduces the size of the widget's content area.

This is generally desirable when you arrange things on screen as you can add border or padding without breaking your layout. Occasionally though you may want to keep the size of the content area constant and grow the size of the widget to fit padding and border. The [box-sizing](../styles/box_sizing.md) style allows you to switch between these two modes.

If you set `box_sizing` to `"content-box"` then space required for padding and border will be added to the widget dimensions. The default value of `box_sizing` is `"border-box"`. Compare the box model diagram for `content-box` to the to the box model for `border-box`.
If you set `box_sizing` to `"content-box"` then the space required for padding and border will be added to the widget dimensions. The default value of `box_sizing` is `"border-box"`. Compare the box model diagram for `content-box` to the box model for `border-box`.

=== "content-box"

Expand Down
63 changes: 63 additions & 0 deletions docs/how-to/render-and-compose.md
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)!
173 changes: 173 additions & 0 deletions examples/merlin.py
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()
Loading

0 comments on commit fe91408

Please sign in to comment.