Skip to content

Commit efbb655

Browse files
authored
Handle other Tabs widgets as DOM descendants of a TabbedContent (#3602)
* Handle other Tabs widgets as DOM descendants of a TabbedContent * Update CHANGELOG.md * Ensure TabbedContent can identify messages from the associated Tabs so that it can ignore messages from other, irrelevant Tabs instances that may exist as descendants deeper in the DOM. Also adds some tests to ensure corresponding issues are fixed. * Improve docstrings for Tabs.Cleared and corresponding handler inside TabbedContent - it now includes a note that the Cleared message is sent when all tabs are hidden.
1 parent 3f33cd1 commit efbb655

File tree

4 files changed

+127
-30
lines changed

4 files changed

+127
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2020
- Fix issue with chunky highlights on buttons https://github.com/Textualize/textual/pull/3571
2121
- Fixed `OptionList` event leakage from `CommandPalette` to `App`.
2222
- Fixed crash in `LoadingIndicator` https://github.com/Textualize/textual/pull/3498
23+
- Fixed crash when `Tabs` appeared as a descendant of `TabbedContent` in the DOM https://github.com/Textualize/textual/pull/3602
2324

2425
### Added
2526

src/textual/widgets/_tabbed_content.py

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ def __init__(self, label: Text, content_id: str, disabled: bool = False):
3737
super().__init__(label, id=content_id, disabled=disabled)
3838

3939

40+
class ContentTabs(Tabs):
41+
"""A Tabs which is associated with a TabbedContent."""
42+
43+
def __init__(
44+
self,
45+
*tabs: Tab | TextType,
46+
active: str | None = None,
47+
tabbed_content: TabbedContent,
48+
):
49+
"""Initialize a ContentTabs.
50+
51+
Args:
52+
*tabs: The child tabs.
53+
active: ID of the tab which should be active on start.
54+
tabbed_content: The associated TabbedContent instance.
55+
"""
56+
super().__init__(*tabs, active=active)
57+
self.tabbed_content = tabbed_content
58+
59+
4060
class TabPane(Widget):
4161
"""A container for switchable content, with additional title.
4262
@@ -245,11 +265,21 @@ def compose(self) -> ComposeResult:
245265
]
246266
# Get a tab for each pane
247267
tabs = [
248-
ContentTab(content._title, content.id or "", disabled=content.disabled)
268+
ContentTab(
269+
content._title,
270+
content.id or "",
271+
disabled=content.disabled,
272+
)
249273
for content in pane_content
250274
]
251-
# Yield the tabs
252-
yield Tabs(*tabs, active=self._initial or None)
275+
276+
# Yield the tabs, and ensure they're linked to this TabbedContent.
277+
# It's important to associate the Tabs with the TabbedContent, so that this
278+
# TabbedContent can determine whether a message received from a Tabs instance
279+
# has been sent from this Tabs, or from a Tabs that may exist as a descendant
280+
# deeper in the DOM.
281+
yield ContentTabs(*tabs, active=self._initial or None, tabbed_content=self)
282+
253283
# Yield the content switcher and panes
254284
with ContentSwitcher(initial=self._initial or None):
255285
yield from pane_content
@@ -282,7 +312,7 @@ def add_pane(
282312
before = before.id
283313
if isinstance(after, TabPane):
284314
after = after.id
285-
tabs = self.get_child_by_type(Tabs)
315+
tabs = self.get_child_by_type(ContentTabs)
286316
pane = self._set_id(pane, tabs.tab_count + 1)
287317
assert pane.id is not None
288318
pane.display = False
@@ -301,7 +331,7 @@ def remove_pane(self, pane_id: str) -> AwaitComplete:
301331
An optionally awaitable object that waits for the pane to be removed
302332
and the Cleared message to be posted.
303333
"""
304-
removal_awaitables = [self.get_child_by_type(Tabs).remove_tab(pane_id)]
334+
removal_awaitables = [self.get_child_by_type(ContentTabs).remove_tab(pane_id)]
305335
try:
306336
removal_awaitables.append(
307337
self.get_child_by_type(ContentSwitcher)
@@ -332,7 +362,7 @@ def clear_panes(self) -> AwaitComplete:
332362
and the Cleared message to be posted.
333363
"""
334364
await_clear = gather(
335-
self.get_child_by_type(Tabs).clear(),
365+
self.get_child_by_type(ContentTabs).clear(),
336366
self.get_child_by_type(ContentSwitcher).remove_children(),
337367
)
338368

@@ -356,35 +386,52 @@ def compose_add_child(self, widget: Widget) -> None:
356386

357387
def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
358388
"""User clicked a tab."""
359-
assert isinstance(event.tab, ContentTab)
360-
assert isinstance(event.tab.id, str)
361-
event.stop()
362-
switcher = self.get_child_by_type(ContentSwitcher)
363-
switcher.current = event.tab.id
364-
self.active = event.tab.id
365-
self.post_message(
366-
TabbedContent.TabActivated(
367-
tabbed_content=self,
368-
tab=event.tab,
389+
if self._is_associated_tabs(event.tabs):
390+
# The message is relevant, so consume it and update state accordingly.
391+
event.stop()
392+
switcher = self.get_child_by_type(ContentSwitcher)
393+
switcher.current = event.tab.id
394+
self.active = event.tab.id
395+
self.post_message(
396+
TabbedContent.TabActivated(
397+
tabbed_content=self,
398+
tab=event.tab,
399+
)
369400
)
370-
)
371401

372402
def _on_tabs_cleared(self, event: Tabs.Cleared) -> None:
373-
"""All tabs were removed."""
374-
event.stop()
375-
self.get_child_by_type(ContentSwitcher).current = None
376-
self.active = ""
403+
"""Called when there are no active tabs. The tabs may have been cleared,
404+
or they may all be hidden."""
405+
if self._is_associated_tabs(event.tabs):
406+
event.stop()
407+
self.get_child_by_type(ContentSwitcher).current = None
408+
self.active = ""
409+
410+
def _is_associated_tabs(self, tabs: Tabs) -> bool:
411+
"""Determine whether a tab is associated with this TabbedContent or not.
412+
413+
A tab is "associated" with a `TabbedContent`, if it's one of the tabs that can
414+
be used to control it. These have a special type: `ContentTab`, and are linked
415+
back to this `TabbedContent` instance via a `tabbed_content` attribute.
416+
417+
Args:
418+
tabs: The Tabs instance to check.
419+
420+
Returns:
421+
True if the tab is associated with this `TabbedContent`.
422+
"""
423+
return isinstance(tabs, ContentTabs) and tabs.tabbed_content is self
377424

378425
def _watch_active(self, active: str) -> None:
379426
"""Switch tabs when the active attributes changes."""
380427
with self.prevent(Tabs.TabActivated):
381-
self.get_child_by_type(Tabs).active = active
428+
self.get_child_by_type(ContentTabs).active = active
382429
self.get_child_by_type(ContentSwitcher).current = active
383430

384431
@property
385432
def tab_count(self) -> int:
386433
"""Total number of tabs."""
387-
return self.get_child_by_type(Tabs).tab_count
434+
return self.get_child_by_type(ContentTabs).tab_count
388435

389436
def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None:
390437
"""Disable the corresponding tab pane."""
@@ -404,7 +451,7 @@ def _on_tab_pane_disabled(self, event: TabPane.Disabled) -> None:
404451
tab_pane_id = event.tab_pane.id or ""
405452
try:
406453
with self.prevent(Tab.Disabled):
407-
self.get_child_by_type(Tabs).query_one(
454+
self.get_child_by_type(ContentTabs).query_one(
408455
f"Tab#{tab_pane_id}"
409456
).disabled = True
410457
except NoMatches:
@@ -428,7 +475,7 @@ def _on_tab_pane_enabled(self, event: TabPane.Enabled) -> None:
428475
tab_pane_id = event.tab_pane.id or ""
429476
try:
430477
with self.prevent(Tab.Enabled):
431-
self.get_child_by_type(Tabs).query_one(
478+
self.get_child_by_type(ContentTabs).query_one(
432479
f"Tab#{tab_pane_id}"
433480
).disabled = False
434481
except NoMatches:
@@ -444,7 +491,7 @@ def disable_tab(self, tab_id: str) -> None:
444491
Tabs.TabError: If there are any issues with the request.
445492
"""
446493

447-
self.get_child_by_type(Tabs).disable(tab_id)
494+
self.get_child_by_type(ContentTabs).disable(tab_id)
448495

449496
def enable_tab(self, tab_id: str) -> None:
450497
"""Enables the tab with the given ID.
@@ -456,7 +503,7 @@ def enable_tab(self, tab_id: str) -> None:
456503
Tabs.TabError: If there are any issues with the request.
457504
"""
458505

459-
self.get_child_by_type(Tabs).enable(tab_id)
506+
self.get_child_by_type(ContentTabs).enable(tab_id)
460507

461508
def hide_tab(self, tab_id: str) -> None:
462509
"""Hides the tab with the given ID.
@@ -468,7 +515,7 @@ def hide_tab(self, tab_id: str) -> None:
468515
Tabs.TabError: If there are any issues with the request.
469516
"""
470517

471-
self.get_child_by_type(Tabs).hide(tab_id)
518+
self.get_child_by_type(ContentTabs).hide(tab_id)
472519

473520
def show_tab(self, tab_id: str) -> None:
474521
"""Shows the tab with the given ID.
@@ -480,4 +527,4 @@ def show_tab(self, tab_id: str) -> None:
480527
Tabs.TabError: If there are any issues with the request.
481528
"""
482529

483-
self.get_child_by_type(Tabs).show(tab_id)
530+
self.get_child_by_type(ContentTabs).show(tab_id)

src/textual/widgets/_tabs.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,10 @@ class TabShown(TabMessage):
266266
"""Sent when a tab is shown."""
267267

268268
class Cleared(Message):
269-
"""Sent when there are no active tabs."""
269+
"""Sent when there are no active tabs.
270+
271+
This can occur when Tabs are cleared, or if all tabs are hidden.
272+
"""
270273

271274
def __init__(self, tabs: Tabs) -> None:
272275
"""Initialize the event.

tests/test_tabbed_content.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,3 +796,49 @@ def compose(self) -> ComposeResult:
796796
await pilot.pause()
797797
tabber.show_tab("tab-1")
798798
await pilot.pause()
799+
800+
801+
async def test_tabs_nested_in_tabbed_content_doesnt_crash():
802+
"""Regression test for https://github.com/Textualize/textual/issues/3412"""
803+
804+
class TabsNestedInTabbedContent(App):
805+
def compose(self) -> ComposeResult:
806+
with TabbedContent():
807+
with TabPane("Outer TabPane"):
808+
yield Tabs("Inner Tab")
809+
810+
app = TabsNestedInTabbedContent()
811+
async with app.run_test() as pilot:
812+
await pilot.pause()
813+
814+
815+
async def test_tabs_nested_doesnt_interfere_with_ancestor_tabbed_content():
816+
"""When a Tabs is nested as a descendant in the DOM of a TabbedContent,
817+
the messages posted from that Tabs should not interfere with the TabbedContent.
818+
A TabbedContent should only handle messages from Tabs which are direct children.
819+
820+
Relates to https://github.com/Textualize/textual/issues/3412
821+
"""
822+
823+
class TabsNestedInTabbedContent(App):
824+
def compose(self) -> ComposeResult:
825+
with TabbedContent():
826+
with TabPane("OuterTab", id="outer1"):
827+
yield Tabs(
828+
Tab("Tab1", id="tab1"),
829+
Tab("Tab2", id="tab2"),
830+
id="inner-tabs",
831+
)
832+
833+
app = TabsNestedInTabbedContent()
834+
async with app.run_test():
835+
inner_tabs = app.query_one("#inner-tabs", Tabs)
836+
tabbed_content = app.query_one(TabbedContent)
837+
838+
assert inner_tabs.active_tab.id == "tab1"
839+
assert tabbed_content.active == "outer1"
840+
841+
await inner_tabs.clear()
842+
843+
assert inner_tabs.active_tab is None
844+
assert tabbed_content.active == "outer1"

0 commit comments

Comments
 (0)