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

Namespace IDs for ContentTab in TabbedContent #3815

Merged
merged 17 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from 13 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Changed

- The tabs within a `TabbedContent` now prefix their IDs to stop any clash with their associated `TabPane` https://github.com/Textualize/textual/pull/3815
- Breaking change: `tab` is no longer a `@on` decorator selector for `TabbedContent.TabActivated` -- use `pane` instead https://github.com/Textualize/textual/pull/3815

### Added

- Added a `pane` attribute to `TabbedContent.TabActivated` https://github.com/Textualize/textual/pull/3815

### Fixed

- Fixed `DataTable.update_cell` not raising an error with an invalid column key https://github.com/Textualize/textual/issues/3335
Expand Down
25 changes: 25 additions & 0 deletions docs/examples/widgets/tabbed_content_label_color.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from textual.app import App, ComposeResult
from textual.widgets import Label, TabbedContent, TabPane


class ColorTabsApp(App):
CSS = """
TabbedContent #--content-tab-green {
color: green;
}

TabbedContent #--content-tab-red {
color: red;
}
"""

def compose(self) -> ComposeResult:
with TabbedContent():
with TabPane("Red", id="red"):
yield Label("Red!")
with TabPane("Green", id="green"):
yield Label("Green!")


if __name__ == "__main__":
ColorTabsApp().run()
24 changes: 24 additions & 0 deletions docs/widgets/tabbed_content.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ The following example contains a `TabbedContent` with three tabs.
--8<-- "docs/examples/widgets/tabbed_content.py"
```

## Styling

The `TabbedContent` widget is composed of two main sub-widgets: a
[`Tabs`](tabs.md) and a [`ContentSwitcher`]((content_switcher.md)); you can
style them accordingly.

The tabs within the `Tabs` widget will have prefixed IDs; each ID being the
ID of the `TabPane` the `Tab` is for, prefixed with `--content-tab-`. If you
wish to style individual tabs within the `TabbedContent` widget you will
need to use that prefix for the `Tab` IDs.

For example, to create a `TabbedContent` that has red and green labels:

=== "Output"

```{.textual path="docs/examples/widgets/tabbed_content_label_color.py"}
```

=== "tabbed_content.py"

```python
--8<-- "docs/examples/widgets/tabbed_content_label_color.py"
```

## Reactive Attributes

| Name | Type | Default | Description |
Expand Down
192 changes: 170 additions & 22 deletions src/textual/widgets/_tabbed_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from rich.repr import Result
from rich.text import Text, TextType
from typing_extensions import Final

from ..app import ComposeResult
from ..await_complete import AwaitComplete
Expand All @@ -26,15 +27,46 @@
class ContentTab(Tab):
"""A Tab with an associated content id."""

def __init__(self, label: Text, content_id: str, disabled: bool = False):
_PREFIX: Final[str] = "--content-tab-"
davep marked this conversation as resolved.
Show resolved Hide resolved
"""The prefix given to the tab IDs."""

@classmethod
def add_prefix(cls, content_id: str) -> str:
"""Add the prefix to the given ID.

Args:
content_id: The ID to add the prefix to.

Returns:
The ID with the prefix added.
"""
return f"{cls._PREFIX}{content_id}" if content_id else content_id

@classmethod
def sans_prefix(cls, content_id: str) -> str:
"""Remove the prefix from the given ID.

Args:
content_id: The ID to remove the prefix from.

Returns:
The ID with the prefix removed.
"""
return (
content_id[len(cls._PREFIX) :]
if content_id.startswith(cls._PREFIX)
else content_id
)
davep marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, label: Text, content_id: str, disabled: bool = False) -> None:
"""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, disabled=disabled)
super().__init__(label, id=self.add_prefix(content_id), disabled=disabled)


class ContentTabs(Tabs):
Expand All @@ -53,9 +85,78 @@ def __init__(
active: ID of the tab which should be active on start.
tabbed_content: The associated TabbedContent instance.
"""
super().__init__(*tabs, active=active)
super().__init__(
*tabs, active=active if active is None else ContentTab.add_prefix(active)
)
self.tabbed_content = tabbed_content

def get_content_tab(self, tab_id: str) -> ContentTab:
"""Get the `ContentTab` associated with the given `TabPane` ID.

Args:
tab_id: The ID of the tab to get.

Returns:
The tab associated with that ID.
"""
return self.query_one(f"#{ContentTab.add_prefix(tab_id)}", ContentTab)

def disable(self, tab_id: str) -> Tab:
"""Disable the indicated tab.

Args:
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to disable.

Returns:
The [`Tab`][textual.widgets.Tab] that was targeted.

Raises:
TabError: If there are any issues with the request.
"""
return super().disable(ContentTab.add_prefix(tab_id))

def enable(self, tab_id: str) -> Tab:
"""Enable the indicated tab.

Args:
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to enable.

Returns:
The [`Tab`][textual.widgets.Tab] that was targeted.

Raises:
TabError: If there are any issues with the request.
"""
return super().enable(ContentTab.add_prefix(tab_id))

def hide(self, tab_id: str) -> Tab:
"""Hide the indicated tab.

Args:
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to hide.

Returns:
The [`Tab`][textual.widgets.Tab] that was targeted.

Raises:
TabError: If there are any issues with the request.
"""
return super().hide(ContentTab.add_prefix(tab_id))

def show(self, tab_id: str) -> Tab:
"""Show the indicated tab.

Args:
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to show.

Returns:
The [`Tab`][textual.widgets.Tab] that was targeted.

Raises:
TabError: If there are any issues with the request.
"""
return super().show(ContentTab.add_prefix(tab_id))


class TabPane(Widget):
"""A container for switchable content, with additional title.
Expand Down Expand Up @@ -142,10 +243,10 @@ class TabbedContent(Widget):
class TabActivated(Message):
"""Posted when the active tab changes."""

ALLOW_SELECTOR_MATCH = {"tab"}
ALLOW_SELECTOR_MATCH = {"pane"}
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""

def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None:
def __init__(self, tabbed_content: TabbedContent, tab: ContentTab) -> None:
"""Initialize message.

Args:
Expand All @@ -156,6 +257,8 @@ def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None:
"""The `TabbedContent` widget that contains the tab activated."""
self.tab = tab
"""The `Tab` widget that was selected (contains the tab label)."""
self.pane = tabbed_content.get_pane(tab)
"""The `TabPane` widget that was activated by selecting the tab."""
super().__init__()

@property
Expand All @@ -170,6 +273,7 @@ def control(self) -> TabbedContent:
def __rich_repr__(self) -> Result:
yield self.tabbed_content
yield self.tab
yield self.pane

class Cleared(Message):
"""Posted when there are no more tab panes."""
Expand Down Expand Up @@ -317,7 +421,11 @@ def add_pane(
assert pane.id is not None
pane.display = False
return AwaitComplete(
tabs.add_tab(ContentTab(pane._title, pane.id), before=before, after=after),
tabs.add_tab(
ContentTab(pane._title, pane.id),
before=before if before is None else ContentTab.add_prefix(before),
after=after if after is None else ContentTab.add_prefix(after),
),
self.get_child_by_type(ContentSwitcher).mount(pane),
)

Expand All @@ -331,7 +439,11 @@ def remove_pane(self, pane_id: str) -> AwaitComplete:
An optionally awaitable object that waits for the pane to be removed
and the Cleared message to be posted.
"""
removal_awaitables = [self.get_child_by_type(ContentTabs).remove_tab(pane_id)]
removal_awaitables = [
self.get_child_by_type(ContentTabs).remove_tab(
ContentTab.add_prefix(pane_id)
)
]
try:
removal_awaitables.append(
self.get_child_by_type(ContentSwitcher)
Expand Down Expand Up @@ -390,8 +502,8 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
# The message is relevant, so consume it and update state accordingly.
event.stop()
switcher = self.get_child_by_type(ContentSwitcher)
switcher.current = event.tab.id
self.active = event.tab.id
switcher.current = ContentTab.sans_prefix(event.tab.id)
self.active = ContentTab.sans_prefix(event.tab.id)
self.post_message(
TabbedContent.TabActivated(
tabbed_content=self,
Expand Down Expand Up @@ -422,38 +534,77 @@ def _is_associated_tabs(self, tabs: Tabs) -> bool:
"""
return isinstance(tabs, ContentTabs) and tabs.tabbed_content is self

def _watch_active(self, active: str) -> None:
def _watch_active(self, old_active: str, active: str) -> None:
davep marked this conversation as resolved.
Show resolved Hide resolved
"""Switch tabs when the active attributes changes."""
with self.prevent(Tabs.TabActivated):
self.get_child_by_type(ContentTabs).active = active
self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(active)
self.get_child_by_type(ContentSwitcher).current = active

@property
def tab_count(self) -> int:
"""Total number of tabs."""
return self.get_child_by_type(ContentTabs).tab_count

def get_tab(self, pane_id: str | TabPane) -> Tab:
"""Get the `Tab` associated with the given ID or `TabPane`.

Args:
pane_id: The ID of the pane, or the pane itself.

Returns:
The Tab associated with the ID.
"""
davep marked this conversation as resolved.
Show resolved Hide resolved
if target_id := pane_id if isinstance(pane_id, str) else pane_id.id:
davep marked this conversation as resolved.
Show resolved Hide resolved
return self.get_child_by_type(ContentTabs).get_content_tab(target_id)
raise ValueError(
"'pane_id' must be a non-empty string or a TabPane with an id."
)

def get_pane(self, pane_id: str | ContentTab) -> TabPane:
"""Get the `TabPane` associated with the given ID or tab.

Args:
pane_id: The ID of the pane to get, or the Tab it is associated with.

Returns:
The `TabPane` associated with the ID or the given tab.

Raises:
ValueError: Raised if no ID was available.
"""
target_id: str | None = None
if isinstance(pane_id, ContentTab):
target_id = (
pane_id.id if pane_id.id is None else ContentTab.sans_prefix(pane_id.id)
)
else:
target_id = pane_id
if target_id:
pane = self.get_child_by_type(ContentSwitcher).get_child_by_id(target_id)
assert isinstance(pane, TabPane)
return pane
raise ValueError(
"'pane_id' must be a non-empty string or a ContentTab with an id."
)

def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None:
"""Disable the corresponding tab pane."""
event.stop()
tab_id = event.tab.id or ""
try:
with self.prevent(TabPane.Disabled):
self.get_child_by_type(ContentSwitcher).get_child_by_id(
tab_id, expect_type=TabPane
ContentTab.sans_prefix(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(ContentTabs).query_one(
f"Tab#{tab_pane_id}"
).disabled = True
self.get_tab(event.tab_pane).disabled = True
except NoMatches:
return

Expand All @@ -464,20 +615,17 @@ def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None:
try:
with self.prevent(TabPane.Enabled):
self.get_child_by_type(ContentSwitcher).get_child_by_id(
tab_id, expect_type=TabPane
ContentTab.sans_prefix(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(ContentTabs).query_one(
f"Tab#{tab_pane_id}"
).disabled = False
with self.prevent(Tab.Disabled):
self.get_tab(event.tab_pane).disabled = False
except NoMatches:
return

Expand Down
6 changes: 3 additions & 3 deletions tests/snapshot_tests/snapshot_apps/modified_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ def compose(self) -> ComposeResult:
yield Button()

def on_mount(self) -> None:
self.query_one(TabbedContent).disable_tab(f"tab-1")
self.query_one(TabbedContent).disable_tab(f"tab-2")
self.query_one(TabbedContent).hide_tab(f"tab-3")
self.query_one(TabbedContent).disable_tab("tab-1")
self.query_one(TabbedContent).disable_tab("tab-2")
self.query_one(TabbedContent).hide_tab("tab-3")


if __name__ == "__main__":
Expand Down
Loading
Loading