Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tabbed content widget #2059

Merged
merged 26 commits into from
Mar 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/api/tabbed_content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
::: textual.widgets.TabbedContent
::: textual.widgets.TabPane
57 changes: 57 additions & 0 deletions docs/examples/widgets/tabbed_content.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/widget_gallery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/widgets/_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/widgets/checkbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/widgets/data_table.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/widgets/list_view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/widgets/radiobutton.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/widgets/switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
106 changes: 106 additions & 0 deletions docs/widgets/tabbed_content.md
Original file line number Diff line number Diff line change
@@ -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).
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

## 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:
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

```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. |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last nit... as of 2ede38f this needs to show that it can be None.



## See also

- [TabbedContent](../api/tabbed_content.md) code reference.
- [Tabs](../api/tabs.md) code reference.
- [ContentSwitcher](../api/content_switcher.md) code reference.
1 change: 1 addition & 0 deletions docs/widgets/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/widgets/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/textual/_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,10 +893,11 @@ def update_widgets(self, widgets: set[Widget]) -> None:
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__
Expand Down
16 changes: 15 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,20 @@ def get_widget_by_id(
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.

Expand Down Expand Up @@ -2202,7 +2216,7 @@ async def prune_widgets_task(
await self._prune_nodes(widgets)
finally:
finished_event.set()
if parent is not None and parent.styles.auto_dimensions:
if parent is not None:
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
parent.refresh(layout=True)

removed_widgets = self._detach_from_dom(widgets)
Expand Down
Loading