Skip to content

Commit 0849e6f

Browse files
authored
mount order (#2702)
* mount order * fix test * simplify hooks * changelog * docstring
1 parent 4ff1d18 commit 0849e6f

File tree

6 files changed

+69
-10
lines changed

6 files changed

+69
-10
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1515
- Fix crash when `Select` widget value attribute was set in `compose` https://github.com/Textualize/textual/pull/2690
1616
- Issue with computing progress in workers https://github.com/Textualize/textual/pull/2686
1717
- Issues with `switch_screen` not updating the results callback appropriately https://github.com/Textualize/textual/issues/2650
18-
18+
- Fixed incorrect mount order https://github.com/Textualize/textual/pull/2702
1919

2020
### Added
2121

@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2929
- `Input` has a new component class `input--suggestion` https://github.com/Textualize/textual/pull/2604
3030
- Added `Widget.remove_children` https://github.com/Textualize/textual/pull/2657
3131
- Added `Validator` framework and validation for `Input` https://github.com/Textualize/textual/pull/2600
32+
- Added `message_hook` to App.run_test https://github.com/Textualize/textual/pull/2702
3233

3334
### Changed
3435

src/textual/_context.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from contextvars import ContextVar
4-
from typing import TYPE_CHECKING
4+
from typing import TYPE_CHECKING, Callable
55

66
if TYPE_CHECKING:
77
from .app import App
@@ -21,3 +21,5 @@ class NoActiveAppError(RuntimeError):
2121
)
2222
visible_screen_stack: ContextVar[list[Screen]] = ContextVar("visible_screen_stack")
2323
"""A stack of visible screens (with background alpha < 1), used in the screen render process."""
24+
message_hook: ContextVar[Callable[[Message], None]] = ContextVar("message_hook")
25+
"""A callable that accepts a message. Used by App.run_test."""

src/textual/app.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
from ._compose import compose
6666
from ._compositor import CompositorUpdate
6767
from ._context import active_app, active_message_pump
68+
from ._context import message_hook as message_hook_context_var
6869
from ._event_broker import NoHandler, extract_handler_actions
6970
from ._path import _make_path_object_relative
7071
from ._wait import wait_for_idle
@@ -100,6 +101,7 @@
100101
# Unused & ignored imports are needed for the docs to link to these objects:
101102
from .css.query import WrongType # type: ignore # noqa: F401
102103
from .devtools.client import DevtoolsClient
104+
from .message import Message
103105
from .pilot import Pilot
104106
from .widget import MountError # type: ignore # noqa: F401
105107

@@ -1060,6 +1062,7 @@ async def run_test(
10601062
headless: bool = True,
10611063
size: tuple[int, int] | None = (80, 24),
10621064
tooltips: bool = False,
1065+
message_hook: Callable[[Message], None] | None = None,
10631066
) -> AsyncGenerator[Pilot, None]:
10641067
"""An asynchronous context manager for testing app.
10651068
@@ -1078,6 +1081,7 @@ async def run_test(
10781081
size: Force terminal size to `(WIDTH, HEIGHT)`,
10791082
or None to auto-detect.
10801083
tooltips: Enable tooltips when testing.
1084+
message_hook: An optional callback that will called with every message going through the app.
10811085
"""
10821086
from .pilot import Pilot
10831087

@@ -1090,6 +1094,8 @@ def on_app_ready() -> None:
10901094
app_ready_event.set()
10911095

