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

tabs widget #2020

Merged
merged 31 commits into from
Mar 13, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d732fa3
tabs widget
willmcgugan Mar 11, 2023
bd8f96a
click underline
willmcgugan Mar 11, 2023
f200e24
color tweak
willmcgugan Mar 11, 2023
9d32b52
docs
willmcgugan Mar 12, 2023
04f03fd
docs update
willmcgugan Mar 12, 2023
d5aa564
expose Tab
willmcgugan Mar 12, 2023
fae1ffa
added remove_tab and clear
willmcgugan Mar 12, 2023
bbc4a2b
fix cycling
willmcgugan Mar 12, 2023
df7c907
add animation
willmcgugan Mar 13, 2023
138afa0
docs
willmcgugan Mar 13, 2023
244d250
changelog
willmcgugan Mar 13, 2023
c469611
remove recompose
willmcgugan Mar 13, 2023
1e5facc
docstrings
willmcgugan Mar 13, 2023
84a0e3a
Update docs/guide/actions.md
willmcgugan Mar 13, 2023
28dd46a
Rodrigoed the tabs
willmcgugan Mar 13, 2023
dbb4ffe
Update docs/widgets/tabs.md
willmcgugan Mar 13, 2023
48db878
Update docs/widgets/tabs.md
willmcgugan Mar 13, 2023
be228dd
copy
willmcgugan Mar 13, 2023
1a97931
docstrings
willmcgugan Mar 13, 2023
efeead0
docstring
willmcgugan Mar 13, 2023
7f64175
docstring
willmcgugan Mar 13, 2023
578760f
Apply suggestions from code review
willmcgugan Mar 13, 2023
3da60f3
stop click
willmcgugan Mar 13, 2023
27843c4
docstring
willmcgugan Mar 13, 2023
9ea0c0e
auto assign consistent IDs
willmcgugan Mar 13, 2023
b0b501e
Apply suggestions from code review
willmcgugan Mar 13, 2023
cd29e87
Document bindings
willmcgugan Mar 13, 2023
dfb693f
document bindings
willmcgugan Mar 13, 2023
228caf0
Apply suggestions from code review
willmcgugan Mar 13, 2023
aef2e16
Apply suggestions from code review
willmcgugan Mar 13, 2023
6e814d6
Merge branch 'main' into tabs-widget
willmcgugan Mar 13, 2023
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added

- Added a LoadingIndicator widget https://github.com/Textualize/textual/pull/2018
- Added Tabs Widget

### Changed

- Breaking change: Renamed Widget.action and App.action to Widget.run_action and App.run_action

## [0.14.0] - 2023-03-09

Expand Down
2 changes: 2 additions & 0 deletions docs/api/tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
::: textual.widgets.Tabs
::: textual.widgets.Tab
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 2 additions & 2 deletions docs/examples/guide/actions/actions02.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from textual.app import App
from textual import events
from textual.app import App


class ActionsApp(App):
Expand All @@ -8,7 +8,7 @@ def action_set_background(self, color: str) -> None:

async def on_key(self, event: events.Key) -> None:
if event.key == "r":
await self.action("set_background('red')")
await self.run_action("set_background('red')")


if __name__ == "__main__":
Expand Down
82 changes: 82 additions & 0 deletions docs/examples/widgets/tabs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from textual.app import App, ComposeResult
from textual.widgets import Footer, Label, Tabs

NAMES = [
"Paul Atreidies",
"Duke Leto Atreides",
"Lady Jessica",
"Gurney Halleck",
"Baron Vladimir Harkonnen",
"Glossu Rabban",
"Chani",
"Silgar",
]


class TabsApp(App):
"""Demonstrates the Tabs widget."""

CSS = """
Tabs {
dock: top;
}
Screen {
align: center middle;
}
Label {
margin:1 1;
width: 100%;
height: 100%;
background: $panel;
border: tall $primary;
content-align: center middle;
}
"""

BINDINGS = [
("a", "add", "Add tab"),
("r", "remove", "Remove active tab"),
("c", "clear", "Clear tabs"),
]

def compose(self) -> ComposeResult:
yield Tabs(NAMES[0])
yield Label()
yield Footer()

def on_mount(self) -> None:
"""Focus the tabs when the app starts."""
self.query_one(Tabs).focus()

def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
"""Handle TabActivated message sent by Tabs."""
label = self.query_one(Label)
if event.tab is None:
# When the tabs are cleared, event.tab will be None
label.visible = False
else:
label.visible = True
label.update(event.tab.label)

def action_add(self) -> None:
"""Add a new tab."""
tabs = self.query_one(Tabs)
# Cycle the names
NAMES[:] = [*NAMES[1:], NAMES[0]]
tabs.add_tab(NAMES[0])

def action_remove(self) -> None:
"""Remove active tab."""
tabs = self.query_one(Tabs)
active_tab = tabs.active_tab
if active_tab is not None:
tabs.remove_tab(active_tab.id)

def action_clear(self) -> None:
"""Clear the tabs."""
self.query_one(Tabs).clear()


