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

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
- [ ] Spark-lines
- [X] Switch
- [ ] Tabs
- [X] Tabs
- [ ] TextArea (multi-line input)
* [ ] Basic controls
* [ ] 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"}
```
## 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
### ::: textual.widgets.Tabs.TabActivated
### ::: textual.widgets.Tabs.TabsCleared
## Bindings

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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):
"""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

View File

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

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

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