Skip to content

Commit d60be19

Browse files
authored
Merge branch 'main' into feature/first_child
2 parents 0c1ee5e + 9095140 commit d60be19

26 files changed

+1424
-93
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,23 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## Unreleased
9+
810
### Fixed
911

1012
- Fixed `OptionList` causing excessive redrawing https://github.com/Textualize/textual/pull/5766
1113
- Added `:first-child` and `:last-child` pseudo classes https://github.com/Textualize/textual/pull/5776
14+
- Log messages could be written to stdout when there was no app, which could happen when using run_async or threads. Now they will be suppressed, unless the env var `TEXTUAL_DEBUG` is set https://github.com/Textualize/textual/pull/5782
15+
16+
### Added
17+
18+
- Added `toggle_class` parameter to reactives https://github.com/Textualize/textual/pull/5778
19+
- Added `compact` parameter and reactive to `Button`, `Input`, `ToggleButton`, `RadioSet`, `OptionList`, `TextArea` https://github.com/Textualize/textual/pull/5778
20+
- Added `HORIZONTAL_BREAKPOINTS` and `VERTICAL_BREAKPOINTS` to `App` and `Screen` https://github.com/Textualize/textual/pull/5779
21+
22+
### Changed
23+
24+
- `RadioSet` now has a default width of `1fr` https://github.com/Textualize/textual/pull/5778
1225

1326
## [3.1.1] - 2025-04-22
1427

docs/guide/reactivity.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ You may want to add explicit type hints if the attribute type is a superset of t
6565

6666
## Smart refresh
6767

68-
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.
68+
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 by calling the widget's [`render()`][textual.widget.Widget.render] method to get updated content.
6969

7070
!!! information
7171

examples/breakpoints.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from textual.app import App, ComposeResult
2+
from textual.containers import Grid
3+
from textual.widgets import Footer, Markdown, Placeholder
4+
5+
HELP = """\
6+
## Breakpoints
7+
8+
A demonstration of how to make an app respond to the dimensions of the terminal.
9+
10+
Try resizing the terminal, then have a look at the source to see how it works!
11+
"""
12+
13+
14+
class BreakpointApp(App):
15+
16+
# A breakpoint consists of a width and a class name to set
17+
HORIZONTAL_BREAKPOINTS = [
18+
(0, "-narrow"),
19+
(40, "-normal"),
20+
(80, "-wide"),
21+
(120, "-very-wide"),
22+
]
23+
24+
CSS = """
25+
Screen {
26+
Placeholder { padding: 2; }
27+
Grid { grid-rows: auto; height: auto; }
28+
# Change the styles according to the breakpoint classes
29+
&.-narrow {
30+
Grid { grid-size: 1; }
31+
}
32+
&.-normal {
33+
Grid { grid-size: 2; }
34+
}
35+
&.-wide {
36+
Grid { grid-size: 4; }
37+
}
38+
&.-very-wide {
39+
Grid { grid-size: 6; }
40+
}
41+
}
42+
"""
43+
44+
def compose(self) -> ComposeResult:
45+
yield Markdown(HELP)
46+
with Grid():
47+
for n in range(16):
48+
yield Placeholder(f"Placeholder {n+1}")
49+
yield Footer()
50+
51+
52+
if __name__ == "__main__":
53+
BreakpointApp().run()

src/textual/__init__.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,12 @@ def __call__(self, *args: object, **kwargs) -> None:
8585
try:
8686
app = active_app.get()
8787
except LookupError:
88-
print_args = (*args, *[f"{key}={value!r}" for key, value in kwargs.items()])
89-
print(*print_args)
88+
if constants.DEBUG:
89+
print_args = (
90+
*args,
91+
*[f"{key}={value!r}" for key, value in kwargs.items()],
92+
)
93+
print(*print_args)
9094
return
9195
if app.devtools is None or not app.devtools.is_connected:
9296
return
@@ -108,8 +112,12 @@ def __call__(self, *args: object, **kwargs) -> None:
108112
)
109113
except LoggerError:
110114
# If there is not active app, try printing
111-
print_args = (*args, *[f"{key}={value!r}" for key, value in kwargs.items()])
112-
print(*print_args)
115+
if constants.DEBUG:
116+
print_args = (
117+
*args,
118+
*[f"{key}={value!r}" for key, value in kwargs.items()],
119+
)
120+
print(*print_args)
113121

