Repository: Textualize/textual

Issue:
Make Title of a Collapsible Widget Editable
Can not update title property of the Collapsible widget , looking at CollapsibleTitle class it seems to only set at init
Looks like this was an intentional choice ?
But I think it would be helpful to make the title property editable ,
Eg: changing content in the collapsed state and don't want to toggle it open to get a quick status update, so write something in .title... 

~~~
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, VerticalScroll
from textual.widgets import Header, Footer, Collapsible, Placeholder

class AnApp(App):

    TITLE = "Collapsible Titles Should Update"

    BINDINGS = [("d", "toggle_dark", "Update"),
    ("c", "collapse_or_expand(True)", "Collapse"),
    ("e", "collapse_or_expand(False)", "Expand")]
    
    def action_collapse_or_expand(self, collapse: bool) -> None:
        for child in self.walk_children(Collapsible):
            child.collapsed = collapse

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header(show_clock=True)
        yield VerticalScroll(
            Collapsible(
                Horizontal(
                 Placeholder(variant="text"),
                 Placeholder(variant="size"),
                 Placeholder(variant="text"),
            ),
            title="Top_Stuff",id="top"
            )
        )
        yield Footer()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        top_collapsible_widget = self.query_one("#top")
        top_collapsible_widget.title = "UPDATED_Top_Stuff"
        self.app.title = "UPDATED_App_Title"
        self.dark = not self.dark


if __name__ == "__main__":
    app = AnApp()
    app.run()
~~~

Discussion:
TomJGooding: Hm, I'm getting this error trying to run `make docs-serve-offline`  - anyone  come across this before?

```
RecursionError: maximum recursion depth exceeded in comparison
```
davep: Same here, @rodrigogiraoserrao too. 
TomJGooding: Blimey was not expecting a reply that fast, thanks Dave. Have you found any workaround?
davep: Unfortunately not; I had to make some doc changes in the blind today because of this.
TomJGooding: Perhaps it has been a while since I updated the docs, but I think this was working _fairly_ recently. Do you happen to remember approximately when you started getting this error?
rodrigogiraoserrao: > Have you found any workaround?

Don't try to build the docs.


davep: > Do you happen to remember approximately when you started getting this error?

Yesterday. It's the first time I've needed to try and personally build the docs in weeks. 
willmcgugan: Docs should be fixed in https://github.com/Textualize/textual/pull/3841

