Skip to content

Commit f9427e4

Browse files
authored
Merge pull request #4298 from Textualize/tabbed-content-issue-dave-didnt-fix
Enable unsetting active tab pane / tab
2 parents 0ce3f43 + edd5b60 commit f9427e4

File tree

6 files changed

+52
-38
lines changed

6 files changed

+52
-38
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
5454
- When the terminal window loses focus, the currently-focused widget will also lose focus.
5555
- When the terminal window regains focus, the previously-focused widget will regain focus.
5656
- TextArea binding for <kbd>ctrl</kbd>+<kbd>k</kbd> will now delete the line if the line is empty https://github.com/Textualize/textual/issues/4277
57+
- The active tab (in `Tabs`) / tab pane (in `TabbedContent`) can now be unset https://github.com/Textualize/textual/issues/4241
5758

5859
## [0.52.1] - 2024-02-20
5960

docs/widgets/tabbed_content.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ For example, to create a `TabbedContent` that has red and green labels:
127127

128128
## Messages
129129

130+
- [TabbedContent.Cleared][textual.widgets.TabbedContent.Cleared]
130131
- [TabbedContent.TabActivated][textual.widgets.TabbedContent.TabActivated]
131132

132133
## Bindings

src/textual/widgets/_tabbed_content.py

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,11 @@ def __rich_repr__(self) -> Result:
276276
yield self.pane
277277

278278
class Cleared(Message):
279-
"""Posted when there are no more tab panes."""
279+
"""Posted when no tab pane is active.
280+
281+
This can happen if all tab panes are removed or if the currently active tab
282+
pane is unset.
283+
"""
280284

281285
def __init__(self, tabbed_content: TabbedContent) -> None:
282286
"""Initialize message.
@@ -329,22 +333,6 @@ def active_pane(self) -> TabPane | None:
329333
return None
330334
return self.get_pane(self.active)
331335

332-
def validate_active(self, active: str) -> str:
333-
"""It doesn't make sense for `active` to be an empty string.
334-
335-
Args:
336-
active: Attribute to be validated.
337-
338-
Returns:
339-
Value of `active`.
340-
341-
Raises:
342-
ValueError: If the active attribute is set to empty string when there are tabs available.
343-
"""
344-
if not active and self.get_child_by_type(ContentSwitcher).current:
345-
raise ValueError("'active' tab must not be empty string.")
346-
return active
347-
348336
@staticmethod
349337
def _set_id(content: TabPane, new_id: int) -> TabPane:
350338
"""Set an id on the content, if not already present.
@@ -467,8 +455,6 @@ def remove_pane(self, pane_id: str) -> AwaitComplete:
467455

468456
async def _remove_content() -> None:
469457
await gather(*removal_awaitables)
470-
if self.tab_count == 0:
471-
self.post_message(self.Cleared(self).set_sender(self))
472458

473459
return AwaitComplete(_remove_content())
474460

@@ -486,7 +472,6 @@ def clear_panes(self) -> AwaitComplete:
486472

487473
async def _clear_content() -> None:
488474
await await_clear
489-
self.post_message(self.Cleared(self).set_sender(self))
490475

491476
return AwaitComplete(_clear_content())
492477

@@ -547,7 +532,7 @@ def _is_associated_tabs(self, tabs: Tabs) -> bool:
547532

548533
def _watch_active(self, active: str) -> None:
549534
"""Switch tabs when the active attributes changes."""
550-
with self.prevent(Tabs.TabActivated):
535+
with self.prevent(Tabs.TabActivated, Tabs.Cleared):
551536
self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(active)
552537
self.get_child_by_type(ContentSwitcher).current = active
553538
if active:
@@ -557,6 +542,10 @@ def _watch_active(self, active: str) -> None:
557542
tab=self.get_child_by_type(ContentTabs).get_content_tab(active),
558543
)
559544
)
545+
else:
546+
self.post_message(
547+
TabbedContent.Cleared(tabbed_content=self).set_sender(self)
548+
)
560549

561550
@property
562551
def tab_count(self) -> int:

src/textual/widgets/_tabs.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,8 @@ class TabShown(TabMessage):
285285
class Cleared(Message):
286286
"""Sent when there are no active tabs.
287287
288-
This can occur when Tabs are cleared, or if all tabs are hidden.
288+
This can occur when Tabs are cleared, if all tabs are hidden, or if the
289+
currently active tab is unset.
289290
"""
290291

