Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow enabling/disabling tab via tab pane. #3152

Merged
merged 6 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
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

## [0.34.0] - 2023-08-22

### Added
Expand Down
75 changes: 70 additions & 5 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,38 @@ class TabPane(Widget):
}
"""

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

tab_pane: TabPane
"""The `TabPane` that was disabled."""

@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 Enabled(Message):
Copy link
Contributor

@davep davep Aug 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might it not make sense to give this and TabPane.Disabled a common parent class (adds to the utility of @on for example)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm honestly not fussed either way. I'll add it if you ask for it, otherwise I'll just merge this PR. 🤷

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like it would be more in keeping with where we're slowly heading with multiple messages on a widget (where appropriate); but it sure wasn't a "must do won't approve or else". ;-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Point taken. 9ef644c.

"""Sent when a tab pane is enabled via its reactive `disabled`."""

tab_pane: TabPane
"""The `TabPane` that was enabled."""

@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

def __init__(
self,
title: TextType,
Expand All @@ -73,6 +107,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 +273,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,7 +420,20 @@ def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None:
event.stop()
tab_id = event.tab.id
try:
self.query_one(f"TabPane#{tab_id}").disabled = True
with self.prevent(TabPane.Disabled):
self.query_one(f"TabPane#{tab_id}").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 @@ -390,7 +442,20 @@ def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None:
event.stop()
tab_id = event.tab.id
try:
self.query_one(f"TabPane#{tab_id}").disabled = False
with self.prevent(TabPane.Enabled):
self.query_one(f"TabPane#{tab_id}").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