Patch:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 882da2b35..02e367aa4 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -17,7 +17,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, windows-latest, macos-latest]
-        python-version: ["3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
     defaults:
       run:
         shell: bash
@@ -32,9 +32,18 @@ jobs:
           cache: 'poetry'
       - name: Install dependencies
         run: poetry install --no-interaction --extras syntax
+        if: ${{ matrix.python-version != '3.12' }}
+      - name: Install dependencies for 3.12  # https://github.com/Textualize/textual/issues/3491#issuecomment-1854156476
+        run: poetry install --no-interaction
+        if: ${{ matrix.python-version == '3.12' }}
       - name: Test with pytest
         run: |
           poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing
+        if: ${{ matrix.python-version != '3.12' }}
+      - name: Test with pytest for 3.12  # https://github.com/Textualize/textual/issues/3491#issuecomment-1854156476
+        run: |
+          poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing -m 'not syntax'
+        if: ${{ matrix.python-version == '3.12' }}
       - name: Upload snapshot report
         if: always()
         uses: actions/upload-artifact@v3
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 625a57b20..a8ec8fe01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 - Added `get_loading_widget` to Widget and App customize the loading widget. https://github.com/Textualize/textual/pull/3816
 - Added messages `Collapsible.Expanded` and `Collapsible.Collapsed` that inherit from `Collapsible.Toggled`. https://github.com/Textualize/textual/issues/3824
+- Added `Collapsible.title` reactive attribute https://github.com/Textualize/textual/pull/3830
 
 ## [0.44.1] - 2023-12-4
 
diff --git a/docs/examples/guide/workers/weather05.py b/docs/examples/guide/workers/weather05.py
index c1da80cb4..a4d804996 100644
--- a/docs/examples/guide/workers/weather05.py
+++ b/docs/examples/guide/workers/weather05.py
@@ -1,3 +1,4 @@
+from urllib.parse import quote
 from urllib.request import Request, urlopen
 
 from rich.text import Text
@@ -30,7 +31,7 @@ class WeatherApp(App):
         worker = get_current_worker()
         if city:
             # Query the network API
-            url = f"https://wttr.in/{city}"
+            url = f"https://wttr.in/{quote(city)}"
             request = Request(url)
             request.add_header("User-agent", "CURL")
             response_text = urlopen(request).read().decode("utf-8")
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 0e031a266..46addc0bc 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -2,7 +2,7 @@ All you need to get started building Textual apps.
 
 ## Requirements
 
-Textual requires Python 3.7 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs.
+Textual requires Python 3.8 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs.
 
 !!! info inline end "Your platform"
 
diff --git a/docs/guide/design.md b/docs/guide/design.md
index 46b8d4d04..0fbd74a21 100644
--- a/docs/guide/design.md
+++ b/docs/guide/design.md
@@ -93,10 +93,13 @@ textual colors
 
 Here's a list of the colors defined in the default light and dark themes.
 
+!!! note
+
+    `$boost` will look different on different backgrounds because of its alpha channel.
+
 ```{.rich title="Textual Theme Colors"}
 from rich import print
 from textual.app import DEFAULT_COLORS
 from textual.design import show_design
 output = show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"])
 ```
-
diff --git a/docs/guide/workers.md b/docs/guide/workers.md
index a8eff8432..5de6f3b32 100644
--- a/docs/guide/workers.md
+++ b/docs/guide/workers.md
@@ -158,7 +158,7 @@ The second difference is that you can't cancel threads in the same way as corout
 Let's demonstrate thread workers by replacing `httpx` with `urllib.request` (in the standard library).
 The `urllib` module is not async aware, so we will need to use threads:
 
-```python title="weather05.py" hl_lines="1 26-43"
+```python title="weather05.py" hl_lines="1-2 27-44"
 --8<-- "docs/examples/guide/workers/weather05.py"
 ```
 
diff --git a/docs/widgets/collapsible.md b/docs/widgets/collapsible.md
index 417ce114c..9ba79f8b4 100644
--- a/docs/widgets/collapsible.md
+++ b/docs/widgets/collapsible.md
@@ -122,9 +122,10 @@ The following example shows `Collapsible` widgets with custom expand/collapse sy
 
 ## Reactive Attributes
 
-| Name        | Type   | Default | Description                                          |
-| ----------- | ------ | ------- | ---------------------------------------------------- |
-| `collapsed` | `bool` | `True`  | Controls the collapsed/expanded state of the widget. |
+| Name        | Type   | Default     | Description                                          |
+| ----------- | ------ | ------------| ---------------------------------------------------- |
+| `collapsed` | `bool` | `True`      | Controls the collapsed/expanded state of the widget. |
+| `title`     | `str`  | `"Toggle"`  | Title of the collapsed/expanded contents.            |
 
 ## Messages
 
diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md
index a8c648d64..bc3a5e25a 100644
--- a/docs/widgets/text_area.md
+++ b/docs/widgets/text_area.md
@@ -56,9 +56,6 @@ To update the parser used for syntax highlighting, set the [`language`][textual.
 text_area.language = "markdown"
 ```
 
-!!! note
-    Syntax highlighting is unavailable on Python 3.7.
-
 !!! note
     More built-in languages will be added in the future. For now, you can [add your own](#adding-support-for-custom-languages).
 
@@ -391,10 +388,6 @@ from tree_sitter_languages import get_language
 java_language = get_language("java")
 ```
 
-!!! note
-
-    `py-tree-sitter-languages` may not be available on some architectures (e.g. Macbooks with Apple Silicon running Python 3.7).
-
 The exact version of the parser used when you call `get_language` can be checked via
 the [`repos.txt` file](https://github.com/grantjenks/py-tree-sitter-languages/blob/a6d4f7c903bf647be1bdcfa504df967d13e40427/repos.txt) in
 the version of `py-tree-sitter-languages` you're using. This file contains links to the GitHub
diff --git a/poetry.lock b/poetry.lock
index afd06065e..3dfffadb4 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
 
 [[package]]
 name = "aiohttp"
@@ -831,6 +831,16 @@ files = [
     {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
     {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
     {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
+    {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
     {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
     {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
     {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
@@ -1512,6 +1522,7 @@ files = [
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
     {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+    {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
     {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
     {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
     {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
@@ -1519,8 +1530,15 @@ files = [
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
     {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+    {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
     {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
     {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+    {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+    {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+    {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+    {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+    {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+    {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
     {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
     {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
@@ -1537,6 +1555,7 @@ files = [
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
     {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+    {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
     {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
     {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
     {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
@@ -1544,6 +1563,7 @@ files = [
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
     {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+    {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
     {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
     {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
     {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
@@ -2318,4 +2338,4 @@ syntax = ["tree-sitter", "tree_sitter_languages"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "af9626b447a24031c00d3dfe01226b1896b4356e753f0a84f4c1748e7a166f8a"
+content-hash = "c4c26f6d0bd1266a7a38b9236c99cf51bf658447a18ffc2c96fb5da442762d6a"
diff --git a/pyproject.toml b/pyproject.toml
index 581772683..0c399fce3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,7 +44,6 @@ python = "^3.8"
 rich = ">=13.3.3"
 markdown-it-py = { extras = ["plugins", "linkify"], version = ">=2.1.0" }
 #rich = {path="../rich", develop=true}
-importlib-metadata = ">=4.11.3"
 typing-extensions = "^4.4.0"
 tree-sitter = { version = "^0.20.1", optional = true }
 tree_sitter_languages = { version = ">=1.7.0", optional = true }
@@ -83,6 +82,7 @@ testpaths = ["tests"]
 addopts = "--strict-markers"
 markers = [
     "integration_test: marks tests as slow integration tests (deselect with '-m \"not integration_test\"')",
+    "syntax: marks tests that require syntax highlighting (deselect with '-m \"not syntax\"')",
 ]
 
 [build-system]
diff --git a/src/textual/__init__.py b/src/textual/__init__.py
index 103f9db2f..0b9301012 100644
--- a/src/textual/__init__.py
+++ b/src/textual/__init__.py
@@ -28,16 +28,11 @@ LogCallable: TypeAlias = "Callable"
 
 
 def __getattr__(name: str) -> str:
-    """Lazily get the version from whatever API is available."""
+    """Lazily get the version."""
     if name == "__version__":
-        try:
-            from importlib.metadata import version
-        except ImportError:
-            import pkg_resources
+        from importlib.metadata import version
 
-            return pkg_resources.get_distribution("textual").version
-        else:
-            return version("textual")
+        return version("textual")
     raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
 
 
diff --git a/src/textual/demo.py b/src/textual/demo.py
index 2638a3312..2f37d3718 100644
--- a/src/textual/demo.py
+++ b/src/textual/demo.py
@@ -1,8 +1,8 @@
 from __future__ import annotations
 
+from importlib.metadata import version
 from pathlib import Path
 
-from importlib_metadata import version
 from rich import box
 from rich.console import RenderableType
 from rich.json import JSON
diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py
index ba3565f20..79a04816d 100644
--- a/src/textual/widgets/_collapsible.py
+++ b/src/textual/widgets/_collapsible.py
@@ -1,8 +1,5 @@
 from __future__ import annotations
 
-from rich.console import RenderableType
-from rich.text import Text
-
 from .. import events
 from ..app import ComposeResult
 from ..binding import Binding
@@ -11,11 +8,12 @@ from ..css.query import NoMatches
 from ..message import Message
 from ..reactive import reactive
 from ..widget import Widget
+from ..widgets import Static
 
 __all__ = ["Collapsible", "CollapsibleTitle"]
 
 
-class CollapsibleTitle(Widget, can_focus=True):
+class CollapsibleTitle(Static, can_focus=True):
     """Title and symbol for the Collapsible."""
 
     DEFAULT_CSS = """
@@ -44,6 +42,7 @@ class CollapsibleTitle(Widget, can_focus=True):
     """
 
     collapsed = reactive(True)
+    label = reactive("Toggle")
 
     def __init__(
         self,
@@ -57,7 +56,9 @@ class CollapsibleTitle(Widget, can_focus=True):
         self.collapsed_symbol = collapsed_symbol
         self.expanded_symbol = expanded_symbol
         self.label = label
-        self.collapse = collapsed
+        self.collapsed = collapsed
+        self._collapsed_label = f"{collapsed_symbol} {label}"
+        self._expanded_label = f"{expanded_symbol} {label}"
 
     class Toggle(Message):
         """Request toggle."""
@@ -71,18 +72,26 @@ class CollapsibleTitle(Widget, can_focus=True):
         """Toggle the state of the parent collapsible."""
         self.post_message(self.Toggle())
 
-    def render(self) -> RenderableType:
-        """Compose right/down arrow and label."""
+    def _watch_label(self, label: str) -> None:
+        self._collapsed_label = f"{self.collapsed_symbol} {label}"
+        self._expanded_label = f"{self.expanded_symbol} {label}"
         if self.collapsed:
-            return Text(f"{self.collapsed_symbol} {self.label}")
+            self.update(self._collapsed_label)
+        else:
+            self.update(self._expanded_label)
+
+    def _watch_collapsed(self, collapsed: bool) -> None:
+        if collapsed:
+            self.update(self._collapsed_label)
         else:
-            return Text(f"{self.expanded_symbol} {self.label}")
+            self.update(self._expanded_label)
 
 
 class Collapsible(Widget):
     """A collapsible container."""
 
     collapsed = reactive(True)
+    title = reactive("Toggle")
 
     DEFAULT_CSS = """
     Collapsible {
@@ -169,14 +178,15 @@ class Collapsible(Widget):
             classes: The CSS classes of the collapsible.
             disabled: Whether the collapsible is disabled or not.
         """
+        super().__init__(name=name, id=id, classes=classes, disabled=disabled)
         self._title = CollapsibleTitle(
             label=title,
             collapsed_symbol=collapsed_symbol,
             expanded_symbol=expanded_symbol,
             collapsed=collapsed,
         )
+        self.title = title
         self._contents_list: list[Widget] = list(children)
-        super().__init__(name=name, id=id, classes=classes, disabled=disabled)
         self.collapsed = collapsed
 
     def _on_collapsible_title_toggle(self, event: CollapsibleTitle.Toggle) -> None:
@@ -214,3 +224,6 @@ class Collapsible(Widget):
             widget: A Widget to add.
         """
         self._contents_list.append(widget)
+
+    def _watch_title(self, title: str) -> None:
+        self._title.label = title


Test patch:
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index be8a4b4e8..66cbe6a53 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -795,6 +795,7 @@ def test_nested_fr(snap_compare) -> None:
     assert snap_compare(SNAPSHOT_APPS_DIR / "nested_fr.py")
 
 
+@pytest.mark.syntax
 @pytest.mark.parametrize("language", BUILTIN_LANGUAGES)
 def test_text_area_language_rendering(language, snap_compare):
     # This test will fail if we're missing a snapshot test for a valid
@@ -846,6 +847,7 @@ I am the final line."""
     )
 
 
+@pytest.mark.syntax
 @pytest.mark.parametrize(
     "theme_name", [theme.name for theme in TextAreaTheme.builtin_themes()]
 )
diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py
index 2214d9fb9..116ac74c9 100644
--- a/tests/test_collapsible.py
+++ b/tests/test_collapsible.py
@@ -189,3 +189,15 @@ async def test_collapse_message():
 
         assert pilot.app.query_one(Collapsible).collapsed
         assert len(hits) == 1
+
+
+async def test_collapsible_title_reactive_change():
+    class CollapsibleApp(App[None]):
+        def compose(self) -> ComposeResult:
+            yield Collapsible(title="Old title")
+
+    async with CollapsibleApp().run_test() as pilot:
+        collapsible = pilot.app.query_one(Collapsible)
+        assert get_title(collapsible).label == "Old title"
+        collapsible.title = "New title"
+        assert get_title(collapsible).label == "New title"
diff --git a/tests/text_area/test_languages.py b/tests/text_area/test_languages.py
index 6124da0cb..7e33fcf72 100644
--- a/tests/text_area/test_languages.py
+++ b/tests/text_area/test_languages.py
@@ -1,5 +1,3 @@
-import sys
-
 import pytest
 
 from textual.app import App, ComposeResult
@@ -61,7 +59,7 @@ async def test_setting_unknown_language():
             text_area.language = "this-language-doesnt-exist"
 
 
-@pytest.mark.skipif(sys.version_info < (3, 8), reason="tree-sitter requires python3.8 or higher")
+@pytest.mark.syntax
 async def test_register_language():
     app = TextAreaApp()
 
@@ -84,7 +82,7 @@ async def test_register_language():
         assert text_area.language == "elm"
 
 
-@pytest.mark.skipif(sys.version_info < (3, 8), reason="tree-sitter requires python3.8 or higher")
+@pytest.mark.syntax
 async def test_register_language_existing_language():
     app = TextAreaApp()
     async with app.run_test():