291292
def __init__(self, tabs: Tabs) -> None:
@@ -527,9 +528,10 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete:
527528
async def do_remove() -> None:
528529
"""Perform the remove after refresh so the underline bar gets new positions."""
529530
await remove_await
530-
if next_tab is None:
531+
if next_tab is None or (removing_active_tab and next_tab.id is None):
531532
self.active = ""
532533
elif removing_active_tab:
534+
assert next_tab.id is not None
533535
self.active = next_tab.id
534536
next_tab.add_class("-active")
535537

@@ -575,12 +577,12 @@ def compose(self) -> ComposeResult:
575577

576578
def watch_active(self, previously_active: str, active: str) -> None:
577579
"""Handle a change to the active tab."""
580+
self.query("#tabs-list > Tab.-active").remove_class("-active")
578581
if active:
579582
try:
580583
active_tab = self.query_one(f"#tabs-list > #{active}", Tab)
581584
except NoMatches:
582585
return
583-
self.query("#tabs-list > Tab.-active").remove_class("-active")
584586
active_tab.add_class("-active")
585587
self._highlight_active(animate=previously_active != "")
586588
self._scroll_active_tab()
@@ -699,17 +701,21 @@ def action_previous_tab(self) -> None:
699701
self._move_tab(-1)
700702

701703
def _move_tab(self, direction: int) -> None:
702-
"""Activate the next tab.
704+
"""Activate the next enabled tab in the given direction.
705+
706+
Tab selection wraps around. If no tab is currently active, the "next"
707+
tab is set to be the first and the "previous" tab is the last one.
703708
704709
Args:
705710
direction: +1 for the next tab, -1 for the previous.
706711
"""
707712
active_tab = self.active_tab
708-
if active_tab is None:
709-
return
710713
tabs = self._potentially_active_tabs
711714
if not tabs:
712715
return
716+
if not active_tab:
717+
self.active = tabs[0 if direction == 1 else -1].id or ""
718+
return
713719
tab_count = len(tabs)
714720
new_tab_index = (tabs.index(active_tab) + direction) % tab_count
715721
self.active = tabs[new_tab_index].id or ""

tests/test_tabbed_content.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,33 @@ def compose(self) -> ComposeResult:
108108
with pytest.raises(ValueError):
109109
tabbed_content.active = "X"
110110

111-
# Check fail with empty tab
112-
with pytest.raises(ValueError):
113-
tabbed_content.active = ""
111+
112+
async def test_unsetting_tabbed_content_active():
113+
"""Check that setting `TabbedContent.active = ""` unsets active tab."""
114+
115+
messages = []
116+
117+
class TabbedApp(App[None]):
118+
def compose(self) -> ComposeResult:
119+
with TabbedContent(initial="bar"):
120+
with TabPane("foo", id="foo"):
121+
yield Label("Foo", id="foo-label")
122+
with TabPane("bar", id="bar"):
123+
yield Label("Bar", id="bar-label")
124+
with TabPane("baz", id="baz"):
125+
yield Label("Baz", id="baz-label")
126+
127+
def on_tabbed_content_cleared(self, event: TabbedContent.Cleared) -> None:
128+
messages.append(event)
129+
130+
app = TabbedApp()
131+
async with app.run_test() as pilot:
132+
tabbed_content = app.query_one(TabbedContent)
133+
assert bool(tabbed_content.active)
134+
tabbed_content.active = ""
135+
await pilot.pause()
136+
assert len(messages) == 1
137+
assert isinstance(messages[0], TabbedContent.Cleared)
114138

115139

116140
async def test_tabbed_content_initial():

tests/test_tabs.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -316,15 +316,8 @@ def compose(self) -> ComposeResult:
316316
assert tabs.active_tab.id == "tab-2"
317317
assert tabs.active == tabs.active_tab.id
318318

319-
# TODO: This one is questionable. It seems Tabs has been designed so
320-
# that you can set the active tab to an empty string, and it remains
321-
# so, and just removes the underline; no other changes. So active
322-
# will be an empty string while active_tab will be a tab. This feels
323-
# like an oversight. Need to investigate and possibly modify this
324-
# behaviour unless there's a good reason for this.
325319
tabs.active = ""
326-
assert tabs.active_tab is not None
327-
assert tabs.active_tab.id == "tab-2"
320+
assert tabs.active_tab is None
328321

329322

330323
async def test_navigate_tabs_with_keyboard():

0 commit comments

Comments
 (0)