Repository: Textualize/textual

Issue:
Live editing doesn't work if editing screen's CSS
Hi! Seems like the live editing doesn't work if you try to edit CSS for a screen (not the app itself).

I've created a repo so you could quickly reproduce the issue: https://github.com/Klavionik/textual-live-editing-issue

# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.38.1 |
| Rich    | 13.6.0 |

## Python

| Name           | Value                                          |
|----------------|------------------------------------------------|
| Version        | 3.10.2                                         |
| Implementation | CPython                                        |
| Compiler       | GCC 11.3.0                                     |
| Executable     | /home/jediroman/Desktop/textual-live-editing-issue/venv/bin/python |

## Operating System

| Name    | Value                                       |
|---------|---------------------------------------------|
| System  | Linux                                       |
| Release | 5.15.0-83-generic                           |
| Version | #92-Ubuntu SMP Mon Aug 14 09:30:42 UTC 2023 |

## Terminal

| Name                 | Value          |
|----------------------|----------------|
| Terminal Application | *Unknown*      |
| TERM                 | xterm-256color |
| COLORTERM            | *Not set*      |
| FORCE_COLOR          | *Not set*      |
| NO_COLOR             | *Not set*      |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=305, height=11 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 305                  |
| is_terminal    | True                 |
| encoding       | utf-8                |
| max_height     | 11                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |



Discussion:
Thanks for the repository with the repro.
I was able to pinpoint the issue and I'll open a PR soon enough.

Resolving patch:
diff --git a/src/textual/app.py b/src/textual/app.py
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -521,7 +521,7 @@ def __init__(
 
         self.css_monitor = (
             FileMonitor(self.css_path, self._on_css_change)
-            if ((watch_css or self.debug) and self.css_path)
+            if watch_css or self.debug
             else None
         )
         self._screenshot: str | None = None
@@ -1407,8 +1407,10 @@ async def run_app() -> None:
         return self.return_value
 
     async def _on_css_change(self) -> None:
-        """Called when the CSS changes (if watch_css is True)."""
-        css_paths = self.css_path
+        """Callback for the file monitor, called when CSS files change."""
+        css_paths = (
+            self.css_monitor._paths if self.css_monitor is not None else self.css_path
+        )
         if css_paths:
             try:
                 time = perf_counter()
diff --git a/src/textual/file_monitor.py b/src/textual/file_monitor.py
--- a/src/textual/file_monitor.py
+++ b/src/textual/file_monitor.py
@@ -31,7 +31,7 @@ def __rich_repr__(self) -> rich.repr.Result:
 
     def _get_last_modified_time(self) -> float:
         """Get the most recent modified time out of all files being watched."""
-        return max(os.stat(path).st_mtime for path in self._paths)
+        return max((os.stat(path).st_mtime for path in self._paths), default=0)
 
     def check(self) -> bool:
         """Check the monitored files. Return True if any were changed since the last modification time."""


Test patch:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -3557,6 +3557,161 @@
   
   '''
 # ---
+# name: test_css_hot_reloading_on_screen
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-3709538693-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-3709538693-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-3709538693-r1 { fill: #e1e1e1 }
+  .terminal-3709538693-r2 { fill: #c5c8c6 }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-3709538693-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-3709538693-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3709538693-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-3709538693-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">HotReloadingApp</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-3709538693-clip-terminal)">
+      <rect fill="#1e1e1e" x="0" y="1.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="158.6" y="1.5" width="817.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-3709538693-matrix">
+      <text class="terminal-3709538693-r1" x="0" y="20" textLength="158.6" clip-path="url(#terminal-3709538693-line-0)">Hello,&#160;world!</text><text class="terminal-3709538693-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-3709538693-line-0)">
+  </text><text class="terminal-3709538693-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-1)">
+  </text><text class="terminal-3709538693-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-2)">
+  </text><text class="terminal-3709538693-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-3709538693-line-3)">
+  </text><text class="terminal-3709538693-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-3709538693-line-4)">
+  </text><text class="terminal-3709538693-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-3709538693-line-5)">
+  </text><text class="terminal-3709538693-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-6)">
+  </text><text class="terminal-3709538693-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-7)">
+  </text><text class="terminal-3709538693-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-3709538693-line-8)">
+  </text><text class="terminal-3709538693-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-3709538693-line-9)">
+  </text><text class="terminal-3709538693-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-3709538693-line-10)">
+  </text><text class="terminal-3709538693-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-11)">
+  </text><text class="terminal-3709538693-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-12)">
+  </text><text class="terminal-3709538693-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-3709538693-line-13)">
+  </text><text class="terminal-3709538693-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-3709538693-line-14)">
+  </text><text class="terminal-3709538693-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-3709538693-line-15)">
+  </text><text class="terminal-3709538693-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-16)">
+  </text><text class="terminal-3709538693-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-17)">
+  </text><text class="terminal-3709538693-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-3709538693-line-18)">
+  </text><text class="terminal-3709538693-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-3709538693-line-19)">
+  </text><text class="terminal-3709538693-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-3709538693-line-20)">
+  </text><text class="terminal-3709538693-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-3709538693-line-21)">
+  </text><text class="terminal-3709538693-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-3709538693-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
 # name: test_css_property[align.py]
   '''
   <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
diff --git a/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.py b/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.py
new file mode 100644
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.py
@@ -0,0 +1,41 @@
+from pathlib import Path
+
+from textual.app import App, ComposeResult
+from textual.containers import Container
+from textual.screen import Screen
+from textual.widgets import Label
+
+
+SCREEN_CSS_PATH = (Path(__file__) / "../hot_reloading_app_with_screen_css.tcss").resolve()
+
+# Write some CSS to the file before the app loads.
+# Then, the test will clear all the CSS to see if the
+# hot reloading applies the changes correctly.
+SCREEN_CSS_PATH.write_text(
+    """
+Container {
+    align: center middle;
+}
+
+Label {
+    border: round $primary;
+    padding: 3;
+}
+"""
+)
+
+
+class MyScreen(Screen[None]):
+    CSS_PATH = SCREEN_CSS_PATH
+
+    def compose(self) -> ComposeResult:
+        yield Container(Label("Hello, world!"))
+
+
+class HotReloadingApp(App[None]):
+    def on_mount(self) -> None:
+        self.push_screen(MyScreen())
+
+
+if __name__ == "__main__":
+    HotReloadingApp(watch_css=True).run()
diff --git a/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.tcss b/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.tcss
new file mode 100644
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.tcss
@@ -0,0 +1 @@
+/* This file is purposefully empty. */
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -531,9 +531,11 @@ def test_scrollbar_thumb_height(snap_compare):
     )
 
 
