diff --git a/CHANGELOG.md b/CHANGELOG.md index be188afcd3..dae30d7d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Issue with parsing action strings whose arguments contained quoted closing parenthesis https://github.com/Textualize/textual/pull/2112 - Issues with parsing action strings with tuple arguments https://github.com/Textualize/textual/pull/2112 +- Issue with watching for CSS file changes https://github.com/Textualize/textual/pull/2128 - Fix for tabs not invalidating https://github.com/Textualize/textual/issues/2125 - Fixed scrollbar layers issue https://github.com/Textualize/textual/issues/1358 - Fix for interaction between pseudo-classes and widget-level render caches https://github.com/Textualize/textual/pull/2155 diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 391c85d128..f6928186d5 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -1,10 +1,11 @@ from __future__ import annotations import hashlib +import inspect import os import shlex from pathlib import Path -from typing import Iterable, cast +from typing import Awaitable, Callable, Iterable, cast from textual._import_app import import_app from textual.app import App @@ -54,6 +55,7 @@ def take_svg_screenshot( press: Iterable[str] = (), title: str | None = None, terminal_size: tuple[int, int] = (80, 24), + run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, ) -> str: """ @@ -63,11 +65,13 @@ def take_svg_screenshot( press: Key presses to run before taking screenshot. "_" is a short pause. title: The terminal title in the output image. terminal_size: A pair of integers (rows, columns), representing terminal size. + run_before: An arbitrary callable that runs arbitrary code before taking the + screenshot. Use this to simulate complex user interactions with the app + that cannot be simulated by key presses. Returns: An SVG string, showing the content of the terminal window at the time the screenshot was taken. - """ if app is None: @@ -90,7 +94,7 @@ def get_cache_key(app: App) -> str: cache_key = f"{hash.hexdigest()}.svg" return cache_key - if app_path is not None: + if app_path is not None and run_before is None: screenshot_cache = Path(SCREENSHOT_CACHE) screenshot_cache.mkdir(exist_ok=True) @@ -100,6 +104,10 @@ def get_cache_key(app: App) -> str: async def auto_pilot(pilot: Pilot) -> None: app = pilot.app + if run_before is not None: + result = run_before(pilot) + if inspect.isawaitable(result): + await result await pilot.press(*press) await pilot.wait_for_scheduled_animations() await pilot.pause() @@ -116,7 +124,7 @@ async def auto_pilot(pilot: Pilot) -> None: ), ) - if app_path is not None: + if app_path is not None and run_before is None: screenshot_path.write_text(svg) assert svg is not None diff --git a/src/textual/app.py b/src/textual/app.py index 9496c64c67..85695409e5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1081,7 +1081,6 @@ async def _on_css_change(self) -> None: self.bell() else: self.stylesheet = stylesheet - self.reset_styles() self.stylesheet.update(self) self.screen.refresh(layout=True) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 0f3d41b3e4..42c7fd18a1 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -1985,6 +1985,161 @@ ''' # --- +# name: test_css_hot_reloading + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HotReloadingApp + + + + + + + + + + Hello, world! + + + + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_css_property[align.py] ''' diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py index c5d9ee21b1..8d5993cca3 100644 --- a/tests/snapshot_tests/conftest.py +++ b/tests/snapshot_tests/conftest.py @@ -7,7 +7,7 @@ from operator import attrgetter from os import PathLike from pathlib import Path, PurePath -from typing import Union, List, Optional, Callable, Iterable +from typing import Awaitable, Union, List, Optional, Callable, Iterable import pytest from _pytest.config import ExitCode @@ -21,6 +21,7 @@ from textual._doc import take_svg_screenshot from textual._import_app import import_app from textual.app import App +from textual.pilot import Pilot TEXTUAL_SNAPSHOT_SVG_KEY = pytest.StashKey[str]() TEXTUAL_ACTUAL_SVG_KEY = pytest.StashKey[str]() @@ -42,6 +43,7 @@ def compare( app_path: str | PurePath, press: Iterable[str] = ("_",), terminal_size: tuple[int, int] = (80, 24), + run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, ) -> bool: """ Compare a current screenshot of the app running at app_path, with @@ -54,9 +56,12 @@ def compare( test this function is called from. press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause. terminal_size (tuple[int, int]): A pair of integers (WIDTH, HEIGHT), representing terminal size. + run_before: An arbitrary callable that runs arbitrary code before taking the + screenshot. Use this to simulate complex user interactions with the app + that cannot be simulated by key presses. Returns: - bool: True if the screenshot matches the snapshot. + Whether the screenshot matches the snapshot. """ node = request.node path = Path(app_path) @@ -74,6 +79,7 @@ def compare( app=app, press=press, terminal_size=terminal_size, + run_before=run_before, ) result = snapshot == actual_screenshot diff --git a/tests/snapshot_tests/snapshot_apps/hot_reloading_app.css b/tests/snapshot_tests/snapshot_apps/hot_reloading_app.css new file mode 100644 index 0000000000..5e9ee82eb7 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/hot_reloading_app.css @@ -0,0 +1 @@ +/* This file is purposefully empty. */ diff --git a/tests/snapshot_tests/snapshot_apps/hot_reloading_app.py b/tests/snapshot_tests/snapshot_apps/hot_reloading_app.py new file mode 100644 index 0000000000..d7fc82f220 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/hot_reloading_app.py @@ -0,0 +1,31 @@ +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.widgets import Label + + +CSS_PATH = (Path(__file__) / "../hot_reloading_app.css").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. +CSS_PATH.write_text( + """ +Container { + align: center middle; +} + +Label { + border: round $primary; + padding: 3; +} +""" +) + + +class HotReloadingApp(App[None]): + CSS_PATH = CSS_PATH + + def compose(self) -> ComposeResult: + yield Container(Label("Hello, world!")) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index ec268741b7..7b32d62aa5 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -342,6 +342,20 @@ def test_scrollbar_thumb_height(snap_compare): ) +def test_css_hot_reloading(snap_compare): + """Regression test for https://github.com/Textualize/textual/issues/2063.""" + + async def run_before(pilot): + css_file = pilot.app.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.py", run_before=run_before + ) + + def test_layer_fix(snap_compare): # Check https://github.com/Textualize/textual/issues/1358 assert snap_compare(SNAPSHOT_APPS_DIR / "layer_fix.py", press=["d"])