Skip to content

Commit 9837bd4

Browse files
authored
Merge branch 'main' into fix-button-prevent-text-selection
2 parents 0692795 + 2f38af7 commit 9837bd4

40 files changed

+2550
-102
lines changed

CHANGELOG.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,32 @@ 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+
89
## Unreleased
910

1011
### Fixed
1112

12-
- Fixed `OptionList` causing excessive redrawing https://github.com/Textualize/textual/pull/5766
13+
- Fixed `VERTICAL_BREAKPOINTS` doesn't work https://github.com/Textualize/textual/pull/5785
1314
- Fixed `Button` allowing text selection https://github.com/Textualize/textual/pull/5770
1415

16+
## [3.2.0] - 2025-05-02
17+
18+
### Fixed
19+
20+
- Fixed `OptionList` causing excessive redrawing https://github.com/Textualize/textual/pull/5766
21+
- 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
22+
23+
### Added
24+
25+
- Added `:first-child` and `:last-child` pseudo classes https://github.com/Textualize/textual/pull/5776
26+
- Added `toggle_class` parameter to reactives https://github.com/Textualize/textual/pull/5778
27+
- Added `compact` parameter and reactive to `Button`, `Input`, `ToggleButton`, `RadioSet`, `OptionList`, `TextArea` https://github.com/Textualize/textual/pull/5778
28+
- Added `HORIZONTAL_BREAKPOINTS` and `VERTICAL_BREAKPOINTS` to `App` and `Screen` https://github.com/Textualize/textual/pull/5779
29+
30+
### Changed
31+
32+
- `RadioSet` now has a default width of `1fr` https://github.com/Textualize/textual/pull/5778
33+
1534
## [3.1.1] - 2025-04-22
1635

1736
### Fixed
@@ -2860,6 +2879,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
28602879
- New handler system for messages that doesn't require inheritance
28612880
- Improved traceback handling
28622881

2882+
[3.2.0]: https://github.com/Textualize/textual/compare/v3.1.1...v3.2.0
28632883
[3.1.1]: https://github.com/Textualize/textual/compare/v3.1.0...v3.1.1
28642884
[3.1.0]: https://github.com/Textualize/textual/compare/v3.0.1...v3.1.0
28652885
[3.0.1]: https://github.com/Textualize/textual/compare/v3.0.0...v3.0.1

docs/guide/CSS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,12 @@ Here are some other pseudo classes:
343343
- `:disabled` Matches widgets which are in a disabled state.
344344
- `:enabled` Matches widgets which are in an enabled state.
345345
- `:even` Matches a widget at an evenly numbered position within its siblings.
346+
- `:first-child` Matches a widget that is the first amongst its siblings.
346347
- `:first-of-type` Matches a widget that is the first of its type amongst its siblings.
347348
- `:focus-within` Matches widgets with a focused child widget.
348349
- `:focus` Matches widgets which have input focus.
349350
- `:inline` Matches widgets when the app is running in inline mode.
351+
- `:last-child` Matches a widget that is the last amongst its siblings.
350352
- `:last-of-type` Matches a widget that is the last of its type amongst its siblings.
351353
- `:light` Matches widgets in light themes (where `App.theme.dark == False`).
352354
- `:odd` Matches a widget at an oddly numbered position within its siblings.

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

docs/tutorial.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Let's examine `stopwatch01.py` in more detail.
118118
```
119119

120120
The first line imports `App` class, which is the base class for all Textual apps.
121-
The second line imports two builtin widgets: [`Footer`](widgets/footer.md) which shows a bar at the bottom of the screen with bound keys, and [`Header`](widgets/header) which shows a title at the top of the screen.
121+
The second line imports two builtin widgets: [`Footer`](widgets/footer.md) which shows a bar at the bottom of the screen with bound keys, and [`Header`](widgets/header.md) which shows a title at the top of the screen.
122122
Widgets are re-usable components responsible for managing a part of the screen.
123123
We will cover how to build widgets in this tutorial.
124124

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()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "3.1.1"
3+
version = "3.2.0"
44
homepage = "https://github.com/Textualize/textual"
55
repository = "https://github.com/Textualize/textual"
66
documentation = "https://textual.textualize.io/"

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/color.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353

5454

5555
class HSL(NamedTuple):
56-
"""A color in HLS (Hue, Saturation, Lightness) format."""
56+
"""A color in HSL (Hue, Saturation, Lightness) format."""
5757

5858
h: float
5959
"""Hue in range 0 to 1."""
@@ -199,12 +199,12 @@ def from_rich_color(
199199

200200
@classmethod
201201
def from_hsl(cls, h: float, s: float, l: float) -> Color:
202-
"""Create a color from HLS components.
202+
"""Create a color from HSL components.
203203
204204
Args:
205205
h: Hue.
206-
l: Lightness.
207206
s: Saturation.
207+
l: Lightness.
208208
209209
Returns:
210210
A new color.

src/textual/css/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
"nocolor",
7777
"first-of-type",
7878
"last-of-type",
79+
"first-child",
80+
"last-child",
7981
"odd",
8082
"even",
8183
}

0 commit comments

Comments
 (0)