mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into M-x
This commit is contained in:
11
.faq/FAQ.md
11
.faq/FAQ.md
@@ -1,16 +1,15 @@
|
||||
---
|
||||
hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
<!-- Auto-generated by FAQtory -->
|
||||
<!-- Do not edit by hand! -->
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
{%- for question in questions %}
|
||||
- [{{ question.title }}](#{{ question.slug }})
|
||||
{%- endfor %}
|
||||
|
||||
|
||||
{%- for question in questions %}
|
||||
|
||||
<a name="{{ question.slug }}"></a>
|
||||
## {{ question.title }}
|
||||
|
||||
{{ question.body }}
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -7,12 +7,30 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add `min-height: 1;` to `LoadingIndicator`'s `DEFAULT_CSS`.
|
||||
|
||||
### Added
|
||||
|
||||
- Methods `TabbedContent.disable_tab` and `TabbedContent.enable_tab` https://github.com/Textualize/textual/pull/3112
|
||||
- Methods `Tabs.disable` and `Tabs.enable` https://github.com/Textualize/textual/pull/3112
|
||||
- Messages `Tab.Disabled`, `Tab.Enabled`, `Tabs.TabDisabled` and `Tabs.Enabled` https://github.com/Textualize/textual/pull/3112
|
||||
- Methods `TabbedContent.hide_tab` and `TabbedContent.show_tab` https://github.com/Textualize/textual/pull/3112
|
||||
- Methods `Tabs.hide` and `Tabs.show` https://github.com/Textualize/textual/pull/3112
|
||||
- Messages `Tabs.TabHidden` and `Tabs.TabShown` https://github.com/Textualize/textual/pull/3112
|
||||
- Added `ListView.extend` method to append multiple items https://github.com/Textualize/textual/pull/3012
|
||||
|
||||
### Changed
|
||||
|
||||
- grid-columns and grid-rows now accept an `auto` token to detect the optimal size https://github.com/Textualize/textual/pull/3107
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed auto height container with default grid-rows https://github.com/Textualize/textual/issues/1597
|
||||
- Fixed `page_up` and `page_down` bug in `DataTable` when `show_header = False` https://github.com/Textualize/textual/pull/3093
|
||||
|
||||
## [0.33.0] - 2023-08-15
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed unintuitive sizing behaviour of TabbedContent https://github.com/Textualize/textual/issues/2411
|
||||
|
||||
8
Makefile
8
Makefile
@@ -28,6 +28,10 @@ format-check:
|
||||
clean-screenshot-cache:
|
||||
rm -rf .screenshot_cache
|
||||
|
||||
.PHONY: faq
|
||||
faq:
|
||||
$(run) faqtory build
|
||||
|
||||
.PHONY: docs-offline-nav
|
||||
docs-offline-nav:
|
||||
echo "INHERIT: mkdocs-offline.yml" > mkdocs-nav-offline.yml
|
||||
@@ -67,10 +71,6 @@ docs-deploy: clean-screenshot-cache docs-online-nav
|
||||
$(run) mkdocs gh-deploy --config-file mkdocs-nav-online.yml
|
||||
rm -f mkdocs-nav-online.yml
|
||||
|
||||
.PHONY: faq
|
||||
faq:
|
||||
$(run) faqtory build
|
||||
|
||||
.PHONY: build
|
||||
build: docs-build-offline
|
||||
poetry build
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
---
|
||||
hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
<!-- Auto-generated by FAQtory -->
|
||||
<!-- Do not edit by hand! -->
|
||||
|
||||
# Frequently Asked Questions
|
||||
- [Does Textual support images?](#does-textual-support-images)
|
||||
- [How can I fix ImportError cannot import name ComposeResult from textual.app ?](#how-can-i-fix-importerror-cannot-import-name-composeresult-from-textualapp-)
|
||||
- [How can I select and copy text in a Textual app?](#how-can-i-select-and-copy-text-in-a-textual-app)
|
||||
- [How can I set a translucent app background?](#how-can-i-set-a-translucent-app-background)
|
||||
- [How do I center a widget in a screen?](#how-do-i-center-a-widget-in-a-screen)
|
||||
- [How do I fix WorkerDeclarationError?](#how-do-i-fix-workerdeclarationerror)
|
||||
- [How do I pass arguments to an app?](#how-do-i-pass-arguments-to-an-app)
|
||||
- [No widget called TextLog](#no-widget-called-textlog)
|
||||
- [Why do some key combinations never make it to my app?](#why-do-some-key-combinations-never-make-it-to-my-app)
|
||||
- [Why doesn't Textual look good on macOS?](#why-doesn't-textual-look-good-on-macos)
|
||||
- [Why doesn't Textual support ANSI themes?](#why-doesn't-textual-support-ansi-themes)
|
||||
- [Why doesn't the `DataTable` scroll programmatically?](#why-doesn't-the-`datatable`-scroll-programmatically)
|
||||
|
||||
<a name="does-textual-support-images"></a>
|
||||
## Does Textual support images?
|
||||
|
||||
Textual doesn't have built-in support for images yet, but it is on the [Roadmap](https://textual.textualize.io/roadmap/).
|
||||
|
||||
See also the [rich-pixels](https://github.com/darrenburns/rich-pixels) project for a Rich renderable for images that works with Textual.
|
||||
|
||||
<a name="how-can-i-fix-importerror-cannot-import-name-composeresult-from-textualapp-"></a>
|
||||
## How can I fix ImportError cannot import name ComposeResult from textual.app ?
|
||||
|
||||
You likely have an older version of Textual. You can install the latest version by adding the `-U` switch which will force pip to upgrade.
|
||||
@@ -33,7 +24,6 @@ The following should do it:
|
||||
pip install textual-dev -U
|
||||
```
|
||||
|
||||
<a name="how-can-i-select-and-copy-text-in-a-textual-app"></a>
|
||||
## How can I select and copy text in a Textual app?
|
||||
|
||||
Running a Textual app puts your terminal in to *application mode* which disables clicking and dragging to select text.
|
||||
@@ -46,7 +36,6 @@ may expect from the command line. The exact modifier key depends on the terminal
|
||||
|
||||
Refer to the documentation for your terminal emulator, if it is not listed above.
|
||||
|
||||
<a name="how-can-i-set-a-translucent-app-background"></a>
|
||||
## How can I set a translucent app background?
|
||||
|
||||
Some terminal emulators have a translucent background feature which allows the desktop underneath to be partially visible.
|
||||
@@ -56,7 +45,6 @@ Textual uses 16.7 million colors where available which enables consistent colors
|
||||
|
||||
For more information on ANSI colors in Textual, see [Why no Ansi Themes?](#why-doesnt-textual-support-ansi-themes).
|
||||
|
||||
<a name="how-do-i-center-a-widget-in-a-screen"></a>
|
||||
## How do I center a widget in a screen?
|
||||
|
||||
To center a widget within a container use
|
||||
@@ -146,7 +134,6 @@ if __name__ == "__main__":
|
||||
ButtonApp().run()
|
||||
```
|
||||
|
||||
<a name="how-do-i-fix-workerdeclarationerror"></a>
|
||||
## How do I fix WorkerDeclarationError?
|
||||
|
||||
Textual version 0.31.0 requires that you set `thread=True` on the `@work` decorator if you want to run a threaded worker.
|
||||
@@ -169,7 +156,6 @@ async def run_in_background():
|
||||
|
||||
This change was made because it was too easy to accidentally create a threaded worker, which may produce unexpected results.
|
||||
|
||||
<a name="how-do-i-pass-arguments-to-an-app"></a>
|
||||
## How do I pass arguments to an app?
|
||||
|
||||
When creating your `App` class, override `__init__` as you would when
|
||||
@@ -203,7 +189,6 @@ Greetings(to_greet="davep").run()
|
||||
Greetings("Well hello", "there").run()
|
||||
```
|
||||
|
||||
<a name="no-widget-called-textlog"></a>
|
||||
## No widget called TextLog
|
||||
|
||||
The `TextLog` widget was renamed to `RichLog` in Textual 0.32.0.
|
||||
@@ -216,7 +201,6 @@ Here's how you should import RichLog:
|
||||
from textual.widgets import RichLog
|
||||
```
|
||||
|
||||
<a name="why-do-some-key-combinations-never-make-it-to-my-app"></a>
|
||||
## Why do some key combinations never make it to my app?
|
||||
|
||||
Textual can only ever support key combinations that are passed on by your
|
||||
@@ -246,7 +230,6 @@ If you need to test what [key
|
||||
combinations](https://textual.textualize.io/guide/input/#keyboard-input)
|
||||
work in different environments you can try them out with `textual keys`.
|
||||
|
||||
<a name="why-doesn't-textual-look-good-on-macos"></a>
|
||||
## Why doesn't Textual look good on macOS?
|
||||
|
||||
You may find that the default macOS Terminal.app doesn't render Textual apps (and likely other TUIs) very well, particuarily when it comes to box characters.
|
||||
@@ -282,7 +265,6 @@ We recommend any of the following terminals:
|
||||
|
||||
<img width="1002" alt="Screenshot 2023-06-19 at 11 00 25" src="https://github.com/Textualize/textual/assets/554369/9a8cde57-5121-49a7-a2e0-5f6fc871b7a6">
|
||||
|
||||
<a name="why-doesn't-textual-support-ansi-themes"></a>
|
||||
## Why doesn't Textual support ANSI themes?
|
||||
|
||||
Textual will not generate escape sequences for the 16 themeable *ANSI* colors.
|
||||
@@ -296,7 +278,6 @@ Textual has a design system which guarantees apps will be readable on all platfo
|
||||
|
||||
There is currently a light and dark version of the design system, but more are planned. It will also be possible for users to customize the source colors on a per-app or per-system basis. This means that in the future you will be able to modify the core colors to blend in with your chosen terminal theme.
|
||||
|
||||
<a name="why-doesn't-the-`datatable`-scroll-programmatically"></a>
|
||||
## Why doesn't the `DataTable` scroll programmatically?
|
||||
|
||||
If scrolling in your `DataTable` is _apparently_ broken, it may be because your `DataTable` is using the default value of `height: auto`.
|
||||
11
docs/examples/guide/layout/grid_layout_auto.css
Normal file
11
docs/examples/guide/layout/grid_layout_auto.css
Normal file
@@ -0,0 +1,11 @@
|
||||
Screen {
|
||||
layout: grid;
|
||||
grid-size: 3;
|
||||
grid-columns: auto 1fr 1fr;
|
||||
grid-rows: 25% 75%;
|
||||
}
|
||||
|
||||
.box {
|
||||
height: 100%;
|
||||
border: solid green;
|
||||
}
|
||||
19
docs/examples/guide/layout/grid_layout_auto.py
Normal file
19
docs/examples/guide/layout/grid_layout_auto.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class GridLayoutExample(App):
|
||||
CSS_PATH = "grid_layout_auto.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("First column", classes="box")
|
||||
yield Static("Two", classes="box")
|
||||
yield Static("Three", classes="box")
|
||||
yield Static("Four", classes="box")
|
||||
yield Static("Five", classes="box")
|
||||
yield Static("Six", classes="box")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = GridLayoutExample()
|
||||
app.run()
|
||||
16
docs/examples/widgets/header_app_title.py
Normal file
16
docs/examples/widgets/header_app_title.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Header
|
||||
|
||||
|
||||
class HeaderApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.title = "Header Application"
|
||||
self.sub_title = "With title and sub-title"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = HeaderApp()
|
||||
app.run()
|
||||
@@ -326,6 +326,33 @@ If you don't specify enough values in a `grid-columns` or `grid-rows` declaratio
|
||||
For example, if your grid has four columns (i.e. `grid-size: 4;`), then `grid-columns: 2 4;` is equivalent to `grid-columns: 2 4 2 4;`.
|
||||
If it instead had three columns, then `grid-columns: 2 4;` would be equivalent to `grid-columns: 2 4 2;`.
|
||||
|
||||
#### Auto rows / columns
|
||||
|
||||
The `grid-columns` and `grid-rows` rules can both accept a value of "auto" in place of any of the dimensions, which tells Textual to calculate an optimal size based on the content.
|
||||
|
||||
Let's modify the previous example to make the first column an `auto` column.
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/guide/layout/grid_layout_auto.py"}
|
||||
```
|
||||
|
||||
=== "grid_layout_auto.py"
|
||||
|
||||
```python hl_lines="6 9"
|
||||
--8<-- "docs/examples/guide/layout/grid_layout_auto.py"
|
||||
```
|
||||
|
||||
=== "grid_layout_auto.css"
|
||||
|
||||
```sass hl_lines="4"
|
||||
--8<-- "docs/examples/guide/layout/grid_layout_auto.css"
|
||||
```
|
||||
|
||||
Notice how the first column is just wide enough to fit the content of each cell.
|
||||
The layout will adjust accordingly if you update the content for any widget in that column.
|
||||
|
||||
|
||||
### Cell spans
|
||||
|
||||
Cells may _span_ multiple rows or columns, to create more interesting grid arrangements.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
A simple header widget which docks itself to the top of the parent container.
|
||||
|
||||
!!! note
|
||||
|
||||
The application title which is shown in the header is taken from the [`title`][textual.app.App.title] and [`sub_title`][textual.app.App.sub_title] of the application.
|
||||
|
||||
- [ ] Focusable
|
||||
- [ ] Container
|
||||
|
||||
@@ -20,6 +24,19 @@ The example below shows an app with a `Header`.
|
||||
--8<-- "docs/examples/widgets/header.py"
|
||||
```
|
||||
|
||||
This example shows how to set the text in the `Header` using `App.title` and `App.sub_title`:
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/widgets/header_app_title.py"}
|
||||
```
|
||||
|
||||
=== "header_app_title.py"
|
||||
|
||||
```python
|
||||
--8<-- "docs/examples/widgets/header_app_title.py"
|
||||
```
|
||||
|
||||
## Reactive Attributes
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|
||||
4
faq.yml
4
faq.yml
@@ -1,7 +1,7 @@
|
||||
# FAQtory settings
|
||||
|
||||
faq_url: "https://github.com/textualize/textual/blob/main/FAQ.md" # Replace this with the URL to your FAQ.md!
|
||||
faq_url: "https://textual.textualize.io/FAQ/" # Replace this with the URL to your FAQ.md!
|
||||
|
||||
questions_path: "./questions" # Where questions should be stored
|
||||
output_path: "./FAQ.md" # Where FAQ.md should be generated
|
||||
output_path: "./docs/FAQ.md" # Where FAQ.md should be generated
|
||||
templates_path: ".faq" # Path to templates
|
||||
|
||||
@@ -203,6 +203,7 @@ nav:
|
||||
- "how-to/index.md"
|
||||
- "how-to/center-things.md"
|
||||
- "how-to/design-a-layout.md"
|
||||
- "FAQ.md"
|
||||
- "roadmap.md"
|
||||
- "Blog":
|
||||
- blog/index.md
|
||||
|
||||
@@ -7,6 +7,7 @@ from rich import box
|
||||
from rich.console import RenderableType
|
||||
from rich.json import JSON
|
||||
from rich.markdown import Markdown
|
||||
from rich.markup import escape
|
||||
from rich.pretty import Pretty
|
||||
from rich.syntax import Syntax
|
||||
from rich.table import Table
|
||||
@@ -269,14 +270,6 @@ class SubTitle(Static):
|
||||
pass
|
||||
|
||||
|
||||
class Notification(Static):
|
||||
def on_mount(self) -> None:
|
||||
self.set_timer(3, self.remove)
|
||||
|
||||
def on_click(self) -> None:
|
||||
self.remove()
|
||||
|
||||
|
||||
class DemoApp(App[None]):
|
||||
CSS_PATH = "demo.css"
|
||||
TITLE = "Textual Demo"
|
||||
@@ -285,7 +278,7 @@ class DemoApp(App[None]):
|
||||
("ctrl+t", "app.toggle_dark", "Toggle Dark mode"),
|
||||
("ctrl+s", "app.screenshot()", "Screenshot"),
|
||||
("f1", "app.toggle_class('RichLog', '-hidden')", "Notes"),
|
||||
Binding("ctrl+c,ctrl+q", "app.quit", "Quit", show=True),
|
||||
Binding("ctrl+q", "app.quit", "Quit", show=True),
|
||||
]
|
||||
|
||||
show_sidebar = reactive(False)
|
||||
@@ -390,9 +383,9 @@ class DemoApp(App[None]):
|
||||
"""
|
||||
self.bell()
|
||||
path = self.save_screenshot(filename, path)
|
||||
message = Text.assemble("Screenshot saved to ", (f"'{path}'", "bold green"))
|
||||
self.add_note(message)
|
||||
self.screen.mount(Notification(message))
|
||||
message = f"Screenshot saved to [bold green]'{escape(str(path))}'[/]"
|
||||
self.add_note(Text.from_markup(message))
|
||||
self.notify(message)
|
||||
|
||||
|
||||
app = DemoApp()
|
||||
|
||||
@@ -535,7 +535,7 @@ class Leave(Event, bubble=False, verbose=True):
|
||||
class Focus(Event, bubble=False):
|
||||
"""Sent when a widget is focussed.
|
||||
|
||||
- [X] Bubbles
|
||||
- [ ] Bubbles
|
||||
- [ ] Verbose
|
||||
"""
|
||||
|
||||
@@ -543,7 +543,7 @@ class Focus(Event, bubble=False):
|
||||
class Blur(Event, bubble=False):
|
||||
"""Sent when a widget is blurred (un-focussed).
|
||||
|
||||
- [X] Bubbles
|
||||
- [ ] Bubbles
|
||||
- [ ] Verbose
|
||||
"""
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ class GridLayout(Layout):
|
||||
self, parent: Widget, children: list[Widget], size: Size
|
||||
) -> ArrangeResult:
|
||||
styles = parent.styles
|
||||
row_scalars = styles.grid_rows or [Scalar.parse("1fr")]
|
||||
row_scalars = styles.grid_rows or (
|
||||
[Scalar.parse("1fr")] if size.height else [Scalar.parse("auto")]
|
||||
)
|
||||
column_scalars = styles.grid_columns or [Scalar.parse("1fr")]
|
||||
gutter_horizontal = styles.grid_gutter_horizontal
|
||||
gutter_vertical = styles.grid_gutter_vertical
|
||||
@@ -114,6 +116,52 @@ class GridLayout(Layout):
|
||||
row_scalars, table_size_rows if table_size_rows else row + 1
|
||||
)
|
||||
|
||||
def apply_width_limits(widget: Widget, width: int) -> int:
|
||||
"""Apply min and max widths to dimension.
|
||||
|
||||
Args:
|
||||
widget: A Widget.
|
||||
width: A width.
|
||||
|
||||
Returns:
|
||||
New width.
|
||||
"""
|
||||
styles = widget.styles
|
||||
if styles.min_width is not None:
|
||||
width = max(
|
||||
width,
|
||||
int(styles.min_width.resolve(size, viewport, Fraction(width))),
|
||||
)
|
||||
if styles.max_width is not None:
|
||||
width = min(
|
||||
width,
|
||||
int(styles.max_width.resolve(size, viewport, Fraction(width))),
|
||||
)
|
||||
return width
|
||||
|
||||
def apply_height_limits(widget: Widget, height: int) -> int:
|
||||
"""Apply min and max height to a dimension.
|
||||
|
||||
Args:
|
||||
widget: A widget.
|
||||
height: A height.
|
||||
|
||||
Returns:
|
||||
New height
|
||||
"""
|
||||
styles = widget.styles
|
||||
if styles.min_height is not None:
|
||||
height = max(
|
||||
height,
|
||||
int(styles.min_height.resolve(size, viewport, Fraction(height))),
|
||||
)
|
||||
if styles.max_height is not None:
|
||||
height = min(
|
||||
height,
|
||||
int(styles.max_height.resolve(size, viewport, Fraction(height))),
|
||||
)
|
||||
return height
|
||||
|
||||
# Handle any auto columns
|
||||
for column, scalar in enumerate(column_scalars):
|
||||
if scalar.is_auto:
|
||||
@@ -129,8 +177,11 @@ class GridLayout(Layout):
|
||||
continue
|
||||
width = max(
|
||||
width,
|
||||
widget.get_content_width(size, viewport)
|
||||
+ widget.styles.gutter.width,
|
||||
apply_width_limits(
|
||||
widget,
|
||||
widget.get_content_width(size, viewport)
|
||||
+ widget.styles.gutter.width,
|
||||
),
|
||||
)
|
||||
column_scalars[column] = Scalar.from_number(width)
|
||||
|
||||
@@ -150,13 +201,14 @@ class GridLayout(Layout):
|
||||
if widget.styles.row_span != 1:
|
||||
continue
|
||||
column_width = columns[column][1]
|
||||
widget_height = (
|
||||
widget_height = apply_height_limits(
|
||||
widget,
|
||||
widget.get_content_height(
|
||||
size,
|
||||
viewport,
|
||||
column_width - parent.styles.grid_gutter_vertical,
|
||||
)
|
||||
+ widget.styles.gutter.height
|
||||
+ widget.styles.gutter.height,
|
||||
)
|
||||
height = max(height, widget_height)
|
||||
row_scalars[row] = Scalar.from_number(height)
|
||||
|
||||
@@ -2205,9 +2205,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
def action_page_down(self) -> None:
|
||||
"""Move the cursor one page down."""
|
||||
self._set_hover_cursor(False)
|
||||
cursor_type = self.cursor_type
|
||||
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||
height = self.size.height - self.header_height if self.show_header else 0
|
||||
if self.show_cursor and self.cursor_type in ("cell", "row"):
|
||||
height = self.size.height - (self.header_height if self.show_header else 0)
|
||||
|
||||
# Determine how many rows constitutes a "page"
|
||||
offset = 0
|
||||
@@ -2228,9 +2227,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
def action_page_up(self) -> None:
|
||||
"""Move the cursor one page up."""
|
||||
self._set_hover_cursor(False)
|
||||
cursor_type = self.cursor_type
|
||||
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||
height = self.size.height - self.header_height if self.show_header else 0
|
||||
if self.show_cursor and self.cursor_type in ("cell", "row"):
|
||||
height = self.size.height - (self.header_height if self.show_header else 0)
|
||||
|
||||
# Determine how many rows constitutes a "page"
|
||||
offset = 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar, Optional
|
||||
from typing import ClassVar, Iterable, Optional
|
||||
|
||||
from textual.await_remove import AwaitRemove
|
||||
from textual.binding import Binding, BindingType
|
||||
@@ -172,6 +172,21 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
|
||||
self._scroll_highlighted_region()
|
||||
self.post_message(self.Highlighted(self, new_child))
|
||||
|
||||
def extend(self, items: Iterable[ListItem]) -> AwaitMount:
|
||||
"""Append multiple new ListItems to the end of the ListView.
|
||||
|
||||
Args:
|
||||
items: The ListItems to append.
|
||||
|
||||
Returns:
|
||||
An awaitable that yields control to the event loop
|
||||
until the DOM has been updated with the new child items.
|
||||
"""
|
||||
await_mount = self.mount(*items)
|
||||
if len(self) == 1:
|
||||
self.index = 0
|
||||
return await_mount
|
||||
|
||||
def append(self, item: ListItem) -> AwaitMount:
|
||||
"""Append a new ListItem to the end of the ListView.
|
||||
|
||||
@@ -182,10 +197,7 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
|
||||
An awaitable that yields control to the event loop
|
||||
until the DOM has been updated with the new child item.
|
||||
"""
|
||||
await_mount = self.mount(item)
|
||||
if len(self) == 1:
|
||||
self.index = 0
|
||||
return await_mount
|
||||
return self.extend([item])
|
||||
|
||||
def clear(self) -> AwaitRemove:
|
||||
"""Clear all items from the ListView.
|
||||
|
||||
@@ -18,6 +18,7 @@ class LoadingIndicator(Widget):
|
||||
LoadingIndicator {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 1;
|
||||
content-align: center middle;
|
||||
color: $accent;
|
||||
}
|
||||
@@ -30,7 +31,7 @@ class LoadingIndicator(Widget):
|
||||
def render(self) -> RenderableType:
|
||||
elapsed = time() - self._start_time
|
||||
speed = 0.8
|
||||
dot = "\u25CF"
|
||||
dot = "\u25cf"
|
||||
_, _, background, color = self.colors
|
||||
|
||||
gradient = Gradient(
|
||||
|
||||
@@ -375,3 +375,69 @@ class TabbedContent(Widget):
|
||||
def tab_count(self) -> int:
|
||||
"""Total number of tabs."""
|
||||
return self.get_child_by_type(Tabs).tab_count
|
||||
|
||||
def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None:
|
||||
"""Disable the corresponding tab pane."""
|
||||
event.stop()
|
||||
tab_id = event.tab.id
|
||||
try:
|
||||
self.query_one(f"TabPane#{tab_id}").disabled = True
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None:
|
||||
"""Enable the corresponding tab pane."""
|
||||
event.stop()
|
||||
tab_id = event.tab.id
|
||||
try:
|
||||
self.query_one(f"TabPane#{tab_id}").disabled = False
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
def disable_tab(self, tab_id: str) -> None:
|
||||
"""Disables the tab with the given ID.
|
||||
|
||||
Args:
|
||||
tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to disable.
|
||||
|
||||
Raises:
|
||||
Tabs.TabError: If there are any issues with the request.
|
||||
"""
|
||||
|
||||
self.query_one(Tabs).disable(tab_id)
|
||||
|
||||
def enable_tab(self, tab_id: str) -> None:
|
||||
"""Enables the tab with the given ID.
|
||||
|
||||
Args:
|
||||
tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to enable.
|
||||
|
||||
Raises:
|
||||
Tabs.TabError: If there are any issues with the request.
|
||||
"""
|
||||
|
||||
self.query_one(Tabs).enable(tab_id)
|
||||
|
||||
def hide_tab(self, tab_id: str) -> None:
|
||||
"""Hides the tab with the given ID.
|
||||
|
||||
Args:
|
||||
tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to hide.
|
||||
|
||||
Raises:
|
||||
Tabs.TabError: If there are any issues with the request.
|
||||
"""
|
||||
|
||||
self.query_one(Tabs).hide(tab_id)
|
||||
|
||||
def show_tab(self, tab_id: str) -> None:
|
||||
"""Shows the tab with the given ID.
|
||||
|
||||
Args:
|
||||
tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to show.
|
||||
|
||||
Raises:
|
||||
Tabs.TabError: If there are any issues with the request.
|
||||
"""
|
||||
|
||||
self.query_one(Tabs).show(tab_id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
import rich.repr
|
||||
@@ -104,17 +105,42 @@ class Tab(Static):
|
||||
Tab.-active:hover {
|
||||
color: $text;
|
||||
}
|
||||
Tab:disabled {
|
||||
color: $text-disabled;
|
||||
text-opacity: 50%;
|
||||
}
|
||||
Tab.-hidden {
|
||||
display: none;
|
||||
}
|
||||
"""
|
||||
|
||||
class Clicked(Message):
|
||||
"""A tab was clicked."""
|
||||
@dataclass
|
||||
class TabMessage(Message):
|
||||
"""Tab-related messages.
|
||||
|
||||
These are mostly intended for internal use when interacting with `Tabs`.
|
||||
"""
|
||||
|
||||
tab: Tab
|
||||
"""The tab that was clicked."""
|
||||
"""The tab that is the object of this message."""
|
||||
|
||||
def __init__(self, tab: Tab) -> None:
|
||||
self.tab = tab
|
||||
super().__init__()
|
||||
@property
|
||||
def control(self) -> Tab:
|
||||
"""The tab that is the object of this message.
|
||||
|
||||
This is an alias for the attribute `tab` and is used by the
|
||||
[`on`][textual.on] decorator.
|
||||
"""
|
||||
return self.tab
|
||||
|
||||
class Clicked(TabMessage):
|
||||
"""A tab was clicked."""
|
||||
|
||||
class Disabled(TabMessage):
|
||||
"""A tab was disabled."""
|
||||
|
||||
class Enabled(TabMessage):
|
||||
"""A tab was enabled."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -143,6 +169,10 @@ class Tab(Static):
|
||||
"""Inform the message that the tab was clicked."""
|
||||
self.post_message(self.Clicked(self))
|
||||
|
||||
def _watch_disabled(self, disabled: bool) -> None:
|
||||
"""Notify the parent `Tabs` that a tab was enabled/disabled."""
|
||||
self.post_message(self.Disabled(self) if disabled else self.Enabled(self))
|
||||
|
||||
|
||||
class Tabs(Widget, can_focus=True):
|
||||
"""A row of tabs."""
|
||||
@@ -184,8 +214,8 @@ class Tabs(Widget, can_focus=True):
|
||||
class TabError(Exception):
|
||||
"""Exception raised when there is an error relating to tabs."""
|
||||
|
||||
class TabActivated(Message):
|
||||
"""Sent when a new tab is activated."""
|
||||
class TabMessage(Message):
|
||||
"""Parent class for all messages that have to do with a specific tab."""
|
||||
|
||||
ALLOW_SELECTOR_MATCH = {"tab"}
|
||||
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
|
||||
@@ -195,20 +225,20 @@ class Tabs(Widget, can_focus=True):
|
||||
|
||||
Args:
|
||||
tabs: The Tabs widget.
|
||||
tab: The tab that was activated.
|
||||
tab: The tab that is the object of this message.
|
||||
"""
|
||||
self.tabs: Tabs = tabs
|
||||
"""The tabs widget containing the tab."""
|
||||
self.tab: Tab = tab
|
||||
"""The tab that was activated."""
|
||||
"""The tab that is the object of this message."""
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def control(self) -> Tabs:
|
||||
"""The tabs widget containing the tab that was activated.
|
||||
"""The tabs widget containing the tab that is the object of this message.
|
||||
|
||||
This is an alias for [`TabActivated.tabs`][textual.widgets.Tabs.TabActivated.tabs]
|
||||
which is used by the [`on`][textual.on] decorator.
|
||||
This is an alias for the attribute `tabs` and is used by the
|
||||
[`on`][textual.on] decorator.
|
||||
"""
|
||||
return self.tabs
|
||||
|
||||
@@ -216,6 +246,21 @@ class Tabs(Widget, can_focus=True):
|
||||
yield self.tabs
|
||||
yield self.tab
|
||||
|
||||
class TabActivated(TabMessage):
|
||||
"""Sent when a new tab is activated."""
|
||||
|
||||
class TabDisabled(TabMessage):
|
||||
"""Sent when a tab is disabled."""
|
||||
|
||||
class TabEnabled(TabMessage):
|
||||
"""Sent when a tab is enabled."""
|
||||
|
||||
class TabHidden(TabMessage):
|
||||
"""Sent when a tab is hidden."""
|
||||
|
||||
class TabShown(TabMessage):
|
||||
"""Sent when a tab is shown."""
|
||||
|
||||
class Cleared(Message):
|
||||
"""Sent when there are no active tabs."""
|
||||
|
||||
@@ -299,10 +344,24 @@ class Tabs(Widget, can_focus=True):
|
||||
"""Total number of tabs."""
|
||||
return len(self.query("#tabs-list > Tab"))
|
||||
|
||||
@property
|
||||
def _potentially_active_tabs(self) -> list[Tab]:
|
||||
"""List of all tabs that could be active.
|
||||
|
||||
This list is comprised of all tabs that are shown and enabled,
|
||||
plus the active tab in case it is disabled.
|
||||
"""
|
||||
return [
|
||||
tab
|
||||
for tab in self.query("#tabs-list > Tab").results(Tab)
|
||||
if ((not tab.disabled or tab is self.active_tab) and tab.display)
|
||||
]
|
||||
|
||||
@property
|
||||
def _next_active(self) -> Tab | None:
|
||||
"""Next tab to make active if the active tab is removed."""
|
||||
tabs = list(self.query("#tabs-list > Tab").results(Tab))
|
||||
active_tab = self.active_tab
|
||||
tabs = self._potentially_active_tabs
|
||||
if self.active_tab is None:
|
||||
return None
|
||||
try:
|
||||
@@ -412,7 +471,7 @@ class Tabs(Widget, can_focus=True):
|
||||
"""Remove a tab.
|
||||
|
||||
Args:
|
||||
tab_or_id: The Tab's id.
|
||||
tab_or_id: The Tab to remove or its id.
|
||||
|
||||
Returns:
|
||||
An awaitable object that waits for the tab to be removed.
|
||||
@@ -590,10 +649,117 @@ class Tabs(Widget, can_focus=True):
|
||||
active_tab = self.active_tab
|
||||
if active_tab is None:
|
||||
return
|
||||
tabs = list(self.query(Tab))
|
||||
tabs = self._potentially_active_tabs
|
||||
if not tabs:
|
||||
return
|
||||
tab_count = len(tabs)
|
||||
new_tab_index = (tabs.index(active_tab) + direction) % tab_count
|
||||
self.active = tabs[new_tab_index].id or ""
|
||||
self._scroll_active_tab()
|
||||
|
||||
def _on_tab_disabled(self, event: Tab.Disabled) -> None:
|
||||
"""Re-post the disabled message."""
|
||||
event.stop()
|
||||
self.post_message(self.TabDisabled(self, event.tab))
|
||||
|
||||
def _on_tab_enabled(self, event: Tab.Enabled) -> None:
|
||||
"""Re-post the enabled message."""
|
||||
event.stop()
|
||||
self.post_message(self.TabEnabled(self, event.tab))
|
||||
|
||||
def disable(self, tab_id: str) -> Tab:
|
||||
"""Disable the indicated tab.
|
||||
|
||||
Args:
|
||||
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to disable.
|
||||
|
||||
Returns:
|
||||
The [`Tab`][textual.widgets.Tab] that was targeted.
|
||||
|
||||
Raises:
|
||||
TabError: If there are any issues with the request.
|
||||
"""
|
||||
|
||||
try:
|
||||
tab_to_disable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
|
||||
except NoMatches:
|
||||
raise self.TabError(
|
||||
f"There is no tab with ID {tab_id!r} to disable."
|
||||
) from None
|
||||
|
||||
tab_to_disable.disabled = True
|
||||
return tab_to_disable
|
||||
|
||||
def enable(self, tab_id: str) -> Tab:
|
||||
"""Enable the indicated tab.
|
||||
|
||||
Args:
|
||||
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to enable.
|
||||
|
||||
Returns:
|
||||
The [`Tab`][textual.widgets.Tab] that was targeted.
|
||||
|
||||
Raises:
|
||||
TabError: If there are any issues with the request.
|
||||
"""
|
||||
|
||||
try:
|
||||
tab_to_enable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
|
||||
except NoMatches:
|
||||
raise self.TabError(
|
||||
f"There is no tab with ID {tab_id!r} to enable."
|
||||
) from None
|
||||
|
||||
tab_to_enable.disabled = False
|
||||
return tab_to_enable
|
||||
|
||||
def hide(self, tab_id: str) -> Tab:
|
||||
"""Hide the indicated tab.
|
||||
|
||||
Args:
|
||||
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to hide.
|
||||
|
||||
Returns:
|
||||
The [`Tab`][textual.widgets.Tab] that was targeted.
|
||||
|
||||
Raises:
|
||||
TabError: If there are any issues with the request.
|
||||
"""
|
||||
|
||||
try:
|
||||
tab_to_hide = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
|
||||
except NoMatches:
|
||||
raise self.TabError(f"There is no tab with ID {tab_id!r} to hide.")
|
||||
|
||||
if tab_to_hide.has_class("-active"):
|
||||
next_tab = self._next_active
|
||||
self.active = next_tab.id or "" if next_tab else ""
|
||||
tab_to_hide.add_class("-hidden")
|
||||
self.post_message(self.TabHidden(self, tab_to_hide))
|
||||
self.call_after_refresh(self._highlight_active)
|
||||
return tab_to_hide
|
||||
|
||||
def show(self, tab_id: str) -> Tab:
|
||||
"""Show the indicated tab.
|
||||
|
||||
Args:
|
||||
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to show.
|
||||
|
||||
Returns:
|
||||
The [`Tab`][textual.widgets.Tab] that was targeted.
|
||||
|
||||
Raises:
|
||||
TabError: If there are any issues with the request.
|
||||
"""
|
||||
|
||||
try:
|
||||
tab_to_show = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
|
||||
except NoMatches:
|
||||
raise self.TabError(f"There is no tab with ID {tab_id!r} to show.")
|
||||
|
||||
tab_to_show.remove_class("-hidden")
|
||||
self.post_message(self.TabShown(self, tab_to_show))
|
||||
if not self.active:
|
||||
self._activate_tab(tab_to_show)
|
||||
self.call_after_refresh(self._highlight_active)
|
||||
return tab_to_show
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,15 +16,33 @@ class GridApp(App):
|
||||
height:auto;
|
||||
border: solid green;
|
||||
}
|
||||
|
||||
#c2 Label {
|
||||
min-width: 20;
|
||||
}
|
||||
|
||||
#c3 Label {
|
||||
max-width: 30;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container():
|
||||
with Container(id="c1"):
|
||||
yield Label("foo")
|
||||
yield Input()
|
||||
yield Label("Longer label")
|
||||
yield Input()
|
||||
with Container(id="c2"):
|
||||
yield Label("foo")
|
||||
yield Input()
|
||||
yield Label("Longer label")
|
||||
yield Input()
|
||||
with Container(id="c3"):
|
||||
yield Label("foo bar " * 10)
|
||||
yield Input()
|
||||
yield Label("Longer label")
|
||||
yield Input()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Type
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, Grid
|
||||
from textual.widgets import Header, Footer, Label
|
||||
from textual.binding import Binding
|
||||
|
||||
|
||||
class GridHeightAuto(App[None]):
|
||||
CSS = """
|
||||
#test-area {
|
||||
|
||||
border: solid red;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
Grid {
|
||||
grid-size: 3;
|
||||
# grid-rows: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("g", "grid", "Grid"),
|
||||
Binding("v", "vertical", "Vertical"),
|
||||
Binding("h", "horizontal", "Horizontal"),
|
||||
Binding("c", "container", "Container"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Vertical(Label("Select a container to test (see footer)"), id="sandbox")
|
||||
yield Footer()
|
||||
|
||||
def build(self, out_of: Type[Container | Grid | Horizontal | Vertical]) -> None:
|
||||
self.query("#sandbox > *").remove()
|
||||
self.query_one("#sandbox", Vertical).mount(
|
||||
Label("Here is some text before the grid"),
|
||||
out_of(*[Label(f"Cell #{n}") for n in range(9)], id="test-area"),
|
||||
Label("Here is some text after the grid"),
|
||||
)
|
||||
|
||||
def action_grid(self):
|
||||
self.build(Grid)
|
||||
|
||||
def action_vertical(self):
|
||||
self.build(Vertical)
|
||||
|
||||
def action_horizontal(self):
|
||||
self.build(Horizontal)
|
||||
|
||||
def action_container(self):
|
||||
self.build(Container)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
GridHeightAuto().run()
|
||||
27
tests/snapshot_tests/snapshot_apps/modified_tabs.py
Normal file
27
tests/snapshot_tests/snapshot_apps/modified_tabs.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Button, TabbedContent
|
||||
|
||||
|
||||
class FiddleWithTabsApp(App[None]):
|
||||
CSS = """
|
||||
TabPane:disabled {
|
||||
background: red;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Button()
|
||||
yield Button()
|
||||
yield Button()
|
||||
yield Button()
|
||||
yield Button()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(TabbedContent).disable_tab(f"tab-1")
|
||||
self.query_one(TabbedContent).disable_tab(f"tab-2")
|
||||
self.query_one(TabbedContent).hide_tab(f"tab-3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
FiddleWithTabsApp().run()
|
||||
@@ -222,6 +222,11 @@ def test_tabbed_content(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py")
|
||||
|
||||
|
||||
def test_tabbed_content_with_modified_tabs(snap_compare):
|
||||
# Tabs enabled and hidden.
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "modified_tabs.py")
|
||||
|
||||
|
||||
def test_option_list_strings(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_strings.py")
|
||||
|
||||
@@ -646,3 +651,7 @@ def test_digits(snap_compare) -> None:
|
||||
|
||||
def test_auto_grid(snap_compare) -> None:
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "auto_grid.py")
|
||||
|
||||
|
||||
def test_auto_grid_default_height(snap_compare) -> None:
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "auto_grid_default_height.py", press=["g"])
|
||||
|
||||
@@ -173,11 +173,13 @@ async def test_empty_table_interactions():
|
||||
assert app.message_names == []
|
||||
|
||||
|
||||
async def test_cursor_movement_with_home_pagedown_etc():
|
||||
@pytest.mark.parametrize("show_header", [True, False])
|
||||
async def test_cursor_movement_with_home_pagedown_etc(show_header):
|
||||
app = DataTableApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
table = app.query_one(DataTable)
|
||||
table.show_header = show_header
|
||||
table.add_columns("A", "B")
|
||||
table.add_rows(ROWS)
|
||||
await pilot.press("right", "pagedown")
|
||||
|
||||
@@ -427,3 +427,272 @@ async def test_tabbed_content_clear():
|
||||
assert tabbed_content.tab_count == 0
|
||||
assert tabbed_content.active == ""
|
||||
assert pilot.app.cleared == 1
|
||||
|
||||
|
||||
async def test_disabling_does_not_deactivate_tab():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("Tab#tab-1").disabled = True
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test():
|
||||
assert app.query_one(Tabs).active == "tab-1"
|
||||
|
||||
|
||||
async def test_disabled_tab_cannot_be_clicked():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
yield Label("tab-2")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("Tab#tab-2").disabled = True
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.click("Tab#tab-2")
|
||||
assert app.query_one(Tabs).active == "tab-1"
|
||||
|
||||
|
||||
async def test_disabling_via_tabbed_content():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
yield Label("tab-2")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(TabbedContent).disable_tab("tab-2")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.click("Tab#tab-2")
|
||||
assert app.query_one(Tabs).active == "tab-1"
|
||||
|
||||
|
||||
async def test_navigation_around_disabled_tabs():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
yield Label("tab-2")
|
||||
yield Label("tab-3")
|
||||
yield Label("tab-4")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("Tab#tab-1").disabled = True
|
||||
self.query_one("Tab#tab-3").disabled = True
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test():
|
||||
tabs = app.query_one(Tabs)
|
||||
assert tabs.active == "tab-1"
|
||||
tabs.action_next_tab()
|
||||
assert tabs.active == "tab-2"
|
||||
tabs.action_next_tab()
|
||||
assert tabs.active == "tab-4"
|
||||
tabs.action_next_tab()
|
||||
assert tabs.active == "tab-2"
|
||||
tabs.action_previous_tab()
|
||||
assert tabs.active == "tab-4"
|
||||
|
||||
|
||||
async def test_reenabling_tab():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
yield Label("tab-2")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one("Tab#tab-2").disabled = True
|
||||
|
||||
def reenable(self) -> None:
|
||||
app.query_one("Tab#tab-2").disabled = False
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.click("Tab#tab-2")
|
||||
assert app.query_one(Tabs).active == "tab-1"
|
||||
app.reenable()
|
||||
await pilot.click("Tab#tab-2")
|
||||
assert app.query_one(Tabs).active == "tab-2"
|
||||
|
||||
|
||||
async def test_reenabling_via_tabbed_content():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
yield Label("tab-2")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(TabbedContent).disable_tab("tab-2")
|
||||
|
||||
def reenable(self) -> None:
|
||||
self.query_one(TabbedContent).enable_tab("tab-2")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.click("Tab#tab-2")
|
||||
assert app.query_one(Tabs).active == "tab-1"
|
||||
app.reenable()
|
||||
await pilot.click("Tab#tab-2")
|
||||
assert app.query_one(Tabs).active == "tab-2"
|
||||
|
||||
|
||||
async def test_disabling_unknown_tab():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test():
|
||||
with pytest.raises(Tabs.TabError):
|
||||
app.query_one(TabbedContent).disable_tab("foo")
|
||||
|
||||
|
||||
async def test_enabling_unknown_tab():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test():
|
||||
with pytest.raises(Tabs.TabError):
|
||||
app.query_one(TabbedContent).enable_tab("foo")
|
||||
|
||||
|
||||
async def test_hide_unknown_tab():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test():
|
||||
with pytest.raises(Tabs.TabError):
|
||||
app.query_one(TabbedContent).hide_tab("foo")
|
||||
|
||||
|
||||
async def test_show_unknown_tab():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test():
|
||||
with pytest.raises(Tabs.TabError):
|
||||
app.query_one(TabbedContent).show_tab("foo")
|
||||
|
||||
|
||||
async def test_hide_show_messages():
|
||||
hide_msg = False
|
||||
show_msg = False
|
||||
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
|
||||
def on_tabs_tab_hidden(self) -> None:
|
||||
nonlocal hide_msg
|
||||
hide_msg = True
|
||||
|
||||
def on_tabs_tab_shown(self) -> None:
|
||||
nonlocal show_msg
|
||||
show_msg = True
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.query_one(TabbedContent).hide_tab("tab-1")
|
||||
await pilot.pause()
|
||||
assert hide_msg
|
||||
app.query_one(TabbedContent).show_tab("tab-1")
|
||||
await pilot.pause()
|
||||
assert show_msg
|
||||
|
||||
|
||||
async def test_hide_last_tab_means_no_tab_active():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test() as pilot:
|
||||
tabbed_content = app.query_one(TabbedContent)
|
||||
tabbed_content.hide_tab("tab-1")
|
||||
await pilot.pause()
|
||||
assert not tabbed_content.active
|
||||
|
||||
|
||||
async def test_hiding_tabs_moves_active_to_next_tab():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
yield Label("tab-2")
|
||||
yield Label("tab-3")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test() as pilot:
|
||||
tabbed_content = app.query_one(TabbedContent)
|
||||
tabbed_content.hide_tab("tab-1")
|
||||
await pilot.pause()
|
||||
assert tabbed_content.active == "tab-2"
|
||||
tabbed_content.hide_tab("tab-2")
|
||||
await pilot.pause()
|
||||
assert tabbed_content.active == "tab-3"
|
||||
|
||||
|
||||
async def test_showing_tabs_does_not_change_active_tab():
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
yield Label("tab-2")
|
||||
yield Label("tab-3")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test() as pilot:
|
||||
tabbed_content = app.query_one(TabbedContent)
|
||||
tabbed_content.hide_tab("tab-1")
|
||||
tabbed_content.hide_tab("tab-2")
|
||||
await pilot.pause()
|
||||
# sanity check
|
||||
assert tabbed_content.active == "tab-3"
|
||||
|
||||
tabbed_content.show_tab("tab-1")
|
||||
tabbed_content.show_tab("tab-2")
|
||||
assert tabbed_content.active == "tab-3"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tab_id", ["tab-1", "tab-2"])
|
||||
async def test_showing_first_tab_activates_tab(tab_id: str):
|
||||
class TabbedApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with TabbedContent():
|
||||
yield Label("tab-1")
|
||||
yield Label("tab-2")
|
||||
|
||||
app = TabbedApp()
|
||||
async with app.run_test() as pilot:
|
||||
tabbed_content = app.query_one(TabbedContent)
|
||||
tabbed_content.hide_tab("tab-1")
|
||||
tabbed_content.hide_tab("tab-2")
|
||||
await pilot.pause()
|
||||
# sanity check
|
||||
assert not tabbed_content.active
|
||||
|
||||
tabbed_content.show_tab(tab_id)
|
||||
await pilot.pause()
|
||||
assert tabbed_content.active == tab_id
|
||||
|
||||
Reference in New Issue
Block a user