Skip to content

Commit

Permalink
Merge pull request #3152 from Textualize/disable-tab-pane
Browse files Browse the repository at this point in the history
Allow enabling/disabling tab via tab pane.
  • Loading branch information
rodrigogiraoserrao authored Aug 23, 2023
2 parents fa9a988 + 9ef644c commit 7563a39
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 10 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Ability to enable/disable tabs via the reactive `disabled` in tab panes https://github.com/Textualize/textual/pull/3152
### Fixed

- Could not hide/show/disable/enable tabs in nested `TabbedContent` https://github.com/Textualize/textual/pull/3150
Expand Down
75 changes: 66 additions & 9 deletions src/textual/widgets/_tabbed_content.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from asyncio import gather
from dataclasses import dataclass
from itertools import zip_longest
from typing import Generator

Expand All @@ -26,14 +27,15 @@
class ContentTab(Tab):
"""A Tab with an associated content id."""

def __init__(self, label: Text, content_id: str):
def __init__(self, label: Text, content_id: str, disabled: bool = False):
"""Initialize a ContentTab.
Args:
label: The label to be displayed within the tab.
content_id: The id of the content associated with the tab.
disabled: Is the tab disabled?
"""
super().__init__(label, id=content_id)
super().__init__(label, id=content_id, disabled=disabled)


class TabPane(Widget):
Expand All @@ -49,6 +51,30 @@ class TabPane(Widget):
}
"""

@dataclass
class TabPaneMessage(Message):
"""Base class for `TabPane` messages."""

tab_pane: TabPane
"""The `TabPane` that is he object of this message."""

@property
def control(self) -> TabPane:
"""The tab pane that is the object of this message.
This is an alias for the attribute `tab_pane` and is used by the
[`on`][textual.on] decorator.
"""
return self.tab_pane

@dataclass
class Disabled(TabPaneMessage):
"""Sent when a tab pane is disabled via its reactive `disabled`."""

@dataclass
class Enabled(TabPaneMessage):
"""Sent when a tab pane is enabled via its reactive `disabled`."""

def __init__(
self,
title: TextType,
Expand All @@ -73,6 +99,10 @@ def __init__(
*children, name=name, id=id, classes=classes, disabled=disabled
)

def _watch_disabled(self, disabled: bool) -> None:
"""Notify the parent `TabbedContent` that a tab pane was enabled/disabled."""
self.post_message(self.Disabled(self) if disabled else self.Enabled(self))


class AwaitTabbedContent:
"""An awaitable returned by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs."""
Expand Down Expand Up @@ -235,7 +265,8 @@ def compose(self) -> ComposeResult:
]
# Get a tab for each pane
tabs = [
ContentTab(content._title, content.id or "") for content in pane_content
ContentTab(content._title, content.id or "", disabled=content.disabled)
for content in pane_content
]
# Yield the tabs
yield Tabs(*tabs, active=self._initial or None)
Expand Down Expand Up @@ -381,9 +412,22 @@ def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None:
event.stop()
tab_id = event.tab.id or ""
try:
self.get_child_by_type(ContentSwitcher).get_child_by_id(
tab_id, expect_type=TabPane
).disabled = True
with self.prevent(TabPane.Disabled):
self.get_child_by_type(ContentSwitcher).get_child_by_id(
tab_id, expect_type=TabPane
).disabled = True
except NoMatches:
return

def _on_tab_pane_disabled(self, event: TabPane.Disabled) -> None:
"""Disable the corresponding tab."""
event.stop()
tab_pane_id = event.tab_pane.id or ""
try:
with self.prevent(Tab.Disabled):
self.get_child_by_type(Tabs).query_one(
f"Tab#{tab_pane_id}"
).disabled = True
except NoMatches:
return

Expand All @@ -392,9 +436,22 @@ def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None:
event.stop()
tab_id = event.tab.id or ""
try:
self.get_child_by_type(ContentSwitcher).get_child_by_id(
tab_id, expect_type=TabPane
).disabled = False
with self.prevent(TabPane.Enabled):
self.get_child_by_type(ContentSwitcher).get_child_by_id(
tab_id, expect_type=TabPane
).disabled = False
except NoMatches:
return

def _on_tab_pane_enabled(self, event: TabPane.Enabled) -> None:
"""Enable the corresponding tab."""
event.stop()
tab_pane_id = event.tab_pane.id or ""
try:
with self.prevent(Tab.Enabled):
self.get_child_by_type(Tabs).query_one(
f"Tab#{tab_pane_id}"
).disabled = False
except NoMatches:
return

Expand Down
4 changes: 3 additions & 1 deletion src/textual/widgets/_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,18 @@ def __init__(
*,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initialise a Tab.
Args:
label: The label to use in the tab.
id: Optional ID for the widget.
classes: Space separated list of class names.
disabled: Whether the tab is disabled or not.
"""
self.label = Text.from_markup(label) if isinstance(label, str) else label
super().__init__(id=id, classes=classes)
super().__init__(id=id, classes=classes, disabled=disabled)
self.update(label)

@property
Expand Down
55 changes: 55 additions & 0 deletions tests/test_tabbed_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,38 @@ def on_mount(self) -> None:
assert app.query_one(Tabs).active == "tab-1"


async def test_disabling_via_tab_pane():
class TabbedApp(App[None]):
def compose(self) -> ComposeResult:
with TabbedContent():
yield Label("tab-1")
yield Label("tab-2")

def on_mount(self) -> None:
self.query_one("TabPane#tab-2").disabled = True

app = TabbedApp()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.click("Tab#tab-2")
assert app.query_one(Tabs).active == "tab-1"


async def test_creating_disabled_tab():
class TabbedApp(App[None]):
def compose(self) -> ComposeResult:
with TabbedContent():
with TabPane("first"):
yield Label("hello")
with TabPane("second", disabled=True):
yield Label("world")

app = TabbedApp()
async with app.run_test() as pilot:
await pilot.click("Tab#tab-2")
assert app.query_one(Tabs).active == "tab-1"


async def test_navigation_around_disabled_tabs():
class TabbedApp(App[None]):
def compose(self) -> ComposeResult:
Expand Down Expand Up @@ -546,6 +578,29 @@ def reenable(self) -> None:
assert app.query_one(Tabs).active == "tab-2"


async def test_reenabling_via_tab_pane():
class TabbedApp(App[None]):
def compose(self) -> ComposeResult:
with TabbedContent():
yield Label("tab-1")
yield Label("tab-2")

def on_mount(self) -> None:
self.query_one("TabPane#tab-2").disabled = True

def reenable(self) -> None:
self.query_one("TabPane#tab-2").disabled = False

app = TabbedApp()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.click("Tab#tab-2")
assert app.query_one(Tabs).active == "tab-1"
app.reenable()
await pilot.click("Tab#tab-2")
assert app.query_one(Tabs).active == "tab-2"


async def test_disabling_unknown_tab():
class TabbedApp(App[None]):
def compose(self) -> ComposeResult:
Expand Down

0 comments on commit 7563a39

Please sign in to comment.