Merge branch 'main' into M-x

This commit is contained in:
Dave Pearson
2023-08-22 08:29:50 +01:00
26 changed files with 1308 additions and 226 deletions

View File

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

View File

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

View File

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

View File

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

View 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;
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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