if __name__ == "__main__":
app = TabsApp()
app.run()
8 changes: 4 additions & 4 deletions docs/guide/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ The `action_set_background` method is an action which sets the background of the

Although it is possible (and occasionally useful) to call action methods in this way, they are intended to be parsed from an _action string_. For instance, the string `"set_background('red')"` is an action string which would call `self.action_set_background('red')`.

The following example replaces the immediate call with a call to [action()][textual.widgets.Widget.action] which parses an action string and dispatches it to the appropriate method.
The following example replaces the immediate call with a call to [run_action()][textual.widgets.Widget.run_action] which parses an action string and dispatches it to the appropriate method.

```python title="actions02.py" hl_lines="9-11"
--8<-- "docs/examples/guide/actions/actions02.py"
```

Note that the `action()` method is a coroutine so `on_key` needs to be prefixed with the `async` keyword.
Note that the `run_action()` method is a coroutine so `on_key` needs to be prefixed with the `async` keyword.

You will not typically need this in a real app as Textual will run actions in links or key bindings. Before we discuss these, let's have a closer look at the syntax for action strings.

Expand All @@ -36,7 +36,7 @@ Action strings have a simple syntax, which for the most part replicates Python's

!!! important

As much as they *look* like Python code, Textual does **not** call Python's `eval` function or similar to compile action strings.
As much as they *look* like Python code, Textual does **not** call Python's `eval` function to compile action strings.

Action strings have the following format:

Expand All @@ -50,7 +50,7 @@ Action strings have the following format:

### Parameters

If the action string contains parameters, these must be valid Python literals. Which means you can include numbers, strings, dicts, lists etc. but you can't include variables or references to any other python symbol.
If the action string contains parameters, these must be valid Python literals. Which means you can include numbers, strings, dicts, lists etc. but you can't include variables or references to any other python symbols.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

Consequently `"set_background('blue')"` is a valid action string, but `"set_background(new_color)"` is not &mdash; because `new_color` is a variable and not a literal.

Expand Down
10 changes: 10 additions & 0 deletions docs/widget_gallery.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,16 @@ A on / off control, inspired by toggle buttons.
```{.textual path="docs/examples/widgets/switch.py"}
```

## Tabs

A row of tabs you can select with the mouse or navigate with keys.

[Tabs reference](./widgets/tabs.md){ .md-button .md-button--primary }

```{.textual path="docs/examples/widgets/tabs.py" press="a,a,a,a,right,right"}
```


## TextLog

Display and update text in a scrolling panel.
Expand Down
2 changes: 1 addition & 1 deletion docs/widgets/checkbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The example below shows check boxes in various states.
## Reactive Attributes

| Name | Type | Default | Description |
|---------|--------|---------|----------------------------|
| ------- | ------ | ------- | -------------------------- |
| `value` | `bool` | `False` | The value of the checkbox. |

## Bindings
Expand Down
2 changes: 1 addition & 1 deletion docs/widgets/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The example below shows how you might create a simple form using two `Input` wid

## Bindings

The input widget defines directly the following bindings:
The Input widget defines the following bindings:

::: textual.widgets.Input.BINDINGS
options:
Expand Down
67 changes: 67 additions & 0 deletions docs/widgets/tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Tabs

Displays a number of tab headers which may be activated with click or navigated with cursor keys.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

- [x] Focusable
- [ ] Container

Construct a `Tabs` widget with strings or [Text][rich.text.Text] objects as positional arguments, which will set the label in the tabs. Here's an example with three tabs:
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

```python
def compose(self) -> ComposeResult:
yield Tabs("First tab", "Second tab", Text.from_markup("[u]Third[/u] tab"))
```

This will create [Tab][textual.widgets.Tab] widgets internally, with an auto-incrementing `id` attribute (`"tab-1"`, `"tab-2"` etc).
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
You can also supply `Tab` objects directly in the constructor, which will allow you to explicitly set an `id`. Here's an example:

```python
def compose(self) -> ComposeResult:
yield Tabs(
Tab("First tab", id="one"),
Tab("Second tab", id="two")
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
)
```

When the user switches to a tab by clicking or pressing keys, then `Tabs` will send a [Tabs.TabActivated][textual.widgets.Tabs.TabActivated] message which contains the `tab` that was activated.
You can then use `event.tab.id` attribute to perform any related actions.

## Clearing tabs

Clear tabs by calling the [clear][textual.widgets.Tabs.clear] method. Clearing the tabs will send a [Tabs.TabActivated][textual.widgets.Tabs.TabActivated] message with the `tab` attribute set to `None`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't clearing the tabs send a TabsCleared message?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Don't know if this warrants adding a new message. I will consider it for a later revision.


## Adding tabs

Tabs may be added dynamically with the [add_tab][textual.widgets.Tabs.add_tab] method, which accepts strings, [Text][rich.text.Text], or [Tab][textual.widgets.Tab] objects.

## Example

The following example adds a Tabs widget above a text label. Press ++a++ to add a tab, ++c++ to clear the tabs.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved

