Repository: Textualize/textual

Issue:
It's possible to somehow break the working of `TabbedContent`
This one isn't exactly easy to explain, but hopefully easy to see and recreate with the following code (which is a distilled version of what I'm doing in a bigger application, where I found it).

Worth noting: the motivation here is that (in the application I'm working on) the `TabbedContent` is acting as a sidebar, where each pane has content that can be focused, and I want folk to be people to switch tabs without needing to navigate to the tab bar and back into the pane again. As such there's some bindings in place that call into the `Tabs` and uses their prev/next tab actions.

```python
from textual.app        import App, ComposeResult
from textual.binding    import Binding
from textual.containers import Vertical
from textual.widgets    import Header, Footer, TabbedContent, TabPane, Tabs, DirectoryTree

class SelfFocusPane(TabPane):

    DEFAULT_CSS = """
    SelfFocusPane {
        height: 100% !important;
    }
    DirectoryTree {
        width: 100%;
        height: 100% !important;
    }
    """

    def compose( self ) -> ComposeResult:
        """Compose the child widgets."""
        yield DirectoryTree(".")

    def on_show( self ) -> None:
        self.query_one( DirectoryTree ).focus()

class TabbedContentIssueApp( App[ None ] ):

    CSS = """
    Screen {
        align: center middle;
    }

    Screen > Vertical {
        width: 42;
    }

    TabbedContent {
        border: round red;
        max-width: 40;
        height: 100%;
    }

    ContentSwitcher {
        height: 1fr !important;
    }
    """

    BINDINGS = [
        Binding( "shift+left", "previous", "Previous" ),
        Binding( "shift+right", "next", "Next" ),
    ]

    def compose( self ) -> ComposeResult:
        yield Header()
        with Vertical():
            with TabbedContent():
                for n in range( 6 ):
                    yield SelfFocusPane( f"Tab {n}")
        yield Footer()

    def on_mount(self) -> None:
        self.query_one(Tabs).focus()

    def action_previous(self) -> None:
        self.query_one(Tabs).action_previous_tab()

    def action_next(self) -> None:
        self.query_one(Tabs).action_next_tab()

if __name__ == "__main__":
    TabbedContentIssueApp().run()
```

In experimenting, it looks like the `SelfFocusPane.on_show` setting focus to the child of the pane is key here; remove that and I can't recreate the issue.


Discussion:
Hey @davep, can you be more specific about what I should see that is wrong, or how to trigger it exactly?

I opened the app, I used the arrow keys and Enter to move around the directory trees, I can use the bindings to shift to different tabs, and everything seems to be working just fine.
Try just holding the bound keys down and let it go full speed, letting it wrap around plenty of times. Hopefully the issue should appear. 

If not I'll retest here with main. 
I held down Shift + Right for a bit, then I let go.
When I do, the underline will stay pretty much frozen somewhere (not necessarily aligned with a tab) and clicking the bindings in the footer will only work occasionally.
Is this more or less the issue you find?
Yeah, that's it.
I found a funnier way of triggering the issue:

Add this handler to your app:

```py
class TabbedContentIssueApp(App[None]):
    # ...
    def key_p(self) -> None:
        self.action_next()
        self.action_next()
```

If you press `P` just **once**, the underline bar is going to take AGES to move two tabs to the right.
I haven't figured out _exactly_ what funky interaction is happening, but I have a potential fix in the works.

Resolving patch:
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -83,9 +83,6 @@ class TabbedContent(Widget):
     }
     """
 
-    active: reactive[str] = reactive("", init=False)
-    """The ID of the active tab, or empty string if none are active."""
-
     class TabActivated(Message):
         """Posted when the active tab changes."""
 
@@ -116,21 +113,16 @@ def __init__(self, *titles: TextType, initial: str = "") -> None:
         self._initial = initial
         super().__init__()
 
-    def validate_active(self, active: str) -> str:
-        """It doesn't make sense for `active` to be an empty string.
-
-        Args:
-            active: Attribute to be validated.
-
-        Returns:
-            Value of `active`.
+    @property
+    def active(self) -> str:
+        """The ID of the active tab, or empty string if none are active."""
+        return self.get_child_by_type(Tabs).active
 
