Repository: Textualize/textual

Issue:
Tree `disabled=True` breaks when setting at initialization
Using the `Tree` example from the docs, if you pass in `disabled=True` during initialization the app break with 2 exceptions.

**Thrown Exceptions:**
1. `AttributeError: 'Tree' object has no attribute '_line_cache'`
2. `ScreenStackError: No screens on stack`

I can see in the source that the call to `super().__init__()` on the base `Widget` object is called before setting the `_line_cache` so I'm not sure if that is the problem or not.

```python
from textual.app import App, ComposeResult
from textual.widgets import Tree


class TreeApp(App):
    def compose(self) -> ComposeResult:
        tree: Tree[dict] = Tree("Dune", disabled=True)
        tree.root.expand()
        characters = tree.root.add("Characters", expand=True)
        characters.add_leaf("Paul")
        characters.add_leaf("Jessica")
        characters.add_leaf("Chani")
        yield tree


if __name__ == "__main__":
    app = TreeApp()
    app.run()
```

Enabling and disabling the Tree after the app is composed works just fine as can be seen using this example (using the 'D' keybinding).

```python
from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, Tree


class TreeApp(App):
    BINDINGS = [
        ("d", "disable_tree", "Toggle Tree Availability"),
    ]

    def compose(self) -> ComposeResult:
        tree: Tree[dict] = Tree("Dune")
        tree.root.expand()
        characters = tree.root.add("Characters", expand=True)
        characters.add_leaf("Paul")
        characters.add_leaf("Jessica")
        characters.add_leaf("Chani")
        yield Header()
        yield Footer()
        yield tree

    def action_disable_tree(self) -> None:
        tree = self.query_one(Tree)
        tree.disabled = not tree.disabled


if __name__ == "__main__":
    app = TreeApp()
    app.run()
```

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 330dac5bf..6fbc58b44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 ### Fixed
 
 - `Pilot.click`/`Pilot.hover` can't use `Screen` as a selector https://github.com/Textualize/textual/issues/3395
+- App exception when a `Tree` is initialized/mounted with `disabled=True` https://github.com/Textualize/textual/issues/3407
 
 ### Added
 
diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py
index b094f7568..c413d7910 100644
--- a/src/textual/widgets/_tree.py
+++ b/src/textual/widgets/_tree.py
@@ -597,8 +597,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
             disabled: Whether the tree is disabled or not.
         """
 
-        super().__init__(name=name, id=id, classes=classes, disabled=disabled)
-
         text_label = self.process_label(label)
 
         self._updates = 0
@@ -610,6 +608,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
         self._tree_lines_cached: list[_TreeLine] | None = None
         self._cursor_node: TreeNode[TreeDataType] | None = None
 
+        super().__init__(name=name, id=id, classes=classes, disabled=disabled)
+
     @property
     def cursor_node(self) -> TreeNode[TreeDataType] | None:
         """The currently selected node, or ``None`` if no selection."""


Test patch:
diff --git a/tests/tree/test_tree_availability.py b/tests/tree/test_tree_availability.py
new file mode 100644
index 000000000..c3f509446
--- /dev/null
+++ b/tests/tree/test_tree_availability.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from typing import Any
+
+from textual import on
+from textual.app import App, ComposeResult
+from textual.widgets import Tree
+
+
+class TreeApp(App[None]):
+    """Test tree app."""
+
+    def __init__(self, disabled: bool, *args: Any, **kwargs: Any) -> None:
+        super().__init__(*args, **kwargs)
+        self.disabled = disabled
+        self.messages: list[tuple[str, str]] = []
+
+    def compose(self) -> ComposeResult:
+        """Compose the child widgets."""
+        yield Tree("Root", disabled=self.disabled, id="test-tree")
+
+    def on_mount(self) -> None:
+        self.query_one(Tree).root.add("Child")
+        self.query_one(Tree).focus()
+
+    def record(
+        self,
+        event: Tree.NodeSelected[None]
+        | Tree.NodeExpanded[None]
+        | Tree.NodeCollapsed[None]
+        | Tree.NodeHighlighted[None],
+    ) -> None:
+        self.messages.append(
+            (event.__class__.__name__, event.node.tree.id or "Unknown")
+        )
+
+    @on(Tree.NodeSelected)
+    def node_selected(self, event: Tree.NodeSelected[None]) -> None:
+        self.record(event)
+
+    @on(Tree.NodeExpanded)
+    def node_expanded(self, event: Tree.NodeExpanded[None]) -> None:
+        self.record(event)
+
+    @on(Tree.NodeCollapsed)
+    def node_collapsed(self, event: Tree.NodeCollapsed[None]) -> None:
+        self.record(event)
+
+    @on(Tree.NodeHighlighted)
+    def node_highlighted(self, event: Tree.NodeHighlighted[None]) -> None:
+        self.record(event)
+
+
+async def test_creating_disabled_tree():
+    """Mounting a disabled `Tree` should result in the base `Widget`
+    having a `disabled` property equal to `True`"""
+    app = TreeApp(disabled=True)
+    async with app.run_test() as pilot:
+        tree = app.query_one(Tree)
+        assert not tree.focusable
+        assert tree.disabled
+        assert tree.cursor_line == 0
+        await pilot.click("#test-tree")
+        await pilot.pause()
+        await pilot.press("down")
+        await pilot.pause()
+        assert tree.cursor_line == 0
+
+
+async def test_creating_enabled_tree():
+    """Mounting an enabled `Tree` should result in the base `Widget`
+    having a `disabled` property equal to `False`"""
+    app = TreeApp(disabled=False)
+    async with app.run_test() as pilot:
+        tree = app.query_one(Tree)
+        assert tree.focusable
+        assert not tree.disabled
+        assert tree.cursor_line == 0
+        await pilot.click("#test-tree")
+        await pilot.pause()
+        await pilot.press("down")
+        await pilot.pause()
+        assert tree.cursor_line == 1
+
+
+async def test_disabled_tree_node_selected_message() -> None:
+    """Clicking the root node disclosure triangle on a disabled tree
+    should result in no messages being emitted."""
+    app = TreeApp(disabled=True)
+    async with app.run_test() as pilot:
+        tree = app.query_one(Tree)
+        # try clicking on a disabled tree
+        await pilot.click("#test-tree")
+        await pilot.pause()
+        assert not pilot.app.messages
+        # make sure messages DO flow after enabling a disabled tree
+        tree.disabled = False
+        await pilot.click("#test-tree")
+        await pilot.pause()
+        assert pilot.app.messages == [("NodeExpanded", "test-tree")]
+
+
+async def test_enabled_tree_node_selected_message() -> None:
+    """Clicking the root node disclosure triangle on an enabled tree
+    should result in an `NodeExpanded` message being emitted."""
+    app = TreeApp(disabled=False)
+    async with app.run_test() as pilot:
+        tree = app.query_one(Tree)
+        # try clicking on an enabled tree
+        await pilot.click("#test-tree")
+        await pilot.pause()
+        assert pilot.app.messages == [("NodeExpanded", "test-tree")]
+        tree.disabled = True
+        # make sure messages DO NOT flow after disabling an enabled tree
+        app.messages = []
+        await pilot.click("#test-tree")
+        await pilot.pause()
+        assert not pilot.app.messages