Compare commits

...

40 commits

Author SHA1 Message Date
c7d73cd466
fix AttributeError 2023-03-16 14:14:35 -05:00
ca04abf091
Implement requested changes 2023-03-16 14:05:36 -05:00
a24c99a566
blacken 2023-03-16 14:03:10 -05:00
4c7e54f6d6
Allow customizing the markdown parser
For instance, code using Markdown might wish to create a markdown
parser that does not parse embedded HTML:
```py
def parser_factory():
    parser = MarkdownIt("gfm-like")
    parser.options["html"] = False
    return parser
```
2023-03-16 09:06:25 -05:00
Will McGugan
45520f2da1
color tests to 100% (#2078)
* color tests to 100%

* tweak

* remove overly complex code
2023-03-16 11:42:51 +00:00
Will McGugan
de692aaf07
fix for remove nodes (#2080)
* fix for remove nodes

* added test
2023-03-16 11:36:10 +00:00
Will McGugan
a404ee5e01
add app to active message pump (#2071)
* add app to active message pump

* made sender private

* changelog
2023-03-16 11:26:08 +00:00
Will McGugan
43253f5d80
fix border issue (#2074)
* fix border issue

* add PR to changelog
2023-03-16 09:03:02 +00:00
Will McGugan
9b191914cb copy change 2023-03-15 20:28:02 +00:00
TomJGooding
111233f1b4
fix(markdown): handle hard line breaks (#2060) 2023-03-15 20:24:15 +00:00
Will McGugan
04340bd0ba
new post (#2069)
* new post

* Update docs/blog/posts/await-me-maybe.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update docs/blog/posts/await-me-maybe.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update docs/blog/posts/await-me-maybe.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/blog/posts/await-me-maybe.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* words

---------

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
Co-authored-by: Dave Pearson <davep@davep.org>
2023-03-15 16:49:12 +00:00
Will McGugan
8d17ad39fd copy edit 2023-03-15 16:39:05 +00:00
Will McGugan
c889b4bfe9 words 2023-03-15 16:35:18 +00:00
Will McGugan
d64e9a7e67
Update docs/blog/posts/await-me-maybe.md
Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
2023-03-15 16:15:47 +00:00
Will McGugan
66535e9c07
Update docs/blog/posts/await-me-maybe.md
Co-authored-by: Dave Pearson <davep@davep.org>
2023-03-15 16:01:11 +00:00
Will McGugan
01045e6b7f
Update docs/blog/posts/await-me-maybe.md
Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
2023-03-15 16:00:55 +00:00
Will McGugan
dab39c719b
Update docs/blog/posts/await-me-maybe.md
Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
2023-03-15 15:51:30 +00:00
Will McGugan
31be9e059e new post 2023-03-15 15:34:39 +00:00
Rodrigo Girão Serrão
1246934643
Merge pull request #2030 from Textualize/add-containers
Add containers
2023-03-15 10:37:01 +00:00
Rodrigo Girão Serrão
10a5d171eb
Get rid of _Clock and move utility time-related functions to _time.py. (#2042)
* Remove _clock.py::sleep.

* Move _clock.py::get_time_no_wait to _time.py.

* Move _clock.py::get_time to _time.py.

* Remove async version of _time.py::get_time.

We started by removing '_time.py::get_time' because that was the async one and then I renamed 'get_time_no_wait' to 'get_time'.

* Make 'get_time' just an alias.
2023-03-15 08:56:10 +00:00
Rodrigo Girão Serrão
285de4b0fa Merge branch 'main' into add-containers 2023-03-14 14:35:23 +00:00
Rodrigo Girão Serrão
d775a90fa2 Address review comments. 2023-03-14 14:33:10 +00:00
Rodrigo Girão Serrão
f9a1e27c6f Merge branch 'main' into add-containers
[skip ci]
2023-03-13 16:21:46 +00:00
Rodrigo Girão Serrão
3518d38d85 Update snapshot tests. 2023-03-13 12:02:36 +00:00
Rodrigo Girão Serrão
cc8f65259e Merge branch 'main' into add-containers 2023-03-13 12:00:08 +00:00
Rodrigo Girão Serrão
429c8a3649 Tweak default Center/Middle dimensions.
We set dimensions to 1fr instead of 100% (original request) or blank (previous change) for consistency with the other containers.
2023-03-13 11:48:49 +00:00
Rodrigo Girão Serrão
0e51520a2c Make docs comply with new containers. 2023-03-13 11:44:47 +00:00
Rodrigo Girão Serrão
48ce1a149d Add snapshot test for Center/Middle. 2023-03-13 11:18:33 +00:00
Rodrigo Girão Serrão
bdeea9fb86 Tweak Center/Middle default dimensions. 2023-03-13 11:10:50 +00:00
Rodrigo Girão Serrão
c13308a360 Add tests for Horizontal/Vertical(Scroll) containers. 2023-03-09 17:39:05 +00:00
Rodrigo Girão Serrão
5674b4b628 Add 'Vertical'.
Related issues: #1957.
2023-03-09 15:38:31 +00:00
Rodrigo Girão Serrão
38c7cc1849 Fix default CSS for 'VerticalScroll'. 2023-03-09 15:36:38 +00:00
Rodrigo Girão Serrão
639d8f0250 Change default 'overflow' style for 'Horizontal'.
Related issues: #1957.
2023-03-09 15:30:44 +00:00
Rodrigo Girão Serrão
58ad5dfdd9 Tweak docstrings. 2023-03-09 15:26:51 +00:00
Rodrigo Girão Serrão
e111449856 Add container 'Middle'.
Related issues: #1957.
2023-03-09 15:24:13 +00:00
Rodrigo Girão Serrão
f91750ed3d Add container 'Center'.
This container will centre children horizontally.

Related issues: #1957.
2023-03-09 15:21:20 +00:00
Rodrigo Girão Serrão
be41797a8d Merge branch 'main' into add-containers 2023-03-09 15:19:43 +00:00
Rodrigo Girão Serrão
90dce06eae Add 'HorizontalScroll'.
Related issues: #1957.
2023-03-09 14:35:49 +00:00
Rodrigo Girão Serrão
4ca62eee60 Update docstring. 2023-03-09 12:21:53 +00:00
Rodrigo Girão Serrão
8565d3cef6 Renamed 'Vertical' to 'VerticalScroll'.
Related issues: #1957.
2023-03-08 18:31:24 +00:00
64 changed files with 1163 additions and 435 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ Screen {
color: black;
}
Vertical {
VerticalScroll {
width: 1fr;
}

View file

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

View file

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

View file

@ -2,7 +2,7 @@ Button {
margin: 1 2;
}
Horizontal > Vertical {
Horizontal > VerticalScroll {
width: 24;
}

View file

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

View file

@ -2,7 +2,7 @@ Screen {
align: center middle;
}
Vertical {
VerticalScroll {
width: auto;
height: auto;
border: solid $primary;

View file

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

View file

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

View file

@ -1,4 +1,4 @@
Vertical {
VerticalScroll {
align: center middle;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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}")
with Vertical():
for box in range(6):
yield Label(id=f"box{box}", classes="boxes")
if __name__ == "__main__":
BorderAlphaApp().run()

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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