mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into add-containers
[skip ci]
This commit is contained in:
23
CHANGELOG.md
23
CHANGELOG.md
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
@@ -14,7 +15,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
### Added
|
||||
|
||||
- Added a LoadingIndicator widget https://github.com/Textualize/textual/pull/2018
|
||||
- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957
|
||||
- Added `Center` https://github.com/Textualize/textual/issues/1957
|
||||
- Added `Middle` https://github.com/Textualize/textual/issues/1957
|
||||
@@ -25,6 +25,26 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Fixed container not resizing when a widget is removed https://github.com/Textualize/textual/issues/2007
|
||||
- Fixes issue where the horizontal scrollbar would be incorrectly enabled https://github.com/Textualize/textual/pull/2024
|
||||
|
||||
## [0.15.0] - 2023-03-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed container not resizing when a widget is removed https://github.com/Textualize/textual/issues/2007
|
||||
- Fixed issue where the horizontal scrollbar would be incorrectly enabled https://github.com/Textualize/textual/pull/2024
|
||||
- Fixed `Pilot.click` not correctly creating the mouse events https://github.com/Textualize/textual/issues/2022
|
||||
- Fixes issue where the horizontal scrollbar would be incorrectly enabled https://github.com/Textualize/textual/pull/2024
|
||||
- Fixes for tracebacks not appearing on exit https://github.com/Textualize/textual/issues/2027
|
||||
|
||||
### Added
|
||||
|
||||
- Added a LoadingIndicator widget https://github.com/Textualize/textual/pull/2018
|
||||
- Added Tabs Widget https://github.com/Textualize/textual/pull/2020
|
||||
|
||||
### Changed
|
||||
|
||||
- Breaking change: Renamed Widget.action and App.action to Widget.run_action and App.run_action
|
||||
- Added `shift`, `meta` and `control` arguments to `Pilot.click`.
|
||||
|
||||
## [0.14.0] - 2023-03-09
|
||||
|
||||
### Changed
|
||||
@@ -37,6 +57,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Changed the `Checkbox` character due to issues with Windows Terminal and Windows 10 https://github.com/Textualize/textual/issues/1934
|
||||
- Changed the `RadioButton` character due to issues with Windows Terminal and Windows 10 and 11 https://github.com/Textualize/textual/issues/1934
|
||||
- Changed the `Markdown` initial bullet character due to issues with Windows Terminal and Windows 10 and 11 https://github.com/Textualize/textual/issues/1982
|
||||
- The underscore `_` is no longer a special alias for the method `pilot.press`
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
2
docs/api/tabs.md
Normal file
2
docs/api/tabs.md
Normal file
@@ -0,0 +1,2 @@
|
||||
::: textual.widgets.Tab
|
||||
::: textual.widgets.Tabs
|
||||
142
docs/blog/images/loading_indicator.svg
Normal file
142
docs/blog/images/loading_indicator.svg
Normal file
@@ -0,0 +1,142 @@
|
||||
<svg class="rich-terminal" viewBox="0 0 897 562.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-2562693105-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-2562693105-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-2562693105-r1 { fill: #c5c8c6 }
|
||||
.terminal-2562693105-r2 { fill: #0d5083 }
|
||||
.terminal-2562693105-r3 { fill: #0763aa }
|
||||
.terminal-2562693105-r4 { fill: #0579d5 }
|
||||
.terminal-2562693105-r5 { fill: #348ceb }
|
||||
.terminal-2562693105-r6 { fill: #1a2832 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-2562693105-clip-terminal">
|
||||
<rect x="0" y="0" width="877.4" height="511.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-0">
|
||||
<rect x="0" y="1.5" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-1">
|
||||
<rect x="0" y="25.9" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-2">
|
||||
<rect x="0" y="50.3" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-3">
|
||||
<rect x="0" y="74.7" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-4">
|
||||
<rect x="0" y="99.1" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-5">
|
||||
<rect x="0" y="123.5" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-6">
|
||||
<rect x="0" y="147.9" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-7">
|
||||
<rect x="0" y="172.3" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-8">
|
||||
<rect x="0" y="196.7" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-9">
|
||||
<rect x="0" y="221.1" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-10">
|
||||
<rect x="0" y="245.5" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-11">
|
||||
<rect x="0" y="269.9" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-12">
|
||||
<rect x="0" y="294.3" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-13">
|
||||
<rect x="0" y="318.7" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-14">
|
||||
<rect x="0" y="343.1" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-15">
|
||||
<rect x="0" y="367.5" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-16">
|
||||
<rect x="0" y="391.9" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-17">
|
||||
<rect x="0" y="416.3" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-18">
|
||||
<rect x="0" y="440.7" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-2562693105-line-19">
|
||||
<rect x="0" y="465.1" width="878.4" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="895" height="560.4" rx="8"/><text class="terminal-2562693105-title" fill="#c5c8c6" text-anchor="middle" x="447" y="27">LoadingApp</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-2562693105-clip-terminal)">
|
||||
<rect fill="#1e1e1e" x="0" y="1.5" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="378.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="378.2" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="402.6" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="427" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="451.4" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="475.8" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="488" y="245.5" width="390.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="878.4" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-2562693105-matrix">
|
||||
<text class="terminal-2562693105-r1" x="878.4" y="20" textLength="12.2" clip-path="url(#terminal-2562693105-line-0)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="44.4" textLength="12.2" clip-path="url(#terminal-2562693105-line-1)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="68.8" textLength="12.2" clip-path="url(#terminal-2562693105-line-2)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="93.2" textLength="12.2" clip-path="url(#terminal-2562693105-line-3)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="117.6" textLength="12.2" clip-path="url(#terminal-2562693105-line-4)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="142" textLength="12.2" clip-path="url(#terminal-2562693105-line-5)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="166.4" textLength="12.2" clip-path="url(#terminal-2562693105-line-6)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="190.8" textLength="12.2" clip-path="url(#terminal-2562693105-line-7)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="215.2" textLength="12.2" clip-path="url(#terminal-2562693105-line-8)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="239.6" textLength="12.2" clip-path="url(#terminal-2562693105-line-9)">
|
||||
</text><text class="terminal-2562693105-r2" x="378.2" y="264" textLength="24.4" clip-path="url(#terminal-2562693105-line-10)">● </text><text class="terminal-2562693105-r3" x="402.6" y="264" textLength="24.4" clip-path="url(#terminal-2562693105-line-10)">● </text><text class="terminal-2562693105-r4" x="427" y="264" textLength="24.4" clip-path="url(#terminal-2562693105-line-10)">● </text><text class="terminal-2562693105-r5" x="451.4" y="264" textLength="24.4" clip-path="url(#terminal-2562693105-line-10)">● </text><text class="terminal-2562693105-r6" x="475.8" y="264" textLength="12.2" clip-path="url(#terminal-2562693105-line-10)">●</text><text class="terminal-2562693105-r1" x="878.4" y="264" textLength="12.2" clip-path="url(#terminal-2562693105-line-10)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="288.4" textLength="12.2" clip-path="url(#terminal-2562693105-line-11)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="312.8" textLength="12.2" clip-path="url(#terminal-2562693105-line-12)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="337.2" textLength="12.2" clip-path="url(#terminal-2562693105-line-13)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="361.6" textLength="12.2" clip-path="url(#terminal-2562693105-line-14)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="386" textLength="12.2" clip-path="url(#terminal-2562693105-line-15)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="410.4" textLength="12.2" clip-path="url(#terminal-2562693105-line-16)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="434.8" textLength="12.2" clip-path="url(#terminal-2562693105-line-17)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="459.2" textLength="12.2" clip-path="url(#terminal-2562693105-line-18)">
|
||||
</text><text class="terminal-2562693105-r1" x="878.4" y="483.6" textLength="12.2" clip-path="url(#terminal-2562693105-line-19)">
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
183
docs/blog/images/tabs_widget.svg
Normal file
183
docs/blog/images/tabs_widget.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
39
docs/blog/posts/release0-15-0.md
Normal file
39
docs/blog/posts/release0-15-0.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-03-13
|
||||
categories:
|
||||
- Release
|
||||
title: "Textual 0.15.0 adds a tabs widget"
|
||||
authors:
|
||||
- willmcgugan
|
||||
---
|
||||
|
||||
# Textual 0.15.0 adds a tabs widget
|
||||
|
||||
We've just pushed Textual 0.15.0, only 4 days after the previous version. That's a little faster than our typical release cadence of 1 to 2 weeks.
|
||||
|
||||
What's new in this release?
|
||||
|
||||
<!-- more -->
|
||||
|
||||
The highlight of this release is a new [Tabs](./widgets/../../../widgets/tabs.md) widget to display tabs which can be navigated much like tabs in a browser. Here's a screenshot:
|
||||
|
||||
<div>
|
||||
--8<-- "docs/blog/images/tabs_widget.svg"
|
||||
</div>
|
||||
|
||||
In a future release, this will be combined with the [ContentSwitcher](../../widgets/content_switcher.md) widget to create a traditional tabbed dialog. Although Tabs is still useful as a standalone widgets.
|
||||
|
||||
!!! tip
|
||||
|
||||
I like to tweet progress with widgets on Twitter. See the [#textualtabs](https://twitter.com/search?q=%23textualtabs&src=typeahead_click) hashtag which documents progress on this widget.
|
||||
|
||||
Also in this release is a new [LoadingIndicator](./../../widgets/loading_indicator.md) widget to display a simple animation while waiting for data. Here's a screenshot:
|
||||
|
||||
<div>
|
||||
--8<-- "docs/blog/images/loading_indicator.svg"
|
||||
</div>
|
||||
|
||||
As always, see the [release notes](https://github.com/Textualize/textual/releases/tag/v0.15.0) for the full details on this update.
|
||||
|
||||
If you want to talk about these widgets, or anything else Textual related, join us on our [Discord server](https://discord.gg/Enf6Z3qhVr).
|
||||
@@ -1,5 +1,5 @@
|
||||
from textual.app import App
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
|
||||
|
||||
class ActionsApp(App):
|
||||
@@ -8,7 +8,7 @@ class ActionsApp(App):
|
||||
|
||||
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__":
|
||||
|
||||
82
docs/examples/widgets/tabs.py
Normal file
82
docs/examples/widgets/tabs.py
Normal file
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Consequently `"set_background('blue')"` is a valid action string, but `"set_background(new_color)"` is not — because `new_color` is a variable and not a literal.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
75
docs/widgets/tabs.md
Normal file
75
docs/widgets/tabs.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Tabs
|
||||
|
||||
Displays a number of tab headers which may be activated with a click or navigated with cursor keys.
|
||||
|
||||
- [x] Focusable
|
||||
- [ ] Container
|
||||
|
||||
Construct a `Tabs` widget with strings or [Text][rich.text.Text] objects as positional arguments, which will set the labels in the tabs. Here's an example with three tabs:
|
||||
|
||||
```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 auto-incrementing `id` attributes (`"tab-1"`, `"tab-2"` etc).
|
||||
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"),
|
||||
)
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
|
||||
=== "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
|
||||
|
||||
## Bindings
|
||||
|
||||
The Tabs widget defines the following bindings:
|
||||
|
||||
::: textual.widgets.Tabs.BINDINGS
|
||||
options:
|
||||
show_root_heading: false
|
||||
show_root_toc_entry: false
|
||||
|
||||
## See Also
|
||||
|
||||
- [Tabs](../api/tabs.md) code reference
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
homepage = "https://github.com/Textualize/textual"
|
||||
description = "Modern Text User Interface framework"
|
||||
authors = ["Will McGugan <will@textualize.io>"]
|
||||
|
||||
@@ -862,9 +862,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
assert driver is not None
|
||||
await wait_for_idle(0)
|
||||
for key in keys:
|
||||
if key == "_":
|
||||
continue
|
||||
elif key.startswith("wait:"):
|
||||
if key.startswith("wait:"):
|
||||
_, wait_ms = key.split(":")
|
||||
print(f"(pause {wait_ms}ms)")
|
||||
await asyncio.sleep(float(wait_ms) / 1000)
|
||||
@@ -1827,13 +1825,14 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
await self._dispatch_message(events.Unmount())
|
||||
|
||||
self._print_error_renderables()
|
||||
if self.devtools is not None and self.devtools.is_connected:
|
||||
await self._disconnect_devtools()
|
||||
|
||||
if self._writer_thread is not None:
|
||||
self._writer_thread.stop()
|
||||
|
||||
self._print_error_renderables()
|
||||
|
||||
async def _on_exit_app(self) -> None:
|
||||
self._begin_batch() # Prevent repaint / layout while shutting down
|
||||
await self._message_queue.put(None)
|
||||
@@ -1936,7 +1935,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
):
|
||||
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
|
||||
|
||||
@@ -1969,7 +1968,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
else:
|
||||
await super().on_event(event)
|
||||
|
||||
async def action(
|
||||
async def run_action(
|
||||
self,
|
||||
action: str | ActionParseResult,
|
||||
default_namespace: object | None = None,
|
||||
@@ -2068,7 +2067,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
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:
|
||||
|
||||
@@ -721,11 +721,13 @@ class StringEnumProperty:
|
||||
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
|
||||
@@ -772,7 +774,11 @@ class StringEnumProperty:
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -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)
|
||||
@@ -429,12 +433,15 @@ class StylesBase(ABC):
|
||||
"""
|
||||
|
||||
@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
|
||||
@@ -641,7 +648,11 @@ class Styles(StylesBase):
|
||||
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:
|
||||
@@ -1068,8 +1079,10 @@ class RenderStyles(StylesBase):
|
||||
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.
|
||||
|
||||
@@ -14,20 +14,24 @@ from .widget import Widget
|
||||
|
||||
|
||||
def _get_mouse_message_arguments(
|
||||
target: Widget, offset: Offset = Offset(), button: int = 0
|
||||
target: Widget,
|
||||
offset: Offset = Offset(),
|
||||
button: int = 0,
|
||||
shift: bool = False,
|
||||
meta: bool = False,
|
||||
control: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Get the arguments to pass into mouse messages for the click and hover methods."""
|
||||
x, y = offset
|
||||
click_x, click_y, _, _ = target.region.translate(offset)
|
||||
message_arguments = {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"x": click_x,
|
||||
"y": click_y,
|
||||
"delta_x": 0,
|
||||
"delta_y": 0,
|
||||
"button": button,
|
||||
"shift": False,
|
||||
"meta": False,
|
||||
"ctrl": False,
|
||||
"shift": shift,
|
||||
"meta": meta,
|
||||
"ctrl": control,
|
||||
"screen_x": click_x,
|
||||
"screen_y": click_y,
|
||||
}
|
||||
@@ -60,7 +64,12 @@ class Pilot(Generic[ReturnType]):
|
||||
await self._app._press_keys(keys)
|
||||
|
||||
async def click(
|
||||
self, selector: QueryType | None = None, offset: Offset = Offset()
|
||||
self,
|
||||
selector: QueryType | None = None,
|
||||
offset: Offset = Offset(),
|
||||
shift: bool = False,
|
||||
meta: bool = False,
|
||||
control: bool = False,
|
||||
) -> None:
|
||||
"""Simulate clicking with the mouse.
|
||||
|
||||
@@ -71,6 +80,9 @@ class Pilot(Generic[ReturnType]):
|
||||
currently hidden or obscured by another widget, then the click may
|
||||
not land on it.
|
||||
offset: The offset to click within the selected widget.
|
||||
shift: Click with the shift key held down.
|
||||
meta: Click with the meta key held down.
|
||||
control: Click with the control key held down.
|
||||
"""
|
||||
app = self.app
|
||||
screen = app.screen
|
||||
@@ -80,7 +92,7 @@ class Pilot(Generic[ReturnType]):
|
||||
target_widget = screen
|
||||
|
||||
message_arguments = _get_mouse_message_arguments(
|
||||
target_widget, offset, button=1
|
||||
target_widget, offset, button=1, shift=shift, meta=meta, control=control
|
||||
)
|
||||
app.post_message(MouseDown(**message_arguments))
|
||||
app.post_message(MouseUp(**message_arguments))
|
||||
|
||||
@@ -2571,13 +2571,13 @@ class Widget(DOMNode):
|
||||
return Text(renderable)
|
||||
return renderable
|
||||
|
||||
async def action(self, action: str) -> None:
|
||||
async def run_action(self, action: str) -> None:
|
||||
"""Perform a given action, with this widget as the default namespace.
|
||||
|
||||
Args:
|
||||
action: Action encoded as a string.
|
||||
"""
|
||||
await self.app.action(action, self)
|
||||
await self.app.run_action(action, self)
|
||||
|
||||
def post_message(self, message: Message) -> bool:
|
||||
"""Post a message to this widget.
|
||||
@@ -2602,6 +2602,9 @@ class Widget(DOMNode):
|
||||
Args:
|
||||
event: Idle event.
|
||||
"""
|
||||
self._check_refresh()
|
||||
|
||||
def _check_refresh(self) -> None:
|
||||
if self._parent is not None and not self._closing:
|
||||
try:
|
||||
screen = self.screen
|
||||
|
||||
@@ -29,6 +29,7 @@ if typing.TYPE_CHECKING:
|
||||
from ._radio_set import RadioSet
|
||||
from ._static import Static
|
||||
from ._switch import Switch
|
||||
from ._tabs import Tab, Tabs
|
||||
from ._text_log import TextLog
|
||||
from ._tree import Tree
|
||||
from ._welcome import Welcome
|
||||
@@ -55,6 +56,8 @@ __all__ = [
|
||||
"RadioSet",
|
||||
"Static",
|
||||
"Switch",
|
||||
"Tab",
|
||||
"Tabs",
|
||||
"TextLog",
|
||||
"Tree",
|
||||
"Welcome",
|
||||
|
||||
@@ -19,6 +19,8 @@ from ._radio_button import RadioButton as RadioButton
|
||||
from ._radio_set import RadioSet as RadioSet
|
||||
from ._static import Static as Static
|
||||
from ._switch import Switch as Switch
|
||||
from ._tabs import Tab as Tab
|
||||
from ._tabs import Tabs as Tabs
|
||||
from ._text_log import TextLog as TextLog
|
||||
from ._tree import Tree as Tree
|
||||
from ._welcome import Welcome as Welcome
|
||||
|
||||
468
src/textual/widgets/_tabs.py
Normal file
468
src/textual/widgets/_tabs.py
Normal file
@@ -0,0 +1,468 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import rich.repr
|
||||
from rich.style import Style
|
||||
from rich.text import Text, TextType
|
||||
|
||||
from .. import events
|
||||
from ..app import ComposeResult, RenderResult
|
||||
from ..binding import Binding, BindingType
|
||||
from ..containers import Container, Horizontal, Vertical
|
||||
from ..css.query import NoMatches
|
||||
from ..geometry import Offset
|
||||
from ..message import Message
|
||||
from ..reactive import reactive
|
||||
from ..renderables.underline_bar import UnderlineBar
|
||||
from ..widget import Widget
|
||||
from ..widgets import Static
|
||||
|
||||
|
||||
class Underline(Widget):
|
||||
"""The animated underline beneath tabs."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Underline {
|
||||
width: 1fr;
|
||||
height: 1;
|
||||
}
|
||||
Underline > .underline--bar {
|
||||
background: $foreground 10%;
|
||||
color: $accent;
|
||||
}
|
||||
"""
|
||||
|
||||
COMPONENT_CLASSES = {"underline--bar"}
|
||||
"""
|
||||
| Class | Description |
|
||||
| :- | :- |
|
||||
| `underline-bar` | Style of the bar (may be used to change the color). |
|
||||
|
||||
"""
|
||||
|
||||
highlight_start = reactive(0)
|
||||
"""First cell in highlight."""
|
||||
highlight_end = reactive(0)
|
||||
"""Last cell (inclusive) in highlight."""
|
||||
|
||||
class Clicked(Message):
|
||||
"""Inform ancestors the underline was clicked."""
|
||||
|
||||
offset: Offset
|
||||
"""The offset of the click, relative to the origin of the bar."""
|
||||
|
||||
def __init__(self, offset: Offset) -> None:
|
||||
self.offset = offset
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def _highlight_range(self) -> tuple[int, int]:
|
||||
"""Highlighted range for underline bar."""
|
||||
return (self.highlight_start, self.highlight_end)
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
"""Render the bar."""
|
||||
bar_style = self.get_component_rich_style("underline--bar")
|
||||
return UnderlineBar(
|
||||
highlight_range=self._highlight_range,
|
||||
highlight_style=Style.from_color(bar_style.color),
|
||||
background_style=Style.from_color(bar_style.bgcolor),
|
||||
)
|
||||
|
||||
def on_click(self, event: events.Click):
|
||||
"""Catch clicks, so that the underline can activate the tabs."""
|
||||
event.stop()
|
||||
self.post_message(self.Clicked(event.screen_offset))
|
||||
|
||||
|
||||
class Tab(Static):
|
||||
"""A Widget to manage a single tab within a Tabs widget."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Tab {
|
||||
width: auto;
|
||||
height: 2;
|
||||
padding: 1 1 0 2;
|
||||
text-align: center;
|
||||
color: $text-disabled;
|
||||
}
|
||||
Tab.-active {
|
||||
text-style: bold;
|
||||
color: $text;
|
||||
}
|
||||
Tab:hover {
|
||||
text-style: bold;
|
||||
}
|
||||
Tab.-active:hover {
|
||||
color: $text;
|
||||
}
|
||||
"""
|
||||
|
||||
class Clicked(Message):
|
||||
"""A tab was clicked."""
|
||||
|
||||
tab: Tab
|
||||
"""The tab that was clicked."""
|
||||
|
||||
def __init__(self, tab: Tab) -> None:
|
||||
self.tab = tab
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: TextType,
|
||||
*,
|
||||
id: str | None = None,
|
||||
) -> None:
|
||||
"""Initialise a Tab.
|
||||
|
||||
Args:
|
||||
label: The label to use in the tab.
|
||||
id: Optional ID for the widget.
|
||||
"""
|
||||
self.label = Text.from_markup(label) if isinstance(label, str) else label
|
||||
super().__init__(id=id)
|
||||
self.update(label)
|
||||
|
||||
@property
|
||||
def label_text(self) -> str:
|
||||
"""Undecorated text of the label."""
|
||||
return self.label.plain
|
||||
|
||||
def _on_click(self):
|
||||
"""Inform the message that the tab was clicked."""
|
||||
self.post_message(self.Clicked(self))
|
||||
|
||||
|
||||
class Tabs(Widget, can_focus=True):
|
||||
"""A row of tabs."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Tabs {
|
||||
width: 100%;
|
||||
height:3;
|
||||
}
|
||||
Tabs > #tabs-scroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
Tabs #tabs-list {
|
||||
width: auto;
|
||||
min-height: 2;
|
||||
}
|
||||
Tabs #tabs-list-bar, Tabs #tabs-list {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 100%;
|
||||
overflow: hidden hidden;
|
||||
}
|
||||
Tabs:focus .underline--bar {
|
||||
background: $foreground 20%;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("left", "previous_tab", "Previous tab", show=False),
|
||||
Binding("right", "next_tab", "Next tab", show=False),
|
||||
]
|
||||
"""
|
||||
| Key(s) | Description |
|
||||
| :- | :- |
|
||||
| left | Move to the previous tab. |
|
||||
| right | Move to the next tab. |
|
||||
"""
|
||||
|
||||
class TabActivated(Message):
|
||||
"""Sent when a new tab is activated."""
|
||||
|
||||
tab: Tab
|
||||
"""The tab that was activated."""
|
||||
|
||||
def __init__(self, tab: Tab | None) -> None:
|
||||
self.tab = tab
|
||||
super().__init__()
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.tab
|
||||
|
||||
active: reactive[str] = reactive("", init=False)
|
||||
"""The ID of the active tab, or empty string if none are active."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*tabs: Tab | TextType,
|
||||
active: str | None = None,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
):
|
||||
"""Construct a Tabs widget.
|
||||
|
||||
Args:
|
||||
*tabs: Positional argument should be explicit Tab objects, or a str or Text.
|
||||
active: ID of the tab which should be active on start.
|
||||
name: Optional name for the input widget.
|
||||
id: Optional ID for the widget.
|
||||
classes: Optional initial classes for the widget.
|
||||
disabled: Whether the input is disabled or not.
|
||||
"""
|
||||
self._tabs_counter = 0
|
||||
|
||||
add_tabs = [
|
||||
(
|
||||
Tab(tab, id=f"tab-{self._new_tab_id}")
|
||||
if isinstance(tab, (str, Text))
|
||||
else self._auto_tab_id(tab)
|
||||
)
|
||||
for tab in tabs
|
||||
]
|
||||
super().__init__(
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
disabled=disabled,
|
||||
)
|
||||
self._tabs = add_tabs
|
||||
self._first_active = active
|
||||
|
||||
def _auto_tab_id(self, tab: Tab) -> Tab:
|
||||
"""Set an automatic ID if not supplied."""
|
||||
if tab.id is None:
|
||||
tab.id = f"tab-{self._new_tab_id}"
|
||||
return tab
|
||||
|
||||
@property
|
||||
def _new_tab_id(self) -> int:
|
||||
"""Get the next tab id in a sequence."""
|
||||
self._tabs_counter += 1
|
||||
return self._tabs_counter
|
||||
|
||||
@property
|
||||
def tab_count(self) -> int:
|
||||
"""Total number of tabs."""
|
||||
return len(self.query("#tabs-list > Tab"))
|
||||
|
||||
@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))
|
||||
if self.active_tab is None:
|
||||
return None
|
||||
try:
|
||||
active_index = tabs.index(self.active_tab)
|
||||
except ValueError:
|
||||
return None
|
||||
del tabs[active_index]
|
||||
try:
|
||||
return tabs[active_index]
|
||||
except IndexError:
|
||||
try:
|
||||
return tabs[active_index - 1]
|
||||
except IndexError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def add_tab(self, tab: Tab | str | Text) -> None:
|
||||
"""Add a new tab to the end of the tab list.
|
||||
|
||||
Args:
|
||||
tab: A new tab object, or a label (str or Text).
|
||||
"""
|
||||
from_empty = self.tab_count == 0
|
||||
tab_widget = (
|
||||
Tab(tab, id=f"tab-{self._new_tab_id}")
|
||||
if isinstance(tab, (str, Text))
|
||||
else self._auto_tab_id(tab)
|
||||
)
|
||||
mount_await = self.query_one("#tabs-list").mount(tab_widget)
|
||||
if from_empty:
|
||||
tab_widget.add_class("-active")
|
||||
self.post_message(self.TabActivated(tab_widget))
|
||||
|
||||
async def refresh_active() -> None:
|
||||
"""Wait for things to be mounted before highlighting."""
|
||||
await mount_await
|
||||
self.active = tab_widget.id or ""
|
||||
self._highlight_active(animate=False)
|
||||
|
||||
self.call_after_refresh(refresh_active)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all the tabs."""
|
||||
underline = self.query_one(Underline)
|
||||
underline.highlight_start = 0
|
||||
underline.highlight_end = 0
|
||||
self.query("#tabs-list > Tab").remove()
|
||||
self.post_message(self.TabActivated(None))
|
||||
|
||||
def remove_tab(self, tab_or_id: Tab | str | None) -> None:
|
||||
"""Remove a tab.
|
||||
|
||||
Args:
|
||||
tab_id: The Tab's id.
|
||||
"""
|
||||
if tab_or_id is None:
|
||||
return
|
||||
if isinstance(tab_or_id, Tab):
|
||||
remove_tab = tab_or_id
|
||||
else:
|
||||
try:
|
||||
remove_tab = self.query_one(f"#tabs-list > #{tab_or_id}", Tab)
|
||||
except NoMatches:
|
||||
return
|
||||
removing_active_tab = remove_tab.has_class("-active")
|
||||
|
||||
next_tab = self._next_active
|
||||
self.post_message(self.TabActivated(next_tab))
|
||||
|
||||
async def do_remove() -> None:
|
||||
await remove_tab.remove()
|
||||
if removing_active_tab:
|
||||
if next_tab is not None:
|
||||
next_tab.add_class("-active")
|
||||
self.call_after_refresh(self._highlight_active, animate=True)
|
||||
|
||||
self.call_after_refresh(do_remove)
|
||||
|
||||
def validate_active(self, active: str) -> str:
|
||||
"""Check id assigned to active attribute is a valid tab."""
|
||||
if active and not self.query(f"#tabs-list > #{active}"):
|
||||
raise ValueError(f"No Tab with id {active!r}")
|
||||
return active
|
||||
|
||||
@property
|
||||
def active_tab(self) -> Tab | None:
|
||||
"""The currently active tab, or None if there are no active tabs."""
|
||||
try:
|
||||
return self.query_one("#tabs-list Tab.-active", Tab)
|
||||
except NoMatches:
|
||||
return None
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Make the first tab active."""
|
||||
if self._first_active is not None:
|
||||
self.active = self._first_active
|
||||
if not self.active:
|
||||
try:
|
||||
tab = self.query("#tabs-list > Tab").first(Tab)
|
||||
except NoMatches:
|
||||
# Tabs are empty!
|
||||
return
|
||||
self.active = tab.id or ""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="tabs-scroll"):
|
||||
with Vertical(id="tabs-list-bar"):
|
||||
with Horizontal(id="tabs-list"):
|
||||
yield from self._tabs
|
||||
yield Underline()
|
||||
|
||||
def watch_active(self, previously_active: str, active: str) -> None:
|
||||
"""Handle a change to the active tab."""
|
||||
if active:
|
||||
active_tab = self.query_one(f"#tabs-list > #{active}", Tab)
|
||||
self.query("#tabs-list > Tab.-active").remove_class("-active")
|
||||
active_tab.add_class("-active")
|
||||
self._highlight_active(animate=previously_active != "")
|
||||
self.post_message(self.TabActivated(active_tab))
|
||||
else:
|
||||
underline = self.query_one(Underline)
|
||||
underline.highlight_start = 0
|
||||
underline.highlight_end = 0
|
||||
self.post_message(self.TabActivated(None))
|
||||
|
||||
def _highlight_active(self, animate: bool = True) -> None:
|
||||
"""Move the underline bar to under the active tab.
|
||||
|
||||
Args:
|
||||
animate: Should the bar animate?
|
||||
"""
|
||||
underline = self.query_one(Underline)
|
||||
try:
|
||||
active_tab = self.query_one(f"#tabs-list > Tab.-active")
|
||||
except NoMatches:
|
||||
underline.highlight_start = 0
|
||||
underline.highlight_end = 0
|
||||
else:
|
||||
tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter)
|
||||
start, end = tab_region.column_span
|
||||
if animate:
|
||||
underline.animate("highlight_start", start, duration=0.3)
|
||||
underline.animate("highlight_end", end, duration=0.3)
|
||||
else:
|
||||
underline.highlight_start = start
|
||||
underline.highlight_end = end
|
||||
|
||||
async def _on_tab_clicked(self, event: Tab.Clicked) -> None:
|
||||
"""Activate a tab that was clicked."""
|
||||
self.focus()
|
||||
event.stop()
|
||||
self._activate_tab(event.tab)
|
||||
|
||||
def _activate_tab(self, tab: Tab) -> None:
|
||||
"""Activate a tab.
|
||||
|
||||
Args:
|
||||
tab: The Tab that was clicked.
|
||||
"""
|
||||
self.query("#tabs-list Tab.-active").remove_class("-active")
|
||||
tab.add_class("-active")
|
||||
self.active = tab.id or ""
|
||||
self.query_one("#tabs-scroll").scroll_to_widget(tab, force=True)
|
||||
|
||||
def _on_underline_clicked(self, event: Underline.Clicked) -> None:
|
||||
"""The underline was clicked.
|
||||
|
||||
Activate the tab above to make a larger clickable area.
|
||||
|
||||
Args:
|
||||
event: The Underline.Clicked event.
|
||||
"""
|
||||
event.stop()
|
||||
offset = event.offset + (0, -1)
|
||||
self.focus()
|
||||
for tab in self.query(Tab):
|
||||
if offset in tab.region:
|
||||
self._activate_tab(tab)
|
||||
break
|
||||
|
||||
def _scroll_active_tab(self) -> None:
|
||||
"""Scroll the active tab into view."""
|
||||
if self.active_tab:
|
||||
try:
|
||||
self.query_one("#tabs-scroll").scroll_to_widget(
|
||||
self.active_tab, force=True
|
||||
)
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _on_resize(self):
|
||||
"""Make the active tab visible on resize."""
|
||||
self._highlight_active(animate=False)
|
||||
self._scroll_active_tab()
|
||||
|
||||
def action_next_tab(self) -> None:
|
||||
"""Make the next tab active."""
|
||||
self._move_tab(+1)
|
||||
|
||||
def action_previous_tab(self) -> None:
|
||||
"""Make the previous tab active."""
|
||||
self._move_tab(-1)
|
||||
|
||||
def _move_tab(self, direction: int) -> None:
|
||||
"""Activate the next tab.
|
||||
|
||||
Args:
|
||||
direction: +1 for the next tab, -1 for the previous.
|
||||
"""
|
||||
active_tab = self.active_tab
|
||||
if active_tab is None:
|
||||
return
|
||||
tabs = list(self.query(Tab))
|
||||
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()
|
||||
@@ -1034,7 +1034,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
|
||||
else:
|
||||
self.cursor_line = cursor_line
|
||||
await self.action("select_cursor")
|
||||
await self.run_action("select_cursor")
|
||||
|
||||
def notify_style_update(self) -> None:
|
||||
self._invalidate()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from os import terminal_size
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -81,8 +80,7 @@ def test_input_and_focus(snap_compare):
|
||||
"tab",
|
||||
*"Darren", # Focus first input, write "Darren"
|
||||
"tab",
|
||||
*"Burns",
|
||||
"_", # Tab focus to second input, write "Burns"
|
||||
*"Burns", # Focus second input, write "Burns"
|
||||
]
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "input.py", press=press)
|
||||
|
||||
@@ -99,17 +97,17 @@ def test_placeholder_render(snap_compare):
|
||||
|
||||
|
||||
def test_datatable_render(snap_compare):
|
||||
press = ["tab", "down", "down", "right", "up", "left", "_"]
|
||||
press = ["tab", "down", "down", "right", "up", "left"]
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "data_table.py", press=press)
|
||||
|
||||
|
||||
def test_datatable_row_cursor_render(snap_compare):
|
||||
press = ["up", "left", "right", "down", "down", "_"]
|
||||
press = ["up", "left", "right", "down", "down"]
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_row_cursor.py", press=press)
|
||||
|
||||
|
||||
def test_datatable_column_cursor_render(snap_compare):
|
||||
press = ["left", "up", "down", "right", "right", "_"]
|
||||
press = ["left", "up", "down", "right", "right"]
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_column_cursor.py", press=press)
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@ from string import punctuation
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
|
||||
KEY_CHARACTERS_TO_TEST = "akTW03" + punctuation.replace("_", "")
|
||||
"""Test some "simple" characters (letters + digits) and all punctuation.
|
||||
Ignore the underscore because that is an alias to add a pause in the pilot.
|
||||
"""
|
||||
KEY_CHARACTERS_TO_TEST = "akTW03" + punctuation
|
||||
"""Test some "simple" characters (letters + digits) and all punctuation."""
|
||||
|
||||
|
||||
async def test_pilot_press_ascii_chars():
|
||||
|
||||
@@ -162,7 +162,7 @@ def test_widget_mount_ids_must_be_unique_mounting_multiple_calls(parent):
|
||||
async def test_remove():
|
||||
class RemoveMeLabel(Label):
|
||||
async def on_mount(self) -> None:
|
||||
await self.action("app.remove_all")
|
||||
await self.run_action("app.remove_all")
|
||||
|
||||
class Container(Widget):
|
||||
async def clear(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user