From b7d7bc31708a968b230b9af68dbddcf20cb0472e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 19 Feb 2024 17:49:36 +0000 Subject: [PATCH] Markdown reloads when component classes change. --- CHANGELOG.md | 6 + src/textual/widgets/_markdown.py | 152 +++++++++------- .../__snapshots__/test_snapshots.ambr | 169 ++++++++++++++++++ .../markdown_component_classes_reloading.py | 50 ++++++ .../markdown_component_classes_reloading.tcss | 1 + tests/snapshot_tests/test_snapshots.py | 25 ++- 6 files changed, 341 insertions(+), 62 deletions(-) create mode 100644 tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.py create mode 100644 tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.tcss diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ff923eb5..9d82d533ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Fixed + +- Markdown component classes weren't refreshed when watching for CSS https://github.com/Textualize/textual/issues/3464 + ## [0.52.0] - 2023-02-19 ### Changed diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 24cde3dd91..4dc1f0e353 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -103,6 +103,7 @@ def __init__(self, markdown: Markdown, *args, **kwargs) -> None: self._markdown: Markdown = markdown """A reference to the Markdown document that contains this block.""" self._text = Text() + self._token: Token | None = None self._blocks: list[MarkdownBlock] = [] super().__init__(*args, **kwargs) @@ -118,6 +119,95 @@ async def action_link(self, href: str) -> None: """Called on link click.""" self.post_message(Markdown.LinkClicked(self._markdown, href)) + def notify_style_update(self) -> None: + """If CSS was reloaded, try to rebuild this block from its token.""" + super().notify_style_update() + self.rebuild() + + def rebuild(self) -> None: + """Rebuild the content of the block if we have a source token.""" + if self._token is not None: + self.build_from_token(self._token) + + def build_from_token(self, token: Token) -> None: + """Build the block content from its source token. + + This method allows the block to be rebuilt on demand, which is useful + when the styles assigned to the + [Markdown.COMPONENT_CLASSES][textual.widgets.Markdown.COMPONENT_CLASSES] + change. + + See https://github.com/Textualize/textual/issues/3464 for more information. + + Args: + token: The token from which this block is built. + """ + + self._token = token + style_stack: list[Style] = [Style()] + content = Text() + if token.children: + for child in token.children: + if child.type == "text": + content.append(child.content, style_stack[-1]) + if child.type == "hardbreak": + content.append("\n") + if child.type == "softbreak": + content.append(" ", style_stack[-1]) + elif child.type == "code_inline": + content.append( + child.content, + style_stack[-1] + + self._markdown.get_component_rich_style( + "code_inline", partial=True + ), + ) + elif child.type == "em_open": + style_stack.append( + style_stack[-1] + + self._markdown.get_component_rich_style("em", partial=True) + ) + elif child.type == "strong_open": + style_stack.append( + style_stack[-1] + + self._markdown.get_component_rich_style( + "strong", partial=True + ) + ) + elif child.type == "s_open": + style_stack.append( + style_stack[-1] + + self._markdown.get_component_rich_style("s", partial=True) + ) + elif child.type == "link_open": + href = child.attrs.get("href", "") + action = f"link({href!r})" + style_stack.append( + style_stack[-1] + Style.from_meta({"@click": action}) + ) + elif child.type == "image": + href = child.attrs.get("src", "") + alt = child.attrs.get("alt", "") + + action = f"link({href!r})" + style_stack.append( + style_stack[-1] + Style.from_meta({"@click": action}) + ) + + content.append("🖼 ", style_stack[-1]) + if alt: + content.append(f"({alt})", style_stack[-1]) + if child.children is not None: + for grandchild in child.children: + content.append(grandchild.content, style_stack[-1]) + + style_stack.pop() + + elif child.type.endswith("_close"): + style_stack.pop() + + self.set_content(content) + class MarkdownHeader(MarkdownBlock): """Base class for a Markdown header.""" @@ -833,67 +923,7 @@ def update(self, markdown: str) -> AwaitComplete: else: output.append(block) elif token.type == "inline": - style_stack: list[Style] = [Style()] - content = Text() - if token.children: - for child in token.children: - if child.type == "text": - content.append(child.content, style_stack[-1]) - if child.type == "hardbreak": - content.append("\n") - if child.type == "softbreak": - content.append(" ", style_stack[-1]) - elif child.type == "code_inline": - content.append( - child.content, - style_stack[-1] - + self.get_component_rich_style( - "code_inline", partial=True - ), - ) - elif child.type == "em_open": - style_stack.append( - style_stack[-1] - + self.get_component_rich_style("em", partial=True) - ) - elif child.type == "strong_open": - style_stack.append( - style_stack[-1] - + self.get_component_rich_style("strong", partial=True) - ) - elif child.type == "s_open": - style_stack.append( - style_stack[-1] - + self.get_component_rich_style("s", partial=True) - ) - elif child.type == "link_open": - href = child.attrs.get("href", "") - action = f"link({href!r})" - style_stack.append( - style_stack[-1] + Style.from_meta({"@click": action}) - ) - elif child.type == "image": - href = child.attrs.get("src", "") - alt = child.attrs.get("alt", "") - - action = f"link({href!r})" - style_stack.append( - style_stack[-1] + Style.from_meta({"@click": action}) - ) - - content.append("🖼 ", style_stack[-1]) - if alt: - content.append(f"({alt})", style_stack[-1]) - if child.children is not None: - for grandchild in child.children: - content.append(grandchild.content, style_stack[-1]) - - style_stack.pop() - - elif child.type.endswith("_close"): - style_stack.pop() - - stack[-1].set_content(content) + stack[-1].build_from_token(token) elif token.type in ("fence", "code_block"): (stack[-1]._blocks if stack else output).append( MarkdownFence(self, token.content.rstrip(), token.info) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index f31002578f..e6ead4ca43 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -22170,6 +22170,175 @@ ''' # --- +# name: test_markdown_component_classes_reloading + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyApp + + + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + This is a header + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + col1                              col2                              +  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  + value 1                           value 2                           + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + Here's some code: from itertools import productBold textEmphasized  + textstrikethrough + + + print("Hello, world!") + + + That was some code. + + + + + + + ''' +# --- # name: test_markdown_dark_theme_override ''' diff --git a/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.py b/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.py new file mode 100644 index 0000000000..f58440a47f --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.py @@ -0,0 +1,50 @@ +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.widgets import Markdown + +CSS_PATH = (Path(__file__) / "../markdown_component_classes_reloading.tcss").resolve() + +CSS_PATH.write_text( + """\ +.code_inline, +.em, +.strong, +.s, +.markdown-table--header, +.markdown-table--lines, +{ + color: yellow; +} +""" +) + +MD = """ +# This is a **header** + +| col1 | col2 | +| :- | :- | +| value 1 | value 2 | + +Here's some code: `from itertools import product`. +**Bold text** +_Emphasized text_ +~~strikethrough~~ + +```py +print("Hello, world!") +``` + +**That** was _some_ code. +""" + + +class MyApp(App[None]): + CSS_PATH = CSS_PATH + + def compose(self) -> ComposeResult: + yield Markdown(MD) + + +if __name__ == "__main__": + MyApp().run() diff --git a/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.tcss b/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.tcss new file mode 100644 index 0000000000..5e9ee82eb7 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/markdown_component_classes_reloading.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 index 8adff1659f..a36e2da4e9 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -638,6 +638,27 @@ async def run_before(pilot): ) +def test_markdown_component_classes_reloading(snap_compare, monkeypatch): + """Tests all markdown component classes reload correctly. + + See https://github.com/Textualize/textual/issues/3464.""" + + 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: + f.write("/* This file is purposefully empty. */\n") # Clear all the CSS. + await pilot.app._on_css_change() + + assert snap_compare( + SNAPSHOT_APPS_DIR / "markdown_component_classes_reloading.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"]) @@ -762,7 +783,9 @@ async def run_before(pilot) -> None: pilot.app.screen.query_one(Input).cursor_blink = False await pilot.app.screen.workers.wait_for_complete() - assert snap_compare(SNAPSHOT_APPS_DIR / "command_palette_discovery.py", run_before=run_before) + assert snap_compare( + SNAPSHOT_APPS_DIR / "command_palette_discovery.py", run_before=run_before + ) # --- textual-dev library preview tests ---