Compare commits
40 commits
main
...
customize-
| Author | SHA1 | Date | |
|---|---|---|---|
| c7d73cd466 | |||
| ca04abf091 | |||
| a24c99a566 | |||
| 4c7e54f6d6 | |||
|
|
45520f2da1 | ||
|
|
de692aaf07 | ||
|
|
a404ee5e01 | ||
|
|
43253f5d80 | ||
|
|
9b191914cb | ||
|
|
111233f1b4 | ||
|
|
04340bd0ba | ||
|
|
8d17ad39fd | ||
|
|
c889b4bfe9 | ||
|
|
d64e9a7e67 | ||
|
|
66535e9c07 | ||
|
|
01045e6b7f | ||
|
|
dab39c719b | ||
|
|
31be9e059e | ||
|
|
1246934643 | ||
|
|
10a5d171eb | ||
|
|
285de4b0fa | ||
|
|
d775a90fa2 | ||
|
|
f9a1e27c6f | ||
|
|
3518d38d85 | ||
|
|
cc8f65259e | ||
|
|
429c8a3649 | ||
|
|
0e51520a2c | ||
|
|
48ce1a149d | ||
|
|
bdeea9fb86 | ||
|
|
c13308a360 | ||
|
|
5674b4b628 | ||
|
|
38c7cc1849 | ||
|
|
639d8f0250 | ||
|
|
58ad5dfdd9 | ||
|
|
e111449856 | ||
|
|
f91750ed3d | ||
|
|
be41797a8d | ||
|
|
90dce06eae | ||
|
|
4ca62eee60 | ||
|
|
8565d3cef6 |
64 changed files with 1163 additions and 435 deletions
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -7,10 +7,36 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Added `parser_factory` argument to `Markdown` and `MarkdownViewer` constructors https://github.com/Textualize/textual/pull/2075
|
||||
|
||||
### Changed
|
||||
|
||||
- Dropped "loading-indicator--dot" component style from LoadingIndicator https://github.com/Textualize/textual/pull/2050
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `sender` attribute from messages. It's now just private (`_sender`). https://github.com/Textualize/textual/pull/2071
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed borders not rendering correctly. https://github.com/Textualize/textual/pull/2074
|
||||
- Fix for error when removing nodes. https://github.com/Textualize/textual/issues/2079
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Breaking change: changed default behaviour of `Vertical` (see `VerticalScroll`) https://github.com/Textualize/textual/issues/1957
|
||||
- The default `overflow` style for `Horizontal` was changed to `hidden hidden` https://github.com/Textualize/textual/issues/1957
|
||||
|
||||
### Added
|
||||
|
||||
- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957
|
||||
- Added `Center` https://github.com/Textualize/textual/issues/1957
|
||||
- Added `Middle` https://github.com/Textualize/textual/issues/1957
|
||||
- Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957
|
||||
|
||||
|
||||
## [0.15.1] - 2023-03-14
|
||||
|
||||
### Fixed
|
||||
|
|
@ -22,6 +48,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
### Fixed
|
||||
|
||||
- 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
|
||||
|
|
|
|||
138
docs/blog/posts/await-me-maybe.md
Normal file
138
docs/blog/posts/await-me-maybe.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
draft: false
|
||||
date: 2023-03-15
|
||||
categories:
|
||||
- DevLog
|
||||
authors:
|
||||
- willmcgugan
|
||||
---
|
||||
|
||||
# No-async async with Python
|
||||
|
||||
A (reasonable) criticism of async is that it tends to proliferate in your code. In order to `await` something, your functions must be `async` all the way up the call-stack. This tends to result in you making things `async` just to support that one call that needs it or, worse, adding `async` just-in-case. Given that going from `def` to `async def` is a breaking change there is a strong incentive to go straight there.
|
||||
|
||||
Before you know it, you have adopted a policy of "async all the things".
|
||||
|
||||
<!-- more -->
|
||||
|
||||
Textual is an async framework, but doesn't *require* the app developer to use the `async` and `await` keywords (but you can if you need to). This post is about how Textual accomplishes this async-agnosticism.
|
||||
|
||||
!!! info
|
||||
|
||||
See this [example](https://textual.textualize.io/guide/widgets/#attributes-down) from the docs for an async-less Textual app.
|
||||
|
||||
|
||||
## An apology
|
||||
|
||||
But first, an apology! In a previous post I said Textual "doesn't do any IO of its own". This is not accurate. Textual responds to keys and mouse events (**I**nput) and writes content to the terminal (**O**utput).
|
||||
|
||||
Although Textual clearly does do IO, it uses `asyncio` mainly for *concurrency*. It allows each widget to update its part of the screen independently from the rest of the app.
|
||||
|
||||
## Await me (maybe)
|
||||
|
||||
The first no-async async technique is the "Await me maybe" pattern, a term first coined by [Simon Willison](https://simonwillison.net/2020/Sep/2/await-me-maybe/). This is particularly applicable to callbacks (or in Textual terms, message handlers).
|
||||
|
||||
The `await_me_maybe` function below can run a callback that is either a plain old function *or* a coroutine (`async def`). It does this by awaiting the result of the callback if it is awaitable, or simply returning the result if it is not.
|
||||
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
|
||||
def plain_old_function():
|
||||
return "Plain old function"
|
||||
|
||||
async def async_function():
|
||||
return "Async function"
|
||||
|
||||
|
||||
async def await_me_maybe(callback):
|
||||
result = callback()
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
|
||||
async def run_framework():
|
||||
print(
|
||||
await await_me_maybe(plain_old_function)
|
||||
)
|
||||
print(
|
||||
await await_me_maybe(async_function)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_framework())
|
||||
```
|
||||
|
||||
## Optionally awaitable
|
||||
|
||||
The "await me maybe" pattern is great when an async framework calls the app's code. The app developer can choose to write async code or not. Things get a little more complicated when the app wants to call the framework's API. If the API has *asynced all the things*, then it would force the app to do the same.
|
||||
|
||||
Textual's API consists of regular methods for the most part, but there are a few methods which are optionally awaitable. These are *not* coroutines (which must be awaited to do anything).
|
||||
|
||||
In practice, this means that those API calls initiate something which will complete a short time later. If you discard the return value then it won't prevent it from working. You only need to `await` if you want to know when it has finished.
|
||||
|
||||
The `mount` method is one such method. Calling it will add a widget to the screen:
|
||||
|
||||
```python
|
||||
def on_key(self):
|
||||
# Add MyWidget to the screen
|
||||
self.mount(MyWidget("Hello, World!"))
|
||||
```
|
||||
|
||||
In this example we don't care that the widget hasn't been mounted immediately, only that it will be soon.
|
||||
|
||||
!!! note
|
||||
|
||||
Textual awaits the result of mount after the message handler, so even if you don't *explicitly* await it, it will have been completed by the time the next message handler runs.
|
||||
|
||||
We might care if we want to mount a widget then make some changes to it. By making the handler `async` and awaiting the result of mount, we can be sure that the widget has been initialized before we update it:
|
||||
|
||||
```python
|
||||
async def on_key(self):
|
||||
# Add MyWidget to the screen
|
||||
await self.mount(MyWidget("Hello, World!"))
|
||||
# add a border
|
||||
self.query_one(MyWidget).styles.border = ("heavy", "red")
|
||||
```
|
||||
|
||||
Incidentally, I found there were very few examples of writing awaitable objects in Python. So here is the code for `AwaitMount` which is returned by the `mount` method:
|
||||
|
||||
```python
|
||||
class AwaitMount:
|
||||
"""An awaitable returned by mount() and mount_all()."""
|
||||
|
||||
def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None:
|
||||
self._parent = parent
|
||||
self._widgets = widgets
|
||||
|
||||
async def __call__(self) -> None:
|
||||
"""Allows awaiting via a call operation."""
|
||||
await self
|
||||
|
||||
def __await__(self) -> Generator[None, None, None]:
|
||||
async def await_mount() -> None:
|
||||
if self._widgets:
|
||||
aws = [
|
||||
create_task(widget._mounted_event.wait(), name="await mount")
|
||||
for widget in self._widgets
|
||||
]
|
||||
if aws:
|
||||
await wait(aws)
|
||||
self._parent.refresh(layout=True)
|
||||
|
||||
return await_mount().__await__()
|
||||
```
|
||||
|
||||
## Summing up
|
||||
|
||||
Textual did initially "async all the things", which you might see if you find some old Textual code. Now async is optional.
|
||||
|
||||
This is not because I dislike async. I'm a fan! But it does place a small burden on the developer (more to type and think about). With the current API you generally don't need to write coroutines, or remember to await things. But async is there if you need it.
|
||||
|
||||
We're finding that Textual is increasingly becoming a UI to things which are naturally concurrent, so async was a good move. Concurrency can be a tricky subject, so we're planning some API magic to take the pain out of running tasks, threads, and processes. Stay tuned!
|
||||
|
||||
Join us on our [Discord server](https://discord.gg/Enf6Z3qhVr) if you want to talk about these things with the Textualize developers.
|
||||
|
|
@ -30,7 +30,7 @@ There are a number of files and modules in [Textual](https://github.com/Textuali
|
|||
|
||||
## Loop first / last
|
||||
|
||||
How often do you find yourself looping over an iterable and needing to know if an element is the first and/or last in the sequence? It's a simple thing, but I find myself nedding this a *lot*, so I wrote some helpers in [_loop.py](https://github.com/Textualize/textual/blob/main/src/textual/_loop.py).
|
||||
How often do you find yourself looping over an iterable and needing to know if an element is the first and/or last in the sequence? It's a simple thing, but I find myself needing this a *lot*, so I wrote some helpers in [_loop.py](https://github.com/Textualize/textual/blob/main/src/textual/_loop.py).
|
||||
|
||||
I'm sure there is an equivalent implementation on PyPI, but steal this if you need it.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from random import randint
|
||||
import time
|
||||
from random import randint
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.color import Color
|
||||
from textual.containers import Grid, Vertical
|
||||
from textual.containers import Grid, VerticalScroll
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Footer, Label
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ class MyApp(App[None]):
|
|||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
ColourChanger(),
|
||||
Vertical(id="log"),
|
||||
VerticalScroll(id="log"),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import asyncio
|
||||
from random import randint
|
||||
import time
|
||||
from random import randint
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.color import Color
|
||||
from textual.containers import Grid, Vertical
|
||||
from textual.containers import Grid, VerticalScroll
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Footer, Label
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ class MyApp(App[None]):
|
|||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
ColourChanger(),
|
||||
Vertical(id="log"),
|
||||
VerticalScroll(id="log"),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import asyncio
|
||||
from random import randint
|
||||
import time
|
||||
from random import randint
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.color import Color
|
||||
from textual.containers import Grid, Vertical
|
||||
from textual.containers import Grid, VerticalScroll
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Footer, Label
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ class MyApp(App[None]):
|
|||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
ColourChanger(),
|
||||
Vertical(id="log"),
|
||||
VerticalScroll(id="log"),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ except ImportError:
|
|||
raise ImportError("Please install httpx with 'pip install httpx' ")
|
||||
|
||||
from rich.json import JSON
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static, Input
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
|
||||
class DictionaryApp(App):
|
||||
|
|
@ -18,7 +19,7 @@ class DictionaryApp(App):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(placeholder="Search for a word")
|
||||
yield Vertical(Static(id="results"), id="results-container")
|
||||
yield VerticalScroll(Static(id="results"), id="results-container")
|
||||
|
||||
async def on_input_changed(self, message: Input.Changed) -> None:
|
||||
"""A coroutine to handle a text changed message."""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.widgets import Header, Static
|
||||
|
||||
|
||||
|
|
@ -9,7 +9,7 @@ class CombiningLayoutsExample(App):
|
|||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Container(id="app-grid"):
|
||||
with Vertical(id="left-pane"):
|
||||
with VerticalScroll(id="left-pane"):
|
||||
for number in range(15):
|
||||
yield Static(f"Vertical layout, child {number}")
|
||||
with Horizontal(id="top-right"):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from textual.app import App
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Placeholder, Label, Static
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Label, Placeholder, Static
|
||||
|
||||
|
||||
class Ruler(Static):
|
||||
|
|
@ -11,7 +11,7 @@ class Ruler(Static):
|
|||
|
||||
class HeightComparisonApp(App):
|
||||
def compose(self):
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
Placeholder(id="cells"), # (1)!
|
||||
Placeholder(id="percent"),
|
||||
Placeholder(id="w"),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
from textual.app import App
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class MaxWidthApp(App):
|
||||
def compose(self):
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
Placeholder("max-width: 50h", id="p1"),
|
||||
Placeholder("max-width: 999", id="p2"),
|
||||
Placeholder("max-width: 50%", id="p3"),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
Vertical {
|
||||
VerticalScroll {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
|
|
@ -10,7 +10,8 @@ Placeholder {
|
|||
}
|
||||
|
||||
#p1 {
|
||||
min-width: 25%; /* (1)! */
|
||||
min-width: 25%;
|
||||
/* (1)! */
|
||||
}
|
||||
|
||||
#p2 {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
from textual.app import App
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class MinWidthApp(App):
|
||||
def compose(self):
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
Placeholder("min-width: 25%", id="p1"),
|
||||
Placeholder("min-width: 75%", id="p2"),
|
||||
Placeholder("min-width: 100", id="p3"),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Screen {
|
|||
color: black;
|
||||
}
|
||||
|
||||
Vertical {
|
||||
VerticalScroll {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from textual.app import App
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.widgets import Static
|
||||
from textual.containers import Horizontal, Vertical
|
||||
|
||||
TEXT = """I must not fear.
|
||||
Fear is the mind-killer.
|
||||
|
|
@ -14,8 +14,8 @@ Where the fear has gone there will be nothing. Only I will remain."""
|
|||
class OverflowApp(App):
|
||||
def compose(self):
|
||||
yield Horizontal(
|
||||
Vertical(Static(TEXT), Static(TEXT), Static(TEXT), id="left"),
|
||||
Vertical(Static(TEXT), Static(TEXT), Static(TEXT), id="right"),
|
||||
VerticalScroll(Static(TEXT), Static(TEXT), Static(TEXT), id="left"),
|
||||
VerticalScroll(Static(TEXT), Static(TEXT), Static(TEXT), id="right"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
from textual.app import App
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class VisibilityContainersApp(App):
|
||||
def compose(self):
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
Horizontal(
|
||||
Placeholder(),
|
||||
Placeholder(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ Button {
|
|||
margin: 1 2;
|
||||
}
|
||||
|
||||
Horizontal > Vertical {
|
||||
Horizontal > VerticalScroll {
|
||||
width: 24;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.widgets import Button, Static
|
||||
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ class ButtonsApp(App[str]):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Horizontal(
|
||||
Vertical(
|
||||
VerticalScroll(
|
||||
Static("Standard Buttons", classes="header"),
|
||||
Button("Default"),
|
||||
Button("Primary!", variant="primary"),
|
||||
|
|
@ -16,7 +16,7 @@ class ButtonsApp(App[str]):
|
|||
Button.warning("Warning!"),
|
||||
Button.error("Error!"),
|
||||
),
|
||||
Vertical(
|
||||
VerticalScroll(
|
||||
Static("Disabled Buttons", classes="header"),
|
||||
Button("Default", disabled=True),
|
||||
Button("Primary!", variant="primary", disabled=True),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ Screen {
|
|||
align: center middle;
|
||||
}
|
||||
|
||||
Vertical {
|
||||
VerticalScroll {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: solid $primary;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Checkbox
|
||||
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ class CheckboxApp(App[None]):
|
|||
CSS_PATH = "checkbox.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
with VerticalScroll():
|
||||
yield Checkbox("Arrakis :sweat:")
|
||||
yield Checkbox("Caladan")
|
||||
yield Checkbox("Chusuk")
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class PlaceholderApp(App):
|
||||
|
||||
CSS_PATH = "placeholder.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
Container(
|
||||
Placeholder("This is a custom label for p1.", id="p1"),
|
||||
Placeholder("Placeholder p2 here!", id="p2"),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
Vertical {
|
||||
VerticalScroll {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.widgets import Label, RadioButton, RadioSet
|
||||
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ class RadioSetChangedApp(App[None]):
|
|||
CSS_PATH = "radio_set_changed.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
with VerticalScroll():
|
||||
with Horizontal():
|
||||
with RadioSet():
|
||||
yield RadioButton("Battlestar Galactica")
|
||||
|
|
|
|||
|
|
@ -134,8 +134,8 @@ exceeds the available horizontal space in the parent container.
|
|||
|
||||
## Utility containers
|
||||
|
||||
Textual comes with several "container" widgets.
|
||||
These are [Vertical][textual.containers.Vertical], [Horizontal][textual.containers.Horizontal], and [Grid][textual.containers.Grid] which have the corresponding layout.
|
||||
Textual comes with [several "container" widgets][textual.containers].
|
||||
Among them, we have [Vertical][textual.containers.Vertical], [Horizontal][textual.containers.Horizontal], and [Grid][textual.containers.Grid] which have the corresponding layout.
|
||||
|
||||
The example below shows how we can combine these containers to create a simple 2x2 grid.
|
||||
Inside a single `Horizontal` container, we place two `Vertical` containers.
|
||||
|
|
@ -163,8 +163,8 @@ However, Textual comes with a more powerful mechanism for achieving this known a
|
|||
|
||||
## Composing with context managers
|
||||
|
||||
In the previous section we've show how you add children to a container (such as `Horizontal` and `Vertical`) using positional arguments.
|
||||
It's fine to do it this way, but Textual offers a simplified syntax using [context managers](https://docs.python.org/3/reference/datamodel.html#context-managers) which is generally easier to write and edit.
|
||||
In the previous section, we've shown how you add children to a container (such as `Horizontal` and `Vertical`) using positional arguments.
|
||||
It's fine to do it this way, but Textual offers a simplified syntax using [context managers](https://docs.python.org/3/reference/datamodel.html#context-managers), which is generally easier to write and edit.
|
||||
|
||||
When composing a widget, you can introduce a container using Python's `with` statement.
|
||||
Any widgets yielded within that block are added as a child of the container.
|
||||
|
|
@ -202,7 +202,7 @@ Let's update the [utility containers](#utility-containers) example to use the co
|
|||
```{.textual path="docs/examples/guide/layout/utility_containers_using_with.py"}
|
||||
```
|
||||
|
||||
Note how the end result is the same, but the code with context managers is a little easer to read. It is up to you which method you want to use, and you can mix context managers with positional arguments if you like!
|
||||
Note how the end result is the same, but the code with context managers is a little easier to read. It is up to you which method you want to use, and you can mix context managers with positional arguments if you like!
|
||||
|
||||
## Grid
|
||||
|
||||
|
|
|
|||
|
|
@ -551,7 +551,7 @@ We also want the switches to update if the user edits the decimal value.
|
|||
Since the switches are children of `ByteEditor` we can update them by setting their attributes directly.
|
||||
This is an example of "attributes down".
|
||||
|
||||
=== "byte02.py"
|
||||
=== "byte03.py"
|
||||
|
||||
```python title="byte03.py" hl_lines="5 45-47 90 92-94 109-114 116-120"
|
||||
--8<-- "docs/examples/guide/compound/byte03.py"
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ Open the CSS file tab to see the comments that explain how each height is comput
|
|||
|
||||
1. This sets the height to 2 lines.
|
||||
2. This sets the height to 12.5% of the space made available by the container. The container is 24 lines tall, so 12.5% of 24 is 3.
|
||||
3. This sets the height to 5% of the width of the direct container, which is the `Vertical` container. Because it expands to fit all of the terminal, the width of the `Vertical` is 80 and 5% of 80 is 4.
|
||||
4. This sets the height to 12.5% of the height of the direct container, which is the `Vertical` container. Because it expands to fit all of the terminal, the height of the `Vertical` is 24 and 12.5% of 24 is 3.
|
||||
3. This sets the height to 5% of the width of the direct container, which is the `VerticalScroll` container. Because it expands to fit all of the terminal, the width of the `VerticalScroll` is 80 and 5% of 80 is 4.
|
||||
4. This sets the height to 12.5% of the height of the direct container, which is the `VerticalScroll` container. Because it expands to fit all of the terminal, the height of the `VerticalScroll` is 24 and 12.5% of 24 is 3.
|
||||
5. This sets the height to 6.25% of the viewport width, which is 80. 6.25% of 80 is 5.
|
||||
6. This sets the height to 12.5% of the viewport height, which is 24. 12.5% of 24 is 3.
|
||||
7. This sets the height of the placeholder to be the optimal size that fits the content without scrolling.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ The default setting for containers is `overflow: auto auto`.
|
|||
|
||||
!!! warning
|
||||
|
||||
Some built-in containers like `Horizontal` and `Vertical` override these defaults.
|
||||
Some built-in containers like `Horizontal` and `VerticalScroll` override these defaults.
|
||||
|
||||
## Example
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from rich.traceback import Traceback
|
|||
|
||||
from textual import events
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.containers import Container, VerticalScroll
|
||||
from textual.reactive import var
|
||||
from textual.widgets import DirectoryTree, Footer, Header, Static
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ class CodeBrowser(App):
|
|||
yield Header()
|
||||
with Container():
|
||||
yield DirectoryTree(path, id="tree-view")
|
||||
with Vertical(id="code-view"):
|
||||
with VerticalScroll(id="code-view"):
|
||||
yield Static(id="code", expand=True)
|
||||
yield Footer()
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
|||
|
||||
from typing_extensions import Protocol, runtime_checkable
|
||||
|
||||
from . import _clock
|
||||
from . import _time
|
||||
from ._callback import invoke
|
||||
from ._easing import DEFAULT_EASING, EASING
|
||||
from ._types import CallbackType
|
||||
|
|
@ -385,7 +385,7 @@ class Animator:
|
|||
"""Get the current wall clock time, via the internal Timer."""
|
||||
# N.B. We could remove this method and always call `self._timer.get_time()` internally,
|
||||
# but it's handy to have in mocking situations
|
||||
return _clock.get_time_no_wait()
|
||||
return _time.get_time()
|
||||
|
||||
async def wait_for_idle(self) -> None:
|
||||
"""Wait for any animations to complete."""
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import asyncio
|
||||
|
||||
from ._time import time
|
||||
|
||||
"""
|
||||
A module that serves as the single source of truth for everything time-related in a Textual app.
|
||||
Having this logic centralised makes it easier to simulate time in integration tests,
|
||||
by mocking the few functions exposed by this module.
|
||||
"""
|
||||
|
||||
|
||||
# N.B. This class and its singleton instance have to be hidden APIs because we want to be able to mock time,
|
||||
# even for Python modules that imported functions such as `get_time` *before* we mocked this internal _Clock.
|
||||
# (so mocking public APIs such as `get_time` wouldn't affect direct references to then that were done during imports)
|
||||
class _Clock:
|
||||
async def get_time(self) -> float:
|
||||
return time()
|
||||
|
||||
def get_time_no_wait(self) -> float:
|
||||
return time()
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
_clock = _Clock()
|
||||
|
||||
|
||||
def get_time_no_wait() -> float:
|
||||
"""
|
||||
Get the current wall clock time.
|
||||
|
||||
Returns:
|
||||
The value (in fractional seconds) of a monotonic clock, i.e. a clock that cannot go backwards.
|
||||
"""
|
||||
return _clock.get_time_no_wait()
|
||||
|
||||
|
||||
async def get_time() -> float:
|
||||
"""
|
||||
Asynchronous version of `get_time`. Useful in situations where we want asyncio to be
|
||||
able to "do things" elsewhere right before we fetch the time.
|
||||
|
||||
Returns:
|
||||
The value (in fractional seconds) of a monotonic clock, i.e. a clock that cannot go backwards.
|
||||
"""
|
||||
return await _clock.get_time()
|
||||
|
||||
|
||||
async def sleep(seconds: float) -> None:
|
||||
"""
|
||||
Coroutine that completes after a given time (in seconds).
|
||||
|
||||
Args:
|
||||
seconds: The duration we should wait for before unblocking the awaiter
|
||||
"""
|
||||
return await _clock.sleep(seconds)
|
||||
|
|
@ -192,11 +192,10 @@ def align_lines(
|
|||
style: Background style.
|
||||
size: Size of container.
|
||||
horizontal: Horizontal alignment.
|
||||
vertical: Vertical alignment
|
||||
vertical: Vertical alignment.
|
||||
|
||||
Returns:
|
||||
Aligned lines.
|
||||
|
||||
"""
|
||||
|
||||
width, height = size
|
||||
|
|
|
|||
|
|
@ -42,3 +42,12 @@ else:
|
|||
sleep_for = secs - 0.0005
|
||||
if sleep_for > 0:
|
||||
await asyncio_sleep(sleep_for)
|
||||
|
||||
|
||||
get_time = time
|
||||
"""Get the current wall clock (monotonic) time.
|
||||
|
||||
Returns:
|
||||
The value (in fractional seconds) of a monotonic clock,
|
||||
i.e. a clock that cannot go backwards.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ from ._ansi_sequences import SYNC_END, SYNC_START
|
|||
from ._asyncio import create_task
|
||||
from ._callback import invoke
|
||||
from ._compose import compose
|
||||
from ._context import active_app
|
||||
from ._context import active_app, active_message_pump
|
||||
from ._event_broker import NoHandler, extract_handler_actions
|
||||
from ._path import _make_path_object_relative
|
||||
from ._wait import wait_for_idle
|
||||
|
|
@ -918,6 +918,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||
)
|
||||
|
||||
# Launch the app in the "background"
|
||||
active_message_pump.set(app)
|
||||
app_task = create_task(run_app(app), name=f"run_test {app}")
|
||||
|
||||
# Wait until the app has performed all startup routines.
|
||||
|
|
@ -973,6 +974,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||
raise
|
||||
|
||||
pilot = Pilot(app)
|
||||
active_message_pump.set(self)
|
||||
auto_pilot_task = create_task(
|
||||
run_auto_pilot(auto_pilot, pilot), name=repr(pilot)
|
||||
)
|
||||
|
|
@ -2174,11 +2176,14 @@ class App(Generic[ReturnType], DOMNode):
|
|||
for child in widget._nodes:
|
||||
push(child)
|
||||
|
||||
def _remove_nodes(self, widgets: list[Widget], parent: DOMNode) -> AwaitRemove:
|
||||
def _remove_nodes(
|
||||
self, widgets: list[Widget], parent: DOMNode | None
|
||||
) -> AwaitRemove:
|
||||
"""Remove nodes from DOM, and return an awaitable that awaits cleanup.
|
||||
|
||||
Args:
|
||||
widgets: List of nodes to remove.
|
||||
parent: Parent node of widgets, or None for no parent.
|
||||
|
||||
Returns:
|
||||
Awaitable that returns when the nodes have been fully removed.
|
||||
|
|
@ -2197,7 +2202,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||
await self._prune_nodes(widgets)
|
||||
finally:
|
||||
finished_event.set()
|
||||
if parent.styles.auto_dimensions:
|
||||
if parent is not None and parent.styles.auto_dimensions:
|
||||
parent.refresh(layout=True)
|
||||
|
||||
removed_widgets = self._detach_from_dom(widgets)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.constants import BORDERS
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Button, Label
|
||||
|
||||
TEXT = """I must not fear.
|
||||
|
|
@ -12,7 +12,7 @@ And when it has gone past, I will turn the inner eye to see its path.
|
|||
Where the fear has gone there will be nothing. Only I will remain."""
|
||||
|
||||
|
||||
class BorderButtons(Vertical):
|
||||
class BorderButtons(VerticalScroll):
|
||||
DEFAULT_CSS = """
|
||||
BorderButtons {
|
||||
dock: left;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.design import ColorSystem
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Footer, Label, Static
|
||||
|
||||
|
||||
class ColorButtons(Vertical):
|
||||
class ColorButtons(VerticalScroll):
|
||||
def compose(self) -> ComposeResult:
|
||||
for border in ColorSystem.COLOR_NAMES:
|
||||
if border:
|
||||
|
|
@ -20,15 +20,15 @@ class ColorItem(Horizontal):
|
|||
pass
|
||||
|
||||
|
||||
class ColorGroup(Vertical):
|
||||
class ColorGroup(VerticalScroll):
|
||||
pass
|
||||
|
||||
|
||||
class Content(Vertical):
|
||||
class Content(VerticalScroll):
|
||||
pass
|
||||
|
||||
|
||||
class ColorsView(Vertical):
|
||||
class ColorsView(VerticalScroll):
|
||||
def compose(self) -> ComposeResult:
|
||||
LEVELS = [
|
||||
"darken-3",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from rich.console import RenderableType
|
|||
from textual._easing import EASING
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.cli.previews.borders import TEXT
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.reactive import reactive, var
|
||||
from textual.scrollbar import ScrollBarRender
|
||||
from textual.widget import Widget
|
||||
|
|
@ -73,7 +73,7 @@ class EasingApp(App):
|
|||
)
|
||||
|
||||
yield EasingButtons()
|
||||
with Vertical():
|
||||
with VerticalScroll():
|
||||
with Horizontal(id="inputs"):
|
||||
yield Label("Animation Duration:", id="label")
|
||||
yield duration_input
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ class Color(NamedTuple):
|
|||
A CSS hex-style color, e.g. "#46b3de"
|
||||
|
||||
"""
|
||||
r, g, b, a = self.clamped
|
||||
r, g, b, _a = self.clamped
|
||||
return f"#{r:02X}{g:02X}{b:02X}"
|
||||
|
||||
@property
|
||||
|
|
@ -504,7 +504,9 @@ class Color(NamedTuple):
|
|||
a = clamp(float(a), 0.0, 1.0)
|
||||
color = Color.from_hsl(h, s, l).with_alpha(a)
|
||||
else:
|
||||
raise AssertionError("Can't get here if RE_COLOR matches")
|
||||
raise AssertionError( # pragma: no-cover
|
||||
"Can't get here if RE_COLOR matches"
|
||||
)
|
||||
return color
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
|
|
@ -545,10 +547,7 @@ class Color(NamedTuple):
|
|||
Returns:
|
||||
A new color, either an off-white or off-black
|
||||
"""
|
||||
brightness = self.brightness
|
||||
white_contrast = abs(brightness - WHITE.brightness)
|
||||
black_contrast = abs(brightness - BLACK.brightness)
|
||||
return (WHITE if white_contrast > black_contrast else BLACK).with_alpha(alpha)
|
||||
return (WHITE if self.brightness < 0.5 else BLACK).with_alpha(alpha)
|
||||
|
||||
|
||||
class Gradient:
|
||||
|
|
@ -566,12 +565,12 @@ class Gradient:
|
|||
Raises:
|
||||
ValueError: If any stops are missing (must be at least a stop for 0 and 1).
|
||||
"""
|
||||
self.stops = sorted(stops)
|
||||
self._stops = sorted(stops)
|
||||
if len(stops) < 2:
|
||||
raise ValueError("At least 2 stops required.")
|
||||
if self.stops[0][0] != 0.0:
|
||||
if self._stops[0][0] != 0.0:
|
||||
raise ValueError("First stop must be 0.")
|
||||
if self.stops[-1][0] != 1.0:
|
||||
if self._stops[-1][0] != 1.0:
|
||||
raise ValueError("Last stop must be 1.")
|
||||
|
||||
def get_color(self, position: float) -> Color:
|
||||
|
|
@ -588,13 +587,13 @@ class Gradient:
|
|||
"""
|
||||
# TODO: consider caching
|
||||
position = clamp(position, 0.0, 1.0)
|
||||
for (stop1, color1), (stop2, color2) in zip(self.stops, self.stops[1:]):
|
||||
for (stop1, color1), (stop2, color2) in zip(self._stops, self._stops[1:]):
|
||||
if stop2 >= position >= stop1:
|
||||
return color1.blend(
|
||||
color2,
|
||||
(position - stop1) / (stop2 - stop1),
|
||||
)
|
||||
return self.stops[-1][1]
|
||||
raise AssertionError("Can't get here if `_stops` is valid")
|
||||
|
||||
|
||||
# Color constants
|
||||
|
|
|
|||
|
|
@ -14,11 +14,23 @@ class Container(Widget):
|
|||
|
||||
|
||||
class Vertical(Widget):
|
||||
"""A container widget which aligns children vertically."""
|
||||
"""A container which arranges children vertically."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Vertical {
|
||||
height: 1fr;
|
||||
width: 1fr;
|
||||
layout: vertical;
|
||||
overflow: hidden hidden;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class VerticalScroll(Widget):
|
||||
"""A container which arranges children vertically, with an automatic vertical scrollbar."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
VerticalScroll {
|
||||
width: 1fr;
|
||||
layout: vertical;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
@ -26,19 +38,55 @@ class Vertical(Widget):
|
|||
|
||||
|
||||
class Horizontal(Widget):
|
||||
"""A container widget which aligns children horizontally."""
|
||||
"""A container which arranges children horizontally."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Horizontal {
|
||||
height: 1fr;
|
||||
layout: horizontal;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden hidden;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class HorizontalScroll(Widget):
|
||||
"""A container which arranges children horizontally, with an automatic horizontal scrollbar."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
HorizontalScroll {
|
||||
height: 1fr;
|
||||
layout: horizontal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Center(Widget):
|
||||
"""A container which centers children horizontally."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Center {
|
||||
align-horizontal: center;
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Middle(Widget):
|
||||
"""A container which aligns children vertically in the middle."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Middle {
|
||||
align-vertical: middle;
|
||||
width: auto;
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class Grid(Widget):
|
||||
"""A container widget with grid alignment."""
|
||||
"""A container with grid alignment."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Grid {
|
||||
|
|
@ -52,7 +100,7 @@ class Content(Widget, can_focus=True, can_focus_children=False):
|
|||
"""A container for content such as text."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Vertical {
|
||||
VerticalScroll {
|
||||
height: 1fr;
|
||||
layout: vertical;
|
||||
overflow-y: auto;
|
||||
|
|
|
|||
|
|
@ -609,7 +609,6 @@ class DOMNode(MessagePump):
|
|||
base_background = background = BLACK
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
if styles.has_rule("background"):
|
||||
base_background = background
|
||||
background += styles.background
|
||||
return (base_background, background)
|
||||
|
|
@ -621,7 +620,6 @@ class DOMNode(MessagePump):
|
|||
base_color = color = BLACK
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
if styles.has_rule("background"):
|
||||
base_background = background
|
||||
background += styles.background
|
||||
if styles.has_rule("color"):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import asyncio
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from . import _clock, events
|
||||
from . import _time, events
|
||||
from ._types import MessageTarget
|
||||
from .events import MouseUp
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ class Driver(ABC):
|
|||
self._debug = debug
|
||||
self._size = size
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._mouse_down_time = _clock.get_time_no_wait()
|
||||
self._mouse_down_time = _time.get_time()
|
||||
self._down_buttons: list[int] = []
|
||||
self._last_move_event: events.MouseMove | None = None
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ class Driver(ABC):
|
|||
|
||||
def process_event(self, event: events.Event) -> None:
|
||||
"""Performs some additional processing of events."""
|
||||
event._set_sender(self._target)
|
||||
if isinstance(event, events.MouseDown):
|
||||
self._mouse_down_time = event.time
|
||||
if event.button:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, ClassVar
|
|||
|
||||
import rich.repr
|
||||
|
||||
from . import _clock
|
||||
from . import _time
|
||||
from ._context import active_message_pump
|
||||
from ._types import MessageTarget as MessageTarget
|
||||
from .case import camel_to_snake
|
||||
|
|
@ -34,7 +34,7 @@ class Message:
|
|||
|
||||
def __init__(self) -> None:
|
||||
self._sender: MessageTarget | None = active_message_pump.get(None)
|
||||
self.time: float = _clock.get_time_no_wait()
|
||||
self.time: float = _time.get_time()
|
||||
self._forwarded = False
|
||||
self._no_default_action = False
|
||||
self._stop_propagation = False
|
||||
|
|
@ -64,12 +64,6 @@ class Message:
|
|||
if namespace is not None:
|
||||
cls.namespace = namespace
|
||||
|
||||
@property
|
||||
def sender(self) -> MessageTarget:
|
||||
"""The sender of the message."""
|
||||
assert self._sender is not None
|
||||
return self._sender
|
||||
|
||||
@property
|
||||
def is_forwarded(self) -> bool:
|
||||
return self._forwarded
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from typing import Awaitable, Callable, Union
|
|||
|
||||
from rich.repr import Result, rich_repr
|
||||
|
||||
from . import _clock, events
|
||||
from . import _time, events
|
||||
from ._asyncio import create_task
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
|
|
@ -126,15 +126,15 @@ class Timer:
|
|||
_repeat = self._repeat
|
||||
_interval = self._interval
|
||||
await self._active.wait()
|
||||
start = _clock.get_time_no_wait()
|
||||
start = _time.get_time()
|
||||
|
||||
while _repeat is None or count <= _repeat:
|
||||
next_timer = start + ((count + 1) * _interval)
|
||||
now = await _clock.get_time()
|
||||
now = _time.get_time()
|
||||
if self._skip and next_timer < now:
|
||||
count += 1
|
||||
continue
|
||||
now = await _clock.get_time()
|
||||
now = _time.get_time()
|
||||
wait_time = max(0, next_timer - now)
|
||||
if wait_time > 1 / 1000:
|
||||
await sleep(wait_time)
|
||||
|
|
@ -142,7 +142,7 @@ class Timer:
|
|||
count += 1
|
||||
await self._active.wait()
|
||||
if self._reset:
|
||||
start = _clock.get_time_no_wait()
|
||||
start = _time.get_time()
|
||||
count = 0
|
||||
self._reset = False
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import ClassVar, Optional
|
|||
|
||||
from textual.await_remove import AwaitRemove
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.geometry import clamp
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
|
|
@ -12,7 +12,7 @@ from textual.widget import AwaitMount, Widget
|
|||
from textual.widgets._list_item import ListItem
|
||||
|
||||
|
||||
class ListView(Vertical, can_focus=True, can_focus_children=False):
|
||||
class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
|
||||
"""A vertical list view widget.
|
||||
|
||||
Displays a vertical list of `ListItem`s which can be highlighted and
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path, PurePath
|
||||
from typing import Iterable
|
||||
from typing import Iterable, Callable
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
from rich.style import Style
|
||||
|
|
@ -10,7 +10,7 @@ from rich.text import Text
|
|||
from typing_extensions import TypeAlias
|
||||
|
||||
from ..app import ComposeResult
|
||||
from ..containers import Horizontal, Vertical
|
||||
from ..containers import Horizontal, VerticalScroll
|
||||
from ..message import Message
|
||||
from ..reactive import reactive, var
|
||||
from ..widget import Widget
|
||||
|
|
@ -266,7 +266,7 @@ class MarkdownBulletList(MarkdownList):
|
|||
width: 1fr;
|
||||
}
|
||||
|
||||
MarkdownBulletList Vertical {
|
||||
MarkdownBulletList VerticalScroll {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
}
|
||||
|
|
@ -277,7 +277,7 @@ class MarkdownBulletList(MarkdownList):
|
|||
if isinstance(block, MarkdownListItem):
|
||||
bullet = MarkdownBullet()
|
||||
bullet.symbol = block.bullet
|
||||
yield Horizontal(bullet, Vertical(*block._blocks))
|
||||
yield Horizontal(bullet, VerticalScroll(*block._blocks))
|
||||
self._blocks.clear()
|
||||
|
||||
|
||||
|
|
@ -295,7 +295,7 @@ class MarkdownOrderedList(MarkdownList):
|
|||
width: 1fr;
|
||||
}
|
||||
|
||||
MarkdownOrderedList Vertical {
|
||||
MarkdownOrderedList VerticalScroll {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
}
|
||||
|
|
@ -311,7 +311,7 @@ class MarkdownOrderedList(MarkdownList):
|
|||
if isinstance(block, MarkdownListItem):
|
||||
bullet = MarkdownBullet()
|
||||
bullet.symbol = block.bullet.rjust(symbol_size + 1)
|
||||
yield Horizontal(bullet, Vertical(*block._blocks))
|
||||
yield Horizontal(bullet, VerticalScroll(*block._blocks))
|
||||
|
||||
self._blocks.clear()
|
||||
|
||||
|
|
@ -410,7 +410,7 @@ class MarkdownListItem(MarkdownBlock):
|
|||
height: auto;
|
||||
}
|
||||
|
||||
MarkdownListItem > Vertical {
|
||||
MarkdownListItem > VerticalScroll {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
|
@ -509,6 +509,7 @@ class Markdown(Widget):
|
|||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
parser_factory: Callable[[], MarkdownIt] | None = None,
|
||||
):
|
||||
"""A Markdown widget.
|
||||
|
||||
|
|
@ -517,9 +518,11 @@ class Markdown(Widget):
|
|||
name: The name of the widget.
|
||||
id: The ID of the widget in the DOM.
|
||||
classes: The CSS classes of the widget.
|
||||
parser_factory: A factory function to return a configured MarkdownIt instance. If `None`, a "gfm-like" parser is used.
|
||||
"""
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._markdown = markdown
|
||||
self._parser_factory = parser_factory
|
||||
|
||||
class TableOfContentsUpdated(Message, bubble=True):
|
||||
"""The table of contents was updated."""
|
||||
|
|
@ -574,7 +577,11 @@ class Markdown(Widget):
|
|||
"""
|
||||
output: list[MarkdownBlock] = []
|
||||
stack: list[MarkdownBlock] = []
|
||||
parser = MarkdownIt("gfm-like")
|
||||
parser = (
|
||||
MarkdownIt("gfm-like")
|
||||
if self._parser_factory is None
|
||||
else self._parser_factory()
|
||||
)
|
||||
|
||||
content = Text()
|
||||
block_id: int = 0
|
||||
|
|
@ -639,6 +646,8 @@ class Markdown(Widget):
|
|||
for child in token.children:
|
||||
if child.type == "text":
|
||||
content.append(child.content, style_stack[-1])
|
||||
if child.type == "hardbreak":
|
||||
content.append("\n")
|
||||
if child.type == "softbreak":
|
||||
content.append(" ")
|
||||
elif child.type == "code_inline":
|
||||
|
|
@ -761,7 +770,7 @@ class MarkdownTableOfContents(Widget, can_focus_children=True):
|
|||
)
|
||||
|
||||
|
||||
class MarkdownViewer(Vertical, can_focus=True, can_focus_children=True):
|
||||
class MarkdownViewer(VerticalScroll, can_focus=True, can_focus_children=True):
|
||||
"""A Markdown viewer widget."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
|
|
@ -797,6 +806,7 @@ class MarkdownViewer(Vertical, can_focus=True, can_focus_children=True):
|
|||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
parser_factory: Callable[[], MarkdownIt] | None = None,
|
||||
):
|
||||
"""Create a Markdown Viewer object.
|
||||
|
||||
|
|
@ -806,10 +816,12 @@ class MarkdownViewer(Vertical, can_focus=True, can_focus_children=True):
|
|||
name: The name of the widget.
|
||||
id: The ID of the widget in the DOM.
|
||||
classes: The CSS classes of the widget.
|
||||
parser_factory: A factory function to return a configured MarkdownIt instance. If `None`, a "gfm-like" parser is used.
|
||||
"""
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.show_table_of_contents = show_table_of_contents
|
||||
self._markdown = markdown
|
||||
self._parser_factory = parser_factory
|
||||
|
||||
@property
|
||||
def document(self) -> Markdown:
|
||||
|
|
@ -848,7 +860,7 @@ class MarkdownViewer(Vertical, can_focus=True, can_focus_children=True):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield MarkdownTableOfContents()
|
||||
yield Markdown()
|
||||
yield Markdown(parser_factory=self._parser_factory)
|
||||
|
||||
def on_markdown_table_of_contents_updated(
|
||||
self, message: Markdown.TableOfContentsUpdated
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
29
tests/snapshot_tests/snapshot_apps/alignment_containers.py
Normal file
29
tests/snapshot_tests/snapshot_apps/alignment_containers.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
App to test alignment containers.
|
||||
"""
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Center, Middle
|
||||
from textual.widgets import Button
|
||||
|
||||
|
||||
class AlignContainersApp(App[None]):
|
||||
CSS = """
|
||||
Center {
|
||||
tint: $primary 10%;
|
||||
}
|
||||
Middle {
|
||||
tint: $secondary 10%;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Center():
|
||||
yield Button.success("center")
|
||||
with Middle():
|
||||
yield Button.error("middle")
|
||||
|
||||
|
||||
app = AlignContainersApp()
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Header, Footer, Label, Input
|
||||
|
||||
|
||||
class InputWidthAutoApp(App[None]):
|
||||
|
||||
CSS = """
|
||||
Input.auto {
|
||||
width: auto;
|
||||
|
|
|
|||
|
|
@ -1,43 +1,40 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Grid
|
||||
from textual.widget import Widget
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Label
|
||||
|
||||
|
||||
class BorderAlphaApp(App[None]):
|
||||
|
||||
CSS = """
|
||||
Grid {
|
||||
height: 100%;
|
||||
.boxes {
|
||||
height: 3;
|
||||
width: 100%;
|
||||
grid-size: 2 2;
|
||||
}
|
||||
|
||||
#b00 { border: 0%; }
|
||||
#b01 { border: 33%; }
|
||||
#b02 { border: 66%; }
|
||||
#b03 { border: 100%; }
|
||||
|
||||
#b10 { border: solid 0%; }
|
||||
#b11 { border: dashed 33%; }
|
||||
#b12 { border: round 66%; }
|
||||
#b13 { border: ascii 100%; }
|
||||
|
||||
#b20 { border: 0% red; }
|
||||
#b21 { border: 33% orange; }
|
||||
#b22 { border: 66% green; }
|
||||
#b23 { border: 100% blue; }
|
||||
|
||||
#b30 { border: solid 0% red; }
|
||||
#b31 { border: dashed 33% orange; }
|
||||
#b32 { border: round 66% green; }
|
||||
#b33 { border: ascii 100% blue; }
|
||||
#box0 {
|
||||
border: heavy green 0%;
|
||||
}
|
||||
#box1 {
|
||||
border: heavy green 20%;
|
||||
}
|
||||
#box2 {
|
||||
border: heavy green 40%;
|
||||
}
|
||||
#box3 {
|
||||
border: heavy green 60%;
|
||||
}
|
||||
#box4 {
|
||||
border: heavy green 80%;
|
||||
}
|
||||
#box5 {
|
||||
border: heavy green 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose( self ) -> ComposeResult:
|
||||
with Grid():
|
||||
for outer in range(4):
|
||||
with Grid():
|
||||
for inner in range(4):
|
||||
yield Widget(id=f"b{outer}{inner}")
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
for box in range(6):
|
||||
yield Label(id=f"box{box}", classes="boxes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
BorderAlphaApp().run()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from textual.app import App, ComposeResult
|
|||
from textual.containers import Horizontal
|
||||
from textual.widgets import Button
|
||||
|
||||
|
||||
class WidgetDisableTestApp(App[None]):
|
||||
CSS = """
|
||||
Horizontal {
|
||||
|
|
@ -28,5 +29,6 @@ class WidgetDisableTestApp(App[None]):
|
|||
yield Button(variant="warning")
|
||||
yield Button(variant="error")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
WidgetDisableTestApp().run()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from rich.text import Text
|
||||
|
||||
from textual.app import App, ComposeResult, RenderResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Header, Footer
|
||||
from textual.widget import Widget
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ class Tester(Widget, can_focus=True):
|
|||
class StyleBugApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Vertical():
|
||||
with VerticalScroll():
|
||||
for n in range(40):
|
||||
yield Tester(n)
|
||||
yield Footer()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
|
|
@ -8,7 +8,6 @@ class StaticText(Static):
|
|||
|
||||
|
||||
class FRApp(App):
|
||||
|
||||
CSS = """
|
||||
StaticText {
|
||||
height: 1fr;
|
||||
|
|
@ -39,7 +38,7 @@ class FRApp(App):
|
|||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
StaticText("HEADER", id="header"),
|
||||
Horizontal(
|
||||
StaticText("foo", id="foo"),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Label
|
||||
from textual.containers import Vertical, Container
|
||||
from textual.containers import VerticalScroll, Container
|
||||
|
||||
|
||||
class Overlay(Container):
|
||||
|
|
@ -9,12 +9,12 @@ class Overlay(Container):
|
|||
yield Label("This should float over the top")
|
||||
|
||||
|
||||
class Body1(Vertical):
|
||||
class Body1(VerticalScroll):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("My God! It's full of stars! " * 300)
|
||||
|
||||
|
||||
class Body2(Vertical):
|
||||
class Body2(VerticalScroll):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("My God! It's full of stars! " * 300)
|
||||
|
||||
|
|
@ -36,7 +36,6 @@ class Bad(Screen):
|
|||
|
||||
|
||||
class Layers(App[None]):
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
layers: base higher;
|
||||
|
|
|
|||
56
tests/snapshot_tests/snapshot_apps/layout_containers.py
Normal file
56
tests/snapshot_tests/snapshot_apps/layout_containers.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""
|
||||
App to test layout containers.
|
||||
"""
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import (
|
||||
Grid,
|
||||
Horizontal,
|
||||
HorizontalScroll,
|
||||
Vertical,
|
||||
VerticalScroll,
|
||||
)
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Input, Label
|
||||
|
||||
|
||||
def sub_compose() -> Iterable[Widget]:
|
||||
yield Button.success("Accept")
|
||||
yield Button.error("Decline")
|
||||
yield Input()
|
||||
yield Label("\n\n".join([str(n * 1_000_000) for n in range(10)]))
|
||||
|
||||
|
||||
class MyApp(App[None]):
|
||||
CSS = """
|
||||
Grid {
|
||||
grid-size: 2 2;
|
||||
grid-rows: 1fr;
|
||||
grid-columns: 1fr;
|
||||
}
|
||||
Grid > Widget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
Input {
|
||||
width: 80;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Grid():
|
||||
with Horizontal():
|
||||
yield from sub_compose()
|
||||
with HorizontalScroll():
|
||||
yield from sub_compose()
|
||||
with Vertical():
|
||||
yield from sub_compose()
|
||||
with VerticalScroll():
|
||||
yield from sub_compose()
|
||||
|
||||
|
||||
app = MyApp()
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from rich.text import Text
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import TextLog
|
||||
|
||||
|
|
@ -27,26 +27,26 @@ class ScrollViewApp(App):
|
|||
height:10;
|
||||
}
|
||||
|
||||
Vertical{
|
||||
VerticalScroll {
|
||||
width:13;
|
||||
height: 10;
|
||||
overflow: scroll;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
MyWidget {
|
||||
width:13;
|
||||
height:auto;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield TextLog()
|
||||
yield Vertical(MyWidget())
|
||||
yield VerticalScroll(MyWidget())
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.query_one(TextLog).write("\n".join(f"{n} 0123456789" for n in range(20)))
|
||||
self.query_one(Vertical).scroll_end(animate=False)
|
||||
self.query_one(VerticalScroll).scroll_end(animate=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
|
|
@ -42,8 +42,8 @@ class NestedAutoApp(App[None]):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._static = Static("", id="my-static")
|
||||
yield Vertical(
|
||||
Vertical(
|
||||
yield VerticalScroll(
|
||||
VerticalScroll(
|
||||
self._static,
|
||||
id="my-static-wrapper",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Label, Static
|
||||
|
||||
|
||||
|
|
@ -18,7 +18,6 @@ class Box(Static):
|
|||
|
||||
|
||||
class OffsetsApp(App):
|
||||
|
||||
CSS = """
|
||||
|
||||
#box1 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from textual.app import App
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ class Visibility(App):
|
|||
Screen {
|
||||
layout: horizontal;
|
||||
}
|
||||
Vertical {
|
||||
VerticalScroll {
|
||||
width: 1fr;
|
||||
border: solid red;
|
||||
}
|
||||
|
|
@ -30,13 +30,12 @@ class Visibility(App):
|
|||
"""
|
||||
|
||||
def compose(self):
|
||||
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
Static("foo"),
|
||||
Static("float", classes="float"),
|
||||
id="container1",
|
||||
)
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
Static("bar"),
|
||||
Static("float", classes="float"),
|
||||
id="container2",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ def test_dock_layout_sidebar(snap_compare):
|
|||
assert snap_compare(LAYOUT_EXAMPLES_DIR / "dock_layout2_sidebar.py")
|
||||
|
||||
|
||||
def test_layout_containers(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "layout_containers.py")
|
||||
|
||||
|
||||
def test_alignment_containers(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "alignment_containers.py")
|
||||
|
||||
|
||||
# --- Widgets - rendering and basic interactions ---
|
||||
# Each widget should have a canonical example that is display in the docs.
|
||||
# When adding a new widget, ideally we should also create a snapshot test
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ def test_rgb():
|
|||
assert Color(10, 20, 30, 0.55).rgb == (10, 20, 30)
|
||||
|
||||
|
||||
def test_hls():
|
||||
def test_hsl():
|
||||
red = Color(200, 20, 32)
|
||||
print(red.hsl)
|
||||
assert red.hsl == pytest.approx(
|
||||
|
|
@ -51,6 +51,7 @@ def test_hls():
|
|||
assert Color.from_hsl(
|
||||
0.9888888888888889, 0.818181818181818, 0.43137254901960786
|
||||
).normalized == pytest.approx(red.normalized, rel=1e-5)
|
||||
assert red.hsl.css == "hsl(356,81.8%,43.1%)"
|
||||
|
||||
|
||||
def test_color_brightness():
|
||||
|
|
@ -65,6 +66,12 @@ def test_color_hex():
|
|||
assert Color(255, 0, 127, 0.5).hex == "#FF007F7F"
|
||||
|
||||
|
||||
def test_color_hex6():
|
||||
assert Color(0, 0, 0).hex6 == "#000000"
|
||||
assert Color(255, 255, 255, 0.25).hex6 == "#FFFFFF"
|
||||
assert Color(255, 0, 127, 0.5).hex6 == "#FF007F"
|
||||
|
||||
|
||||
def test_color_css():
|
||||
assert Color(255, 0, 127).css == "rgb(255,0,127)"
|
||||
assert Color(255, 0, 127, 0.5).css == "rgba(255,0,127,0.5)"
|
||||
|
|
@ -74,6 +81,11 @@ def test_color_with_alpha():
|
|||
assert Color(255, 50, 100).with_alpha(0.25) == Color(255, 50, 100, 0.25)
|
||||
|
||||
|
||||
def test_multiply_alpha():
|
||||
assert Color(100, 100, 100).multiply_alpha(0.5) == Color(100, 100, 100, 0.5)
|
||||
assert Color(100, 100, 100, 0.5).multiply_alpha(0.5) == Color(100, 100, 100, 0.25)
|
||||
|
||||
|
||||
def test_color_blend():
|
||||
assert Color(0, 0, 0).blend(Color(255, 255, 255), 0) == Color(0, 0, 0)
|
||||
assert Color(0, 0, 0).blend(Color(255, 255, 255), 1.0) == Color(255, 255, 255)
|
||||
|
|
@ -149,6 +161,7 @@ def test_color_parse_color():
|
|||
|
||||
def test_color_add():
|
||||
assert Color(50, 100, 200) + Color(10, 20, 30, 0.9) == Color(14, 28, 47)
|
||||
assert Color(50, 100, 200).__add__("foo") == NotImplemented
|
||||
|
||||
|
||||
# Computed with http://www.easyrgb.com/en/convert.php,
|
||||
|
|
@ -220,6 +233,10 @@ def test_inverse():
|
|||
def test_gradient_errors():
|
||||
with pytest.raises(ValueError):
|
||||
Gradient()
|
||||
with pytest.raises(ValueError):
|
||||
Gradient((0.1, Color.parse("red")))
|
||||
with pytest.raises(ValueError):
|
||||
Gradient((0.1, Color.parse("red")), (1, Color.parse("blue")))
|
||||
with pytest.raises(ValueError):
|
||||
Gradient((0, Color.parse("red")))
|
||||
|
||||
|
|
@ -243,3 +260,7 @@ def test_gradient():
|
|||
assert gradient.get_color(1.2) == Color(0, 255, 0)
|
||||
assert gradient.get_color(0.5) == Color(0, 0, 255)
|
||||
assert gradient.get_color(0.7) == Color(0, 101, 153)
|
||||
|
||||
gradient._stops.pop()
|
||||
with pytest.raises(AssertionError):
|
||||
gradient.get_color(1.0)
|
||||
|
|
|
|||
100
tests/test_containers.py
Normal file
100
tests/test_containers.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Test basic functioning of some containers."""
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import (
|
||||
Center,
|
||||
Horizontal,
|
||||
HorizontalScroll,
|
||||
Middle,
|
||||
Vertical,
|
||||
VerticalScroll,
|
||||
)
|
||||
from textual.widgets import Label
|
||||
|
||||
|
||||
async def test_horizontal_vs_horizontalscroll_scrolling():
|
||||
"""Check the default scrollbar behaviours for `Horizontal` and `HorizontalScroll`."""
|
||||
|
||||
class HorizontalsApp(App[None]):
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: vertical;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
for _ in range(10):
|
||||
yield Label("How is life going? " * 3 + " | ")
|
||||
with HorizontalScroll():
|
||||
for _ in range(10):
|
||||
yield Label("How is life going? " * 3 + " | ")
|
||||
|
||||
WIDTH = 80
|
||||
HEIGHT = 24
|
||||
app = HorizontalsApp()
|
||||
async with app.run_test(size=(WIDTH, HEIGHT)):
|
||||
horizontal = app.query_one(Horizontal)
|
||||
horizontal_scroll = app.query_one(HorizontalScroll)
|
||||
assert horizontal.size.height == horizontal_scroll.size.height
|
||||
assert horizontal.scrollbars_enabled == (False, False)
|
||||
assert horizontal_scroll.scrollbars_enabled == (False, True)
|
||||
|
||||
|
||||
async def test_vertical_vs_verticalscroll_scrolling():
|
||||
"""Check the default scrollbar behaviours for `Vertical` and `VerticalScroll`."""
|
||||
|
||||
class VerticalsApp(App[None]):
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: horizontal;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
for _ in range(10):
|
||||
yield Label("How is life going?\n" * 3 + "\n\n")
|
||||
with VerticalScroll():
|
||||
for _ in range(10):
|
||||
yield Label("How is life going?\n" * 3 + "\n\n")
|
||||
|
||||
WIDTH = 80
|
||||
HEIGHT = 24
|
||||
app = VerticalsApp()
|
||||
async with app.run_test(size=(WIDTH, HEIGHT)):
|
||||
vertical = app.query_one(Vertical)
|
||||
vertical_scroll = app.query_one(VerticalScroll)
|
||||
assert vertical.size.width == vertical_scroll.size.width
|
||||
assert vertical.scrollbars_enabled == (False, False)
|
||||
assert vertical_scroll.scrollbars_enabled == (True, False)
|
||||
|
||||
|
||||
async def test_center_container():
|
||||
"""Check the size of the container `Center`."""
|
||||
|
||||
class CenterApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with Center():
|
||||
yield Label("<>\n<>\n<>")
|
||||
|
||||
app = CenterApp()
|
||||
async with app.run_test():
|
||||
center = app.query_one(Center)
|
||||
assert center.size.width == app.size.width
|
||||
assert center.size.height == 3
|
||||
|
||||
|
||||
async def test_middle_container():
|
||||
"""Check the size of the container `Middle`."""
|
||||
|
||||
class MiddleApp(App[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
with Middle():
|
||||
yield Label("1234")
|
||||
|
||||
app = MiddleApp()
|
||||
async with app.run_test():
|
||||
middle = app.query_one(Middle)
|
||||
assert middle.size.width == 4
|
||||
assert middle.size.height == app.size.height
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Test Widget.disabled."""
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
DataTable,
|
||||
|
|
@ -21,7 +21,7 @@ class DisableApp(App[None]):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the child widgets."""
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
Button(),
|
||||
DataTable(),
|
||||
DirectoryTree("."),
|
||||
|
|
@ -56,7 +56,7 @@ async def test_enabled_widgets_have_enabled_pseudo_class() -> None:
|
|||
async def test_all_individually_disabled() -> None:
|
||||
"""Post-disable all widgets should report being disabled."""
|
||||
async with DisableApp().run_test() as pilot:
|
||||
for node in pilot.app.screen.query("Vertical > *"):
|
||||
for node in pilot.app.screen.query("VerticalScroll > *"):
|
||||
node.disabled = True
|
||||
assert all(
|
||||
node.disabled for node in pilot.app.screen.query("#test-container > *")
|
||||
|
|
@ -77,7 +77,7 @@ async def test_disabled_widgets_have_disabled_pseudo_class() -> None:
|
|||
async def test_disable_via_container() -> None:
|
||||
"""All child widgets should appear (to CSS) as disabled by a container being disabled."""
|
||||
async with DisableApp().run_test() as pilot:
|
||||
pilot.app.screen.query_one("#test-container", Vertical).disabled = True
|
||||
pilot.app.screen.query_one("#test-container", VerticalScroll).disabled = True
|
||||
assert all(
|
||||
node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled")
|
||||
for node in pilot.app.screen.query("#test-container > *")
|
||||
|
|
|
|||
|
|
@ -151,11 +151,11 @@ def test_focus_next_and_previous_with_type_selector_without_self():
|
|||
|
||||
screen = app.screen
|
||||
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.widgets import Button, Input, Switch
|
||||
|
||||
screen._add_children(
|
||||
Vertical(
|
||||
VerticalScroll(
|
||||
Horizontal(
|
||||
Input(id="w3"),
|
||||
Switch(id="w4"),
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
"""Regression test for #1616 https://github.com/Textualize/textual/issues/1616"""
|
||||
import pytest
|
||||
|
||||
from textual.app import App
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
|
||||
|
||||
async def test_overflow_change_updates_virtual_size_appropriately():
|
||||
class MyApp(App):
|
||||
def compose(self):
|
||||
yield Vertical()
|
||||
yield VerticalScroll()
|
||||
|
||||
app = MyApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
vertical = app.query_one(Vertical)
|
||||
vertical = app.query_one(VerticalScroll)
|
||||
|
||||
height = vertical.virtual_size.height
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""See https://github.com/Textualize/textual/issues/1355 as the motivation for these tests."""
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ class VisibleTester(App[None]):
|
|||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Vertical(
|
||||
yield VerticalScroll(
|
||||
Widget(id="keep"), Widget(id="hide-via-code"), Widget(id="hide-via-css")
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -181,3 +181,20 @@ async def test_remove():
|
|||
await pilot.press("r")
|
||||
await pilot.pause()
|
||||
assert app.return_value == 123
|
||||
|
||||
|
||||
# Regression test for https://github.com/Textualize/textual/issues/2079
|
||||
async def test_remove_unmounted():
|
||||
mounted = False
|
||||
|
||||
class RemoveApp(App):
|
||||
def on_mount(self):
|
||||
nonlocal mounted
|
||||
label = Label()
|
||||
label.remove()
|
||||
mounted = True
|
||||
|
||||
app = RemoveApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert mounted
|
||||
|
|
|
|||
Loading…
Reference in a new issue