tabbed content widget (#2059)

* tabbed content widget

* TabbedContent widget and docs

* missing docs

* fix active

* doc fix

* test fix

* additional test

* test for render_str

* docstring

* changelog

* doc update

* changelog

* fix bad optimization

* Update docs/widgets/tabbed_content.md

Co-authored-by: Dave Pearson <davep@davep.org>

* fix for empty initial

* docstrings

* Update src/textual/widgets/_content_switcher.py

Co-authored-by: Dave Pearson <davep@davep.org>

* docstring

* remove log

* permit nested tabs

* renamed TabsCleared to Cleared

* added tests, fix types on click

* tests

* fix broken test

* fix for nested tabs

---------

Co-authored-by: Dave Pearson <davep@davep.org>
This commit is contained in:
Will McGugan
2023-03-18 10:38:41 +00:00
committed by GitHub
parent 2c120c0983
commit f5e779c4c4
33 changed files with 789 additions and 48 deletions

View File

@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed ### Changed
- Dropped "loading-indicator--dot" component style from LoadingIndicator https://github.com/Textualize/textual/pull/2050 - 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 ### 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 - 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 - 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
- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957 - Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957
- Added `Center` 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 `Middle` https://github.com/Textualize/textual/issues/1957
- Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) 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 ## [0.15.1] - 2023-03-14

View File

@@ -0,0 +1,2 @@
::: textual.widgets.TabbedContent
::: textual.widgets.TabPane

View File

@@ -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()

View File

@@ -73,7 +73,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
- [X] Radio boxes - [X] Radio boxes
- [ ] Spark-lines - [ ] Spark-lines
- [X] Switch - [X] Switch
- [ ] Tabs - [X] Tabs
- [ ] TextArea (multi-line input) - [ ] TextArea (multi-line input)
* [ ] Basic controls * [ ] Basic controls
* [ ] Indentation guides * [ ] Indentation guides

View File

@@ -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"} ```{.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 ## TextLog

View File

@@ -33,7 +33,7 @@ Example app showing the widget:
## Bindings ## Bindings
The WIDGET widget defines directly the following bindings: The WIDGET widget defines the following bindings:
::: textual.widgets.WIDGET.BINDINGS ::: textual.widgets.WIDGET.BINDINGS
options: options:

View File

@@ -34,7 +34,7 @@ The example below shows check boxes in various states.
## Bindings ## Bindings
The checkbox widget defines directly the following bindings: The checkbox widget defines the following bindings:
::: textual.widgets._toggle_button.ToggleButton.BINDINGS ::: textual.widgets._toggle_button.ToggleButton.BINDINGS
options: options:

View File

@@ -23,7 +23,7 @@ The example below populates a table with CSV data.
## Reactive Attributes ## Reactive Attributes
| Name | Type | Default | Description | | Name | Type | Default | Description |
|---------------------|---------------------------------------------|--------------------|-------------------------------------------------------| | ------------------- | ------------------------------------------- | ------------------ | ----------------------------------------------------- |
| `show_header` | `bool` | `True` | Show the table header | | `show_header` | `bool` | `True` | Show the table header |
| `fixed_rows` | `int` | `0` | Number of fixed rows (rows which do not scroll) | | `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) | | `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 ## Bindings
The data table widget defines directly the following bindings: The data table widget defines the following bindings:
::: textual.widgets.DataTable.BINDINGS ::: textual.widgets.DataTable.BINDINGS
options: options:

View File

@@ -41,7 +41,7 @@ The example below shows an app with a simple `ListView`.
## Bindings ## Bindings
The list view widget defines directly the following bindings: The list view widget defines the following bindings:
::: textual.widgets.ListView.BINDINGS ::: textual.widgets.ListView.BINDINGS
options: options:

View File

@@ -31,12 +31,12 @@ The example below shows radio buttons, used within a [`RadioSet`](./radioset.md)
## Reactive Attributes ## Reactive Attributes
| Name | Type | Default | Description | | Name | Type | Default | Description |
|---------|--------|---------|--------------------------------| | ------- | ------ | ------- | ------------------------------ |
| `value` | `bool` | `False` | The value of the radio button. | | `value` | `bool` | `False` | The value of the radio button. |
## Bindings ## Bindings
The radio button widget defines directly the following bindings: The radio button widget defines the following bindings:
::: textual.widgets._toggle_button.ToggleButton.BINDINGS ::: textual.widgets._toggle_button.ToggleButton.BINDINGS
options: options:

