Skip to content

Commit

Permalink
Merge pull request #3815 from davep/tab-discontent
Browse files Browse the repository at this point in the history
Namespace IDs for `ContentTab` in `TabbedContent`
  • Loading branch information
davep authored Dec 15, 2023
2 parents c5cf804 + dbf0237 commit c94752d
Show file tree
Hide file tree
Showing 7 changed files with 291 additions and 66 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [0.45.0] - 2023-12-12

### 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
193 changes: 172 additions & 21 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-"
"""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
)

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 @@ -425,35 +537,77 @@ def _is_associated_tabs(self, tabs: Tabs) -> bool:
def _watch_active(self, active: str) -> None:
"""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.
Raises:
ValueError: Raised if no ID was available.
"""
if target_id := (pane_id if isinstance(pane_id, str) else pane_id.id):
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 +618,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

0 comments on commit c94752d

Please sign in to comment.