From bba0b4f466cf3756b6b845958b9ccd382bbeabf3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Feb 2025 15:51:41 +0000 Subject: [PATCH] test fixes --- .../styles/border_sub_title_align_all.py | 2 +- docs/examples/styles/link_background.py | 2 +- docs/examples/styles/link_background_hover.py | 2 +- docs/examples/styles/link_color.py | 2 +- docs/examples/styles/link_color_hover.py | 2 +- docs/examples/styles/link_style.py | 2 +- docs/examples/styles/link_style_hover.py | 2 +- src/textual/content.py | 2 +- src/textual/css/parse.py | 2 + src/textual/css/stylesheet.py | 5 +- src/textual/markup.py | 70 +++++++++++++--- src/textual/style.py | 80 +++++++++++++++++-- src/textual/visual.py | 1 + src/textual/widget.py | 1 - tests/test_issue_4248.py | 3 - 15 files changed, 142 insertions(+), 36 deletions(-) diff --git a/docs/examples/styles/border_sub_title_align_all.py b/docs/examples/styles/border_sub_title_align_all.py index 1832d97f7..7632c095c 100644 --- a/docs/examples/styles/border_sub_title_align_all.py +++ b/docs/examples/styles/border_sub_title_align_all.py @@ -39,7 +39,7 @@ class BorderSubTitleAlignAll(App[None]): "had to fill up", "lbl4", "", # (4)! - "[link=https://textual.textualize.io]Left[/]", # (5)! + "[link='https://textual.textualize.io']Left[/]", # (5)! ) yield make_label_container( # (6)! "nine labels", "lbl5", "Title", "Subtitle" diff --git a/docs/examples/styles/link_background.py b/docs/examples/styles/link_background.py index 6516f1b6a..1959de722 100644 --- a/docs/examples/styles/link_background.py +++ b/docs/examples/styles/link_background.py @@ -7,7 +7,7 @@ class LinkBackgroundApp(App): def compose(self): yield Label( - "Visit the [link=https://textualize.io]Textualize[/link] website.", + "Visit the [link='https://textualize.io']Textualize[/link] website.", id="lbl1", # (1)! ) yield Label( diff --git a/docs/examples/styles/link_background_hover.py b/docs/examples/styles/link_background_hover.py index fc33e576d..9dfee854e 100644 --- a/docs/examples/styles/link_background_hover.py +++ b/docs/examples/styles/link_background_hover.py @@ -7,7 +7,7 @@ class LinkHoverBackgroundApp(App): def compose(self): yield Label( - "Visit the [link=https://textualize.io]Textualize[/link] website.", + "Visit the [link='https://textualize.io']Textualize[/link] website.", id="lbl1", # (1)! ) yield Label( diff --git a/docs/examples/styles/link_color.py b/docs/examples/styles/link_color.py index 3d6a83cc7..38acb8b85 100644 --- a/docs/examples/styles/link_color.py +++ b/docs/examples/styles/link_color.py @@ -7,7 +7,7 @@ class LinkColorApp(App): def compose(self): yield Label( - "Visit the [link=https://textualize.io]Textualize[/link] website.", + "Visit the [link='https://textualize.io']Textualize[/link] website.", id="lbl1", # (1)! ) yield Label( diff --git a/docs/examples/styles/link_color_hover.py b/docs/examples/styles/link_color_hover.py index 7344123ae..ac0bfb565 100644 --- a/docs/examples/styles/link_color_hover.py +++ b/docs/examples/styles/link_color_hover.py @@ -7,7 +7,7 @@ class LinkHoverColorApp(App): def compose(self): yield Label( - "Visit the [link=https://textualize.io]Textualize[/link] website.", + "Visit the [link='https://textualize.io']Textualize[/link] website.", id="lbl1", # (1)! ) yield Label( diff --git a/docs/examples/styles/link_style.py b/docs/examples/styles/link_style.py index 405666ca2..77d17bba9 100644 --- a/docs/examples/styles/link_style.py +++ b/docs/examples/styles/link_style.py @@ -7,7 +7,7 @@ class LinkStyleApp(App): def compose(self): yield Label( - "Visit the [link=https://textualize.io]Textualize[/link] website.", + "Visit the [link='https://textualize.io']Textualize[/link] website.", id="lbl1", # (1)! ) yield Label( diff --git a/docs/examples/styles/link_style_hover.py b/docs/examples/styles/link_style_hover.py index 3d47406b4..aed277795 100644 --- a/docs/examples/styles/link_style_hover.py +++ b/docs/examples/styles/link_style_hover.py @@ -7,7 +7,7 @@ class LinkHoverStyleApp(App): def compose(self): yield Label( - "Visit the [link=https://textualize.io]Textualize[/link] website.", + "Visit the [link='https://textualize.io']Textualize[/link] website.", id="lbl1", # (1)! ) yield Label( diff --git a/src/textual/content.py b/src/textual/content.py index 7906c291c..9501d783b 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -878,7 +878,7 @@ class Content(Visual): """Render Content in to an iterable of strings and styles. This is typically called by Textual when displaying Content, but may be used if you want to do more advanced - pricessing of the output. + processing of the output. Args: base_style (_type_, optional): The style used as a base. This will typically be the style of the widget underneath the content. diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index d0365c825..c4a38b21b 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -405,6 +405,7 @@ def substitute_references( ) ) else: + 1 / 0 _unresolved(ref_name, variables.keys(), token) else: variable_tokens.append(token) @@ -422,6 +423,7 @@ def substitute_references( ReferencedBy(variable_name, ref_location, ref_length, ref_code) ) else: + 1 / 0 _unresolved(variable_name, variables.keys(), token) else: yield token diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 95ee15bc4..a715162f3 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -231,10 +231,7 @@ class Stylesheet: """ if style_text in self._style_parse_cache: return self._style_parse_cache[style_text] - try: - style = parse_style(style_text) - except Exception: - style = Style.null() + style = parse_style(style_text) self._style_parse_cache[style_text] = style return style diff --git a/src/textual/markup.py b/src/textual/markup.py index 00bb9c996..0deca6232 100644 --- a/src/textual/markup.py +++ b/src/textual/markup.py @@ -160,6 +160,15 @@ def escape( def parse_style(style: str, variables: dict[str, str] | None = None) -> Style: + """Parse an encoded style. + + Args: + style: Style encoded in a string. + variables: Mapping of variables, or `None` to import from active app. + + Returns: + A Style object. + """ styles: dict[str, bool | None] = {} color: Color | None = None @@ -222,7 +231,10 @@ def parse_style(style: str, variables: dict[str, str] | None = None) -> Style: color = Color.parse(token.value) elif token_name == "token": - if token_value == "on": + if token_value == "link": + if "link" not in meta: + meta["link"] = "" + elif token_value == "on": is_background = True elif token_value == "auto": if is_background: @@ -252,7 +264,7 @@ def parse_style(style: str, variables: dict[str, str] | None = None) -> Style: if color is not None: color = color.multiply_alpha(percent) - parsed_style = Style(background, color, link=meta.get("link", None), **styles) + parsed_style = Style(background, color, link=meta.pop("link", None), **styles) if meta: parsed_style += Style.from_meta(meta) @@ -260,14 +272,39 @@ def parse_style(style: str, variables: dict[str, str] | None = None) -> Style: def to_content(markup: str, style: str | Style = "") -> Content: + """Convert markup to Content. + + Args: + markup: String containing markup. + style: Optional base style. + + Raises: + MarkupError: If the markup is invalid. + + Returns: + Content that renders the markup. + """ _rich_traceback_omit = True try: return _to_content(markup, style) except Exception as error: + # Ensure all errors are wrapped in a MarkupError raise MarkupError(str(error)) from None def _to_content(markup: str, style: str | Style = "") -> Content: + """Internal function to convert markup to Content. + + Args: + markup: String containing markup. + style: Optional base style. + + Raises: + MarkupError: If the markup is invalid. + + Returns: + Content that renders the markup. + """ from textual.content import Content, Span @@ -275,12 +312,15 @@ def _to_content(markup: str, style: str | Style = "") -> Content: text: list[str] = [] iter_tokens = iter(tokenizer(markup, ("inline", ""))) - style_stack: list[tuple[int, str]] = [] + style_stack: list[tuple[int, str, str]] = [] spans: list[Span] = [] position = 0 tag_text: list[str] + + normalize_markup_tag = Style._normalize_markup_tag + for token in iter_tokens: token_name = token.name @@ -295,7 +335,9 @@ def _to_content(markup: str, style: str | Style = "") -> Content: break tag_text.append(token.value) opening_tag = "".join(tag_text).strip() - style_stack.append((position, opening_tag)) + style_stack.append( + (position, opening_tag, normalize_markup_tag(opening_tag)) + ) elif token_name == "open_closing_tag": tag_text = [] @@ -304,30 +346,32 @@ def _to_content(markup: str, style: str | Style = "") -> Content: break tag_text.append(token.value) closing_tag = "".join(tag_text).strip() - if closing_tag: - for index, (tag_position, tag_body) in enumerate( + normalized_closing_tag = normalize_markup_tag(closing_tag) + if normalized_closing_tag: + for index, (tag_position, tag_body, normalized_tag_body) in enumerate( reversed(style_stack), 1 ): - if tag_body == closing_tag: + if normalized_tag_body == normalized_closing_tag: style_stack.pop(-index) - spans.append(Span(tag_position, position, tag_body)) + if tag_position != position: + spans.append(Span(tag_position, position, tag_body)) break else: raise MarkupError( - f"closing tag '[/{tag_body}]' does not match any open tag" + f"closing tag '[/{closing_tag}]' does not match any open tag" ) else: if not style_stack: raise MarkupError("auto closing tag ('[/]') has nothing to close") - open_position, tag = style_stack.pop() - spans.append(Span(open_position, position, tag)) + open_position, tag_body, _ = style_stack.pop() + spans.append(Span(open_position, position, tag_body)) content_text = "".join(text) text_length = len(content_text) while style_stack: - position, tag = style_stack.pop() - spans.append(Span(position, text_length, tag)) + position, tag_body, _ = style_stack.pop() + spans.append(Span(position, text_length, tag_body)) if style: content = Content(content_text, [Span(0, len(content_text), style), *spans]) diff --git a/src/textual/style.py b/src/textual/style.py index ed3507937..ed6bd1842 100644 --- a/src/textual/style.py +++ b/src/textual/style.py @@ -103,21 +103,71 @@ class Style: return not self._is_null def __str__(self) -> str: + return self.style_definition + + @cached_property + def style_definition(self) -> str: + """Style encoded in a string (may be parsed from `Style.parse`).""" output: list[str] = [] + output_append = output.append if self.foreground is not None: - output.append(self.foreground.css) + output_append(self.foreground.css) if self.background is not None: - output.append(f"on {self.background.css}") + output_append(f"on {self.background.css}") if self.bold is not None: - output.append("bold" if self.bold else "not bold") + output_append("bold" if self.bold else "not bold") if self.dim is not None: - output.append("dim" if self.dim else "not dim") + output_append("dim" if self.dim else "not dim") if self.italic is not None: - output.append("italic" if self.italic else "not italic") + output_append("italic" if self.italic else "not italic") if self.underline is not None: - output.append("underline" if self.underline else "not underline") + output_append("underline" if self.underline else "not underline") if self.strike is not None: - output.append("strike" if self.strike else "not strike") + output_append("strike" if self.strike else "not strike") + if self.link is not None: + if "'" not in self.link: + output_append(f"link='{self.link}'") + elif '"' not in self.link: + output_append(f'link="{self.link}"') + if self._meta is not None: + for key, value in self.meta.items(): + if isinstance(value, str): + if "'" not in key: + output_append(f"{key}='{value}'") + elif '"' not in key: + output_append(f'{key}="{value}"') + else: + output_append(f"{key}={value!r}") + else: + output_append(f"{key}={value!r}") + + return " ".join(output) + + @cached_property + def markup_tag(self) -> str: + """Identifier used to close tags in markup.""" + output: list[str] = [] + output_append = output.append + if self.foreground is not None: + output_append(self.foreground.css) + if self.background is not None: + output_append(f"on {self.background.css}") + if self.bold is not None: + output_append("bold" if self.bold else "not bold") + if self.dim is not None: + output_append("dim" if self.dim else "not dim") + if self.italic is not None: + output_append("italic" if self.italic else "not italic") + if self.underline is not None: + output_append("underline" if self.underline else "not underline") + if self.strike is not None: + output_append("strike" if self.strike else "not strike") + if self.link is not None: + output_append("link") + if self._meta is not None: + for key, value in self.meta.items(): + if isinstance(value, str): + output_append(f"{key}=") return " ".join(output) @@ -176,6 +226,22 @@ class Style: return parse_style(text_style, variables) return app.stylesheet.parse_style(text_style) + @classmethod + def _normalize_markup_tag(cls, text_style: str) -> str: + """Produces a normalized from of a style, used to match closing tags with opening tags. + + Args: + text_style: Style to normalize. + + Returns: + Normalized markup tag. + """ + try: + style = cls.parse(text_style) + except Exception: + return text_style.strip() + return style.markup_tag + @classmethod def from_rich_style( cls, rich_style: RichStyle, theme: TerminalTheme | None = None diff --git a/src/textual/visual.py b/src/textual/visual.py index 3dbc87402..15d3332f1 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -69,6 +69,7 @@ def visualize(widget: Widget, obj: object, markup: bool = True) -> Visual: Returns: A Visual instance to render the object, or `None` if there is no associated visual. """ + _rich_traceback_omit = True if isinstance(obj, Visual): # Already a visual return obj diff --git a/src/textual/widget.py b/src/textual/widget.py index de701c31a..8e87ca1b1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3869,7 +3869,6 @@ class Widget(DOMNode): self._render_content() try: line = self._render_cache.lines[y] - print(repr(line)) except IndexError: line = Strip.blank(self.size.width, self.rich_style) diff --git a/tests/test_issue_4248.py b/tests/test_issue_4248.py index 012b11eda..beada864f 100644 --- a/tests/test_issue_4248.py +++ b/tests/test_issue_4248.py @@ -11,7 +11,6 @@ async def test_issue_4248() -> None: class ActionApp(App[None]): def compose(self) -> ComposeResult: - yield Label("[@click]click me and crash[/]", id="nothing") yield Label("[@click=]click me and crash[/]", id="no-params") yield Label("[@click=()]click me and crash[/]", id="empty-params") yield Label("[@click=foobar]click me[/]", id="unknown-sans-parens") @@ -26,8 +25,6 @@ async def test_issue_4248() -> None: app = ActionApp() async with app.run_test() as pilot: - assert bumps == 0 - await pilot.click("#nothing") assert bumps == 0 await pilot.click("#no-params") assert bumps == 0