View File

@@ -29,12 +29,12 @@ The example below shows switches in various states.
## Reactive Attributes ## Reactive Attributes
| Name | Type | Default | Description | | Name | Type | Default | Description |
|---------|--------|---------|--------------------------| | ------- | ------ | ------- | ------------------------ |
| `value` | `bool` | `False` | The value of the switch. | | `value` | `bool` | `False` | The value of the switch. |
## Bindings ## Bindings
The switch widget defines directly the following bindings: The switch widget defines the following bindings:
::: textual.widgets.Switch.BINDINGS ::: textual.widgets.Switch.BINDINGS
options: options:

View File

@@ -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.

View File

@@ -60,6 +60,7 @@ The following example adds a `Tabs` widget above a text label. Press ++a++ to ad
## Messages ## Messages
### ::: textual.widgets.Tabs.TabActivated ### ::: textual.widgets.Tabs.TabActivated
### ::: textual.widgets.Tabs.TabsCleared
## Bindings ## Bindings

View File

@@ -44,7 +44,7 @@ Tree widgets have a "root" attribute which is an instance of a [TreeNode][textua
## Bindings ## Bindings
The tree widget defines directly the following bindings: The tree widget defines the following bindings:
::: textual.widgets.Tree.BINDINGS ::: textual.widgets.Tree.BINDINGS
options: options:

View File

@@ -140,6 +140,7 @@ nav:
- "widgets/radioset.md" - "widgets/radioset.md"
- "widgets/static.md" - "widgets/static.md"
- "widgets/switch.md" - "widgets/switch.md"
- "widgets/tabbed_content.md"
- "widgets/tabs.md" - "widgets/tabs.md"
- "widgets/text_log.md" - "widgets/text_log.md"
- "widgets/tree.md" - "widgets/tree.md"
@@ -181,10 +182,11 @@ nav:
- "api/static.md" - "api/static.md"
- "api/strip.md" - "api/strip.md"
- "api/switch.md" - "api/switch.md"
- "api/tabbed_content.md"
- "api/tabs.md" - "api/tabs.md"
- "api/text_log.md" - "api/text_log.md"
- "api/toggle_button.md"
- "api/timer.md" - "api/timer.md"
- "api/toggle_button.md"
- "api/tree.md" - "api/tree.md"
- "api/walk.md" - "api/walk.md"
- "api/welcome.md" - "api/welcome.md"

View File

@@ -28,7 +28,7 @@ def compose(node: App | Widget) -> list[Widget]:
nodes.extend(composed) nodes.extend(composed)
composed.clear() composed.clear()
if compose_stack: if compose_stack:
compose_stack[-1]._nodes._append(child) compose_stack[-1].compose_add_child(child)
else: else:
nodes.append(child) nodes.append(child)
if composed: if composed:

View File

@@ -893,10 +893,11 @@ class Compositor:
widget: Widget to update. widget: Widget to update.
""" """
if not self._full_map_invalidated and not widgets.issuperset( if not self._full_map_invalidated and not widgets.issubset(
self.visible_widgets self.visible_widgets.keys()
): ):
self._full_map_invalidated = True self._full_map_invalidated = True
regions: list[Region] = [] regions: list[Region] = []
add_region = regions.append add_region = regions.append
get_widget = self.visible_widgets.__getitem__ get_widget = self.visible_widgets.__getitem__

View File

@@ -1133,6 +1133,20 @@ class App(Generic[ReturnType], DOMNode):
else self.screen.get_widget_by_id(id, expect_type) 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: def update_styles(self, node: DOMNode | None = None) -> None:
"""Request update of styles. """Request update of styles.
@@ -2202,7 +2216,7 @@ class App(Generic[ReturnType], DOMNode):
await self._prune_nodes(widgets) await self._prune_nodes(widgets)
finally: finally:
finished_event.set() finished_event.set()
if parent is not None and parent.styles.auto_dimensions: if parent is not None:
parent.refresh(layout=True) parent.refresh(layout=True)
removed_widgets = self._detach_from_dom(widgets) removed_widgets = self._detach_from_dom(widgets)

View File

@@ -761,7 +761,11 @@ class StringEnumProperty:
if value is None: if value is None:
if obj.clear_rule(self.name): if obj.clear_rule(self.name):
self._before_refresh(obj, value) 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: else:
if value not in self._valid_values: if value not in self._valid_values:
raise StyleValueError( raise StyleValueError(

View File

@@ -151,6 +151,18 @@ class DOMNode(MessagePump):
super().__init__() 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 @property
def children(self) -> Sequence["Widget"]: def children(self) -> Sequence["Widget"]:
"""A view on to the children.""" """A view on to the children."""

View File

@@ -7,7 +7,6 @@ import rich.repr
from ._wait import wait_for_idle from ._wait import wait_for_idle
from .app import App, ReturnType from .app import App, ReturnType
from .css.query import QueryType
from .events import Click, MouseDown, MouseMove, MouseUp from .events import Click, MouseDown, MouseMove, MouseUp
from .geometry import Offset from .geometry import Offset
from .widget import Widget from .widget import Widget
@@ -65,7 +64,7 @@ class Pilot(Generic[ReturnType]):
async def click( async def click(
self, self,
selector: QueryType | None = None, selector: type[Widget] | str | None = None,
offset: Offset = Offset(), offset: Offset = Offset(),
shift: bool = False, shift: bool = False,
meta: bool = False, meta: bool = False,
@@ -100,7 +99,9 @@ class Pilot(Generic[ReturnType]):
await self.pause() await self.pause()
async def hover( async def hover(
self, selector: QueryType | None = None, offset: Offset = Offset() self,
selector: type[Widget] | str | None | None = None,
offset: Offset = Offset(),
) -> None: ) -> None:
"""Simulate hovering with the mouse cursor. """Simulate hovering with the mouse cursor.

View File

@@ -390,7 +390,7 @@ class Widget(DOMNode):
compose_stack = self.app._compose_stacks[-1] compose_stack = self.app._compose_stacks[-1]
composed = compose_stack.pop() composed = compose_stack.pop()
if compose_stack: if compose_stack:
compose_stack[-1]._nodes._append(composed) compose_stack[-1].compose_add_child(composed)
else: else:
self.app._composed[-1].append(composed) self.app._composed[-1].append(composed)
@@ -475,6 +475,25 @@ class Widget(DOMNode):
) from exc ) from exc
raise NoMatches(f"No descendant found with id={id!r}") 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: def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style:
"""Get a *Rich* style for a component. """Get a *Rich* style for a component.
@@ -496,6 +515,24 @@ class Widget(DOMNode):
return partial_style if partial else style 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: def _arrange(self, size: Size) -> DockArrangeResult:
"""Arrange children. """Arrange children.
@@ -2589,7 +2626,7 @@ class Widget(DOMNode):
True if the message was posted, False if this widget was closed / closing. 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: try:
self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent") self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent")
except NoActiveAppError: except NoActiveAppError:

View File

@@ -29,6 +29,7 @@ if typing.TYPE_CHECKING:
from ._radio_set import RadioSet from ._radio_set import RadioSet
from ._static import Static from ._static import Static
from ._switch import Switch from ._switch import Switch
from ._tabbed_content import TabbedContent, TabPane
from ._tabs import Tab, Tabs from ._tabs import Tab, Tabs
from ._text_log import TextLog from ._text_log import TextLog
from ._tree import Tree from ._tree import Tree
@@ -57,6 +58,8 @@ __all__ = [
"Static", "Static",
"Switch", "Switch",
"Tab", "Tab",
"TabbedContent",
"TabPane",
"Tabs", "Tabs",
"TextLog", "TextLog",
"Tree", "Tree",

View File

@@ -19,6 +19,8 @@ from ._radio_button import RadioButton as RadioButton
from ._radio_set import RadioSet as RadioSet from ._radio_set import RadioSet as RadioSet
from ._static import Static as Static from ._static import Static as Static
from ._switch import Switch as Switch 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 Tab as Tab
from ._tabs import Tabs as Tabs from ._tabs import Tabs as Tabs
from ._text_log import TextLog as TextLog from ._text_log import TextLog as TextLog

View File

@@ -17,7 +17,7 @@ class ContentSwitcher(Container):
Children that have no ID will be hidden and ignored. 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. """The ID of the currently-displayed widget.
If set to `None` then no widget is visible. 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. id: The ID of the content switcher in the DOM.
classes: The CSS classes of the content switcher. classes: The CSS classes of the content switcher.
disabled: Whether the content switcher is disabled or not. 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: Note:
If `initial` is not supplied no children will be shown to start If `initial` is not supplied no children will be shown to start with.
with.
""" """
super().__init__( super().__init__(
*children, *children,
@@ -61,12 +60,11 @@ class ContentSwitcher(Container):
def on_mount(self) -> None: def on_mount(self) -> None:
"""Perform the initial setup of the widget once the DOM is ready.""" """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(): with self.app.batch_update():
for child in self.children: for child in self.children:
child.display = False child.display = bool(initial) and child.id == initial
# Then set the initial display. self._reactive_current = initial
self.current = self._initial
@property @property
def visible_content(self) -> Widget | None: 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). new: The new widget ID (or `None` if nothing should be shown).
""" """
with self.app.batch_update(): with self.app.batch_update():
if old is not None: if old:
self.get_child_by_id(old).display = False self.get_child_by_id(old).display = False
if new is not None: if new:
self.get_child_by_id(new).display = True self.get_child_by_id(new).display = True

View File

@@ -0,0 +1,3 @@
from ._tabbed_content import TabPane
__all__ = ["TabPane"]

View File

@@ -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

View File

@@ -175,16 +175,44 @@ class Tabs(Widget, can_focus=True):
class TabActivated(Message): class TabActivated(Message):
"""Sent when a new tab is activated.""" """Sent when a new tab is activated."""
tabs: Tabs
"""The tabs widget containing the tab."""
tab: Tab tab: Tab
"""The tab that was activated.""" """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 self.tab = tab
super().__init__() super().__init__()
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield self.tabs
yield self.tab 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) active: reactive[str] = reactive("", init=False)
"""The ID of the active tab, or empty string if none are active.""" """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) mount_await = self.query_one("#tabs-list").mount(tab_widget)
if from_empty: if from_empty:
tab_widget.add_class("-active") 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: async def refresh_active() -> None:
"""Wait for things to be mounted before highlighting.""" """Wait for things to be mounted before highlighting."""
@@ -294,7 +322,7 @@ class Tabs(Widget, can_focus=True):
underline.highlight_start = 0 underline.highlight_start = 0
underline.highlight_end = 0 underline.highlight_end = 0
self.query("#tabs-list > Tab").remove() 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: def remove_tab(self, tab_or_id: Tab | str | None) -> None:
"""Remove a tab. """Remove a tab.
@@ -314,9 +342,13 @@ class Tabs(Widget, can_focus=True):
removing_active_tab = remove_tab.has_class("-active") removing_active_tab = remove_tab.has_class("-active")
next_tab = self._next_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: async def do_remove() -> None:
"""Perform the remove after refresh so the underline bar gets new positions."""
await remove_tab.remove() await remove_tab.remove()
if removing_active_tab: if removing_active_tab:
if next_tab is not None: 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") self.query("#tabs-list > Tab.-active").remove_class("-active")
active_tab.add_class("-active") active_tab.add_class("-active")
self._highlight_active(animate=previously_active != "") self._highlight_active(animate=previously_active != "")
self.post_message(self.TabActivated(active_tab)) self.post_message(self.TabActivated(self, active_tab))
else: else:
underline = self.query_one(Underline) underline = self.query_one(Underline)
underline.highlight_start = 0 underline.highlight_start = 0
underline.highlight_end = 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: def _highlight_active(self, animate: bool = True) -> None:
"""Move the underline bar to under the active tab. """Move the underline bar to under the active tab.

File diff suppressed because one or more lines are too long

View File

@@ -22,8 +22,6 @@ class ProgrammaticScrollbarGutterChange(App[None]):
self.query_one(Grid).styles.scrollbar_gutter = "stable" self.query_one(Grid).styles.scrollbar_gutter = "stable"
app = ProgrammaticScrollbarGutterChange()
if __name__ == "__main__": if __name__ == "__main__":
app().run() app = ProgrammaticScrollbarGutterChange()
app.run()

View File

@@ -183,6 +183,10 @@ def test_content_switcher_example_switch(snap_compare):
) )
def test_tabbed_content(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py")
# --- CSS properties --- # --- CSS properties ---
# We have a canonical example for each CSS property that is shown in their docs. # We have a canonical example for each CSS property that is shown in their docs.
# If any of these change, something has likely broken, so snapshot each of them. # If any of these change, something has likely broken, so snapshot each of them.

View File

@@ -0,0 +1,87 @@
import pytest
from textual.app import App, ComposeResult
from textual.widgets import Label, TabbedContent, TabPane
async def test_tabbed_content_switch():
"""Check tab navigation."""
class TabbedApp(App):
def compose(self) -> ComposeResult:
with TabbedContent():
with TabPane("foo", id="foo"):
yield Label("Foo", id="foo-label")
with TabPane("bar", id="bar"):
yield Label("Bar", id="bar-label")
with TabPane("baz`", id="baz"):
yield Label("Baz", id="baz-label")
app = TabbedApp()
async with app.run_test() as pilot:
tabbed_content = app.query_one(TabbedContent)
# Check first tab
assert tabbed_content.active == "foo"
assert app.query_one("#foo-label").region
assert not app.query_one("#bar-label").region
assert not app.query_one("#baz-label").region
# Click second tab
await pilot.click("Tab#bar")
assert tabbed_content.active == "bar"
assert not app.query_one("#foo-label").region
assert app.query_one("#bar-label").region
assert not app.query_one("#baz-label").region
# Click third tab
await pilot.click("Tab#baz")
assert tabbed_content.active == "baz"
assert not app.query_one("#foo-label").region
assert not app.query_one("#bar-label").region
assert app.query_one("#baz-label").region
# Press left
await pilot.press("left")
assert tabbed_content.active == "bar"
assert not app.query_one("#foo-label").region
assert app.query_one("#bar-label").region
assert not app.query_one("#baz-label").region
# Press right
await pilot.press("right")
assert tabbed_content.active == "baz"
assert not app.query_one("#foo-label").region
assert not app.query_one("#bar-label").region
assert app.query_one("#baz-label").region
# Check fail with non existent tab
with pytest.raises(ValueError):
tabbed_content.active = "X"
# Check fail with empty tab
with pytest.raises(ValueError):
tabbed_content.active = ""
async def test_tabbed_content_initial():
"""Checked tabbed content with non-default tab."""
class TabbedApp(App):
def compose(self) -> ComposeResult:
with TabbedContent(initial="bar"):
with TabPane("foo", id="foo"):
yield Label("Foo", id="foo-label")
with TabPane("bar", id="bar"):
yield Label("Bar", id="bar-label")
with TabPane("baz`", id="baz"):
yield Label("Baz", id="baz-label")
app = TabbedApp()
async with app.run_test() as pilot:
tabbed_content = app.query_one(TabbedContent)
assert tabbed_content.active == "bar"
# Check only bar is visible
assert not app.query_one("#foo-label").region
assert app.query_one("#bar-label").region
assert not app.query_one("#baz-label").region

View File

@@ -1,7 +1,9 @@
import pytest import pytest
from rich.text import Text
from textual._node_list import DuplicateIds from textual._node_list import DuplicateIds
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Container
from textual.css.errors import StyleValueError from textual.css.errors import StyleValueError
from textual.css.query import NoMatches from textual.css.query import NoMatches
from textual.geometry import Size from textual.geometry import Size
@@ -111,6 +113,24 @@ def test_get_child_by_id_only_immediate_descendents(parent):
parent.get_child_by_id(id="grandchild1") parent.get_child_by_id(id="grandchild1")
async def test_get_child_by_type():
class GetChildApp(App):
def compose(self) -> ComposeResult:
yield Widget(id="widget1")
yield Container(
Label(id="label1"),
Widget(id="widget2"),
id="container1",
)
app = GetChildApp()
async with app.run_test():
assert app.get_child_by_type(Widget).id == "widget1"
assert app.get_child_by_type(Container).id == "container1"
with pytest.raises(NoMatches):
app.get_child_by_type(Label)
def test_get_widget_by_id_no_matching_child(parent): def test_get_widget_by_id_no_matching_child(parent):
with pytest.raises(NoMatches): with pytest.raises(NoMatches):
parent.get_widget_by_id(id="i-dont-exist") parent.get_widget_by_id(id="i-dont-exist")
@@ -198,3 +218,12 @@ async def test_remove_unmounted():
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.pause() await pilot.pause()
assert mounted assert mounted
def test_render_str() -> None:
widget = Label()
assert widget.render_str("foo") == Text("foo")
assert widget.render_str("[b]foo") == Text.from_markup("[b]foo")
# Text objects are passed unchanged
text = Text("bar")
assert widget.render_str(text) is text