diff --git a/CHANGELOG.md b/CHANGELOG.md index 626d79f492..1fe057f93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Parameter `animate` from `DataTable.move_cursor` was being ignored https://github.com/Textualize/textual/issues/3840 - Fixed a crash if `DirectoryTree.show_root` was set before the DOM was fully available https://github.com/Textualize/textual/issues/2363 +- Live reloading of TCSS wouldn't apply CSS changes to screens under the top screen of the stack https://github.com/Textualize/textual/issues/3931 ## [0.47.1] - 2023-01-05 diff --git a/src/textual/app.py b/src/textual/app.py index 59332971aa..4f679c4787 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -428,7 +428,7 @@ def __init__( self._workers = WorkerManager(self) self.error_console = Console(markup=False, stderr=True) self.driver_class = driver_class or self.get_driver_class() - self._screen_stacks: dict[str, list[Screen]] = {"_default": []} + self._screen_stacks: dict[str, list[Screen[Any]]] = {"_default": []} """A stack of screens per mode.""" self._current_mode: str = "_default" """The current mode the app is in.""" @@ -722,7 +722,7 @@ def is_headless(self) -> bool: return False if self._driver is None else self._driver.is_headless @property - def screen_stack(self) -> Sequence[Screen]: + def screen_stack(self) -> Sequence[Screen[Any]]: """A snapshot of the current screen stack. Returns: @@ -731,7 +731,7 @@ def screen_stack(self) -> Sequence[Screen]: return self._screen_stacks[self._current_mode].copy() @property - def _screen_stack(self) -> list[Screen]: + def _screen_stack(self) -> list[Screen[Any]]: """A reference to the current screen stack. Note: @@ -1494,7 +1494,8 @@ async def _on_css_change(self) -> None: self._css_has_errors = False self.stylesheet = stylesheet self.stylesheet.update(self) - self.screen.refresh(layout=True) + for screen in self.screen_stack: + self.stylesheet.update(screen) def render(self) -> RenderableType: return Blank(self.styles.background) diff --git a/tests/css/css_reloading.tcss b/tests/css/css_reloading.tcss new file mode 100644 index 0000000000..12579d4579 --- /dev/null +++ b/tests/css/css_reloading.tcss @@ -0,0 +1 @@ +/* This file has no rules intentionally. */ diff --git a/tests/css/test_css_reloading.py b/tests/css/test_css_reloading.py new file mode 100644 index 0000000000..8da5312dfe --- /dev/null +++ b/tests/css/test_css_reloading.py @@ -0,0 +1,65 @@ +""" +Regression test for https://github.com/Textualize/textual/issues/3931 +""" + +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Label + +CSS_PATH = (Path(__file__) / "../css_reloading.tcss").resolve() + +Path(CSS_PATH).write_text( + """\ +Label { + height: 5; + border: panel white; +} +""" +) + + +class BaseScreen(Screen[None]): + def compose(self) -> ComposeResult: + yield Label("I am the base screen") + + +class TopScreen(Screen[None]): + DEFAULT_CSS = """ + TopScreen { + opacity: 1; + background: green 0%; + } + """ + + +class MyApp(App[None]): + CSS_PATH = CSS_PATH + + def on_mount(self) -> None: + self.push_screen(BaseScreen()) + self.push_screen(TopScreen()) + + +async def test_css_reloading_applies_to_non_top_screen(monkeypatch) -> None: # type: ignore + """Regression test for https://github.com/Textualize/textual/issues/2063.""" + + monkeypatch.setenv( + "TEXTUAL", "debug" + ) # This will make sure we create a file monitor. + + app = MyApp() + async with app.run_test() as pilot: + await pilot.pause() + first_label = pilot.app.screen_stack[-2].query(Label).first() + # Sanity check. + assert first_label.styles.height is not None + assert first_label.styles.height.value == 5 + + # Clear the CSS from the file. + Path(CSS_PATH).write_text("/* This file has no rules intentionally. */\n") + await pilot.app._on_css_change() + # Height should fall back to 1. + assert first_label.styles.height is not None + assert first_label.styles.height.value == 1