-        Raises:
-            ValueError: If the active attribute is set to empty string.
-        """
+    @active.setter
+    def active(self, active: str) -> None:
         if not active:
             raise ValueError("'active' tab must not be empty string.")
-        return active
+        self.get_child_by_type(Tabs).active = active
 
     def compose(self) -> ComposeResult:
         """Compose the tabbed content."""
@@ -186,7 +178,6 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
         switcher = self.get_child_by_type(ContentSwitcher)
         assert isinstance(event.tab, ContentTab)
         switcher.current = event.tab.id
-        self.active = event.tab.id
         self.post_message(
             TabbedContent.TabActivated(
                 tabbed_content=self,
@@ -197,7 +188,3 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
     def _on_tabs_cleared(self, event: Tabs.Cleared) -> None:
         """All tabs were removed."""
         event.stop()
-
-    def watch_active(self, active: str) -> None:
-        """Switch tabs when the active attributes changes."""
-        self.get_child_by_type(Tabs).active = active


Test patch:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -19676,6 +19676,165 @@
   
   '''
 # ---
+# name: test_quickly_change_tabs
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-1586716314-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-1586716314-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-1586716314-r1 { fill: #c5c8c6 }
+  .terminal-1586716314-r2 { fill: #737373 }
+  .terminal-1586716314-r3 { fill: #e1e1e1;font-weight: bold }
+  .terminal-1586716314-r4 { fill: #323232 }
+  .terminal-1586716314-r5 { fill: #0178d4 }
+  .terminal-1586716314-r6 { fill: #e1e1e1 }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-1586716314-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-1586716314-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1586716314-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1586716314-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">QuicklyChangeTabsApp</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-1586716314-clip-terminal)">
+      <rect fill="#1e1e1e" x="0" y="1.5" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="73.2" y="1.5" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="146.4" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="244" y="1.5" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="25.9" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="61" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="73.2" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="97.6" y="25.9" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="134.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="146.4" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="170.8" y="25.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="231.8" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="244" y="25.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="158.6" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="170.8" y="50.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="231.8" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="244" y="50.3" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="99.1" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="85.4" y="99.1" width="866.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="951.6" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-1586716314-matrix">
+      <text class="terminal-1586716314-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1586716314-line-0)">
+  </text><text class="terminal-1586716314-r2" x="24.4" y="44.4" textLength="36.6" clip-path="url(#terminal-1586716314-line-1)">one</text><text class="terminal-1586716314-r2" x="97.6" y="44.4" textLength="36.6" clip-path="url(#terminal-1586716314-line-1)">two</text><text class="terminal-1586716314-r3" x="170.8" y="44.4" textLength="61" clip-path="url(#terminal-1586716314-line-1)">three</text><text class="terminal-1586716314-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1586716314-line-1)">
+  </text><text class="terminal-1586716314-r4" x="0" y="68.8" textLength="158.6" clip-path="url(#terminal-1586716314-line-2)">━━━━━━━━━━━━━</text><text class="terminal-1586716314-r4" x="158.6" y="68.8" textLength="12.2" clip-path="url(#terminal-1586716314-line-2)">╸</text><text class="terminal-1586716314-r5" x="170.8" y="68.8" textLength="61" clip-path="url(#terminal-1586716314-line-2)">━━━━━</text><text class="terminal-1586716314-r4" x="231.8" y="68.8" textLength="12.2" clip-path="url(#terminal-1586716314-line-2)">╺</text><text class="terminal-1586716314-r4" x="244" y="68.8" textLength="732" clip-path="url(#terminal-1586716314-line-2)">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text class="terminal-1586716314-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1586716314-line-2)">
+  </text><text class="terminal-1586716314-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1586716314-line-3)">
+  </text><text class="terminal-1586716314-r6" x="24.4" y="117.6" textLength="61" clip-path="url(#terminal-1586716314-line-4)">three</text><text class="terminal-1586716314-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1586716314-line-4)">
+  </text><text class="terminal-1586716314-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1586716314-line-5)">
+  </text><text class="terminal-1586716314-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1586716314-line-6)">
+  </text><text class="terminal-1586716314-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1586716314-line-7)">
+  </text><text class="terminal-1586716314-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1586716314-line-8)">
+  </text><text class="terminal-1586716314-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1586716314-line-9)">
+  </text><text class="terminal-1586716314-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1586716314-line-10)">
+  </text><text class="terminal-1586716314-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1586716314-line-11)">
+  </text><text class="terminal-1586716314-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1586716314-line-12)">
+  </text><text class="terminal-1586716314-r1" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1586716314-line-13)">
+  </text><text class="terminal-1586716314-r1" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1586716314-line-14)">
+  </text><text class="terminal-1586716314-r1" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1586716314-line-15)">
+  </text><text class="terminal-1586716314-r1" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1586716314-line-16)">
+  </text><text class="terminal-1586716314-r1" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1586716314-line-17)">
+  </text><text class="terminal-1586716314-r1" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1586716314-line-18)">
+  </text><text class="terminal-1586716314-r1" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1586716314-line-19)">
+  </text><text class="terminal-1586716314-r1" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1586716314-line-20)">
+  </text><text class="terminal-1586716314-r1" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1586716314-line-21)">
+  </text><text class="terminal-1586716314-r1" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1586716314-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
 # name: test_radio_button_example
   '''
   <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
diff --git a/tests/snapshot_tests/snapshot_apps/quickly_change_tabs.py b/tests/snapshot_tests/snapshot_apps/quickly_change_tabs.py
new file mode 100644
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/quickly_change_tabs.py
@@ -0,0 +1,24 @@
+"""Regression test for https://github.com/Textualize/textual/issues/2229."""
+from textual.app import App, ComposeResult
+from textual.widgets import TabbedContent, TabPane, Tabs, Label
+
+
+class QuicklyChangeTabsApp(App[None]):
+    def compose(self) -> ComposeResult:
+        with TabbedContent():
+            with TabPane("one"):
+                yield Label("one")
+            with TabPane("two"):
+                yield Label("two")
+            with TabPane("three", id="three"):
+                yield Label("three")
+
+    def key_p(self) -> None:
+        self.query_one(Tabs).action_next_tab()
+        self.query_one(Tabs).action_next_tab()
+
+
+app = QuicklyChangeTabsApp()
+
+if __name__ == "__main__":
+    app.run()
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -429,3 +429,8 @@ def test_scroll_to_center(snap_compare):
     # scrolled so that the red string >>bullseye<< is centered on the screen.
     # When this snapshot "breaks" because #2254 is fixed, this snapshot can be updated.
     assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_to_center.py", press=["s"])
+
+
+def test_quickly_change_tabs(snap_compare):
+    # https://github.com/Textualize/textual/issues/2229
+    assert snap_compare(SNAPSHOT_APPS_DIR / "quickly_change_tabs.py", press=["p"])