diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66c4330cd..0e068003e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed
- Dropped "loading-indicator--dot" component style from LoadingIndicator https://github.com/Textualize/textual/pull/2050
+- Tabs widget now sends Tabs.Cleared when there is no active tab.
+- Breaking change: changed default behaviour of `Vertical` (see `VerticalScroll`) https://github.com/Textualize/textual/issues/1957
+- The default `overflow` style for `Horizontal` was changed to `hidden hidden` https://github.com/Textualize/textual/issues/1957
### Removed
@@ -20,18 +23,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed borders not rendering correctly. https://github.com/Textualize/textual/pull/2074
- Fix for error when removing nodes. https://github.com/Textualize/textual/issues/2079
-
-### Changed
-
-- Breaking change: changed default behaviour of `Vertical` (see `VerticalScroll`) https://github.com/Textualize/textual/issues/1957
-- The default `overflow` style for `Horizontal` was changed to `hidden hidden` https://github.com/Textualize/textual/issues/1957
-
### Added
- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957
- Added `Center` https://github.com/Textualize/textual/issues/1957
- Added `Middle` https://github.com/Textualize/textual/issues/1957
- Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957
+- Added `TabbedContent` widget https://github.com/Textualize/textual/pull/2059
+- Added `get_child_by_type` method to widgets / app https://github.com/Textualize/textual/pull/2059
+- Added `Widget.render_str` method https://github.com/Textualize/textual/pull/2059
## [0.15.1] - 2023-03-14
diff --git a/docs/api/tabbed_content.md b/docs/api/tabbed_content.md
new file mode 100644
index 000000000..6d4338d21
--- /dev/null
+++ b/docs/api/tabbed_content.md
@@ -0,0 +1,2 @@
+::: textual.widgets.TabbedContent
+::: textual.widgets.TabPane
diff --git a/docs/examples/widgets/tabbed_content.py b/docs/examples/widgets/tabbed_content.py
new file mode 100644
index 000000000..0a56d3cf0
--- /dev/null
+++ b/docs/examples/widgets/tabbed_content.py
@@ -0,0 +1,57 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Footer, Label, Markdown, TabbedContent, TabPane
+
+LETO = """
+# Duke Leto I Atreides
+
+Head of House Atreides.
+"""
+
+JESSICA = """
+# Lady Jessica
+
+Bene Gesserit and concubine of Leto, and mother of Paul and Alia.
+"""
+
+PAUL = """
+# Paul Atreides
+
+Son of Leto and Jessica.
+"""
+
+
+class TabbedApp(App):
+ """An example of tabbed content."""
+
+ BINDINGS = [
+ ("l", "show_tab('leto')", "Leto"),
+ ("j", "show_tab('jessica')", "Jessica"),
+ ("p", "show_tab('paul')", "Paul"),
+ ]
+
+ def compose(self) -> ComposeResult:
+ """Compose app with tabbed content."""
+ # Footer to show keys
+ yield Footer()
+
+ # Add the TabbedContent widget
+ with TabbedContent(initial="jessica"):
+ with TabPane("Leto", id="leto"): # First tab
+ yield Markdown(LETO) # Tab content
+ with TabPane("Jessica", id="jessica"):
+ yield Markdown(JESSICA)
+ with TabbedContent("Paul", "Alia"):
+ yield TabPane("Paul", Label("First child"))
+ yield TabPane("Alia", Label("Second child"))
+
+ with TabPane("Paul", id="paul"):
+ yield Markdown(PAUL)
+
+ def action_show_tab(self, tab: str) -> None:
+ """Switch to a new tab."""
+ self.get_child_by_type(TabbedContent).active = tab
+
+
+if __name__ == "__main__":
+ app = TabbedApp()
+ app.run()
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 478ac534e..4637235d7 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -73,7 +73,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
- [X] Radio boxes
- [ ] Spark-lines
- [X] Switch
-- [ ] Tabs
+- [X] Tabs
- [ ] TextArea (multi-line input)
* [ ] Basic controls
* [ ] Indentation guides
diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md
index ec5d885c9..abcd8a749 100644
--- a/docs/widget_gallery.md
+++ b/docs/widget_gallery.md
@@ -194,6 +194,14 @@ A row of tabs you can select with the mouse or navigate with keys.
```{.textual path="docs/examples/widgets/tabs.py" press="a,a,a,a,right,right"}
```
+## TabbedContent
+
+A Combination of Tabs and ContentSwitcher to navigate static content.
+
+[TabbedContent reference](./widgets/tabbed_content.md){ .md-button .md-button--primary }
+
+```{.textual path="docs/examples/widgets/tabbed_content.py" press="j"}
+```
## TextLog
diff --git a/docs/widgets/_template.md b/docs/widgets/_template.md
index 1e8c6f972..c9e60413b 100644
--- a/docs/widgets/_template.md
+++ b/docs/widgets/_template.md
@@ -33,7 +33,7 @@ Example app showing the widget:
## Bindings
-The WIDGET widget defines directly the following bindings:
+The WIDGET widget defines the following bindings:
::: textual.widgets.WIDGET.BINDINGS
options:
diff --git a/docs/widgets/checkbox.md b/docs/widgets/checkbox.md
index 1f7b51a8a..809de2e66 100644
--- a/docs/widgets/checkbox.md
+++ b/docs/widgets/checkbox.md
@@ -34,7 +34,7 @@ The example below shows check boxes in various states.
## Bindings
-The checkbox widget defines directly the following bindings:
+The checkbox widget defines the following bindings:
::: textual.widgets._toggle_button.ToggleButton.BINDINGS
options:
diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md
index 7cc0a00d6..8c09a032c 100644
--- a/docs/widgets/data_table.md
+++ b/docs/widgets/data_table.md
@@ -23,7 +23,7 @@ The example below populates a table with CSV data.
## Reactive Attributes
| Name | Type | Default | Description |
-|---------------------|---------------------------------------------|--------------------|-------------------------------------------------------|
+| ------------------- | ------------------------------------------- | ------------------ | ----------------------------------------------------- |
| `show_header` | `bool` | `True` | Show the table header |
| `fixed_rows` | `int` | `0` | Number of fixed rows (rows which do not scroll) |
| `fixed_columns` | `int` | `0` | Number of fixed columns (columns which do not scroll) |
@@ -52,7 +52,7 @@ The example below populates a table with CSV data.
## Bindings
-The data table widget defines directly the following bindings:
+The data table widget defines the following bindings:
::: textual.widgets.DataTable.BINDINGS
options:
diff --git a/docs/widgets/list_view.md b/docs/widgets/list_view.md
index 6e294323e..2aa03cb10 100644
--- a/docs/widgets/list_view.md
+++ b/docs/widgets/list_view.md
@@ -41,7 +41,7 @@ The example below shows an app with a simple `ListView`.
## Bindings
-The list view widget defines directly the following bindings:
+The list view widget defines the following bindings:
::: textual.widgets.ListView.BINDINGS
options:
diff --git a/docs/widgets/radiobutton.md b/docs/widgets/radiobutton.md
index 95f510365..6eb698312 100644
--- a/docs/widgets/radiobutton.md
+++ b/docs/widgets/radiobutton.md
@@ -31,12 +31,12 @@ The example below shows radio buttons, used within a [`RadioSet`](./radioset.md)
## Reactive Attributes
| Name | Type | Default | Description |
-|---------|--------|---------|--------------------------------|
+| ------- | ------ | ------- | ------------------------------ |
| `value` | `bool` | `False` | The value of the radio button. |
## Bindings
-The radio button widget defines directly the following bindings:
+The radio button widget defines the following bindings:
::: textual.widgets._toggle_button.ToggleButton.BINDINGS
options:
diff --git a/docs/widgets/switch.md b/docs/widgets/switch.md
index 0b497da50..e5f3bcd7c 100644
--- a/docs/widgets/switch.md
+++ b/docs/widgets/switch.md
@@ -29,12 +29,12 @@ The example below shows switches in various states.
## Reactive Attributes
| Name | Type | Default | Description |
-|---------|--------|---------|--------------------------|
+| ------- | ------ | ------- | ------------------------ |
| `value` | `bool` | `False` | The value of the switch. |
## Bindings
-The switch widget defines directly the following bindings:
+The switch widget defines the following bindings:
::: textual.widgets.Switch.BINDINGS
options:
diff --git a/docs/widgets/tabbed_content.md b/docs/widgets/tabbed_content.md
new file mode 100644
index 000000000..8c0c2dbde
--- /dev/null
+++ b/docs/widgets/tabbed_content.md
@@ -0,0 +1,106 @@
+# TabbedContent
+
+Switch between mutually exclusive content panes via a row of tabs.
+
+- [x] Focusable
+- [x] Container
+
+This widget combines the [Tabs](../widgets/tabs.md) and [ContentSwitcher](../widgets/content_switcher.md) widgets to create a convenient way of navigating content.
+
+Only a single child of TabbedContent is visible at once.
+Each child has an associated tab which will make it visible and hide the others.
+
+## Composing
+
+There are two ways to provide the titles for the tab.
+You can pass them as positional arguments to the [TabbedContent][textual.widgets.TabbedContent] constructor:
+
+```python
+def compose(self) -> ComposeResult:
+ with TabbedContent("Leto", "Jessica", "Paul"):
+ yield Markdown(LETO)
+ yield Markdown(JESSICA)
+ yield Markdown(PAUL)
+```
+
+Alternatively you can wrap the content in a [TabPane][textual.widgets.TabPane] widget, which takes the tab title as the first parameter:
+
+```python
+def compose(self) -> ComposeResult:
+ with TabbedContent():
+ with TabPane("Leto"):
+ yield Markdown(LETO)
+ with TabPane("Jessica"):
+ yield Markdown(JESSICA)
+ with TabPane("Paul"):
+ yield Markdown(PAUL)
+```
+
+## Switching tabs
+
+If you need to programmatically switch tabs, you should provide an `id` attribute to the `TabPane`s.
+
+```python
+def compose(self) -> ComposeResult:
+ with TabbedContent():
+ with TabPane("Leto", id="leto"):
+ yield Markdown(LETO)
+ with TabPane("Jessica", id="jessica"):
+ yield Markdown(JESSICA)
+ with TabPane("Paul", id="paul"):
+ yield Markdown(PAUL)
+```
+
+You can then switch tabs by setting the `active` reactive attribute:
+
+```python
+# Switch to Jessica tab
+self.query_one(TabbedContent).active = "jessica"
+```
+
+!!! note
+
+ If you don't provide `id` attributes to the tab panes, they will be assigned sequentially starting at `tab-1` (then `tab-2` etc).
+
+## Initial tab
+
+The first child of `TabbedContent` will be the initial active tab by default. You can pick a different initial tab by setting the `initial` argument to the `id` of the tab:
+
+```python
+def compose(self) -> ComposeResult:
+ with TabbedContent(initial="jessica"):
+ with TabPane("Leto", id="leto"):
+ yield Markdown(LETO)
+ with TabPane("Jessica", id="jessica"):
+ yield Markdown(JESSICA)
+ with TabPane("Paul", id="paul"):
+ yield Markdown(PAUL)
+```
+
+## Example
+
+The following example contains a `TabbedContent` with three tabs.
+
+=== "Output"
+
+ ```{.textual path="docs/examples/widgets/tabbed_content.py"}
+ ```
+
+=== "tabbed_content.py"
+
+ ```python
+ --8<-- "docs/examples/widgets/tabbed_content.py"
+ ```
+
+## Reactive attributes
+
+| Name | Type | Default | Description |
+| -------- | ----- | ------- | -------------------------------------------------------------- |
+| `active` | `str` | `""` | The `id` attribute of the active tab. Set this to switch tabs. |
+
+
+## See also
+
+- [TabbedContent](../api/tabbed_content.md) code reference.
+- [Tabs](../api/tabs.md) code reference.
+- [ContentSwitcher](../api/content_switcher.md) code reference.
diff --git a/docs/widgets/tabs.md b/docs/widgets/tabs.md
index 77b94fc7a..5546e5dbd 100644
--- a/docs/widgets/tabs.md
+++ b/docs/widgets/tabs.md
@@ -60,6 +60,7 @@ The following example adds a `Tabs` widget above a text label. Press ++a++ to ad
## Messages
### ::: textual.widgets.Tabs.TabActivated
+### ::: textual.widgets.Tabs.TabsCleared
## Bindings
diff --git a/docs/widgets/tree.md b/docs/widgets/tree.md
index 9014f3d97..ff7df3ed0 100644
--- a/docs/widgets/tree.md
+++ b/docs/widgets/tree.md
@@ -44,7 +44,7 @@ Tree widgets have a "root" attribute which is an instance of a [TreeNode][textua
## Bindings
-The tree widget defines directly the following bindings:
+The tree widget defines the following bindings:
::: textual.widgets.Tree.BINDINGS
options:
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index 3805239fb..704bd75e2 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -140,6 +140,7 @@ nav:
- "widgets/radioset.md"
- "widgets/static.md"
- "widgets/switch.md"
+ - "widgets/tabbed_content.md"
- "widgets/tabs.md"
- "widgets/text_log.md"
- "widgets/tree.md"
@@ -181,10 +182,11 @@ nav:
- "api/static.md"
- "api/strip.md"
- "api/switch.md"
+ - "api/tabbed_content.md"
- "api/tabs.md"
- "api/text_log.md"
- - "api/toggle_button.md"
- "api/timer.md"
+ - "api/toggle_button.md"
- "api/tree.md"
- "api/walk.md"
- "api/welcome.md"
diff --git a/src/textual/_compose.py b/src/textual/_compose.py
index 272e8690a..022664d2b 100644
--- a/src/textual/_compose.py
+++ b/src/textual/_compose.py
@@ -28,7 +28,7 @@ def compose(node: App | Widget) -> list[Widget]:
nodes.extend(composed)
composed.clear()
if compose_stack:
- compose_stack[-1]._nodes._append(child)
+ compose_stack[-1].compose_add_child(child)
else:
nodes.append(child)
if composed:
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index 79080f59f..ebb356906 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -893,10 +893,11 @@ class Compositor:
widget: Widget to update.
"""
- if not self._full_map_invalidated and not widgets.issuperset(
- self.visible_widgets
+ if not self._full_map_invalidated and not widgets.issubset(
+ self.visible_widgets.keys()
):
self._full_map_invalidated = True
+
regions: list[Region] = []
add_region = regions.append
get_widget = self.visible_widgets.__getitem__
diff --git a/src/textual/app.py b/src/textual/app.py
index 9091acf69..34a9538ea 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -1133,6 +1133,20 @@ class App(Generic[ReturnType], DOMNode):
else self.screen.get_widget_by_id(id, expect_type)
)
+ def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType:
+ """Get a child of a give type.
+
+ Args:
+ expect_type: The type of the expected child.
+
+ Raises:
+ NoMatches: If no valid child is found.
+
+ Returns:
+ A widget.
+ """
+ return self.screen.get_child_by_type(expect_type)
+
def update_styles(self, node: DOMNode | None = None) -> None:
"""Request update of styles.
@@ -2202,7 +2216,7 @@ class App(Generic[ReturnType], DOMNode):
await self._prune_nodes(widgets)
finally:
finished_event.set()
- if parent is not None and parent.styles.auto_dimensions:
+ if parent is not None:
parent.refresh(layout=True)
removed_widgets = self._detach_from_dom(widgets)
diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py
index 5fc5b9932..720dc5e7d 100644
--- a/src/textual/css/_style_properties.py
+++ b/src/textual/css/_style_properties.py
@@ -761,7 +761,11 @@ class StringEnumProperty:
if value is None:
if obj.clear_rule(self.name):
self._before_refresh(obj, value)
- obj.refresh(layout=self._layout, children=self._refresh_children)
+ obj.refresh(
+ layout=self._layout,
+ children=self._refresh_children,
+ parent=self._refresh_parent,
+ )
else:
if value not in self._valid_values:
raise StyleValueError(
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 9e9ec8163..23e878ba4 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -151,6 +151,18 @@ class DOMNode(MessagePump):
super().__init__()
+ def compose_add_child(self, widget: Widget) -> None:
+ """Add a node to children.
+
+ This is used by the compose process when it adds children.
+ There is no need to use it directly, but you may want to override it in a subclass
+ if you want children to be attached to a different node.
+
+ Args:
+ widget: A Widget to add.
+ """
+ self._nodes._append(widget)
+
@property
def children(self) -> Sequence["Widget"]:
"""A view on to the children."""
diff --git a/src/textual/pilot.py b/src/textual/pilot.py
index 9f52190f4..d5582f068 100644
--- a/src/textual/pilot.py
+++ b/src/textual/pilot.py
@@ -7,7 +7,6 @@ import rich.repr
from ._wait import wait_for_idle
from .app import App, ReturnType
-from .css.query import QueryType
from .events import Click, MouseDown, MouseMove, MouseUp
from .geometry import Offset
from .widget import Widget
@@ -65,7 +64,7 @@ class Pilot(Generic[ReturnType]):
async def click(
self,
- selector: QueryType | None = None,
+ selector: type[Widget] | str | None = None,
offset: Offset = Offset(),
shift: bool = False,
meta: bool = False,
@@ -100,7 +99,9 @@ class Pilot(Generic[ReturnType]):
await self.pause()
async def hover(
- self, selector: QueryType | None = None, offset: Offset = Offset()
+ self,
+ selector: type[Widget] | str | None | None = None,
+ offset: Offset = Offset(),
) -> None:
"""Simulate hovering with the mouse cursor.
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 6008bc62f..2bbff4909 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -390,7 +390,7 @@ class Widget(DOMNode):
compose_stack = self.app._compose_stacks[-1]
composed = compose_stack.pop()
if compose_stack:
- compose_stack[-1]._nodes._append(composed)
+ compose_stack[-1].compose_add_child(composed)
else:
self.app._composed[-1].append(composed)
@@ -475,6 +475,25 @@ class Widget(DOMNode):
) from exc
raise NoMatches(f"No descendant found with id={id!r}")
+ def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType:
+ """Get a child of a give type.
+
+ Args:
+ expect_type: The type of the expected child.
+
+ Raises:
+ NoMatches: If no valid child is found.
+
+ Returns:
+ A widget.
+ """
+ for child in self._nodes:
+ # We want the child with the exact type (not subclasses)
+ if type(child) is expect_type:
+ assert isinstance(child, expect_type)
+ return child
+ raise NoMatches(f"No immediate child of type {expect_type}; {self._nodes}")
+
def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style:
"""Get a *Rich* style for a component.
@@ -496,6 +515,24 @@ class Widget(DOMNode):
return partial_style if partial else style
+ def render_str(self, text_content: str | Text) -> Text:
+ """Convert str in to a Text object.
+
+ If you pass in an existing Text object it will be returned unaltered.
+
+ Args:
+ text_content: Text or str.
+
+ Returns:
+ A text object.
+ """
+ text = (
+ Text.from_markup(text_content)
+ if isinstance(text_content, str)
+ else text_content
+ )
+ return text
+
def _arrange(self, size: Size) -> DockArrangeResult:
"""Arrange children.
@@ -2589,7 +2626,7 @@ class Widget(DOMNode):
True if the message was posted, False if this widget was closed / closing.
"""
- if not self.is_running:
+ if not self.is_running and not message.no_dispatch:
try:
self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent")
except NoActiveAppError:
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index 51985b13a..db5717284 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -29,6 +29,7 @@ if typing.TYPE_CHECKING:
from ._radio_set import RadioSet
from ._static import Static
from ._switch import Switch
+ from ._tabbed_content import TabbedContent, TabPane
from ._tabs import Tab, Tabs
from ._text_log import TextLog
from ._tree import Tree
@@ -57,6 +58,8 @@ __all__ = [
"Static",
"Switch",
"Tab",
+ "TabbedContent",
+ "TabPane",
"Tabs",
"TextLog",
"Tree",
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index d19537ac7..66e78fe94 100644
--- a/src/textual/widgets/__init__.pyi
+++ b/src/textual/widgets/__init__.pyi
@@ -19,6 +19,8 @@ from ._radio_button import RadioButton as RadioButton
from ._radio_set import RadioSet as RadioSet
from ._static import Static as Static
from ._switch import Switch as Switch
+from ._tabbed_content import TabbedContent as TabbedContent
+from ._tabbed_content import TabPane as TabPane
from ._tabs import Tab as Tab
from ._tabs import Tabs as Tabs
from ._text_log import TextLog as TextLog
diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py
index 72ab0f96a..aca1f6862 100644
--- a/src/textual/widgets/_content_switcher.py
+++ b/src/textual/widgets/_content_switcher.py
@@ -17,7 +17,7 @@ class ContentSwitcher(Container):
Children that have no ID will be hidden and ignored.
"""
- current: reactive[str | None] = reactive[Optional[str]](None)
+ current: reactive[str | None] = reactive[Optional[str]](None, init=False)
"""The ID of the currently-displayed widget.
If set to `None` then no widget is visible.
@@ -44,11 +44,10 @@ class ContentSwitcher(Container):
id: The ID of the content switcher in the DOM.
classes: The CSS classes of the content switcher.
disabled: Whether the content switcher is disabled or not.
- initial: The ID of the initial widget to show.
+ initial: The ID of the initial widget to show, ``None`` or empty string for the first tab.
Note:
- If `initial` is not supplied no children will be shown to start
- with.
+ If `initial` is not supplied no children will be shown to start with.
"""
super().__init__(
*children,
@@ -61,12 +60,11 @@ class ContentSwitcher(Container):
def on_mount(self) -> None:
"""Perform the initial setup of the widget once the DOM is ready."""
- # On startup, ensure everything is hidden.
+ initial = self._initial
with self.app.batch_update():
for child in self.children:
- child.display = False
- # Then set the initial display.
- self.current = self._initial
+ child.display = bool(initial) and child.id == initial
+ self._reactive_current = initial
@property
def visible_content(self) -> Widget | None:
@@ -84,7 +82,7 @@ class ContentSwitcher(Container):
new: The new widget ID (or `None` if nothing should be shown).
"""
with self.app.batch_update():
- if old is not None:
+ if old:
self.get_child_by_id(old).display = False
- if new is not None:
+ if new:
self.get_child_by_id(new).display = True
diff --git a/src/textual/widgets/_tab_pane.py b/src/textual/widgets/_tab_pane.py
new file mode 100644
index 000000000..db70d22a3
--- /dev/null
+++ b/src/textual/widgets/_tab_pane.py
@@ -0,0 +1,3 @@
+from ._tabbed_content import TabPane
+
+__all__ = ["TabPane"]
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
new file mode 100644
index 000000000..f2b9b06cd
--- /dev/null
+++ b/src/textual/widgets/_tabbed_content.py
@@ -0,0 +1,177 @@
+from __future__ import annotations
+
+from itertools import zip_longest
+
+from rich.text import Text, TextType
+
+from ..app import ComposeResult
+from ..reactive import reactive
+from ..widget import Widget
+from ._content_switcher import ContentSwitcher
+from ._tabs import Tab, Tabs
+
+__all__ = [
+ "ContentTab",
+ "TabbedContent",
+ "TabPane",
+]
+
+
+class ContentTab(Tab):
+ """A Tab with an associated content id."""
+
+ def __init__(self, label: Text, content_id: str):
+ """Initialize a ContentTab.
+
+ Args:
+ label: The label to be displayed within the tab.
+ content_id: The id of the content associated with the tab.
+ """
+ super().__init__(label, id=content_id)
+
+
+class TabPane(Widget):
+ """A container for switchable content, with additional title.
+
+ This widget is intended to be used with [TabbedContent][textual.widgets.TabbedContent].
+
+ """
+
+ DEFAULT_CSS = """
+ TabPane {
+ height: auto;
+ padding: 1 2;
+ }
+ """
+
+ def __init__(
+ self,
+ title: TextType,
+ *children: Widget,
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ):
+ """Initialize a TabPane.
+
+ Args:
+ title: Title of the TabPane (will be displayed in a tab label).
+ *children: Widget to go inside the TabPane.
+ name: Optional name for the TabPane.
+ id: Optional ID for the TabPane.
+ classes: Optional initial classes for the widget.
+ disabled: Whether the TabPane is disabled or not.
+ """
+ self._title = self.render_str(title)
+ super().__init__(
+ *children, name=name, id=id, classes=classes, disabled=disabled
+ )
+
+
+class TabbedContent(Widget):
+ """A container with associated tabs to toggle content visibility."""
+
+ DEFAULT_CSS = """
+ TabbedContent {
+ height: auto;
+ }
+ TabbedContent > ContentSwitcher {
+ height: auto;
+ }
+ """
+
+ active: reactive[str] = reactive("", init=False)
+ """The ID of the active tab, or empty string if none are active."""
+
+ def __init__(self, *titles: TextType, initial: str = "") -> None:
+ """Initialize a TabbedContent widgets.
+
+ Args:
+ *titles: Positional argument will be used as title.
+ initial: The id of the initial tab, or empty string to select the first tab.
+ """
+ self.titles = [self.render_str(title) for title in titles]
+ self._tab_content: list[Widget] = []
+ self._initial = initial
+ super().__init__()
+
+ def validate_active(self, active: str) -> str:
+ """It doesn't make sense for `active` to be an empty string.
+
+ Args:
+ active: Attribute to be validated.
+
+ Returns:
+ Value of `active`.
+
+ Raises:
+ ValueError: If the active attribute is set to empty string.
+ """
+ if not active:
+ raise ValueError("'active' tab must not be empty string.")
+ return active
+
+ def compose(self) -> ComposeResult:
+ """Compose the tabbed content."""
+
+ def set_id(content: TabPane, new_id: str) -> TabPane:
+ """Set an id on the content, if not already present.
+
+ Args:
+ content: a TabPane.
+ new_id: New `is` attribute, if it is not already set.
+
+ Returns:
+ The same TabPane.
+ """
+ if content.id is None:
+ content.id = new_id
+ return content
+
+ # Wrap content in a `TabPane` if required.
+ pane_content = [
+ (
+ set_id(content, f"tab-{index}")
+ if isinstance(content, TabPane)
+ else TabPane(
+ title or self.render_str(f"Tab {index}"), content, id=f"tab-{index}"
+ )
+ )
+ for index, (title, content) in enumerate(
+ zip_longest(self.titles, self._tab_content), 1
+ )
+ ]
+ # Get a tab for each pane
+ tabs = [
+ ContentTab(content._title, content.id or "") for content in pane_content
+ ]
+ # Yield the tabs
+ yield Tabs(*tabs, active=self._initial or None)
+ # Yield the content switcher and panes
+ with ContentSwitcher(initial=self._initial or None):
+ yield from pane_content
+
+ def compose_add_child(self, widget: Widget) -> None:
+ """When using the context manager compose syntax, we want to attach nodes to the switcher.
+
+ Args:
+ widget: A Widget to add.
+ """
+ self._tab_content.append(widget)
+
+ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
+ """User clicked a tab."""
+ event.stop()
+ switcher = self.get_child_by_type(ContentSwitcher)
+ assert isinstance(event.tab, ContentTab)
+ switcher.current = event.tab.id
+ self.active = event.tab.id
+
+ def _on_tabs_cleared(self, event: Tabs.Cleared) -> None:
+ """All tabs were removed."""
+ event.stop()
+
+ def watch_active(self, active: str) -> None:
+ """Switch tabs when the active attributes changes."""
+ self.get_child_by_type(Tabs).active = active
diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py
index 7c190bf94..1474975ed 100644
--- a/src/textual/widgets/_tabs.py
+++ b/src/textual/widgets/_tabs.py
@@ -175,16 +175,44 @@ class Tabs(Widget, can_focus=True):
class TabActivated(Message):
"""Sent when a new tab is activated."""
+ tabs: Tabs
+ """The tabs widget containing the tab."""
tab: Tab
"""The tab that was activated."""
- def __init__(self, tab: Tab | None) -> None:
+ def __init__(self, tabs: Tabs, tab: Tab) -> None:
+ """Initialize event.
+
+ Args:
+ tabs: The Tabs widget.
+ tab: The tab that was activated.
+ """
+ self.tabs = tabs
self.tab = tab
super().__init__()
def __rich_repr__(self) -> rich.repr.Result:
+ yield self.tabs
yield self.tab
+ class Cleared(Message):
+ """Sent when there are no active tabs."""
+
+ tabs: Tabs
+ """The tabs widget which was cleared."""
+
+ def __init__(self, tabs: Tabs) -> None:
+ """Initialize the event.
+
+ Args:
+ tabs: The tabs widget.
+ """
+ self.tabs = tabs
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield self.tabs
+
active: reactive[str] = reactive("", init=False)
"""The ID of the active tab, or empty string if none are active."""
@@ -278,7 +306,7 @@ class Tabs(Widget, can_focus=True):
mount_await = self.query_one("#tabs-list").mount(tab_widget)
if from_empty:
tab_widget.add_class("-active")
- self.post_message(self.TabActivated(tab_widget))
+ self.post_message(self.TabActivated(self, tab_widget))
async def refresh_active() -> None:
"""Wait for things to be mounted before highlighting."""
@@ -294,7 +322,7 @@ class Tabs(Widget, can_focus=True):
underline.highlight_start = 0
underline.highlight_end = 0
self.query("#tabs-list > Tab").remove()
- self.post_message(self.TabActivated(None))
+ self.post_message(self.Cleared(self))
def remove_tab(self, tab_or_id: Tab | str | None) -> None:
"""Remove a tab.
@@ -314,9 +342,13 @@ class Tabs(Widget, can_focus=True):
removing_active_tab = remove_tab.has_class("-active")
next_tab = self._next_active
- self.post_message(self.TabActivated(next_tab))
+ if next_tab is None:
+ self.post_message(self.Cleared(self))
+ else:
+ self.post_message(self.TabActivated(self, next_tab))
async def do_remove() -> None:
+ """Perform the remove after refresh so the underline bar gets new positions."""
await remove_tab.remove()
if removing_active_tab:
if next_tab is not None:
@@ -365,12 +397,12 @@ class Tabs(Widget, can_focus=True):
self.query("#tabs-list > Tab.-active").remove_class("-active")
active_tab.add_class("-active")
self._highlight_active(animate=previously_active != "")
- self.post_message(self.TabActivated(active_tab))
+ self.post_message(self.TabActivated(self, active_tab))
else:
underline = self.query_one(Underline)
underline.highlight_start = 0
underline.highlight_end = 0
- self.post_message(self.TabActivated(None))
+ self.post_message(self.Cleared(self))
def _highlight_active(self, animate: bool = True) -> None:
"""Move the underline bar to under the active tab.
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 36930d78d..326be7e82 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -17909,6 +17909,169 @@
'''
# ---
+# name: test_tabbed_content
+ '''
+
+
+ '''
+# ---
# name: test_textlog_max_lines
'''