114122
def verbosity(self, verbose: bool) -> Logger:
115123
"""Get a new logger with selective verbosity.

src/textual/app.py

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import threading
1919
import uuid
2020
import warnings
21-
from asyncio import Task, create_task
21+
from asyncio import AbstractEventLoop, Task, create_task
2222
from concurrent.futures import Future
2323
from contextlib import (
2424
asynccontextmanager,
@@ -150,9 +150,6 @@
150150
if constants.DEBUG:
151151
warnings.simplefilter("always", ResourceWarning)
152152

153-
# `asyncio.get_event_loop()` is deprecated since Python 3.10:
154-
_ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0)
155-
156153
ComposeResult = Iterable[Widget]
157154
RenderResult: TypeAlias = "RenderableType | Visual | SupportsVisual"
158155
"""Result of Widget.render()"""
@@ -482,6 +479,31 @@ class MyApp(App[None]):
482479
SUSPENDED_SCREEN_CLASS: ClassVar[str] = ""
483480
"""Class to apply to suspended screens, or empty string for no class."""
484481

482+
HORIZONTAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = []
483+
"""List of horizontal breakpoints for responsive classes.
484+
485+
This allows for styles to be responsive to the dimensions of the terminal.
486+
For instance, you might want to show less information, or fewer columns on a narrow displays -- or more information when the terminal is sized wider than usual.
487+
488+
A breakpoint consists of a tuple containing the minimum width where the class should applied, and the name of the class to set.
489+
490+
Note that only one class name is set, and if you should avoid having more than one breakpoint set for the same size.
491+
492+
Example:
493+
```python
494+
# Up to 80 cells wide, the app has the class "-normal"
495+
# 80 - 119 cells wide, the app has the class "-wide"
496+
# 120 cells or wider, the app has the class "-very-wide"
497+
HORIZONTAL_BREAKPOINTS = [(0, "-normal"), (80, "-wide"), (120, "-very-wide")]
498+
```
499+
500+
"""
501+
VERTICAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = []
502+
"""List of vertical breakpoints for responsive classes.
503+
504+
Contents are the same as [`HORIZONTAL_BREAKPOINTS`][textual.app.App.HORIZONTAL_BREAKPOINTS], but the integer is compared to the height, rather than the width.
505+
"""
506+
485507
_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = {
486508
"focus": lambda app: app.app_focus,
487509
"blur": lambda app: not app.app_focus,
@@ -2034,7 +2056,6 @@ async def run_async(
20342056
from textual.pilot import Pilot
20352057

20362058
app = self
2037-
20382059
auto_pilot_task: Task | None = None
20392060

20402061
if auto_pilot is None and constants.PRESS:
@@ -2067,27 +2088,29 @@ async def run_auto_pilot(
20672088
run_auto_pilot(auto_pilot, pilot), name=repr(pilot)
20682089
)
20692090

2070-
try:
2071-
app._loop = asyncio.get_running_loop()
2072-
app._thread_id = threading.get_ident()
2073-
2074-
await app._process_messages(
2075-
ready_callback=None if auto_pilot is None else app_ready,
2076-
headless=headless,
2077-
inline=inline,
2078-
inline_no_clear=inline_no_clear,
2079-
mouse=mouse,
2080-
terminal_size=size,
2081-
)
2082-
finally:
2091+
app._loop = asyncio.get_running_loop()
2092+
app._thread_id = threading.get_ident()
2093+
with app._context():
20832094
try:
2084-
if auto_pilot_task is not None:
2085-
await auto_pilot_task
2095+
await app._process_messages(
2096+
ready_callback=None if auto_pilot is None else app_ready,
2097+
headless=headless,
2098+
inline=inline,
2099+
inline_no_clear=inline_no_clear,
2100+
mouse=mouse,
2101+
terminal_size=size,
2102+
)
20862103
finally:
20872104
try:
2088-
await asyncio.shield(app._shutdown())
2089-
except asyncio.CancelledError:
2090-
pass
2105+
if auto_pilot_task is not None:
2106+
await auto_pilot_task
2107+
finally:
2108+
try:
2109+
await asyncio.shield(app._shutdown())
2110+
except asyncio.CancelledError:
2111+
pass
2112+
app._loop = None
2113+
app._thread_id = 0
20912114

20922115
return app.return_value
20932116

@@ -2100,6 +2123,7 @@ def run(
21002123
mouse: bool = True,
21012124
size: tuple[int, int] | None = None,
21022125
auto_pilot: AutopilotCallbackType | None = None,
2126+
loop: AbstractEventLoop | None = None,
21032127
) -> ReturnType | None:
21042128
"""Run the app.
21052129
@@ -2111,36 +2135,24 @@ def run(
21112135
size: Force terminal size to `(WIDTH, HEIGHT)`,
21122136
or None to auto-detect.
21132137
auto_pilot: An auto pilot coroutine.
2114-
2138+
loop: Asyncio loop instance, or `None` to use default.
21152139
Returns:
21162140
App return value.
21172141
"""
21182142

21192143
async def run_app() -> None:
21202144
"""Run the app."""
2121-
self._loop = asyncio.get_running_loop()
2122-
self._thread_id = threading.get_ident()
2123-
with self._context():
2124-
try:
2125-
await self.run_async(
2126-
headless=headless,
2127-
inline=inline,
2128-
inline_no_clear=inline_no_clear,
2129-
mouse=mouse,
2130-
size=size,
2131-
auto_pilot=auto_pilot,
2132-
)
2133-
finally:
2134-
self._loop = None
2135-
self._thread_id = 0
2145+
await self.run_async(
2146+
headless=headless,
2147+
inline=inline,
2148+
inline_no_clear=inline_no_clear,
2149+
mouse=mouse,
2150+
size=size,
2151+
auto_pilot=auto_pilot,
2152+
)
21362153

2137-
if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
2138-
# N.B. This doesn't work with Python<3.10, as we end up with 2 event loops:
2139-
asyncio.run(run_app())
2140-
else:
2141-
# However, this works with Python<3.10:
2142-
event_loop = asyncio.get_event_loop()
2143-
event_loop.run_until_complete(run_app())
2154+
event_loop = asyncio.get_event_loop() if loop is None else loop
2155+
event_loop.run_until_complete(run_app())
21442156
return self.return_value
21452157

21462158
async def _on_css_change(self) -> None:

src/textual/reactive.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class Reactive(Generic[ReactiveType]):
111111
compute: Run compute methods when attribute is changed.
112112
recompose: Compose the widget again when the attribute changes.
113113
bindings: Refresh bindings when the reactive changes.
114+
toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value.
114115
"""
115116

116117
_reactives: ClassVar[dict[str, object]] = {}
@@ -126,6 +127,7 @@ def __init__(
126127
compute: bool = True,
127128
recompose: bool = False,
128129
bindings: bool = False,
130+
toggle_class: str | None = None,
129131
) -> None:
130132
self._default = default
131133
self._layout = layout
@@ -135,6 +137,7 @@ def __init__(
135137
self._run_compute = compute
136138
self._recompose = recompose
137139
self._bindings = bindings
140+
self._toggle_class = toggle_class
138141
self._owner: Type[MessageTarget] | None = None
139142
self.name: str
140143

@@ -175,6 +178,7 @@ def _initialize_reactive(self, obj: Reactable, name: str) -> None:
175178
name: Name of attribute.
176179
"""
177180
_rich_traceback_omit = True
181+
178182
internal_name = f"_reactive_{name}"
179183
if hasattr(obj, internal_name):
180184
# Attribute already has a value
@@ -308,6 +312,11 @@ def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> Non
308312
public_validate_function = getattr(obj, f"validate_{name}", None)
309313
if callable(public_validate_function):
310314
value = public_validate_function(value)
315+
316+
# Toggle the classes using the value's truthiness
317+
if (toggle_class := self._toggle_class) is not None:
318+
obj.set_class(bool(value), *toggle_class.split())
319+
311320
# If the value has changed, or this is the first time setting the value
312321
if always or self._always_update or current_value != value:
313322
# Store the internal value
@@ -407,6 +416,7 @@ class reactive(Reactive[ReactiveType]):
407416
always_update: Call watchers even when the new value equals the old value.
408417
recompose: Compose the widget again when the attribute changes.
409418
bindings: Refresh bindings when the reactive changes.
419+
toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value.
410420
"""
411421

412422
def __init__(
@@ -419,6 +429,7 @@ def __init__(
419429
always_update: bool = False,
420430
recompose: bool = False,
421431
bindings: bool = False,
432+
toggle_class: str | None = None,
422433
) -> None:
423434
super().__init__(
424435
default,
@@ -428,6 +439,7 @@ def __init__(
428439
always_update=always_update,
429440
recompose=recompose,
430441
bindings=bindings,
442+
toggle_class=toggle_class,
431443
)
432444

433445

@@ -439,6 +451,7 @@ class var(Reactive[ReactiveType]):
439451
init: Call watchers on initialize (post mount).
440452
always_update: Call watchers even when the new value equals the old value.
441453
bindings: Refresh bindings when the reactive changes.
454+
toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value.
442455
"""
443456

444457
def __init__(
@@ -447,6 +460,7 @@ def __init__(
447460
init: bool = True,
448461
always_update: bool = False,
449462
bindings: bool = False,
463+
toggle_class: str | None = None,
450464
) -> None:
451465
super().__init__(
452466
default,
@@ -455,6 +469,7 @@ def __init__(
455469
init=init,
456470
always_update=always_update,
457471
bindings=bindings,
472+
toggle_class=toggle_class,
458473
)
459474

460475

0 commit comments

Comments
 (0)