-def test_css_hot_reloading(snap_compare):
+def test_css_hot_reloading(snap_compare, monkeypatch):
     """Regression test for https://github.com/Textualize/textual/issues/2063."""
 
+    monkeypatch.setenv("TEXTUAL", "debug")  # This will make sure we create a file monitor.
+
     async def run_before(pilot):
         css_file = pilot.app.CSS_PATH
         with open(css_file, "w") as f:
@@ -545,9 +547,27 @@ async def run_before(pilot):
     )
 
 
-def test_datatable_hot_reloading(snap_compare):
+def test_css_hot_reloading_on_screen(snap_compare, monkeypatch):
+    """Regression test for https://github.com/Textualize/textual/issues/3454."""
+
+    monkeypatch.setenv("TEXTUAL", "debug")  # This will make sure we create a file monitor.
+
+    async def run_before(pilot):
+        css_file = pilot.app.screen.CSS_PATH
+        with open(css_file, "w") as f:
+            f.write("/* This file is purposefully empty. */\n")  # Clear all the CSS.
+        await pilot.app._on_css_change()
+
+    assert snap_compare(
+        SNAPSHOT_APPS_DIR / "hot_reloading_app_with_screen_css.py", run_before=run_before
+    )
+
+
+def test_datatable_hot_reloading(snap_compare, monkeypatch):
     """Regression test for https://github.com/Textualize/textual/issues/3312."""
 
+    monkeypatch.setenv("TEXTUAL", "debug")  # This will make sure we create a file monitor.
+
     async def run_before(pilot):
         css_file = pilot.app.CSS_PATH
         with open(css_file, "w") as f: