Repository: Textualize/textual

Issue:
Collapsible: unavoidable MarkupError with title containing invalid markup
## The bug

Using a Collapsible title containing invalid markup raises MarkupError.

I could not find a way to disable the CollapsibleTitle markup.
Wrapping the title in a `textual.content.Content` does not help.
Escaping the title using `textual.markup.escape` does not help.

Found while upgrading from 0.86.1

My use case is displaying logs in Collapsibles, with a short summary like "[&lt;timestamp&gt;] &lt;command&gt; &lt;args&gt;" as title, and stdout/stderr in RichLogs inside each Collapsible.

## MRE
```python
from textual.content import Content
from textual.markup import escape, MarkupError
from textual.widgets import Collapsible

bad_title = "[:"

for title in [bad_title, Content(bad_title), escape(bad_title)]:
    try:
        Collapsible(title=title)
        assert False
    except MarkupError as error:
        print("MarkupError:", error)
```

The bare `bad_title` raising an error is possibly normal, I am not yet familiar with the Content changes from 2.0.0

## Workaround

For my use case, manually replacing `[` with `\[` works (e.g. `Collapsible(title=bad_title.replace('[', '\\['))`)

<!-- This is valid Markdown, please paste the following directly in to a GitHub issue -->
# Textual Diagnostics

I ran the MRE tests in a `uv` project, as my global version is still 0.86.1, so `textual==3.0` and `textual-dev>=1.7.0`.

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 3.0.0  |
| Rich    | 14.0.0 |

## Python

| Name           | Value                                                                                                                  |
|----------------|------------------------------------------------------------------------------------------------------------------------|
| Version        | 3.12.5                                                                                                                 |
| Implementation | CPython                                                                                                                |
| Compiler       | MSC v.1940 64 bit (AMD64)                                                                                              |
| Executable     | C:\Users\paul.thebault\Documents\Personnel\dev\python\textual\tx-playground\collapsible_title\.venv\Scripts\python.exe |

## Operating System

| Name    | Value      |
|---------|------------|
| System  | Windows    |
| Release | 11         |
| Version | 10.0.26100 |

## Terminal

| Name                 | Value            |
|----------------------|------------------|
| Terminal Application | Windows Terminal |
| TERM                 | *Not set*        |
| COLORTERM            | *Not set*        |
| FORCE_COLOR          | *Not set*        |
| NO_COLOR             | *Not set*        |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=208, height=51 |
| legacy_windows | True                 |
| min_width      | 1                    |
| max_width      | 208                  |
| is_terminal    | False                |
| encoding       | cp1252               |
| max_height     | 51                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |

Discussion:
@github-actions: We found the following entry in the [FAQ](https://textual.textualize.io/FAQ/) which you may find helpful:


- [No widget called TextLog](https://textual.textualize.io/FAQ/#no-widget-called-textlog)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)@github-actions: Don't forget to [star](https://github.com/Textualize/textual) the repository!

Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11be54485d..c5916b5238 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ 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
+
+- Fixed markup escaping edge cases https://github.com/Textualize/textual/pull/5697
+
+### Changed
+
+- Collapsible title now accepts str, Text, or Content https://github.com/Textualize/textual/pull/5697
+
 ## [3.0.1] - 2025-04-01
 
 ### Fixed
diff --git a/src/textual/_parser.py b/src/textual/_parser.py
index 4820c7da18..8b1bc982c6 100644
--- a/src/textual/_parser.py
+++ b/src/textual/_parser.py
@@ -83,7 +83,7 @@ def feed(self, data: str) -> Iterable[T]:
         if not data:
             self._eof = True
             try:
-                self._gen.throw(EOFError())
+                self._gen.throw(ParseEOF())
             except StopIteration:
                 pass
             while tokens:
diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py
index 4cc21fecc5..3936290d21 100644
--- a/src/textual/_xterm_parser.py
+++ b/src/textual/_xterm_parser.py
@@ -9,7 +9,7 @@
 from textual import constants, events, messages
 from textual._ansi_sequences import ANSI_SEQUENCES_KEYS, IGNORE_SEQUENCE
 from textual._keyboard_protocol import FUNCTIONAL_KEYS
-from textual._parser import Parser, ParseTimeout, Peek1, Read1, TokenCallback
+from textual._parser import ParseEOF, Parser, ParseTimeout, Peek1, Read1, TokenCallback
 from textual.keys import KEY_NAME_REPLACEMENTS, Keys, _character_to_key
 from textual.message import Message
 
@@ -187,7 +187,7 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None:
 
             try:
                 character = yield read1()
-            except EOFError:
+            except ParseEOF:
                 return
 
             if bracketed_paste:
@@ -216,7 +216,7 @@ def send_escape() -> None:
                 except ParseTimeout:
                     send_escape()
                     break
-                except EOFError:
+                except ParseEOF:
                     send_escape()
                     return
 
diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py
index d0365c825f..61b759253b 100644
--- a/src/textual/css/parse.py
+++ b/src/textual/css/parse.py
@@ -17,7 +17,7 @@
 )
 from textual.css.styles import Styles
 from textual.css.tokenize import Token, tokenize, tokenize_declarations, tokenize_values
-from textual.css.tokenizer import EOFError, ReferencedBy
+from textual.css.tokenizer import ReferencedBy, UnexpectedEnd
 from textual.css.types import CSSLocation, Specificity3
 from textual.suggestions import get_suggestion
 
@@ -66,7 +66,7 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
     while True:
         try:
             token = next(tokens, None)
-        except EOFError:
+        except UnexpectedEnd:
             break
         if token is None:
             break
diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py
index 770b3379ff..3b31b3df15 100644
--- a/src/textual/css/tokenizer.py
+++ b/src/textual/css/tokenizer.py
@@ -102,8 +102,8 @@ def __rich__(self) -> RenderableType:
         return Group(*errors)
 
 
-class EOFError(TokenError):
-    """Indicates that the CSS ended prematurely."""
+class UnexpectedEnd(TokenError):
+    """Indicates that the text being tokenized ended prematurely."""
 
 
 @rich.repr.auto
@@ -231,7 +231,7 @@ def get_token(self, expect: Expect) -> Token:
             expect: Expect object which describes which tokens may be read.
 
         Raises:
-            EOFError: If there is an unexpected end of file.
+            UnexpectedEnd: If there is an unexpected end of file.
             TokenError: If there is an error with the token.
 
         Returns:
@@ -251,11 +251,15 @@ def get_token(self, expect: Expect) -> Token:
                     None,
                 )
             else:
-                raise EOFError(
+                raise UnexpectedEnd(
                     self.read_from,
                     self.code,
                     (line_no + 1, col_no + 1),
-                    "Unexpected end of file; did you forget a '}' ?",
+                    (
+                        "Unexpected end of file; did you forget a '}' ?"
+                        if expect._expect_semicolon
+                        else "Unexpected end of text"
+                    ),
                 )
         line = self.lines[line_no]
         preceding_text: str = ""
@@ -348,7 +352,7 @@ def skip_to(self, expect: Expect) -> Token:
             expect: Expect object describing the expected token.
 
         Raises:
-            EOFError: If end of file is reached.
+            UnexpectedEndOfText: If end of file is reached.
 
         Returns:
             A new token.
@@ -358,11 +362,15 @@ def skip_to(self, expect: Expect) -> Token:
 
         while True:
             if line_no >= len(self.lines):
-                raise EOFError(
+                raise UnexpectedEnd(
                     self.read_from,
                     self.code,
                     (line_no, col_no),
-                    "Unexpected end of file; did you forget a '}' ?",
+                    (
+                        "Unexpected end of file; did you forget a '}' ?"
+                        if expect._expect_semicolon
+                        else "Unexpected end of markup"
+                    ),
                 )
             line = self.lines[line_no]
             match = expect.search(line, col_no)
diff --git a/src/textual/markup.py b/src/textual/markup.py
index 11ee5a132f..33edbaaef4 100644
--- a/src/textual/markup.py
+++ b/src/textual/markup.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from textual.css.parse import substitute_references
+from textual.css.tokenizer import UnexpectedEnd
 
 __all__ = ["MarkupError", "escape", "to_content"]
 
@@ -40,7 +41,7 @@ class MarkupError(Exception):
         variable_ref=VARIABLE_REF,
         whitespace=r"\s+",
     )
-    .expect_eof()
+    .expect_eof(False)
     .expect_semicolon(False)
 )
 
@@ -66,7 +67,7 @@ class MarkupError(Exception):
         double_string=r"\".*?\"",
         single_string=r"'.*?'",
     )
-    .expect_eof()
+    .expect_eof(True)
     .expect_semicolon(False)
 )
 
@@ -302,6 +303,10 @@ def to_content(
     _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
diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py
index 80b9872a92..b4c47df97e 100644
--- a/src/textual/widgets/_collapsible.py
+++ b/src/textual/widgets/_collapsible.py
@@ -4,6 +4,7 @@
 from textual.app import ComposeResult
 from textual.binding import Binding
 from textual.containers import Container
+from textual.content import Content, ContentText
 from textual.css.query import NoMatches
 from textual.message import Message
 from textual.reactive import reactive
@@ -47,12 +48,12 @@ class CollapsibleTitle(Static, can_focus=True):
     """
 
     collapsed = reactive(True)
-    label = reactive("Toggle")
+    label: reactive[ContentText] = reactive(Content("Toggle"))
 
     def __init__(
         self,
         *,
-        label: str,
+        label: ContentText,
         collapsed_symbol: str,
         expanded_symbol: str,
         collapsed: bool,
@@ -60,10 +61,8 @@ def __init__(
         super().__init__()
         self.collapsed_symbol = collapsed_symbol
         self.expanded_symbol = expanded_symbol
-        self.label = label
+        self.label = Content.from_text(label)
         self.collapsed = collapsed
-        self._collapsed_label = f"{collapsed_symbol} {label}"
-        self._expanded_label = f"{expanded_symbol} {label}"
 
     class Toggle(Message):
         """Request toggle."""
@@ -77,19 +76,21 @@ def action_toggle_collapsible(self) -> None:
         """Toggle the state of the parent collapsible."""
         self.post_message(self.Toggle())
 
-    def _watch_label(self, label: str) -> None:
-        self._collapsed_label = f"{self.collapsed_symbol} {label}"
-        self._expanded_label = f"{self.expanded_symbol} {label}"
+    def validate_label(self, label: ContentText) -> Content:
+        return Content.from_text(label)
+
+    def _update_label(self) -> None:
+        assert isinstance(self.label, Content)
         if self.collapsed:
-            self.update(self._collapsed_label)
+            self.update(Content.assemble(self.collapsed_symbol, " ", self.label))
         else:
-            self.update(self._expanded_label)
+            self.update(Content.assemble(self.expanded_symbol, " ", self.label))
+
+    def _watch_label(self) -> None:
+        self._update_label()
 
     def _watch_collapsed(self, collapsed: bool) -> None:
-        if collapsed:
-            self.update(self._collapsed_label)
-        else:
-            self.update(self._expanded_label)
+        self._update_label()
 
 
 class Collapsible(Widget):


Test patch:
diff --git a/tests/css/test_nested_css.py b/tests/css/test_nested_css.py
index bd6b5714a8..44f1236f55 100644
--- a/tests/css/test_nested_css.py
+++ b/tests/css/test_nested_css.py
@@ -6,7 +6,7 @@
 from textual.color import Color
 from textual.containers import Vertical
 from textual.css.parse import parse
-from textual.css.tokenizer import EOFError, TokenError
+from textual.css.tokenizer import TokenError, UnexpectedEnd
 from textual.widgets import Button, Label
 
 
@@ -94,16 +94,16 @@ async def test_rule_declaration_after_nested() -> None:
 @pytest.mark.parametrize(
     ("css", "exception"),
     [
-        ("Selector {", EOFError),
-        ("Selector{ Foo {", EOFError),
-        ("Selector{ Foo {}", EOFError),
+        ("Selector {", UnexpectedEnd),
+        ("Selector{ Foo {", UnexpectedEnd),
+        ("Selector{ Foo {}", UnexpectedEnd),
         ("> {}", TokenError),
         ("&", TokenError),
         ("&&", TokenError),
         ("&.foo", TokenError),
         ("& .foo", TokenError),
         ("{", TokenError),
-        ("*{", EOFError),
+        ("*{", UnexpectedEnd),
     ],
 )
 def test_parse_errors(css: str, exception: type[Exception]) -> None:
diff --git a/tests/test_content.py b/tests/test_content.py
index c4a42b355f..cd01886d4d 100644
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import pytest
 from rich.text import Text
 
 from textual.content import Content, Span
@@ -219,10 +220,21 @@ def test_assemble():
     ]
 
 
-def test_escape():
+@pytest.mark.parametrize(
+    "markup,plain",
+    [
+        ("\\[", "["),
+        ("\\[foo", "[foo"),
+        ("\\[foo]", "[foo]"),
+        ("\\[/foo", "[/foo"),
+        ("\\[/foo]", "[/foo]"),
+        ("\\[]", "[]"),
+    ],
+)
+def test_escape(markup: str, plain: str) -> None:
     """Test that escaping the first square bracket."""
-    content = Content.from_markup("\\[bold]Not really bold")
-    assert content.plain == "[bold]Not really bold"
+    content = Content.from_markup(markup)
+    assert content.plain == plain
     assert content.spans == []
 
 
@@ -261,3 +273,14 @@ def test_first_line():
     first_line = content.first_line
     assert first_line.plain == "foo"
     assert first_line.spans == [Span(0, 3, "red")]
+
+
+def test_errors():
+    with pytest.raises(Exception):
+        Content.from_markup("[")
+
+    with pytest.raises(Exception):
+        Content.from_markup("[:")
+
+    with pytest.raises(Exception):
+        Content.from_markup("[foo")