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
|
### 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
|
||||||
|
|||||||
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
|
- [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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
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
|
## Messages
|
||||||
|
|
||||||
### ::: textual.widgets.Tabs.TabActivated
|
### ::: textual.widgets.Tabs.TabActivated
|
||||||
|
### ::: textual.widgets.Tabs.TabsCleared
|
||||||
|
|
||||||
## Bindings
|
## Bindings
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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__
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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):
|
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
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user