10921096
async def run_app(app) -> None:
1097+
if message_hook is not None:
1098+
message_hook_context_var.set(message_hook)
10931099
app._loop = asyncio.get_running_loop()
10941100
app._thread_id = threading.get_ident()
10951101
await app._process_messages(
@@ -1824,6 +1830,7 @@ async def _process_messages(
18241830
ready_callback: CallbackType | None = None,
18251831
headless: bool = False,
18261832
terminal_size: tuple[int, int] | None = None,
1833+
message_hook: Callable[[Message], None] | None = None,
18271834
) -> None:
18281835
self._set_active()
18291836
active_message_pump.set(self)
@@ -1978,7 +1985,7 @@ async def take_screenshot() -> None:
19781985

19791986
async def _on_compose(self) -> None:
19801987
try:
1981-
widgets = compose(self)
1988+
widgets = [*self.screen._nodes, *compose(self)]
19821989
except TypeError as error:
19831990
raise TypeError(
19841991
f"{self!r} compose() method returned an invalid result; {error}"

src/textual/message_pump.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,9 @@
1616
from . import Logger, events, log, messages
1717
from ._asyncio import create_task
1818
from ._callback import invoke
19-
from ._context import (
20-
NoActiveAppError,
21-
active_app,
22-
active_message_pump,
23-
prevent_message_types_stack,
24-
)
19+
from ._context import NoActiveAppError, active_app, active_message_pump
20+
from ._context import message_hook as message_hook_context_var
21+
from ._context import prevent_message_types_stack
2522
from ._on import OnNoWidget
2623
from ._time import time
2724
from ._types import CallbackType
@@ -554,6 +551,13 @@ async def _dispatch_message(self, message: Message) -> None:
554551
if message.no_dispatch:
555552
return
556553

554+
try:
555+
message_hook = message_hook_context_var.get()
556+
except LookupError:
557+
pass
558+
else:
559+
message_hook(message)
560+
557561
with self.prevent(*message._prevent):
558562
# Allow apps to treat events and messages separately
559563
if isinstance(message, Event):

src/textual/widget.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3121,7 +3121,7 @@ async def handle_key(self, event: events.Key) -> bool:
31213121

31223122
async def _on_compose(self) -> None:
31233123
try:
3124-
widgets = compose(self)
3124+
widgets = [*self._nodes, *compose(self)]
31253125
except TypeError as error:
31263126
raise TypeError(
31273127
f"{self!r} compose() method returned an invalid result; {error}"

tests/test_widget.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import pytest
22
from rich.text import Text
33

4+
from textual import events
45
from textual._node_list import DuplicateIds
56
from textual.app import App, ComposeResult
67
from textual.containers import Container
78
from textual.css.errors import StyleValueError
89
from textual.css.query import NoMatches
910
from textual.geometry import Size
11+
from textual.message import Message
1012
from textual.widget import MountError, PseudoClasses, Widget
1113
from textual.widgets import Label
1214

@@ -260,3 +262,46 @@ def test_render_str() -> None:
260262
# Text objects are passed unchanged
261263
text = Text("bar")
262264
assert widget.render_str(text) is text
265+
266+
267+
async def test_compose_order() -> None:
268+
from textual.containers import Horizontal
269+
from textual.screen import Screen
270+
from textual.widgets import Select
271+
272+
class MyScreen(Screen):
273+
def on_mount(self) -> None:
274+
self.query_one(Select).value = 1
275+
276+
def compose(self) -> ComposeResult:
277+
yield Horizontal(
278+
Select(((str(n), n) for n in range(10)), id="select"),
279+
id="screen-horizontal",
280+
)
281+
282+
class SelectBugApp(App[None]):
283+
async def on_mount(self):
284+
await self.push_screen(MyScreen(id="my-screen"))
285+
self.query_one(Select)
286+
287+
app = SelectBugApp()
288+
messages: list[Message] = []
289+
290+
async with app.run_test(message_hook=messages.append) as pilot:
291+
await pilot.pause()
292+
293+
mounts = [
294+
message._sender.id
295+
for message in messages
296+
if isinstance(message, events.Mount) and message._sender.id is not None
297+
]
298+
299+
expected = [
300+
"_default", # default screen
301+
"label", # A static in select
302+
"select", # The select
303+
"screen-horizontal", # The horizontal in MyScreen.compose
304+
"my-screen", # THe screen mounted in the app
305+
]
306+
307+
assert mounts == expected

0 commit comments

Comments
 (0)