=== "Output"

```{.textual path="docs/examples/widgets/tabs.py" press="a,a,a,a,right,right"}
```

=== "tabs.py"

```python
--8<-- "docs/examples/widgets/tabs.py"
```


## Reactive Attributes

| Name | Type | Default | Description |
| -------- | ----- | ------- | ---------------------------------------------------------------------------------- |
| `active` | `str` | `""` | The ID of the active tab. Set this attribute to a tab ID to change the active tab. |


## Messages

### ::: textual.widgets.Tabs.TabActivated


## See Also

- [Tabs](../api/tabs.md) code reference
2 changes: 2 additions & 0 deletions 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/tabs.md"
- "widgets/text_log.md"
- "widgets/tree.md"
- API:
Expand Down Expand Up @@ -180,6 +181,7 @@ nav:
- "api/static.md"
- "api/strip.md"
- "api/switch.md"
- "api/tabs.md"
- "api/text_log.md"
- "api/toggle_button.md"
- "api/timer.md"
Expand Down
6 changes: 3 additions & 3 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1936,7 +1936,7 @@ async def check_bindings(self, key: str, priority: bool = False) -> bool:
):
binding = bindings.keys.get(key)
if binding is not None and binding.priority == priority:
if await self.action(binding.action, namespace):
if await self.run_action(binding.action, namespace):
return True
return False

Expand Down Expand Up @@ -1969,7 +1969,7 @@ async def on_event(self, event: events.Event) -> None:
else:
await super().on_event(event)

async def action(
async def run_action(
self,
action: str | ActionParseResult,
default_namespace: object | None = None,
Expand Down Expand Up @@ -2068,7 +2068,7 @@ async def _broker_event(
else:
event.stop()
if isinstance(action, (str, tuple)):
await self.action(action, default_namespace=default_namespace) # type: ignore[arg-type]
await self.run_action(action, default_namespace=default_namespace) # type: ignore[arg-type]
elif callable(action):
await action()
else:
Expand Down
8 changes: 7 additions & 1 deletion src/textual/css/_style_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,11 +721,13 @@ def __init__(
default: str,
layout: bool = False,
refresh_children: bool = False,
refresh_parent: bool = False,
) -> None:
self._valid_values = valid_values
self._default = default
self._layout = layout
self._refresh_children = refresh_children
self._refresh_parent = refresh_parent

def __set_name__(self, owner: StylesBase, name: str) -> None:
self.name = name
Expand Down Expand Up @@ -772,7 +774,11 @@ def __set__(self, obj: StylesBase, value: str | None = None):
)
if obj.set_rule(self.name, 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,
)


class OverflowProperty(StringEnumProperty):
Expand Down
29 changes: 21 additions & 8 deletions src/textual/css/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,12 @@ class StylesBase(ABC):

node: DOMNode | None = None

display = StringEnumProperty(VALID_DISPLAY, "block", layout=True)
visibility = StringEnumProperty(VALID_VISIBILITY, "visible", layout=True)
display = StringEnumProperty(
VALID_DISPLAY, "block", layout=True, refresh_parent=True
)
visibility = StringEnumProperty(
VALID_VISIBILITY, "visible", layout=True, refresh_parent=True
)
layout = LayoutProperty()

auto_color = BooleanProperty(default=False)
Expand Down Expand Up @@ -429,12 +433,15 @@ def get_rule(self, rule: str, default: object = None) -> object:
"""

@abstractmethod
def refresh(self, *, layout: bool = False, children: bool = False) -> None:
def refresh(
self, *, layout: bool = False, children: bool = False, parent: bool = False
) -> None:
"""Mark the styles as requiring a refresh.

Args:
layout: Also require a layout. Defaults to False.
children: Also refresh children. Defaults to False.
layout: Also require a layout.
children: Also refresh children.
parent: Also refresh the parent.
"""

@abstractmethod
Expand Down Expand Up @@ -641,7 +648,11 @@ def set_rule(self, rule: str, value: object | None) -> bool:
def get_rule(self, rule: str, default: object = None) -> object:
return self._rules.get(rule, default)

def refresh(self, *, layout: bool = False, children: bool = False) -> None:
def refresh(
self, *, layout: bool = False, children: bool = False, parent: bool = False
) -> None:
if parent and self.node and self.node.parent:
self.node.parent.refresh()
if self.node is not None:
self.node.refresh(layout=layout)
if children:
Expand Down Expand Up @@ -1068,8 +1079,10 @@ def __rich_repr__(self) -> rich.repr.Result:
if self.has_rule(rule_name):
yield rule_name, getattr(self, rule_name)

def refresh(self, *, layout: bool = False, children: bool = False) -> None:
self._inline_styles.refresh(layout=layout, children=children)
def refresh(
self, *, layout: bool = False, children: bool = False, parent: bool = False
) -> None:
self._inline_styles.refresh(layout=layout, children=children, parent=parent)

def merge(self, other: StylesBase) -> None:
"""Merge values from another Styles.
Expand Down
Loading