mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
12
CHANGELOG.md
12
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
|
||||
|
||||
2
docs/api/tabbed_content.md
Normal file
2
docs/api/tabbed_content.md
Normal file
@@ -0,0 +1,2 @@
|
||||
::: textual.widgets.TabbedContent
|
||||
::: textual.widgets.TabPane
|
||||
57
docs/examples/widgets/tabbed_content.py
Normal file
57
docs/examples/widgets/tabbed_content.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
106
docs/widgets/tabbed_content.md
Normal file
106
docs/widgets/tabbed_content.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
3
src/textual/widgets/_tab_pane.py
Normal file
3
src/textual/widgets/_tab_pane.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ._tabbed_content import TabPane
|
||||
|
||||
__all__ = ["TabPane"]
|
||||
177
src/textual/widgets/_tabbed_content.py
Normal file
177
src/textual/widgets/_tabbed_content.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,8 +22,6 @@ class ProgrammaticScrollbarGutterChange(App[None]):
|
||||
self.query_one(Grid).styles.scrollbar_gutter = "stable"
|
||||
|
||||
|
||||
app = ProgrammaticScrollbarGutterChange()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app().run()
|
||||
app = ProgrammaticScrollbarGutterChange()
|
||||
app.run()
|
||||
|
||||
@@ -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 ---
|
||||
# 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.
|
||||
|
||||
87
tests/test_tabbed_content.py
Normal file
87
tests/test_tabbed_content.py
Normal 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
|
||||
@@ -1,7 +1,9 @@
|
||||
import pytest
|
||||
from rich.text import Text
|
||||
|
||||
from textual._node_list import DuplicateIds
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container
|
||||
from textual.css.errors import StyleValueError
|
||||
from textual.css.query import NoMatches
|
||||
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")
|
||||
|
||||
|
||||
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):
|
||||
with pytest.raises(NoMatches):
|
||||
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:
|
||||
await pilot.pause()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user