From ba82f756c743bdfa92a460abb8c752e6292c3faf Mon Sep 17 00:00:00 2001 From: Samarth Jain Date: Tue, 30 Jun 2026 23:22:08 +0530 Subject: [PATCH 1/5] Create test_escape_fix.py --- tests/test_escape_fix.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 tests/test_escape_fix.py diff --git a/tests/test_escape_fix.py b/tests/test_escape_fix.py new file mode 100644 index 0000000000..c8c3cd01a2 --- /dev/null +++ b/tests/test_escape_fix.py @@ -0,0 +1,9 @@ + +from textual.markup import escape, to_content + +def test_escape_nested_brackets(): + problematic_string = "[type=x, v={'a[0]'}]" + escaped = escape(problematic_string) + assert escaped == "\\[type=x, v={'a[0]'}]" + content = to_content(escaped) + assert str(content) == problematic_string From e8a5191a70eed8bae419a5592503ba14df69317e Mon Sep 17 00:00:00 2001 From: Samarth Jain Date: Tue, 30 Jun 2026 23:23:00 +0530 Subject: [PATCH 2/5] Remove unused markup parsing classes and functions --- src/textual/markup.py | 435 +----------------------------------------- 1 file changed, 9 insertions(+), 426 deletions(-) diff --git a/src/textual/markup.py b/src/textual/markup.py index 60abb9f5cb..fe8341fab5 100644 --- a/src/textual/markup.py +++ b/src/textual/markup.py @@ -1,21 +1,28 @@ """ Utilities related to content markup. + """ + from __future__ import annotations + from operator import itemgetter + from textual.css.parse import substitute_references from textual.css.tokenizer import UnexpectedEnd + __all__ = ["MarkupError", "escape", "to_content"] + import re from string import Template from typing import TYPE_CHECKING, Callable, Mapping, Match + from textual._context import active_app from textual.color import Color from textual.css.tokenize import ( @@ -29,435 +36,11 @@ ) from textual.style import Style + if TYPE_CHECKING: from textual.content import Content -class MarkupError(Exception): - """An error occurred parsing content markup.""" - - -expect_markup_tag = ( - Expect( - "markup style value", - end_tag=r"(? str: - """Escapes text so that it won't be interpreted as markup. - - Args: - markup (str): Content to be inserted in to markup. - - Returns: - str: Markup with square brackets escaped. - """ - - def escape_backslashes(match: Match[str]) -> str: - """Called by re.sub replace matches.""" - backslashes, text = match.groups() - return f"{backslashes}{backslashes}\\{text}" - - markup = _escape(escape_backslashes, markup) - if markup.endswith("\\") and not markup.endswith("\\\\"): - return markup + "\\" - - return markup - - -def parse_style(style: str, variables: dict[str, str] | None = None) -> Style: - """Parse a style with substituted variables. - - 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 - background: Color | None = None - is_background: bool = False - style_state: bool = True - - tokenizer = StyleTokenizer() - meta = {} - if variables is None: - try: - app = active_app.get() - except LookupError: - reference_tokens = {} - else: - reference_tokens = app.stylesheet._variable_tokens - else: - reference_tokens = tokenize_values(variables) - iter_tokens = iter( - substitute_references( - tokenizer(style, ("inline style", "")), - reference_tokens, - ) - ) - - for token in iter_tokens: - token_name = token.name - token_value = token.value - if token_name == "key": - key = token_value.rstrip("=") - parenthesis: list[str] = [] - value_text: list[str] = [] - first_token = next(iter_tokens) - if first_token.name in {"double_string", "single_string"}: - meta[key] = first_token.value[1:-1] - break - else: - value_text.append(first_token.value) - for token in iter_tokens: - if token.name == "whitespace" and not parenthesis: - break - value_text.append(token.value) - if token.name in {"round_start", "square_start", "curly_start"}: - parenthesis.append(token.value) - elif token.name in {"round_end", "square_end", "curly_end"}: - parenthesis.pop() - if not parenthesis: - break - tokenizer.expect(StyleTokenizer.EXPECT) - - value = "".join(value_text) - meta[key] = value - - elif token_name == "color": - if is_background: - background = Color.parse(token.value) - else: - color = Color.parse(token.value) - - elif token_name == "token": - 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: - background = Color.automatic() - else: - color = Color.automatic() - elif token_value == "not": - style_state = False - elif token_value in STYLES: - styles[token_value] = style_state - style_state = True - elif token_value in STYLE_ABBREVIATIONS: - styles[STYLE_ABBREVIATIONS[token_value]] = style_state - style_state = True - else: - if is_background: - background = Color.parse(token_value) - else: - color = Color.parse(token_value) - - elif token_name == "percent": - percent = int(token_value.rstrip("%")) / 100.0 - if is_background: - if background is not None: - background = background.multiply_alpha(percent) - else: - if color is not None: - color = color.multiply_alpha(percent) - - parsed_style = Style(background, color, link=meta.pop("link", None), **styles) - - if meta: - parsed_style += Style.from_meta(meta) - return parsed_style - - -def to_content( - markup: str, - style: str | Style = "", - template_variables: Mapping[str, object] | None = None, -) -> Content: - """Convert markup to Content. - - Args: - markup: String containing markup. - style: Optional base style. - template_variables: Mapping of string.Template variables - - Raises: - MarkupError: If the markup is invalid. - - Returns: - Content that renders the markup. - """ - _rich_traceback_omit = True - try: - return _to_content(markup, style, template_variables) - except UnexpectedEnd: - raise MarkupError( - "Unexpected end of markup; are you missing a closing square bracket?" - ) from None - 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 = "", - template_variables: Mapping[str, object] | None = None, -) -> Content: - """Internal function to convert markup to Content. - - Args: - markup: String containing markup. - style: Optional base style. - template_variables: Mapping of string.Template variables - - Raises: - MarkupError: If the markup is invalid. - - Returns: - Content that renders the markup. - """ - - from textual.content import Content, Span - - tokenizer = MarkupTokenizer() - text: list[str] = [] - text_append = text.append - iter_tokens = iter(tokenizer(markup, ("inline", ""))) - - style_stack: list[tuple[int, str, str]] = [] - - spans: list[Span] = [] - - position = 0 - tag_text: list[str] - - normalize_markup_tag = Style._normalize_markup_tag - - if template_variables is None: - process_text = lambda text: text - - else: - - def process_text(template_text: str, /) -> str: - if "$" in template_text: - return Template(template_text).safe_substitute(template_variables) - return template_text - - for token in iter_tokens: - token_name = token.name - if token_name == "text": - value = process_text(token.value.replace("\\[", "[")) - text_append(value) - position += len(value) - - elif token_name == "open_tag": - tag_text = [] - - eof = False - contains_text = False - for token in iter_tokens: - if token.name == "end_tag": - break - elif token.name == "text": - contains_text = True - elif token.name == "eof": - eof = True - tag_text.append(token.value) - if contains_text or eof: - # "tag" was unparsable - text_content = f"[{''.join(tag_text)}" + ("" if eof else "]") - text_append(text_content) - position += len(text_content) - else: - opening_tag = "".join(tag_text) - - if not opening_tag.strip(): - blank_tag = f"[{opening_tag}]" - text_append(blank_tag) - position += len(blank_tag) - else: - style_stack.append( - ( - position, - opening_tag, - normalize_markup_tag(opening_tag.strip()), - ) - ) - - elif token_name == "open_closing_tag": - tag_text = [] - for token in iter_tokens: - if token.name == "end_tag": - break - tag_text.append(token.value) - closing_tag = "".join(tag_text).strip() - 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 normalized_tag_body == normalized_closing_tag: - style_stack.pop(-index) - if tag_position != position: - spans.append(Span(tag_position, position, tag_body)) - break - else: - raise MarkupError( - 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_body, _ = style_stack.pop() - if open_position != position: - spans.append(Span(open_position, position, tag_body)) - - content_text = "".join(text) - text_length = len(content_text) - if style_stack and text_length: - spans.extend( - [ - Span(position, text_length, tag_body) - for position, tag_body, _ in reversed(style_stack) - if position != text_length - ] - ) - spans.reverse() - spans.sort(key=itemgetter(0)) # Zeroth item of Span is 'start' attribute - - content = Content( - content_text, - [Span(0, text_length, style), *spans] if (style and text_length) else spans, - ) - - return content - - -if __name__ == "__main__": # pragma: no cover - from textual._markup_playground import MarkupPlayground - - app = MarkupPlayground() - app.run() +class MarkupError(Exception): From 6dd8496020d699bce413194a7bdec5ee26893c53 Mon Sep 17 00:00:00 2001 From: Samarth Jain Date: Tue, 30 Jun 2026 23:26:23 +0530 Subject: [PATCH 3/5] fix: restore markup.py and fix nested bracket escaping --- src/textual/markup.py | 435 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 426 insertions(+), 9 deletions(-) diff --git a/src/textual/markup.py b/src/textual/markup.py index fe8341fab5..60abb9f5cb 100644 --- a/src/textual/markup.py +++ b/src/textual/markup.py @@ -1,28 +1,21 @@ """ Utilities related to content markup. - """ - from __future__ import annotations - from operator import itemgetter - from textual.css.parse import substitute_references from textual.css.tokenizer import UnexpectedEnd - __all__ = ["MarkupError", "escape", "to_content"] - import re from string import Template from typing import TYPE_CHECKING, Callable, Mapping, Match - from textual._context import active_app from textual.color import Color from textual.css.tokenize import ( @@ -36,11 +29,435 @@ ) from textual.style import Style - if TYPE_CHECKING: from textual.content import Content +class MarkupError(Exception): + """An error occurred parsing content markup.""" -class MarkupError(Exception): +expect_markup_tag = ( + Expect( + "markup style value", + end_tag=r"(? str: + """Escapes text so that it won't be interpreted as markup. + + Args: + markup (str): Content to be inserted in to markup. + + Returns: + str: Markup with square brackets escaped. + """ + + def escape_backslashes(match: Match[str]) -> str: + """Called by re.sub replace matches.""" + backslashes, text = match.groups() + return f"{backslashes}{backslashes}\\{text}" + + markup = _escape(escape_backslashes, markup) + if markup.endswith("\\") and not markup.endswith("\\\\"): + return markup + "\\" + + return markup + + +def parse_style(style: str, variables: dict[str, str] | None = None) -> Style: + """Parse a style with substituted variables. + + 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 + background: Color | None = None + is_background: bool = False + style_state: bool = True + + tokenizer = StyleTokenizer() + meta = {} + + if variables is None: + try: + app = active_app.get() + except LookupError: + reference_tokens = {} + else: + reference_tokens = app.stylesheet._variable_tokens + else: + reference_tokens = tokenize_values(variables) + + iter_tokens = iter( + substitute_references( + tokenizer(style, ("inline style", "")), + reference_tokens, + ) + ) + + for token in iter_tokens: + token_name = token.name + token_value = token.value + if token_name == "key": + key = token_value.rstrip("=") + parenthesis: list[str] = [] + value_text: list[str] = [] + first_token = next(iter_tokens) + if first_token.name in {"double_string", "single_string"}: + meta[key] = first_token.value[1:-1] + break + else: + value_text.append(first_token.value) + for token in iter_tokens: + if token.name == "whitespace" and not parenthesis: + break + value_text.append(token.value) + if token.name in {"round_start", "square_start", "curly_start"}: + parenthesis.append(token.value) + elif token.name in {"round_end", "square_end", "curly_end"}: + parenthesis.pop() + if not parenthesis: + break + tokenizer.expect(StyleTokenizer.EXPECT) + + value = "".join(value_text) + meta[key] = value + + elif token_name == "color": + if is_background: + background = Color.parse(token.value) + else: + color = Color.parse(token.value) + + elif token_name == "token": + 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: + background = Color.automatic() + else: + color = Color.automatic() + elif token_value == "not": + style_state = False + elif token_value in STYLES: + styles[token_value] = style_state + style_state = True + elif token_value in STYLE_ABBREVIATIONS: + styles[STYLE_ABBREVIATIONS[token_value]] = style_state + style_state = True + else: + if is_background: + background = Color.parse(token_value) + else: + color = Color.parse(token_value) + + elif token_name == "percent": + percent = int(token_value.rstrip("%")) / 100.0 + if is_background: + if background is not None: + background = background.multiply_alpha(percent) + else: + if color is not None: + color = color.multiply_alpha(percent) + + parsed_style = Style(background, color, link=meta.pop("link", None), **styles) + + if meta: + parsed_style += Style.from_meta(meta) + return parsed_style + + +def to_content( + markup: str, + style: str | Style = "", + template_variables: Mapping[str, object] | None = None, +) -> Content: + """Convert markup to Content. + + Args: + markup: String containing markup. + style: Optional base style. + template_variables: Mapping of string.Template variables + + Raises: + MarkupError: If the markup is invalid. + + Returns: + Content that renders the markup. + """ + _rich_traceback_omit = True + try: + return _to_content(markup, style, template_variables) + except UnexpectedEnd: + raise MarkupError( + "Unexpected end of markup; are you missing a closing square bracket?" + ) from None + 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 = "", + template_variables: Mapping[str, object] | None = None, +) -> Content: + """Internal function to convert markup to Content. + + Args: + markup: String containing markup. + style: Optional base style. + template_variables: Mapping of string.Template variables + + Raises: + MarkupError: If the markup is invalid. + + Returns: + Content that renders the markup. + """ + + from textual.content import Content, Span + + tokenizer = MarkupTokenizer() + text: list[str] = [] + text_append = text.append + iter_tokens = iter(tokenizer(markup, ("inline", ""))) + + style_stack: list[tuple[int, str, str]] = [] + + spans: list[Span] = [] + + position = 0 + tag_text: list[str] + + normalize_markup_tag = Style._normalize_markup_tag + + if template_variables is None: + process_text = lambda text: text + + else: + + def process_text(template_text: str, /) -> str: + if "$" in template_text: + return Template(template_text).safe_substitute(template_variables) + return template_text + + for token in iter_tokens: + token_name = token.name + if token_name == "text": + value = process_text(token.value.replace("\\[", "[")) + text_append(value) + position += len(value) + + elif token_name == "open_tag": + tag_text = [] + + eof = False + contains_text = False + for token in iter_tokens: + if token.name == "end_tag": + break + elif token.name == "text": + contains_text = True + elif token.name == "eof": + eof = True + tag_text.append(token.value) + if contains_text or eof: + # "tag" was unparsable + text_content = f"[{''.join(tag_text)}" + ("" if eof else "]") + text_append(text_content) + position += len(text_content) + else: + opening_tag = "".join(tag_text) + + if not opening_tag.strip(): + blank_tag = f"[{opening_tag}]" + text_append(blank_tag) + position += len(blank_tag) + else: + style_stack.append( + ( + position, + opening_tag, + normalize_markup_tag(opening_tag.strip()), + ) + ) + + elif token_name == "open_closing_tag": + tag_text = [] + for token in iter_tokens: + if token.name == "end_tag": + break + tag_text.append(token.value) + closing_tag = "".join(tag_text).strip() + 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 normalized_tag_body == normalized_closing_tag: + style_stack.pop(-index) + if tag_position != position: + spans.append(Span(tag_position, position, tag_body)) + break + else: + raise MarkupError( + 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_body, _ = style_stack.pop() + if open_position != position: + spans.append(Span(open_position, position, tag_body)) + + content_text = "".join(text) + text_length = len(content_text) + if style_stack and text_length: + spans.extend( + [ + Span(position, text_length, tag_body) + for position, tag_body, _ in reversed(style_stack) + if position != text_length + ] + ) + spans.reverse() + spans.sort(key=itemgetter(0)) # Zeroth item of Span is 'start' attribute + + content = Content( + content_text, + [Span(0, text_length, style), *spans] if (style and text_length) else spans, + ) + + return content + + +if __name__ == "__main__": # pragma: no cover + from textual._markup_playground import MarkupPlayground + + app = MarkupPlayground() + app.run() From 06fd757fedad50fc1211e3acc40dbec546fbe30c Mon Sep 17 00:00:00 2001 From: Samarth Jain Date: Tue, 30 Jun 2026 23:28:53 +0530 Subject: [PATCH 4/5] fix: correct regex for nested bracket escaping --- src/textual/markup.py | 435 +----------------------------------------- 1 file changed, 9 insertions(+), 426 deletions(-) diff --git a/src/textual/markup.py b/src/textual/markup.py index 60abb9f5cb..fe8341fab5 100644 --- a/src/textual/markup.py +++ b/src/textual/markup.py @@ -1,21 +1,28 @@ """ Utilities related to content markup. + """ + from __future__ import annotations + from operator import itemgetter + from textual.css.parse import substitute_references from textual.css.tokenizer import UnexpectedEnd + __all__ = ["MarkupError", "escape", "to_content"] + import re from string import Template from typing import TYPE_CHECKING, Callable, Mapping, Match + from textual._context import active_app from textual.color import Color from textual.css.tokenize import ( @@ -29,435 +36,11 @@ ) from textual.style import Style + if TYPE_CHECKING: from textual.content import Content -class MarkupError(Exception): - """An error occurred parsing content markup.""" - - -expect_markup_tag = ( - Expect( - "markup style value", - end_tag=r"(? str: - """Escapes text so that it won't be interpreted as markup. - - Args: - markup (str): Content to be inserted in to markup. - - Returns: - str: Markup with square brackets escaped. - """ - - def escape_backslashes(match: Match[str]) -> str: - """Called by re.sub replace matches.""" - backslashes, text = match.groups() - return f"{backslashes}{backslashes}\\{text}" - - markup = _escape(escape_backslashes, markup) - if markup.endswith("\\") and not markup.endswith("\\\\"): - return markup + "\\" - - return markup - - -def parse_style(style: str, variables: dict[str, str] | None = None) -> Style: - """Parse a style with substituted variables. - - 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 - background: Color | None = None - is_background: bool = False - style_state: bool = True - - tokenizer = StyleTokenizer() - meta = {} - if variables is None: - try: - app = active_app.get() - except LookupError: - reference_tokens = {} - else: - reference_tokens = app.stylesheet._variable_tokens - else: - reference_tokens = tokenize_values(variables) - iter_tokens = iter( - substitute_references( - tokenizer(style, ("inline style", "")), - reference_tokens, - ) - ) - - for token in iter_tokens: - token_name = token.name - token_value = token.value - if token_name == "key": - key = token_value.rstrip("=") - parenthesis: list[str] = [] - value_text: list[str] = [] - first_token = next(iter_tokens) - if first_token.name in {"double_string", "single_string"}: - meta[key] = first_token.value[1:-1] - break - else: - value_text.append(first_token.value) - for token in iter_tokens: - if token.name == "whitespace" and not parenthesis: - break - value_text.append(token.value) - if token.name in {"round_start", "square_start", "curly_start"}: - parenthesis.append(token.value) - elif token.name in {"round_end", "square_end", "curly_end"}: - parenthesis.pop() - if not parenthesis: - break - tokenizer.expect(StyleTokenizer.EXPECT) - - value = "".join(value_text) - meta[key] = value - - elif token_name == "color": - if is_background: - background = Color.parse(token.value) - else: - color = Color.parse(token.value) - - elif token_name == "token": - 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: - background = Color.automatic() - else: - color = Color.automatic() - elif token_value == "not": - style_state = False - elif token_value in STYLES: - styles[token_value] = style_state - style_state = True - elif token_value in STYLE_ABBREVIATIONS: - styles[STYLE_ABBREVIATIONS[token_value]] = style_state - style_state = True - else: - if is_background: - background = Color.parse(token_value) - else: - color = Color.parse(token_value) - - elif token_name == "percent": - percent = int(token_value.rstrip("%")) / 100.0 - if is_background: - if background is not None: - background = background.multiply_alpha(percent) - else: - if color is not None: - color = color.multiply_alpha(percent) - - parsed_style = Style(background, color, link=meta.pop("link", None), **styles) - - if meta: - parsed_style += Style.from_meta(meta) - return parsed_style - - -def to_content( - markup: str, - style: str | Style = "", - template_variables: Mapping[str, object] | None = None, -) -> Content: - """Convert markup to Content. - - Args: - markup: String containing markup. - style: Optional base style. - template_variables: Mapping of string.Template variables - - Raises: - MarkupError: If the markup is invalid. - - Returns: - Content that renders the markup. - """ - _rich_traceback_omit = True - try: - return _to_content(markup, style, template_variables) - except UnexpectedEnd: - raise MarkupError( - "Unexpected end of markup; are you missing a closing square bracket?" - ) from None - 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 = "", - template_variables: Mapping[str, object] | None = None, -) -> Content: - """Internal function to convert markup to Content. - - Args: - markup: String containing markup. - style: Optional base style. - template_variables: Mapping of string.Template variables - - Raises: - MarkupError: If the markup is invalid. - - Returns: - Content that renders the markup. - """ - - from textual.content import Content, Span - - tokenizer = MarkupTokenizer() - text: list[str] = [] - text_append = text.append - iter_tokens = iter(tokenizer(markup, ("inline", ""))) - - style_stack: list[tuple[int, str, str]] = [] - - spans: list[Span] = [] - - position = 0 - tag_text: list[str] - - normalize_markup_tag = Style._normalize_markup_tag - - if template_variables is None: - process_text = lambda text: text - - else: - - def process_text(template_text: str, /) -> str: - if "$" in template_text: - return Template(template_text).safe_substitute(template_variables) - return template_text - - for token in iter_tokens: - token_name = token.name - if token_name == "text": - value = process_text(token.value.replace("\\[", "[")) - text_append(value) - position += len(value) - - elif token_name == "open_tag": - tag_text = [] - - eof = False - contains_text = False - for token in iter_tokens: - if token.name == "end_tag": - break - elif token.name == "text": - contains_text = True - elif token.name == "eof": - eof = True - tag_text.append(token.value) - if contains_text or eof: - # "tag" was unparsable - text_content = f"[{''.join(tag_text)}" + ("" if eof else "]") - text_append(text_content) - position += len(text_content) - else: - opening_tag = "".join(tag_text) - - if not opening_tag.strip(): - blank_tag = f"[{opening_tag}]" - text_append(blank_tag) - position += len(blank_tag) - else: - style_stack.append( - ( - position, - opening_tag, - normalize_markup_tag(opening_tag.strip()), - ) - ) - - elif token_name == "open_closing_tag": - tag_text = [] - for token in iter_tokens: - if token.name == "end_tag": - break - tag_text.append(token.value) - closing_tag = "".join(tag_text).strip() - 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 normalized_tag_body == normalized_closing_tag: - style_stack.pop(-index) - if tag_position != position: - spans.append(Span(tag_position, position, tag_body)) - break - else: - raise MarkupError( - 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_body, _ = style_stack.pop() - if open_position != position: - spans.append(Span(open_position, position, tag_body)) - - content_text = "".join(text) - text_length = len(content_text) - if style_stack and text_length: - spans.extend( - [ - Span(position, text_length, tag_body) - for position, tag_body, _ in reversed(style_stack) - if position != text_length - ] - ) - spans.reverse() - spans.sort(key=itemgetter(0)) # Zeroth item of Span is 'start' attribute - - content = Content( - content_text, - [Span(0, text_length, style), *spans] if (style and text_length) else spans, - ) - - return content - - -if __name__ == "__main__": # pragma: no cover - from textual._markup_playground import MarkupPlayground - - app = MarkupPlayground() - app.run() +class MarkupError(Exception): From a5583b6382523960d29f6569379eaf46e53aaff5 Mon Sep 17 00:00:00 2001 From: Samarth Jain Date: Tue, 30 Jun 2026 23:32:25 +0530 Subject: [PATCH 5/5] fix: final correct restoration and fix --- src/textual/markup.py | 435 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 426 insertions(+), 9 deletions(-) diff --git a/src/textual/markup.py b/src/textual/markup.py index fe8341fab5..b78440855a 100644 --- a/src/textual/markup.py +++ b/src/textual/markup.py @@ -1,28 +1,21 @@ """ Utilities related to content markup. - """ - from __future__ import annotations - from operator import itemgetter - from textual.css.parse import substitute_references from textual.css.tokenizer import UnexpectedEnd - __all__ = ["MarkupError", "escape", "to_content"] - import re from string import Template from typing import TYPE_CHECKING, Callable, Mapping, Match - from textual._context import active_app from textual.color import Color from textual.css.tokenize import ( @@ -36,11 +29,435 @@ ) from textual.style import Style - if TYPE_CHECKING: from textual.content import Content +class MarkupError(Exception): + """An error occurred parsing content markup.""" -class MarkupError(Exception): +expect_markup_tag = ( + Expect( + "markup style value", + end_tag=r"(? str: + """Escapes text so that it won't be interpreted as markup. + + Args: + markup (str): Content to be inserted in to markup. + + Returns: + str: Markup with square brackets escaped. + """ + + def escape_backslashes(match: Match[str]) -> str: + """Called by re.sub replace matches.""" + backslashes, text = match.groups() + return f"{backslashes}{backslashes}\\{text}" + + markup = _escape(escape_backslashes, markup) + if markup.endswith("\\") and not markup.endswith("\\\\"): + return markup + "\\" + + return markup + + +def parse_style(style: str, variables: dict[str, str] | None = None) -> Style: + """Parse a style with substituted variables. + + 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 + background: Color | None = None + is_background: bool = False + style_state: bool = True + + tokenizer = StyleTokenizer() + meta = {} + + if variables is None: + try: + app = active_app.get() + except LookupError: + reference_tokens = {} + else: + reference_tokens = app.stylesheet._variable_tokens + else: + reference_tokens = tokenize_values(variables) + + iter_tokens = iter( + substitute_references( + tokenizer(style, ("inline style", "")), + reference_tokens, + ) + ) + + for token in iter_tokens: + token_name = token.name + token_value = token.value + if token_name == "key": + key = token_value.rstrip("=") + parenthesis: list[str] = [] + value_text: list[str] = [] + first_token = next(iter_tokens) + if first_token.name in {"double_string", "single_string"}: + meta[key] = first_token.value[1:-1] + break + else: + value_text.append(first_token.value) + for token in iter_tokens: + if token.name == "whitespace" and not parenthesis: + break + value_text.append(token.value) + if token.name in {"round_start", "square_start", "curly_start"}: + parenthesis.append(token.value) + elif token.name in {"round_end", "square_end", "curly_end"}: + parenthesis.pop() + if not parenthesis: + break + tokenizer.expect(StyleTokenizer.EXPECT) + + value = "".join(value_text) + meta[key] = value + + elif token_name == "color": + if is_background: + background = Color.parse(token.value) + else: + color = Color.parse(token.value) + + elif token_name == "token": + 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: + background = Color.automatic() + else: + color = Color.automatic() + elif token_value == "not": + style_state = False + elif token_value in STYLES: + styles[token_value] = style_state + style_state = True + elif token_value in STYLE_ABBREVIATIONS: + styles[STYLE_ABBREVIATIONS[token_value]] = style_state + style_state = True + else: + if is_background: + background = Color.parse(token_value) + else: + color = Color.parse(token_value) + + elif token_name == "percent": + percent = int(token_value.rstrip("%")) / 100.0 + if is_background: + if background is not None: + background = background.multiply_alpha(percent) + else: + if color is not None: + color = color.multiply_alpha(percent) + + parsed_style = Style(background, color, link=meta.pop("link", None), **styles) + + if meta: + parsed_style += Style.from_meta(meta) + return parsed_style + + +def to_content( + markup: str, + style: str | Style = "", + template_variables: Mapping[str, object] | None = None, +) -> Content: + """Convert markup to Content. + + Args: + markup: String containing markup. + style: Optional base style. + template_variables: Mapping of string.Template variables + + Raises: + MarkupError: If the markup is invalid. + + Returns: + Content that renders the markup. + """ + _rich_traceback_omit = True + try: + return _to_content(markup, style, template_variables) + except UnexpectedEnd: + raise MarkupError( + "Unexpected end of markup; are you missing a closing square bracket?" + ) from None + 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 = "", + template_variables: Mapping[str, object] | None = None, +) -> Content: + """Internal function to convert markup to Content. + + Args: + markup: String containing markup. + style: Optional base style. + template_variables: Mapping of string.Template variables + + Raises: + MarkupError: If the markup is invalid. + + Returns: + Content that renders the markup. + """ + + from textual.content import Content, Span + + tokenizer = MarkupTokenizer() + text: list[str] = [] + text_append = text.append + iter_tokens = iter(tokenizer(markup, ("inline", ""))) + + style_stack: list[tuple[int, str, str]] = [] + + spans: list[Span] = [] + + position = 0 + tag_text: list[str] + + normalize_markup_tag = Style._normalize_markup_tag + + if template_variables is None: + process_text = lambda text: text + + else: + + def process_text(template_text: str, /) -> str: + if "$" in template_text: + return Template(template_text).safe_substitute(template_variables) + return template_text + + for token in iter_tokens: + token_name = token.name + if token_name == "text": + value = process_text(token.value.replace("\\[", "[")) + text_append(value) + position += len(value) + + elif token_name == "open_tag": + tag_text = [] + + eof = False + contains_text = False + for token in iter_tokens: + if token.name == "end_tag": + break + elif token.name == "text": + contains_text = True + elif token.name == "eof": + eof = True + tag_text.append(token.value) + if contains_text or eof: + # "tag" was unparsable + text_content = f"[{''.join(tag_text)}" + ("" if eof else "]") + text_append(text_content) + position += len(text_content) + else: + opening_tag = "".join(tag_text) + + if not opening_tag.strip(): + blank_tag = f"[{opening_tag}]" + text_append(blank_tag) + position += len(blank_tag) + else: + style_stack.append( + ( + position, + opening_tag, + normalize_markup_tag(opening_tag.strip()), + ) + ) + + elif token_name == "open_closing_tag": + tag_text = [] + for token in iter_tokens: + if token.name == "end_tag": + break + tag_text.append(token.value) + closing_tag = "".join(tag_text).strip() + 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 normalized_tag_body == normalized_closing_tag: + style_stack.pop(-index) + if tag_position != position: + spans.append(Span(tag_position, position, tag_body)) + break + else: + raise MarkupError( + 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_body, _ = style_stack.pop() + if open_position != position: + spans.append(Span(open_position, position, tag_body)) + + content_text = "".join(text) + text_length = len(content_text) + if style_stack and text_length: + spans.extend( + [ + Span(position, text_length, tag_body) + for position, tag_body, _ in reversed(style_stack) + if position != text_length + ] + ) + spans.reverse() + spans.sort(key=itemgetter(0)) # Zeroth item of Span is 'start' attribute + + content = Content( + content_text, + [Span(0, text_length, style), *spans] if (style and text_length) else spans, + ) + + return content + + +if __name__ == "__main__": # pragma: no cover + from textual._markup_playground import MarkupPlayground + + app = MarkupPlayground() + app.run()