Repository: Textualize/textual

Issue:
CSS does not refresh for `non-active` screens when CSS File is changed
> Have you checked closed issues? https://github.com/Textualize/textual/issues?q=is%3Aissue+is%3Aclosed

Yes

> Please give a brief but clear explanation of the issue. If you can, include a complete working example that demonstrates the bug. **Check it can run without modifications.**

If `watch_css` is turned on and the CSS file is changed, only the current screen's CSS is refreshed, the non-active screen widgets refresh after the top screen is popped from the stack and there's some activity on the widget such as `:hover`

```python
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Label


class BaseScreen(Screen):
    def compose(self) -> ComposeResult:
        yield Label("I am the base screen")


class TopScreen(Screen):
    DEFAULT_CSS = """
    TopScreen {
        opacity: 1;
        background: green 0%;
    }
    """


class MyApp(App):
    CSS_PATH = "css_update.tcss"
    SCREENS = {
        "base": BaseScreen(),
        "top": TopScreen(),
    }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs, watch_css=True)

    async def on_mount(self):
        self.push_screen("base")

        # If this line is commented out then it works!
        self.push_screen("top")


MyApp().run()
```

> update_css.tcss

```css
Label {
  color: red;
}

/* Change the color above */
```

It will be helpful if you run the following command and paste the results:

```
textual diagnose
```

<!-- This is valid Markdown, please paste the following directly in to a GitHub issue -->
# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.46.0 |
| Rich    | 13.6.0 |

## Python

| Name           | Value                              |
|----------------|------------------------------------|
| Version        | 3.11.6                             |
| Implementation | CPython                            |
| Compiler       | GCC 13.2.1 20230801                |
| Executable     | /home/kraanzu/test/venv/bin/python |

## Operating System

| Name    | Value                                                  |
|---------|--------------------------------------------------------|
| System  | Linux                                                  |
| Release | 6.6.2-arch1-1                                          |
| Version | #1 SMP PREEMPT_DYNAMIC Mon, 20 Nov 2023 23:18:21 +0000 |

## Terminal

| Name                 | Value                              |
|----------------------|------------------------------------|
| Terminal Application | WezTerm (20230712-072601-f4abf8fd) |
| TERM                 | xterm-256color                     |
| COLORTERM            | truecolor                          |
| FORCE_COLOR          | *Not set*                          |
| NO_COLOR             | *Not set*                          |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=144, height=34 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 144                  |
| is_terminal    | True                 |
| encoding       | utf-8                |
| max_height     | 34                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |



Feel free to add screenshots and / or videos. These can be very helpful!




Discussion:
We found the following entry in the [FAQ](https://textual.textualize.io/FAQ/) which you may find helpful:


- [No widget called TextLog](https://textual.textualize.io/FAQ/#no-widget-called-textlog)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
Let's say the css file is this:

```css
Label {
  color: yellow;
}

Label:hover {
  color: red;
}
```

Then if the color is changed from `yellow` to `green` while the `TopScreen` is still there, it won't change
But if we pop the screen and then hover over the label, it'll turn `red` and then `green` if the mouse is removed :D
If I override the `_on_css_change`  function in the `App` class to something like, it works :)

```python
async def _on_css_change(self) -> None:
    await super()._on_css_change()
    self.refresh_css()
```

Resolving patch:
diff --git a/src/textual/app.py b/src/textual/app.py
--- 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)


Test patch:
diff --git a/tests/css/css_reloading.tcss b/tests/css/css_reloading.tcss
new file mode 100644
--- /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
--- /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