mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' of github.com:Textualize/textual into text-input-cursor-to-click
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
from textual.app import App
|
||||
|
||||
|
||||
class Colorizer(App):
|
||||
async def on_load(self):
|
||||
await self.bind("r", "color('red')")
|
||||
await self.bind("g", "color('green')")
|
||||
await self.bind("b", "color('blue')")
|
||||
|
||||
def action_color(self, color: str) -> None:
|
||||
self.background = f"on {color}"
|
||||
|
||||
|
||||
Colorizer.run()
|
||||
@@ -1,9 +0,0 @@
|
||||
from textual.app import App
|
||||
|
||||
|
||||
class Quiter(App):
|
||||
async def on_load(self):
|
||||
await self.bind("q", "quit")
|
||||
|
||||
|
||||
Quiter.run()
|
||||
@@ -1,9 +0,0 @@
|
||||
from textual.app import App
|
||||
|
||||
|
||||
class Beeper(App):
|
||||
def on_key(self):
|
||||
self.console.bell()
|
||||
|
||||
|
||||
Beeper.run()
|
||||
@@ -1,10 +0,0 @@
|
||||
from textual.app import App
|
||||
from textual import events
|
||||
|
||||
|
||||
class Beeper(App):
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
self.console.bell()
|
||||
|
||||
|
||||
Beeper.run()
|
||||
@@ -1,10 +0,0 @@
|
||||
from textual.app import App
|
||||
|
||||
|
||||
class ColorChanger(App):
|
||||
def on_key(self, event):
|
||||
if event.key.isdigit():
|
||||
self.background = f"on color({event.key})"
|
||||
|
||||
|
||||
ColorChanger.run(log_path="textual.log")
|
||||
14
docs/examples/simple.css
Normal file
14
docs/examples/simple.css
Normal file
@@ -0,0 +1,14 @@
|
||||
Screen {
|
||||
background: darkblue;
|
||||
color: white;
|
||||
layout: vertical;
|
||||
align: center middle;
|
||||
}
|
||||
Static {
|
||||
height: auto;
|
||||
padding: 2;
|
||||
margin: 2;
|
||||
border: white;
|
||||
background: #ffffff 30%;
|
||||
content-align: center middle;
|
||||
}
|
||||
11
docs/examples/simple.py
Normal file
11
docs/examples/simple.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class TextApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("Hello")
|
||||
yield Static("[b]World![/b]")
|
||||
|
||||
|
||||
app = TextApp(css_path="simple.css")
|
||||
@@ -1,24 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from rich.align import Align
|
||||
from rich.style import Style
|
||||
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class Clock(Widget):
|
||||
def on_mount(self):
|
||||
self.set_interval(1, self.refresh)
|
||||
|
||||
def render(self, style: Style):
|
||||
time = datetime.now().strftime("%c")
|
||||
return Align.center(time, vertical="middle")
|
||||
|
||||
|
||||
class ClockApp(App):
|
||||
async def on_mount(self):
|
||||
await self.screen.dock(Clock())
|
||||
|
||||
|
||||
ClockApp.run()
|
||||
@@ -1,33 +0,0 @@
|
||||
from rich.panel import Panel
|
||||
from rich.style import Style
|
||||
|
||||
from textual.app import App
|
||||
from textual.reactive import Reactive
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class Hover(Widget):
|
||||
|
||||
mouse_over = Reactive(False)
|
||||
|
||||
def render(self, style: Style) -> Panel:
|
||||
return Panel("Hello [b]World[/b]", style=("on red" if self.mouse_over else ""))
|
||||
|
||||
def on_enter(self) -> None:
|
||||
self.mouse_over = True
|
||||
|
||||
def on_leave(self) -> None:
|
||||
self.mouse_over = False
|
||||
|
||||
|
||||
class HoverApp(App):
|
||||
"""Demonstrates smooth animation"""
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Build layout here."""
|
||||
|
||||
hovers = (Hover() for _ in range(10))
|
||||
await self.screen.dock(*hovers, edge="top")
|
||||
|
||||
|
||||
HoverApp.run(log_path="textual.log")
|
||||
@@ -1,15 +0,0 @@
|
||||
from textual.app import App
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class SimpleApp(App):
|
||||
"""Demonstrates smooth animation"""
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Build layout here."""
|
||||
|
||||
await self.screen.dock(Placeholder(), edge="left", size=40)
|
||||
await self.screen.dock(Placeholder(), Placeholder(), edge="top")
|
||||
|
||||
|
||||
SimpleApp.run(log_path="textual.log")
|
||||
@@ -4,8 +4,27 @@ Textual is framework for rapidly creating _text user interfaces_ (TUIs from here
|
||||
|
||||
A TUI is an application that lives within a terminal, which can have mouse and keyboard support and user interface elements like windows and panels, but is rendered purely with text. They have a number of advantages over GUI applications: they can be launched from the command line, and return to the command line, and they work over ssh.
|
||||
|
||||
## Foo
|
||||
|
||||
Creating a TUI can be challenging. It may be easier to create a GUI or web application than it is to build a TUI with traditional techniques. Often projects that could use one or the other never manage to ship either.
|
||||
|
||||
Textual seeks to lower the difficulty level of building a TUI by borrowing developments from the web world and to a lesser extent desktop applications. The goal is for it to be as easy to develop a TUI for your project as it would be to add a command line interface.
|
||||
Textual seeks to lower the difficulty level of building a TUI by borrowing developments from the web world and to a lesser extent desktop applications. The goal is for it to be as easy to develop a TUI for your project as it would be to add a command line interface.XX
|
||||
|
||||
=== "simple.py"
|
||||
|
||||
```python
|
||||
--8<-- "docs/examples/simple.py"
|
||||
```
|
||||
|
||||
=== "simple.css"
|
||||
|
||||
```scss
|
||||
--8<-- "docs/examples/simple.css"
|
||||
```
|
||||
|
||||
=== "Result"
|
||||
|
||||
```{.textual path="docs/examples/simple.py" columns="80" lines="24"}
|
||||
```
|
||||
|
||||
Textual also offers a number of enhancements over traditional TUI applications by taking advantage of improvements to terminal software and the hardware it runs on. Terminals are a far cry from their roots in ancient hardware and dial-up modems, yet much of the software that runs on them hasn't kept pace.
|
||||
|
||||
20
mkdocs.yml
20
mkdocs.yml
@@ -1,12 +1,26 @@
|
||||
site_name: Textual
|
||||
site_url: https://example.com/
|
||||
site_url: https://www.textualize.io/
|
||||
|
||||
markdown_extensions:
|
||||
- pymdownx.highlight
|
||||
- pymdownx.superfences
|
||||
- admonition
|
||||
- meta
|
||||
- toc:
|
||||
permalink: true
|
||||
- pymdownx.keys
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: textual
|
||||
class: textual
|
||||
format: !!python/name:textual._doc.format_svg
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.superfences
|
||||
- pymdownx.snippets
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
- pymdownx.snippets
|
||||
- markdown.extensions.attr_list
|
||||
|
||||
theme:
|
||||
name: material
|
||||
|
||||
162
poetry.lock
generated
162
poetry.lock
generated
@@ -166,7 +166,7 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "6.3.3"
|
||||
version = "6.4"
|
||||
description = "Code coverage measurement for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -219,7 +219,7 @@ dev = ["twine", "markdown", "flake8", "wheel"]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
description = "File identification library for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -238,7 +238,7 @@ python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "4.11.3"
|
||||
version = "4.11.4"
|
||||
description = "Read metadata from Python packages"
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -263,11 +263,11 @@ python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.2"
|
||||
version = "3.0.3"
|
||||
description = "A very fast and expressive template engine."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
MarkupSafe = ">=2.0"
|
||||
@@ -650,7 +650,7 @@ pyyaml = "*"
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "12.4.1"
|
||||
version = "12.4.4"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -701,7 +701,7 @@ python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "typed-ast"
|
||||
version = "1.5.3"
|
||||
version = "1.5.4"
|
||||
description = "a fork of Python 2 and 3 ast modules with type comment support"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -773,7 +773,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "3579be8d55deb729ef79984823765900552e19284c205b7bedc92897190fb6fd"
|
||||
content-hash = "eba121f02e102fd9c551a654bcfab3028ec4fc05fe9b4cf7d5f64002e3586ba0"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = [
|
||||
@@ -924,47 +924,47 @@ commonmark = [
|
||||
{file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
|
||||
]
|
||||
coverage = [
|
||||
{file = "coverage-6.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df32ee0f4935a101e4b9a5f07b617d884a531ed5666671ff6ac66d2e8e8246d8"},
|
||||
{file = "coverage-6.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:75b5dbffc334e0beb4f6c503fb95e6d422770fd2d1b40a64898ea26d6c02742d"},
|
||||
{file = "coverage-6.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:114944e6061b68a801c5da5427b9173a0dd9d32cd5fcc18a13de90352843737d"},
|
||||
{file = "coverage-6.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ab88a01cd180b5640ccc9c47232e31924d5f9967ab7edd7e5c91c68eee47a69"},
|
||||
{file = "coverage-6.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad8f9068f5972a46d50fe5f32c09d6ee11da69c560fcb1b4c3baea246ca4109b"},
|
||||
{file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4cd696aa712e6cd16898d63cf66139dc70d998f8121ab558f0e1936396dbc579"},
|
||||
{file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c1a9942e282cc9d3ed522cd3e3cab081149b27ea3bda72d6f61f84eaf88c1a63"},
|
||||
{file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c06455121a089252b5943ea682187a4e0a5cf0a3fb980eb8e7ce394b144430a9"},
|
||||
{file = "coverage-6.3.3-cp310-cp310-win32.whl", hash = "sha256:cb5311d6ccbd22578c80028c5e292a7ab9adb91bd62c1982087fad75abe2e63d"},
|
||||
{file = "coverage-6.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:6d4a6f30f611e657495cc81a07ff7aa8cd949144e7667c5d3e680d73ba7a70e4"},
|
||||
{file = "coverage-6.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:79bf405432428e989cad7b8bc60581963238f7645ae8a404f5dce90236cc0293"},
|
||||
{file = "coverage-6.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:338c417613f15596af9eb7a39353b60abec9d8ce1080aedba5ecee6a5d85f8d3"},
|
||||
{file = "coverage-6.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db094a6a4ae6329ed322a8973f83630b12715654c197dd392410400a5bfa1a73"},
|
||||
{file = "coverage-6.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1414e8b124611bf4df8d77215bd32cba6e3425da8ce9c1f1046149615e3a9a31"},
|
||||
{file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:93b16b08f94c92cab88073ffd185070cdcb29f1b98df8b28e6649145b7f2c90d"},
|
||||
{file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fbc86ae8cc129c801e7baaafe3addf3c8d49c9c1597c44bdf2d78139707c3c62"},
|
||||
{file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b5ba058610e8289a07db2a57bce45a1793ec0d3d11db28c047aae2aa1a832572"},
|
||||
{file = "coverage-6.3.3-cp37-cp37m-win32.whl", hash = "sha256:8329635c0781927a2c6ae068461e19674c564e05b86736ab8eb29c420ee7dc20"},
|
||||
{file = "coverage-6.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:e5af1feee71099ae2e3b086ec04f57f9950e1be9ecf6c420696fea7977b84738"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e814a4a5a1d95223b08cdb0f4f57029e8eab22ffdbae2f97107aeef28554517e"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f4fbf3633cb0713437291b8848634ea97f89c7e849c2be17a665611e433f53"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3401b0d2ed9f726fadbfa35102e00d1b3547b73772a1de5508ef3bdbcb36afe7"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8586b177b4407f988731eb7f41967415b2197f35e2a6ee1a9b9b561f6323c8e9"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:892e7fe32191960da559a14536768a62e83e87bbb867e1b9c643e7e0fbce2579"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:afb03f981fadb5aed1ac6e3dd34f0488e1a0875623d557b6fad09b97a942b38a"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cbe91bc84be4e5ef0b1480d15c7b18e29c73bdfa33e07d3725da7d18e1b0aff2"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:91502bf27cbd5c83c95cfea291ef387469f2387508645602e1ca0fd8a4ba7548"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-win32.whl", hash = "sha256:c488db059848702aff30aa1d90ef87928d4e72e4f00717343800546fdbff0a94"},
|
||||
{file = "coverage-6.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6534fcdfb5c503affb6b1130db7b5bfc8a0f77fa34880146f7a5c117987d0"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc692c9ee18f0dd3214843779ba6b275ee4bb9b9a5745ba64265bce911aefd1a"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:462105283de203df8de58a68c1bb4ba2a8a164097c2379f664fa81d6baf94b81"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc972d829ad5ef4d4c5fcabd2bbe2add84ce8236f64ba1c0c72185da3a273130"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06f54765cdbce99901871d50fe9f41d58213f18e98b170a30ca34f47de7dd5e8"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7835f76a081787f0ca62a53504361b3869840a1620049b56d803a8cb3a9eeea3"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6f5fee77ec3384b934797f1873758f796dfb4f167e1296dc00f8b2e023ce6ee9"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:baa8be8aba3dd1e976e68677be68a960a633a6d44c325757aefaa4d66175050f"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d06380e777dd6b35ee936f333d55b53dc4a8271036ff884c909cf6e94be8b6c"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-win32.whl", hash = "sha256:f8cabc5fd0091976ab7b020f5708335033e422de25e20ddf9416bdce2b7e07d8"},
|
||||
{file = "coverage-6.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c9441d57b0963cf8340268ad62fc83de61f1613034b79c2b1053046af0c5284"},
|
||||
{file = "coverage-6.3.3-pp36.pp37.pp38-none-any.whl", hash = "sha256:d522f1dc49127eab0bfbba4e90fa068ecff0899bbf61bf4065c790ddd6c177fe"},
|
||||
{file = "coverage-6.3.3.tar.gz", hash = "sha256:2781c43bffbbec2b8867376d4d61916f5e9c4cc168232528562a61d1b4b01879"},
|
||||
{file = "coverage-6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50ed480b798febce113709846b11f5d5ed1e529c88d8ae92f707806c50297abf"},
|
||||
{file = "coverage-6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:26f8f92699756cb7af2b30720de0c5bb8d028e923a95b6d0c891088025a1ac8f"},
|
||||
{file = "coverage-6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60c2147921da7f4d2d04f570e1838db32b95c5509d248f3fe6417e91437eaf41"},
|
||||
{file = "coverage-6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:750e13834b597eeb8ae6e72aa58d1d831b96beec5ad1d04479ae3772373a8088"},
|
||||
{file = "coverage-6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af5b9ee0fc146e907aa0f5fb858c3b3da9199d78b7bb2c9973d95550bd40f701"},
|
||||
{file = "coverage-6.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a022394996419142b33a0cf7274cb444c01d2bb123727c4bb0b9acabcb515dea"},
|
||||
{file = "coverage-6.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5a78cf2c43b13aa6b56003707c5203f28585944c277c1f3f109c7b041b16bd39"},
|
||||
{file = "coverage-6.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9229d074e097f21dfe0643d9d0140ee7433814b3f0fc3706b4abffd1e3038632"},
|
||||
{file = "coverage-6.4-cp310-cp310-win32.whl", hash = "sha256:fb45fe08e1abc64eb836d187b20a59172053999823f7f6ef4f18a819c44ba16f"},
|
||||
{file = "coverage-6.4-cp310-cp310-win_amd64.whl", hash = "sha256:3cfd07c5889ddb96a401449109a8b97a165be9d67077df6802f59708bfb07720"},
|
||||
{file = "coverage-6.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:03014a74023abaf5a591eeeaf1ac66a73d54eba178ff4cb1fa0c0a44aae70383"},
|
||||
{file = "coverage-6.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c82f2cd69c71698152e943f4a5a6b83a3ab1db73b88f6e769fabc86074c3b08"},
|
||||
{file = "coverage-6.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b546cf2b1974ddc2cb222a109b37c6ed1778b9be7e6b0c0bc0cf0438d9e45a6"},
|
||||
{file = "coverage-6.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc173f1ce9ffb16b299f51c9ce53f66a62f4d975abe5640e976904066f3c835d"},
|
||||
{file = "coverage-6.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c53ad261dfc8695062fc8811ac7c162bd6096a05a19f26097f411bdf5747aee7"},
|
||||
{file = "coverage-6.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:eef5292b60b6de753d6e7f2d128d5841c7915fb1e3321c3a1fe6acfe76c38052"},
|
||||
{file = "coverage-6.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:543e172ce4c0de533fa892034cce260467b213c0ea8e39da2f65f9a477425211"},
|
||||
{file = "coverage-6.4-cp37-cp37m-win32.whl", hash = "sha256:00c8544510f3c98476bbd58201ac2b150ffbcce46a8c3e4fb89ebf01998f806a"},
|
||||
{file = "coverage-6.4-cp37-cp37m-win_amd64.whl", hash = "sha256:b84ab65444dcc68d761e95d4d70f3cfd347ceca5a029f2ffec37d4f124f61311"},
|
||||
{file = "coverage-6.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d548edacbf16a8276af13063a2b0669d58bbcfca7c55a255f84aac2870786a61"},
|
||||
{file = "coverage-6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:033ebec282793bd9eb988d0271c211e58442c31077976c19c442e24d827d356f"},
|
||||
{file = "coverage-6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742fb8b43835078dd7496c3c25a1ec8d15351df49fb0037bffb4754291ef30ce"},
|
||||
{file = "coverage-6.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55fae115ef9f67934e9f1103c9ba826b4c690e4c5bcf94482b8b2398311bf9c"},
|
||||
{file = "coverage-6.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd698341626f3c77784858427bad0cdd54a713115b423d22ac83a28303d1d95"},
|
||||
{file = "coverage-6.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:62d382f7d77eeeaff14b30516b17bcbe80f645f5cf02bb755baac376591c653c"},
|
||||
{file = "coverage-6.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:016d7f5cf1c8c84f533a3c1f8f36126fbe00b2ec0ccca47cc5731c3723d327c6"},
|
||||
{file = "coverage-6.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:69432946f154c6add0e9ede03cc43b96e2ef2733110a77444823c053b1ff5166"},
|
||||
{file = "coverage-6.4-cp38-cp38-win32.whl", hash = "sha256:83bd142cdec5e4a5c4ca1d4ff6fa807d28460f9db919f9f6a31babaaa8b88426"},
|
||||
{file = "coverage-6.4-cp38-cp38-win_amd64.whl", hash = "sha256:4002f9e8c1f286e986fe96ec58742b93484195defc01d5cc7809b8f7acb5ece3"},
|
||||
{file = "coverage-6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e4f52c272fdc82e7c65ff3f17a7179bc5f710ebc8ce8a5cadac81215e8326740"},
|
||||
{file = "coverage-6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b5578efe4038be02d76c344007b13119b2b20acd009a88dde8adec2de4f630b5"},
|
||||
{file = "coverage-6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8099ea680201c2221f8468c372198ceba9338a5fec0e940111962b03b3f716a"},
|
||||
{file = "coverage-6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a00441f5ea4504f5abbc047589d09e0dc33eb447dc45a1a527c8b74bfdd32c65"},
|
||||
{file = "coverage-6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e76bd16f0e31bc2b07e0fb1379551fcd40daf8cdf7e24f31a29e442878a827c"},
|
||||
{file = "coverage-6.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8d2e80dd3438e93b19e1223a9850fa65425e77f2607a364b6fd134fcd52dc9df"},
|
||||
{file = "coverage-6.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:341e9c2008c481c5c72d0e0dbf64980a4b2238631a7f9780b0fe2e95755fb018"},
|
||||
{file = "coverage-6.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:21e6686a95025927775ac501e74f5940cdf6fe052292f3a3f7349b0abae6d00f"},
|
||||
{file = "coverage-6.4-cp39-cp39-win32.whl", hash = "sha256:968ed5407f9460bd5a591cefd1388cc00a8f5099de9e76234655ae48cfdbe2c3"},
|
||||
{file = "coverage-6.4-cp39-cp39-win_amd64.whl", hash = "sha256:e35217031e4b534b09f9b9a5841b9344a30a6357627761d4218818b865d45055"},
|
||||
{file = "coverage-6.4-pp36.pp37.pp38-none-any.whl", hash = "sha256:e637ae0b7b481905358624ef2e81d7fb0b1af55f5ff99f9ba05442a444b11e45"},
|
||||
{file = "coverage-6.4.tar.gz", hash = "sha256:727dafd7f67a6e1cad808dc884bd9c5a2f6ef1f8f6d2f22b37b96cb0080d4f49"},
|
||||
]
|
||||
distlib = [
|
||||
{file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"},
|
||||
@@ -1040,24 +1040,24 @@ ghp-import = [
|
||||
{file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
|
||||
]
|
||||
identify = [
|
||||
{file = "identify-2.5.0-py2.py3-none-any.whl", hash = "sha256:3acfe15a96e4272b4ec5662ee3e231ceba976ef63fd9980ed2ce9cc415df393f"},
|
||||
{file = "identify-2.5.0.tar.gz", hash = "sha256:c83af514ea50bf2be2c4a3f2fb349442b59dc87284558ae9ff54191bff3541d2"},
|
||||
{file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"},
|
||||
{file = "identify-2.5.1.tar.gz", hash = "sha256:3d11b16f3fe19f52039fb7e39c9c884b21cb1b586988114fbe42671f03de3e82"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
|
||||
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
|
||||
]
|
||||
importlib-metadata = [
|
||||
{file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"},
|
||||
{file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"},
|
||||
{file = "importlib_metadata-4.11.4-py3-none-any.whl", hash = "sha256:c58c8eb8a762858f49e18436ff552e83914778e50e9d2f1660535ffb364552ec"},
|
||||
{file = "importlib_metadata-4.11.4.tar.gz", hash = "sha256:5d26852efe48c0a32b0509ffbc583fda1a2266545a78d104a6f4aff3db17d700"},
|
||||
]
|
||||
iniconfig = [
|
||||
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
|
||||
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
|
||||
]
|
||||
jinja2 = [
|
||||
{file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
|
||||
{file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
|
||||
{file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"},
|
||||
{file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"},
|
||||
]
|
||||
markdown = [
|
||||
{file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"},
|
||||
@@ -1360,8 +1360,8 @@ pyyaml-env-tag = [
|
||||
{file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
|
||||
]
|
||||
rich = [
|
||||
{file = "rich-12.4.1-py3-none-any.whl", hash = "sha256:d13c6c90c42e24eb7ce660db397e8c398edd58acb7f92a2a88a95572b838aaa4"},
|
||||
{file = "rich-12.4.1.tar.gz", hash = "sha256:d239001c0fb7de985e21ec9a4bb542b5150350330bbc1849f835b9cbc8923b91"},
|
||||
{file = "rich-12.4.4-py3-none-any.whl", hash = "sha256:d2bbd99c320a2532ac71ff6a3164867884357da3e3301f0240090c5d2fdac7ec"},
|
||||
{file = "rich-12.4.4.tar.gz", hash = "sha256:4c586de507202505346f3e32d1363eb9ed6932f0c2f63184dea88983ff4971e2"},
|
||||
]
|
||||
six = [
|
||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||
@@ -1418,30 +1418,30 @@ tomli = [
|
||||
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
||||
]
|
||||
typed-ast = [
|
||||
{file = "typed_ast-1.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ad3b48cf2b487be140072fb86feff36801487d4abb7382bb1929aaac80638ea"},
|
||||
{file = "typed_ast-1.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:542cd732351ba8235f20faa0fc7398946fe1a57f2cdb289e5497e1e7f48cfedb"},
|
||||
{file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc2c11ae59003d4a26dda637222d9ae924387f96acae9492df663843aefad55"},
|
||||
{file = "typed_ast-1.5.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd5df1313915dbd70eaaa88c19030b441742e8b05e6103c631c83b75e0435ccc"},
|
||||
{file = "typed_ast-1.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:e34f9b9e61333ecb0f7d79c21c28aa5cd63bec15cb7e1310d7d3da6ce886bc9b"},
|
||||
{file = "typed_ast-1.5.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f818c5b81966d4728fec14caa338e30a70dfc3da577984d38f97816c4b3071ec"},
|
||||
{file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3042bfc9ca118712c9809201f55355479cfcdc17449f9f8db5e744e9625c6805"},
|
||||
{file = "typed_ast-1.5.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4fff9fdcce59dc61ec1b317bdb319f8f4e6b69ebbe61193ae0a60c5f9333dc49"},
|
||||
{file = "typed_ast-1.5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8e0b8528838ffd426fea8d18bde4c73bcb4167218998cc8b9ee0a0f2bfe678a6"},
|
||||
{file = "typed_ast-1.5.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ef1d96ad05a291f5c36895d86d1375c0ee70595b90f6bb5f5fdbee749b146db"},
|
||||
{file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed44e81517364cb5ba367e4f68fca01fba42a7a4690d40c07886586ac267d9b9"},
|
||||
{file = "typed_ast-1.5.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f60d9de0d087454c91b3999a296d0c4558c1666771e3460621875021bf899af9"},
|
||||
{file = "typed_ast-1.5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9e237e74fd321a55c90eee9bc5d44be976979ad38a29bbd734148295c1ce7617"},
|
||||
{file = "typed_ast-1.5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ee852185964744987609b40aee1d2eb81502ae63ee8eef614558f96a56c1902d"},
|
||||
{file = "typed_ast-1.5.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:27e46cdd01d6c3a0dd8f728b6a938a6751f7bd324817501c15fb056307f918c6"},
|
||||
{file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d64dabc6336ddc10373922a146fa2256043b3b43e61f28961caec2a5207c56d5"},
|
||||
{file = "typed_ast-1.5.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8cdf91b0c466a6c43f36c1964772918a2c04cfa83df8001ff32a89e357f8eb06"},
|
||||
{file = "typed_ast-1.5.3-cp38-cp38-win_amd64.whl", hash = "sha256:9cc9e1457e1feb06b075c8ef8aeb046a28ec351b1958b42c7c31c989c841403a"},
|
||||
{file = "typed_ast-1.5.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e20d196815eeffb3d76b75223e8ffed124e65ee62097e4e73afb5fec6b993e7a"},
|
||||
{file = "typed_ast-1.5.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:37e5349d1d5de2f4763d534ccb26809d1c24b180a477659a12c4bde9dd677d74"},
|
||||
{file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9f1a27592fac87daa4e3f16538713d705599b0a27dfe25518b80b6b017f0a6d"},
|
||||
{file = "typed_ast-1.5.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8831479695eadc8b5ffed06fdfb3e424adc37962a75925668deeb503f446c0a3"},
|
||||
{file = "typed_ast-1.5.3-cp39-cp39-win_amd64.whl", hash = "sha256:20d5118e494478ef2d3a2702d964dae830aedd7b4d3b626d003eea526be18718"},
|
||||
{file = "typed_ast-1.5.3.tar.gz", hash = "sha256:27f25232e2dd0edfe1f019d6bfaaf11e86e657d9bdb7b0956db95f560cceb2b3"},
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"},
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"},
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"},
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"},
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"},
|
||||
{file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"},
|
||||
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"},
|
||||
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"},
|
||||
{file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"},
|
||||
{file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"},
|
||||
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"},
|
||||
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"},
|
||||
{file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"},
|
||||
{file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"},
|
||||
]
|
||||
typing-extensions = [
|
||||
{file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"},
|
||||
|
||||
@@ -22,9 +22,8 @@ textual = "textual.cli.cli:run"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.7"
|
||||
rich = "^12.4.0"
|
||||
|
||||
#rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"}
|
||||
rich = "^12.4.3"
|
||||
#rich = {path="../rich", develop=true}
|
||||
click = "8.1.2"
|
||||
importlib-metadata = "^4.11.3"
|
||||
typing-extensions = { version = "^4.0.0", python = "<3.8" }
|
||||
@@ -35,12 +34,13 @@ pytest = "^6.2.3"
|
||||
black = "^22.3.0"
|
||||
mypy = "^0.950"
|
||||
pytest-cov = "^2.12.1"
|
||||
mkdocs = "^1.2.3"
|
||||
mkdocs = "^1.3.0"
|
||||
mkdocstrings = "^0.17.0"
|
||||
mkdocs-material = "^7.3.6"
|
||||
pre-commit = "^2.13.0"
|
||||
pytest-aiohttp = "^1.0.4"
|
||||
time-machine = "^2.6.0"
|
||||
Jinja2 = "<3.1.0"
|
||||
|
||||
[tool.black]
|
||||
includes = "src"
|
||||
@@ -49,7 +49,7 @@ includes = "src"
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"integration_test: marks tests as slow integration tests(deselect with '-m \"not integration_test\"')",
|
||||
"integration_test: marks tests as slow integration tests (deselect with '-m \"not integration_test\"')",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -84,7 +84,7 @@ Tweet {
|
||||
/* border: outer $primary; */
|
||||
padding: 1;
|
||||
border: wide $panel-darken-2;
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
/* scrollbar-gutter: stable; */
|
||||
align-horizontal: center;
|
||||
box-sizing: border-box;
|
||||
@@ -114,10 +114,10 @@ TweetHeader {
|
||||
}
|
||||
|
||||
TweetBody {
|
||||
width: 100w;
|
||||
width: 130%;
|
||||
background: $panel;
|
||||
color: $text-panel;
|
||||
height: auto;
|
||||
height: auto;
|
||||
padding: 0 1 0 0;
|
||||
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class Success(Widget):
|
||||
return Text("This is a success message", justify="center")
|
||||
|
||||
|
||||
class BasicApp(App):
|
||||
class BasicApp(App, css_path="basic.css"):
|
||||
"""A basic app demonstrating CSS"""
|
||||
|
||||
def on_load(self):
|
||||
@@ -158,11 +158,7 @@ class BasicApp(App):
|
||||
tweet_body.refresh(layout=True)
|
||||
|
||||
|
||||
app = BasicApp(
|
||||
css_path="basic.css",
|
||||
watch_css=True,
|
||||
log_path="textual.log",
|
||||
)
|
||||
app = BasicApp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
||||
@@ -16,7 +16,7 @@ class TextWidget(Widget):
|
||||
return TEXT
|
||||
|
||||
|
||||
class AutoApp(App):
|
||||
class AutoApp(App, css_path="nest.css"):
|
||||
def on_mount(self) -> None:
|
||||
self.bind("t", "tree")
|
||||
|
||||
@@ -30,9 +30,3 @@ class AutoApp(App):
|
||||
|
||||
def action_tree(self):
|
||||
self.log(self.screen.tree)
|
||||
|
||||
|
||||
app = AutoApp(css_path="nest.css")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
||||
@@ -35,7 +35,7 @@ class Introduction(Widget):
|
||||
}
|
||||
"""
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
def render(self, styles) -> RenderableType:
|
||||
return Text(
|
||||
"Press keys 0 to 9 to scroll to the Placeholder with that ID.",
|
||||
justify="center",
|
||||
|
||||
3
sandbox/simplest.py
Normal file
3
sandbox/simplest.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from textual.app import App
|
||||
|
||||
app = App()
|
||||
@@ -3,14 +3,12 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import sys
|
||||
from time import monotonic
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import log
|
||||
from . import _clock
|
||||
from ._easing import DEFAULT_EASING, EASING
|
||||
from ._profile import timer
|
||||
from ._timer import Timer
|
||||
from ._types import MessageTarget
|
||||
|
||||
@@ -139,10 +137,6 @@ class Animator:
|
||||
pause=True,
|
||||
)
|
||||
|
||||
def get_time(self) -> float:
|
||||
"""Get the current wall clock time."""
|
||||
return monotonic()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the animator task."""
|
||||
|
||||
@@ -186,10 +180,13 @@ class Animator:
|
||||
raise AttributeError(
|
||||
f"Can't animate attribute {attribute!r} on {obj!r}; attribute does not exist"
|
||||
)
|
||||
assert (duration is not None and speed is None) or (
|
||||
duration is None and speed is not None
|
||||
), "An Animation should have a duration OR a speed"
|
||||
|
||||
if final_value is ...:
|
||||
final_value = value
|
||||
start_time = self.get_time()
|
||||
start_time = self._get_time()
|
||||
|
||||
animation_key = (id(obj), attribute)
|
||||
|
||||
@@ -240,9 +237,15 @@ class Animator:
|
||||
if not self._animations:
|
||||
self._timer.pause()
|
||||
else:
|
||||
animation_time = self.get_time()
|
||||
animation_time = self._get_time()
|
||||
animation_keys = list(self._animations.keys())
|
||||
for animation_key in animation_keys:
|
||||
animation = self._animations[animation_key]
|
||||
if animation(animation_time):
|
||||
del self._animations[animation_key]
|
||||
|
||||
def _get_time(self) -> float:
|
||||
"""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()
|
||||
|
||||
@@ -47,7 +47,7 @@ ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = {
|
||||
# support it. (Most terminals send ControlH when backspace is pressed.)
|
||||
# See: http://www.ibb.net/~anne/keyboard.html
|
||||
"\x7f": (Keys.ControlH,),
|
||||
# --
|
||||
"\x1b\x7f": (Keys.ControlW,),
|
||||
# Various
|
||||
"\x1b[1~": (Keys.Home,), # tmux
|
||||
"\x1b[2~": (Keys.Insert,),
|
||||
|
||||
58
src/textual/_clock.py
Normal file
58
src/textual/_clock.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import asyncio
|
||||
from time import monotonic
|
||||
|
||||
|
||||
"""
|
||||
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 self.get_time_no_wait()
|
||||
|
||||
def get_time_no_wait(self) -> float:
|
||||
return monotonic()
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
# That's our target for mocking time! :-)
|
||||
_clock = _Clock()
|
||||
|
||||
|
||||
def get_time_no_wait() -> float:
|
||||
"""
|
||||
Get the current wall clock time.
|
||||
|
||||
Returns:
|
||||
float: 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:
|
||||
float: 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 (float): the duration we should wait for before unblocking the awaiter
|
||||
"""
|
||||
return await _clock.sleep(seconds)
|
||||
@@ -552,7 +552,7 @@ class Compositor:
|
||||
]
|
||||
return segment_lines
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
def render(self, full: bool = True) -> RenderableType:
|
||||
"""Render a layout.
|
||||
|
||||
Returns:
|
||||
@@ -561,11 +561,14 @@ class Compositor:
|
||||
width, height = self.size
|
||||
screen_region = Region(0, 0, width, height)
|
||||
|
||||
update_regions = self._dirty_regions.copy()
|
||||
self._dirty_regions.clear()
|
||||
if screen_region in update_regions:
|
||||
# If one of the updates is the entire screen, then we only need one update
|
||||
update_regions.clear()
|
||||
if full:
|
||||
update_regions: set[Region] = set()
|
||||
else:
|
||||
update_regions = self._dirty_regions.copy()
|
||||
self._dirty_regions.clear()
|
||||
if screen_region in update_regions:
|
||||
# If one of the updates is the entire screen, then we only need one update
|
||||
update_regions.clear()
|
||||
|
||||
if update_regions:
|
||||
# Create a crop regions that surrounds all updates
|
||||
|
||||
@@ -5,4 +5,9 @@ from contextvars import ContextVar
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
|
||||
|
||||
class NoActiveAppError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
active_app: ContextVar["App"] = ContextVar("active_app")
|
||||
|
||||
36
src/textual/_doc.py
Normal file
36
src/textual/_doc.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
|
||||
|
||||
# This module defines our "Custom Fences", powered by SuperFences
|
||||
# @link https://facelessuser.github.io/pymdown-extensions/extensions/superfences/#custom-fences
|
||||
def format_svg(source, language, css_class, options, md, attrs, **kwargs):
|
||||
"""A superfences formatter to insert a SVG screenshot."""
|
||||
|
||||
os.environ["TEXTUAL"] = "headless"
|
||||
os.environ["TEXTUAL_SCREENSHOT"] = "0.1"
|
||||
os.environ["COLUMNS"] = attrs.get("columns", "80")
|
||||
os.environ["LINES"] = attrs.get("lines", "24")
|
||||
path = attrs.get("path")
|
||||
|
||||
if path:
|
||||
cwd = os.getcwd()
|
||||
examples_path, filename = os.path.split(path)
|
||||
try:
|
||||
os.chdir(examples_path)
|
||||
with open(filename, "rt") as python_code:
|
||||
source = python_code.read()
|
||||
app_vars = {}
|
||||
exec(source, app_vars)
|
||||
app = app_vars["app"]
|
||||
app.run()
|
||||
svg = app._screenshot
|
||||
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
else:
|
||||
app_vars = {}
|
||||
exec(source, app_vars)
|
||||
app = app_vars["app"]
|
||||
app.run()
|
||||
svg = app._screenshot
|
||||
return svg
|
||||
@@ -5,17 +5,15 @@ import weakref
|
||||
from asyncio import (
|
||||
CancelledError,
|
||||
Event,
|
||||
sleep,
|
||||
Task,
|
||||
)
|
||||
from functools import partial
|
||||
from time import monotonic
|
||||
from typing import Awaitable, Callable, Union
|
||||
|
||||
from rich.repr import Result, rich_repr
|
||||
|
||||
from . import events
|
||||
from ._callback import invoke
|
||||
from . import _clock
|
||||
from ._types import MessageTarget
|
||||
|
||||
TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]]
|
||||
@@ -109,32 +107,38 @@ class Timer:
|
||||
count = 0
|
||||
_repeat = self._repeat
|
||||
_interval = self._interval
|
||||
start = monotonic()
|
||||
start = _clock.get_time_no_wait()
|
||||
try:
|
||||
while _repeat is None or count <= _repeat:
|
||||
next_timer = start + ((count + 1) * _interval)
|
||||
if self._skip and next_timer < monotonic():
|
||||
now = await _clock.get_time()
|
||||
if self._skip and next_timer < now:
|
||||
count += 1
|
||||
continue
|
||||
wait_time = max(0, next_timer - monotonic())
|
||||
now = await _clock.get_time()
|
||||
wait_time = max(0, next_timer - now)
|
||||
if wait_time:
|
||||
await sleep(wait_time)
|
||||
event = events.Timer(
|
||||
self.sender,
|
||||
timer=self,
|
||||
time=next_timer,
|
||||
count=count,
|
||||
callback=self._callback,
|
||||
)
|
||||
await _clock.sleep(wait_time)
|
||||
count += 1
|
||||
try:
|
||||
if self._callback is not None:
|
||||
await invoke(self._callback)
|
||||
else:
|
||||
await self.target.post_priority_message(event)
|
||||
|
||||
await self._tick(next_timer=next_timer, count=count)
|
||||
except EventTargetGone:
|
||||
break
|
||||
await self._active.wait()
|
||||
except CancelledError:
|
||||
pass
|
||||
|
||||
async def _tick(self, *, next_timer: float, count: int) -> None:
|
||||
"""Triggers the Timer's action: either call its callback, or sends an event to its target"""
|
||||
if self._callback is not None:
|
||||
await invoke(self._callback)
|
||||
else:
|
||||
event = events.Timer(
|
||||
self.sender,
|
||||
timer=self,
|
||||
time=next_timer,
|
||||
count=count,
|
||||
callback=self._callback,
|
||||
)
|
||||
|
||||
await self.target.post_priority_message(event)
|
||||
|
||||
@@ -109,6 +109,8 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
"""
|
||||
|
||||
CSS_PATH: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver_class: Type[Driver] | None = None,
|
||||
@@ -136,8 +138,13 @@ class App(Generic[ReturnType], DOMNode):
|
||||
# this will create some first references to an asyncio loop.
|
||||
_init_uvloop()
|
||||
|
||||
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
|
||||
|
||||
self.console = Console(
|
||||
file=sys.__stdout__, markup=False, highlight=False, emoji=False
|
||||
file=(open(os.devnull, "wt") if self.is_headless else sys.__stdout__),
|
||||
markup=True,
|
||||
highlight=False,
|
||||
emoji=False,
|
||||
)
|
||||
self.error_console = Console(markup=False, stderr=True)
|
||||
self.driver_class = driver_class or self.get_driver_class()
|
||||
@@ -183,10 +190,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
self.stylesheet = Stylesheet(variables=self.get_css_variables())
|
||||
self._require_styles_update = False
|
||||
|
||||
self.css_path = css_path
|
||||
|
||||
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
|
||||
self.css_path = css_path or self.CSS_PATH
|
||||
|
||||
self.registry: set[MessagePump] = set()
|
||||
self.devtools = DevtoolsClient()
|
||||
@@ -200,6 +204,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
super().__init__()
|
||||
|
||||
def __init_subclass__(cls, css_path: str | None = None) -> None:
|
||||
super().__init_subclass__()
|
||||
cls.CSS_PATH = css_path
|
||||
|
||||
title: Reactive[str] = Reactive("Textual")
|
||||
sub_title: Reactive[str] = Reactive("")
|
||||
background: Reactive[str] = Reactive("black")
|
||||
@@ -215,6 +223,11 @@ class App(Generic[ReturnType], DOMNode):
|
||||
"""Check if debug mode is enabled."""
|
||||
return "debug" in self.features
|
||||
|
||||
@property
|
||||
def is_headless(self) -> bool:
|
||||
"""Check if the app is running in 'headless' mode."""
|
||||
return "headless" in self.features
|
||||
|
||||
def exit(self, result: ReturnType | None = None) -> None:
|
||||
"""Exit the app, and return the supplied result.
|
||||
|
||||
@@ -430,7 +443,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
color_system="truecolor",
|
||||
record=True,
|
||||
)
|
||||
console.print(self.screen._compositor)
|
||||
console.print(self.screen._compositor.render(full=True))
|
||||
return console.export_svg(title=self.title)
|
||||
|
||||
def save_screenshot(self, path: str | None = None) -> str:
|
||||
@@ -443,6 +456,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
Returns:
|
||||
str: Filename of screenshot.
|
||||
"""
|
||||
self.bell()
|
||||
if path is None:
|
||||
svg_path = f"{self.title.lower()}_{datetime.now().isoformat()}.svg"
|
||||
svg_path = svg_path.replace("/", "_").replace("\\", "_")
|
||||
@@ -727,13 +741,12 @@ class App(Generic[ReturnType], DOMNode):
|
||||
mount_event = events.Mount(sender=self)
|
||||
await self.dispatch_message(mount_event)
|
||||
|
||||
# TODO: don't override `self.console` here
|
||||
self.console = Console(file=sys.__stdout__)
|
||||
self.title = self._title
|
||||
self.refresh()
|
||||
await self.animator.start()
|
||||
|
||||
with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore
|
||||
await self._ready()
|
||||
await super().process_messages()
|
||||
await self.animator.stop()
|
||||
await self.close_all()
|
||||
@@ -754,6 +767,28 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._log_file.close()
|
||||
self._log_console = None
|
||||
|
||||
async def _ready(self) -> None:
|
||||
"""Called immediately prior to processing messages.
|
||||
|
||||
May be used as a hook for any operations that should run first.
|
||||
|
||||
"""
|
||||
try:
|
||||
screenshot_timer = float(os.environ.get("TEXTUAL_SCREENSHOT", "0"))
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if not screenshot_timer:
|
||||
return
|
||||
|
||||
async def on_screenshot():
|
||||
"""Used by docs plugin."""
|
||||
svg = self.export_screenshot()
|
||||
self._screenshot = svg # type: ignore
|
||||
await self.shutdown()
|
||||
|
||||
self.set_timer(screenshot_timer, on_screenshot)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
widgets = list(self.compose())
|
||||
if widgets:
|
||||
@@ -856,7 +891,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
Args:
|
||||
renderable (RenderableType): A Rich renderable.
|
||||
"""
|
||||
if self._running and not self._closed:
|
||||
if self._running and not self._closed and not self.is_headless:
|
||||
console = self.console
|
||||
if self._sync_available:
|
||||
console.file.write("\x1bP=1s\x1b\\")
|
||||
|
||||
@@ -96,8 +96,8 @@ def get_box_model(
|
||||
content_height = max(1, content_height)
|
||||
|
||||
# Get box dimensions by adding gutter
|
||||
width = content_width + gutter.width
|
||||
height = content_height + gutter.height
|
||||
|
||||
model = BoxModel(Size(width, height), margin)
|
||||
size = Size(content_width, content_height) + gutter.totals
|
||||
|
||||
model = BoxModel(size, margin)
|
||||
return model
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import click
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast, TYPE_CHECKING
|
||||
|
||||
from importlib_metadata import version
|
||||
|
||||
import click
|
||||
|
||||
from textual.devtools.server import _run_devtools
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version("textual"))
|
||||
@@ -13,3 +21,139 @@ def run():
|
||||
@run.command(help="Run the Textual Devtools console")
|
||||
def console():
|
||||
_run_devtools()
|
||||
|
||||
|
||||
class AppFail(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def import_app(import_name: str) -> App:
|
||||
"""Import an app from it's import name.
|
||||
|
||||
Args:
|
||||
import_name (str): A name to import, such as `foo.bar`
|
||||
|
||||
Raises:
|
||||
AppFail: If the app could not be found for any reason.
|
||||
|
||||
Returns:
|
||||
App: A Textual application
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
from textual.app import App
|
||||
|
||||
lib, _colon, name = import_name.partition(":")
|
||||
|
||||
if lib.endswith(".py"):
|
||||
# We're assuming the user wants to load a .py file
|
||||
try:
|
||||
with open(lib) as python_file:
|
||||
py_code = python_file.read()
|
||||
except Exception as error:
|
||||
raise AppFail(str(error))
|
||||
|
||||
global_vars: dict[str, object] = {}
|
||||
exec(py_code, global_vars)
|
||||
|
||||
if name:
|
||||
# User has given a name, use that
|
||||
try:
|
||||
app = global_vars[name]
|
||||
except KeyError:
|
||||
raise AppFail(f"App {name!r} not found in {lib!r}")
|
||||
else:
|
||||
# User has not given a name
|
||||
if "app" in global_vars:
|
||||
# App exists, lets use that
|
||||
try:
|
||||
app = global_vars["app"]
|
||||
except KeyError:
|
||||
raise AppFail(f"App {name!r} not found in {lib!r}")
|
||||
else:
|
||||
# Find a App class or instance that is *not* the base class
|
||||
apps = [
|
||||
value
|
||||
for key, value in global_vars.items()
|
||||
if (
|
||||
isinstance(value, App)
|
||||
or (inspect.isclass(value) and issubclass(value, App))
|
||||
and value is not App
|
||||
)
|
||||
]
|
||||
if not apps:
|
||||
raise AppFail(
|
||||
f'Unable to find app in {lib!r}, try specifying app with "foo.py:app"'
|
||||
)
|
||||
if len(apps) > 1:
|
||||
raise AppFail(
|
||||
f'Multiple apps found {lib!r}, try specifying app with "foo.py:app"'
|
||||
)
|
||||
app = apps[0]
|
||||
|
||||
else:
|
||||
# Assuming the user wants to import the file
|
||||
sys.path.append("")
|
||||
try:
|
||||
module = importlib.import_module(lib)
|
||||
except ImportError as error:
|
||||
raise AppFail(str(error))
|
||||
|
||||
try:
|
||||
app = getattr(module, name or "app")
|
||||
except AttributeError:
|
||||
raise AppFail(f"Unable to find {name!r} in {module!r}")
|
||||
|
||||
if inspect.isclass(app) and issubclass(app, App):
|
||||
app = app()
|
||||
|
||||
return cast(App, app)
|
||||
|
||||
|
||||
@run.command("run")
|
||||
@click.argument("import_name", metavar="FILE or FILE:APP")
|
||||
@click.option("--dev", "dev", help="Enable development mode", is_flag=True)
|
||||
def run_app(import_name: str, dev: bool) -> None:
|
||||
"""Run a Textual app.
|
||||
|
||||
The code to run may be given as a path (ending with .py) or as a Python
|
||||
import, which will load the code and run an app called "app". You may optionally
|
||||
add a colon plus the class or class instance you want to run.
|
||||
|
||||
Here are some examples:
|
||||
|
||||
textual run foo.py
|
||||
|
||||
textual run foo.py:MyApp
|
||||
|
||||
textual run module.foo
|
||||
|
||||
textual run module.foo:MyApp
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from textual.features import parse_features
|
||||
|
||||
features = set(parse_features(os.environ.get("TEXTUAL", "")))
|
||||
if dev:
|
||||
features.add("debug")
|
||||
features.add("devtools")
|
||||
|
||||
os.environ["TEXTUAL"] = ",".join(sorted(features))
|
||||
|
||||
try:
|
||||
app = import_app(import_name)
|
||||
except AppFail as error:
|
||||
from rich.console import Console
|
||||
|
||||
console = Console(stderr=True)
|
||||
console.print(str(error))
|
||||
sys.exit(1)
|
||||
|
||||
app.run()
|
||||
|
||||
@@ -9,6 +9,7 @@ from rich.style import Style
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
from ._context import NoActiveAppError
|
||||
from ._node_list import NodeList
|
||||
from .color import Color
|
||||
from .css._error_tools import friendly_list
|
||||
@@ -463,7 +464,7 @@ class DOMNode(MessagePump):
|
||||
try:
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
except LookupError:
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
|
||||
def remove_class(self, *class_names: str) -> None:
|
||||
@@ -477,7 +478,7 @@ class DOMNode(MessagePump):
|
||||
try:
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
except LookupError:
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
|
||||
def toggle_class(self, *class_names: str) -> None:
|
||||
@@ -491,7 +492,7 @@ class DOMNode(MessagePump):
|
||||
try:
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
except LookupError:
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
|
||||
def has_pseudo_class(self, *class_names: str) -> bool:
|
||||
|
||||
@@ -14,8 +14,9 @@ from threading import Event, Thread
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import Console
|
||||
|
||||
from .. import log
|
||||
import rich.repr
|
||||
|
||||
from .. import log
|
||||
from .. import events
|
||||
from ..driver import Driver
|
||||
from ..geometry import Size
|
||||
@@ -24,6 +25,7 @@ from .._xterm_parser import XTermParser
|
||||
from .._profile import timer
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class LinuxDriver(Driver):
|
||||
"""Powers display and input for Linux / MacOS"""
|
||||
|
||||
@@ -36,14 +38,19 @@ class LinuxDriver(Driver):
|
||||
self.exit_event = Event()
|
||||
self._key_thread: Thread | None = None
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "debug", self._debug
|
||||
|
||||
def _get_terminal_size(self) -> tuple[int, int]:
|
||||
width: int | None = 80
|
||||
height: int | None = 25
|
||||
import shutil
|
||||
|
||||
try:
|
||||
width, height = os.get_terminal_size(sys.__stdin__.fileno())
|
||||
width, height = shutil.get_terminal_size()
|
||||
except (AttributeError, ValueError, OSError):
|
||||
try:
|
||||
width, height = os.get_terminal_size(sys.__stdout__.fileno())
|
||||
width, height = shutil.get_terminal_size()
|
||||
except (AttributeError, ValueError, OSError):
|
||||
pass
|
||||
width = width or 80
|
||||
|
||||
@@ -9,16 +9,16 @@ if sys.version_info >= (3, 8):
|
||||
else:
|
||||
from typing_extensions import Final, Literal
|
||||
|
||||
FEATURES: Final = {"devtools", "debug"}
|
||||
FEATURES: Final = {"devtools", "debug", "headless"}
|
||||
|
||||
FeatureFlag = Literal["devtools", "debug"]
|
||||
FeatureFlag = Literal["devtools", "debug", "headless"]
|
||||
|
||||
|
||||
def parse_features(features: str) -> frozenset[FeatureFlag]:
|
||||
"""Parse features env var
|
||||
|
||||
Args:
|
||||
features (str): Comma seprated feature flags
|
||||
features (str): Comma separated feature flags
|
||||
|
||||
Returns:
|
||||
frozenset[FeatureFlag]: A frozen set of known features.
|
||||
|
||||
@@ -38,7 +38,12 @@ class VerticalLayout(Layout):
|
||||
displayed_children = cast("list[Widget]", parent.displayed_children)
|
||||
for widget, box_model, margin in zip(displayed_children, box_models, margins):
|
||||
content_width, content_height = box_model.size
|
||||
offset_x = widget.styles.align_width(content_width, size.width)
|
||||
offset_x = (
|
||||
widget.styles.align_width(
|
||||
content_width, size.width - box_model.margin.width
|
||||
)
|
||||
+ box_model.margin.left
|
||||
)
|
||||
region = Region(offset_x, y, content_width, content_height)
|
||||
add_placement(WidgetPlacement(region, widget, 0))
|
||||
y += region.height + margin
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from time import monotonic
|
||||
from typing import ClassVar
|
||||
|
||||
import rich.repr
|
||||
|
||||
from . import _clock
|
||||
from .case import camel_to_snake
|
||||
from ._types import MessageTarget
|
||||
|
||||
@@ -39,7 +39,7 @@ class Message:
|
||||
|
||||
self.sender = sender
|
||||
self.name = camel_to_snake(self.__class__.__name__.replace("Message", ""))
|
||||
self.time = monotonic()
|
||||
self.time = _clock.get_time_no_wait()
|
||||
self._forwarded = False
|
||||
self._no_default_action = False
|
||||
self._stop_propagation = False
|
||||
|
||||
@@ -12,7 +12,7 @@ from . import events
|
||||
from . import log
|
||||
from ._timer import Timer, TimerCallback
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
from ._context import active_app, NoActiveAppError
|
||||
from .message import Message
|
||||
from . import messages
|
||||
|
||||
@@ -74,8 +74,16 @@ class MessagePump:
|
||||
|
||||
@property
|
||||
def app(self) -> "App":
|
||||
"""Get the current app."""
|
||||
return active_app.get()
|
||||
"""
|
||||
Get the current app.
|
||||
|
||||
Raises:
|
||||
NoActiveAppError: if no active app could be found for the current asyncio context
|
||||
"""
|
||||
try:
|
||||
return active_app.get()
|
||||
except LookupError:
|
||||
raise NoActiveAppError()
|
||||
|
||||
@property
|
||||
def is_parent_active(self):
|
||||
@@ -152,7 +160,13 @@ class MessagePump:
|
||||
pause: bool = False,
|
||||
) -> Timer:
|
||||
timer = Timer(
|
||||
self, delay, self, name=name, callback=callback, repeat=0, pause=pause
|
||||
self,
|
||||
delay,
|
||||
self,
|
||||
name=name or f"set_timer#{Timer._timer_count}",
|
||||
callback=callback,
|
||||
repeat=0,
|
||||
pause=pause,
|
||||
)
|
||||
self._child_tasks.add(timer.start())
|
||||
return timer
|
||||
@@ -170,7 +184,7 @@ class MessagePump:
|
||||
self,
|
||||
interval,
|
||||
self,
|
||||
name=name,
|
||||
name=name or f"set_interval#{Timer._timer_count}",
|
||||
callback=callback,
|
||||
repeat=repeat or None,
|
||||
pause=pause,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from rich.console import RenderableType
|
||||
import rich.repr
|
||||
from rich.style import Style
|
||||
@@ -12,6 +14,14 @@ from ._compositor import Compositor, MapGeometry
|
||||
from .reactive import Reactive
|
||||
from .widget import Widget
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from typing import Final
|
||||
else:
|
||||
from typing_extensions import Final
|
||||
|
||||
# Screen updates will be batched so that they don't happen more often than 20 times per second:
|
||||
UPDATE_PERIOD: Final = 1 / 20
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Screen(Widget):
|
||||
@@ -158,7 +168,9 @@ class Screen(Widget):
|
||||
self.check_idle()
|
||||
|
||||
def on_mount(self, event: events.Mount) -> None:
|
||||
self._update_timer = self.set_interval(1 / 20, self._on_update, pause=True)
|
||||
self._update_timer = self.set_interval(
|
||||
UPDATE_PERIOD, self._on_update, name="screen_update", pause=True
|
||||
)
|
||||
|
||||
async def on_resize(self, event: events.Resize) -> None:
|
||||
self.size_updated(event.size, event.virtual_size, event.container_size)
|
||||
|
||||
@@ -184,7 +184,10 @@ class Widget(DOMNode):
|
||||
int: The optimal width of the content.
|
||||
"""
|
||||
if self.is_container:
|
||||
return self.layout.get_content_width(self, container, viewport)
|
||||
return (
|
||||
self.layout.get_content_width(self, container, viewport)
|
||||
+ self.scrollbar_width
|
||||
)
|
||||
|
||||
cache_key = container.width
|
||||
if self._content_width_cache[0] == cache_key:
|
||||
@@ -214,11 +217,14 @@ class Widget(DOMNode):
|
||||
"""
|
||||
if self.is_container:
|
||||
assert self.layout is not None
|
||||
height = self.layout.get_content_height(
|
||||
self,
|
||||
container,
|
||||
viewport,
|
||||
width,
|
||||
height = (
|
||||
self.layout.get_content_height(
|
||||
self,
|
||||
container,
|
||||
viewport,
|
||||
width,
|
||||
)
|
||||
+ self.scrollbar_height
|
||||
)
|
||||
else:
|
||||
cache_key = width
|
||||
@@ -255,11 +261,21 @@ class Widget(DOMNode):
|
||||
|
||||
@property
|
||||
def max_scroll_x(self) -> float:
|
||||
return max(0, self.virtual_size.width - self.container_size.width)
|
||||
"""The maximum value of `scroll_x`."""
|
||||
return max(
|
||||
0,
|
||||
self.virtual_size.width - self.container_size.width + self.scrollbar_width,
|
||||
)
|
||||
|
||||
@property
|
||||
def max_scroll_y(self) -> float:
|
||||
return max(0, self.virtual_size.height - self.container_size.height)
|
||||
"""The maximum value of `scroll_y`."""
|
||||
return max(
|
||||
0,
|
||||
self.virtual_size.height
|
||||
- self.container_size.height
|
||||
+ self.scrollbar_height,
|
||||
)
|
||||
|
||||
@property
|
||||
def vertical_scrollbar(self) -> ScrollBar:
|
||||
@@ -335,12 +351,27 @@ class Widget(DOMNode):
|
||||
tuple[bool, bool]: A tuple of (<vertical scrollbar enabled>, <horizontal scrollbar enabled>)
|
||||
|
||||
"""
|
||||
if self.layout is None:
|
||||
if not self.is_container:
|
||||
return False, False
|
||||
|
||||
enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar
|
||||
return enabled
|
||||
|
||||
@property
|
||||
def scrollbar_dimensions(self) -> tuple[int, int]:
|
||||
"""Get the size of any scrollbars on the widget"""
|
||||
return (int(self.show_horizontal_scrollbar), int(self.show_vertical_scrollbar))
|
||||
|
||||
@property
|
||||
def scrollbar_width(self) -> int:
|
||||
"""Get the width used by the *vertical* scrollbar."""
|
||||
return int(self.show_vertical_scrollbar)
|
||||
|
||||
@property
|
||||
def scrollbar_height(self) -> int:
|
||||
"""Get the height used by the *horizontal* scrollbar."""
|
||||
return int(self.show_horizontal_scrollbar)
|
||||
|
||||
def set_dirty(self) -> None:
|
||||
"""Set the Widget as 'dirty' (requiring re-render)."""
|
||||
self._dirty_regions.clear()
|
||||
@@ -366,9 +397,10 @@ class Widget(DOMNode):
|
||||
bool: True if the scroll position changed, otherwise False.
|
||||
"""
|
||||
scrolled_x = scrolled_y = False
|
||||
|
||||
if animate:
|
||||
# TODO: configure animation speed
|
||||
if duration is None and speed is None:
|
||||
speed = 50
|
||||
if x is not None:
|
||||
self.scroll_target_x = x
|
||||
if x != self.scroll_x:
|
||||
@@ -915,8 +947,6 @@ class Widget(DOMNode):
|
||||
|
||||
def on_descendant_focus(self, event: events.DescendantFocus) -> None:
|
||||
self.descendant_has_focus = True
|
||||
if self.is_container and isinstance(event.sender, Widget):
|
||||
self.scroll_to_widget(event.sender, animate=True)
|
||||
|
||||
def on_descendant_blur(self, event: events.DescendantBlur) -> None:
|
||||
self.descendant_has_focus = False
|
||||
|
||||
@@ -177,12 +177,12 @@ class MockAnimator(Animator):
|
||||
self._time = 0.0
|
||||
self._on_animation_frame_called = False
|
||||
|
||||
def get_time(self):
|
||||
return self._time
|
||||
|
||||
def on_animation_frame(self):
|
||||
self._on_animation_frame_called = True
|
||||
|
||||
def _get_time(self):
|
||||
return self._time
|
||||
|
||||
|
||||
def test_animator():
|
||||
|
||||
@@ -245,10 +245,3 @@ def test_bound_animator():
|
||||
easing=EASING[DEFAULT_EASING],
|
||||
)
|
||||
assert animator._animations[(id(animate_test), "foo")] == expected
|
||||
|
||||
|
||||
def test_animator_get_time():
|
||||
target = Mock()
|
||||
animator = Animator(target)
|
||||
assert isinstance(animator.get_time(), float)
|
||||
assert animator.get_time() <= animator.get_time()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import cast, List
|
||||
|
||||
import pytest
|
||||
@@ -107,7 +106,6 @@ async def test_composition_of_vertical_container_with_children(
|
||||
expected_placeholders_size: tuple[int, int],
|
||||
expected_root_widget_virtual_size: tuple[int, int],
|
||||
expected_placeholders_offset_x: int,
|
||||
event_loop: asyncio.AbstractEventLoop,
|
||||
):
|
||||
class VerticalContainer(Widget):
|
||||
CSS = (
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Sequence, cast
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from typing import Literal
|
||||
else:
|
||||
from typing_extensions import Literal # pragma: no cover
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from sandbox.vertical_container import VerticalContainer
|
||||
from tests.utilities.test_app import AppTest
|
||||
from textual.app import ComposeResult
|
||||
from textual.geometry import Size
|
||||
@@ -21,7 +12,6 @@ from textual.widgets import Placeholder
|
||||
SCREEN_SIZE = Size(100, 30)
|
||||
|
||||
|
||||
@pytest.mark.skip("flaky test")
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts
|
||||
@pytest.mark.parametrize(
|
||||
@@ -32,23 +22,19 @@ SCREEN_SIZE = Size(100, 30)
|
||||
"scroll_to_animate",
|
||||
"waiting_duration",
|
||||
"last_screen_expected_placeholder_ids",
|
||||
"last_screen_expected_out_of_viewport_placeholder_ids",
|
||||
),
|
||||
(
|
||||
[SCREEN_SIZE, 10, None, None, 0.01, (0, 1, 2, 3, 4), "others"],
|
||||
[SCREEN_SIZE, 10, "placeholder_3", False, 0.01, (0, 1, 2, 3, 4), "others"],
|
||||
[SCREEN_SIZE, 10, "placeholder_5", False, 0.01, (1, 2, 3, 4, 5), "others"],
|
||||
[SCREEN_SIZE, 10, "placeholder_7", False, 0.01, (3, 4, 5, 6, 7), "others"],
|
||||
[SCREEN_SIZE, 10, "placeholder_9", False, 0.01, (5, 6, 7, 8, 9), "others"],
|
||||
[SCREEN_SIZE, 10, None, None, 0.01, (0, 1, 2, 3, 4)],
|
||||
[SCREEN_SIZE, 10, "placeholder_3", False, 0.01, (0, 1, 2, 3, 4)],
|
||||
[SCREEN_SIZE, 10, "placeholder_5", False, 0.01, (1, 2, 3, 4, 5)],
|
||||
[SCREEN_SIZE, 10, "placeholder_7", False, 0.01, (3, 4, 5, 6, 7)],
|
||||
[SCREEN_SIZE, 10, "placeholder_9", False, 0.01, (5, 6, 7, 8, 9)],
|
||||
# N.B. Scroll duration is hard-coded to 0.2 in the `scroll_to_widget` method atm
|
||||
# Waiting for this duration should allow us to see the scroll finished:
|
||||
[SCREEN_SIZE, 10, "placeholder_9", True, 0.21, (5, 6, 7, 8, 9), "others"],
|
||||
[SCREEN_SIZE, 10, "placeholder_9", True, 0.21, (5, 6, 7, 8, 9)],
|
||||
# After having waited for approximately half of the scrolling duration, we should
|
||||
# see the middle Placeholders as we're scrolling towards the last of them.
|
||||
# The state of the screen at this "halfway there" timing looks to not be deterministic though,
|
||||
# depending on the environment - so let's only assert stuff for the middle placeholders
|
||||
# and the first and last ones, but without being too specific about the others:
|
||||
[SCREEN_SIZE, 10, "placeholder_9", True, 0.1, (5, 6, 7), (1, 2, 9)],
|
||||
[SCREEN_SIZE, 10, "placeholder_9", True, 0.1, (4, 5, 6, 7, 8)],
|
||||
),
|
||||
)
|
||||
async def test_scroll_to_widget(
|
||||
@@ -58,9 +44,19 @@ async def test_scroll_to_widget(
|
||||
scroll_to_placeholder_id: str | None,
|
||||
waiting_duration: float | None,
|
||||
last_screen_expected_placeholder_ids: Sequence[int],
|
||||
last_screen_expected_out_of_viewport_placeholder_ids: Sequence[int]
|
||||
| Literal["others"],
|
||||
):
|
||||
class VerticalContainer(Widget):
|
||||
CSS = """
|
||||
VerticalContainer {
|
||||
layout: vertical;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
VerticalContainer Placeholder {
|
||||
margin: 1 0;
|
||||
height: 5;
|
||||
}
|
||||
"""
|
||||
|
||||
class MyTestApp(AppTest):
|
||||
CSS = """
|
||||
Placeholder {
|
||||
@@ -78,7 +74,7 @@ async def test_scroll_to_widget(
|
||||
|
||||
app = MyTestApp(size=screen_size, test_name="scroll_to_widget")
|
||||
|
||||
async with app.in_running_state(waiting_duration_post_yield=waiting_duration or 0):
|
||||
async with app.in_running_state(waiting_duration_after_yield=waiting_duration or 0):
|
||||
if scroll_to_placeholder_id:
|
||||
target_widget_container = cast(Widget, app.query("#root").first())
|
||||
target_widget = cast(
|
||||
@@ -97,21 +93,21 @@ async def test_scroll_to_widget(
|
||||
|
||||
# Let's start by checking placeholders that should be visible:
|
||||
for placeholder_id in last_screen_expected_placeholder_ids:
|
||||
assert (
|
||||
placeholders_visibility_by_id[placeholder_id] is True
|
||||
), f"Placeholder '{placeholder_id}' should be visible but isn't"
|
||||
assert placeholders_visibility_by_id[placeholder_id] is True, (
|
||||
f"Placeholder '{placeholder_id}' should be visible but isn't"
|
||||
f" :: placeholders_visibility_by_id={placeholders_visibility_by_id}"
|
||||
)
|
||||
|
||||
# Ok, now for placeholders that should *not* be visible:
|
||||
if last_screen_expected_out_of_viewport_placeholder_ids == "others":
|
||||
# We're simply going to check that all the placeholders that are not in
|
||||
# `last_screen_expected_placeholder_ids` are not on the screen:
|
||||
last_screen_expected_out_of_viewport_placeholder_ids = sorted(
|
||||
tuple(
|
||||
set(range(placeholders_count))
|
||||
- set(last_screen_expected_placeholder_ids)
|
||||
)
|
||||
# We're simply going to check that all the placeholders that are not in
|
||||
# `last_screen_expected_placeholder_ids` are not on the screen:
|
||||
last_screen_expected_out_of_viewport_placeholder_ids = sorted(
|
||||
tuple(
|
||||
set(range(placeholders_count)) - set(last_screen_expected_placeholder_ids)
|
||||
)
|
||||
)
|
||||
for placeholder_id in last_screen_expected_out_of_viewport_placeholder_ids:
|
||||
assert (
|
||||
placeholders_visibility_by_id[placeholder_id] is False
|
||||
), f"Placeholder '{placeholder_id}' should not be visible but is"
|
||||
assert placeholders_visibility_by_id[placeholder_id] is False, (
|
||||
f"Placeholder '{placeholder_id}' should not be visible but is"
|
||||
f" :: placeholders_visibility_by_id={placeholders_visibility_by_id}"
|
||||
)
|
||||
|
||||
@@ -3,16 +3,20 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
from math import ceil
|
||||
from pathlib import Path
|
||||
from typing import AsyncContextManager, cast
|
||||
from time import monotonic
|
||||
from typing import AsyncContextManager, cast, ContextManager
|
||||
from unittest import mock
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from textual import events, errors
|
||||
from textual.app import App, ReturnType, ComposeResult
|
||||
from textual._clock import _Clock
|
||||
from textual.app import App, ComposeResult, WINDOWS
|
||||
from textual._context import active_app
|
||||
from textual.driver import Driver
|
||||
from textual.geometry import Size
|
||||
|
||||
from textual.geometry import Size, Region
|
||||
|
||||
# N.B. These classes would better be named TestApp/TestConsole/TestDriver/etc,
|
||||
# but it makes pytest emit warning as it will try to collect them as classes containing test cases :-/
|
||||
@@ -61,33 +65,42 @@ class AppTest(App):
|
||||
def in_running_state(
|
||||
self,
|
||||
*,
|
||||
waiting_duration_after_initialisation: float = 0.1,
|
||||
waiting_duration_post_yield: float = 0,
|
||||
) -> AsyncContextManager:
|
||||
time_mocking_ticks_granularity_fps: int = 60, # i.e. when moving forward by 1 second we'll do it though 60 ticks
|
||||
waiting_duration_after_initialisation: float = 1,
|
||||
waiting_duration_after_yield: float = 0,
|
||||
) -> AsyncContextManager[ClockMock]:
|
||||
async def run_app() -> None:
|
||||
await self.process_messages()
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def get_running_state_context_manager():
|
||||
self._set_active()
|
||||
run_task = asyncio.create_task(run_app())
|
||||
timeout_before_yielding_task = asyncio.create_task(
|
||||
asyncio.sleep(waiting_duration_after_initialisation)
|
||||
)
|
||||
done, pending = await asyncio.wait(
|
||||
(
|
||||
run_task,
|
||||
timeout_before_yielding_task,
|
||||
),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if run_task in done or run_task not in pending:
|
||||
raise RuntimeError(
|
||||
"TestApp is no longer running after its initialization period"
|
||||
)
|
||||
yield
|
||||
if waiting_duration_post_yield:
|
||||
await asyncio.sleep(waiting_duration_post_yield)
|
||||
with mock_textual_timers(
|
||||
ticks_granularity_fps=time_mocking_ticks_granularity_fps
|
||||
) as clock_mock:
|
||||
run_task = asyncio.create_task(run_app())
|
||||
|
||||
# We have to do this because `run_app()` is running in its own async task, and our test is going to
|
||||
# run in this one - so the app must also be the active App in our current context:
|
||||
self._set_active()
|
||||
|
||||
await clock_mock.advance_clock(waiting_duration_after_initialisation)
|
||||
# make sure the App has entered its main loop at this stage:
|
||||
assert self._driver is not None
|
||||
|
||||
await self.force_full_screen_update()
|
||||
|
||||
# And now it's time to pass the torch on to the test function!
|
||||
# We provide the `move_clock_forward` function to it,
|
||||
# so it can also do some time-based Textual stuff if it needs to:
|
||||
yield clock_mock
|
||||
|
||||
await clock_mock.advance_clock(waiting_duration_after_yield)
|
||||
|
||||
# Make sure our screen is up-to-date before exiting the context manager,
|
||||
# so tests using our `last_display_capture` for example can assert things on a fully refreshed screen:
|
||||
await self.force_full_screen_update()
|
||||
|
||||
# End of simulated time: we just shut down ourselves:
|
||||
assert not run_task.done()
|
||||
await self.shutdown()
|
||||
|
||||
@@ -102,27 +115,10 @@ class AppTest(App):
|
||||
"""Just a commodity shortcut for `async with app.in_running_state(): pass`, for simple cases"""
|
||||
async with self.in_running_state(
|
||||
waiting_duration_after_initialisation=waiting_duration_after_initialisation,
|
||||
waiting_duration_post_yield=waiting_duration_before_shutdown,
|
||||
waiting_duration_after_yield=waiting_duration_before_shutdown,
|
||||
):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
raise NotImplementedError(
|
||||
"Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`"
|
||||
)
|
||||
|
||||
@property
|
||||
def total_capture(self) -> str | None:
|
||||
return self.console.file.getvalue()
|
||||
|
||||
@property
|
||||
def last_display_capture(self) -> str | None:
|
||||
total_capture = self.total_capture
|
||||
if not total_capture:
|
||||
return None
|
||||
last_display_start_index = total_capture.rindex(CLEAR_SCREEN_SEQUENCE)
|
||||
return total_capture[last_display_start_index:]
|
||||
|
||||
def get_char_at(self, x: int, y: int) -> str:
|
||||
"""Get the character at the given cell or empty string
|
||||
|
||||
@@ -153,6 +149,54 @@ class AppTest(App):
|
||||
return segment.text[0]
|
||||
return ""
|
||||
|
||||
async def force_full_screen_update(
|
||||
self, *, repaint: bool = True, layout: bool = True
|
||||
) -> None:
|
||||
try:
|
||||
screen = self.screen
|
||||
except IndexError:
|
||||
return # the app may not have a screen yet
|
||||
|
||||
# We artificially tell the Compositor that the whole area should be refreshed
|
||||
screen._compositor._dirty_regions = {
|
||||
Region(0, 0, screen.size.width, screen.size.height),
|
||||
}
|
||||
screen.refresh(repaint=repaint, layout=layout)
|
||||
# We also have to make sure we have at least one dirty widget, or `screen._on_update()` will early return:
|
||||
screen._dirty_widgets.add(screen)
|
||||
screen._on_update()
|
||||
|
||||
await let_asyncio_process_some_events()
|
||||
|
||||
def on_exception(self, error: Exception) -> None:
|
||||
# In tests we want the errors to be raised, rather than printed to a Console
|
||||
raise error
|
||||
|
||||
def run(self):
|
||||
raise NotImplementedError(
|
||||
"Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`"
|
||||
)
|
||||
|
||||
@property
|
||||
def active_app(self) -> App | None:
|
||||
return active_app.get()
|
||||
|
||||
@property
|
||||
def total_capture(self) -> str | None:
|
||||
return self.console.file.getvalue()
|
||||
|
||||
@property
|
||||
def last_display_capture(self) -> str | None:
|
||||
total_capture = self.total_capture
|
||||
if not total_capture:
|
||||
return None
|
||||
screen_captures = total_capture.split(CLEAR_SCREEN_SEQUENCE)
|
||||
for single_screen_capture in reversed(screen_captures):
|
||||
if len(single_screen_capture) > 30:
|
||||
# let's return the last occurrence of a screen that seem to be properly "fully-paint"
|
||||
return single_screen_capture
|
||||
return None
|
||||
|
||||
@property
|
||||
def console(self) -> ConsoleTest:
|
||||
return self._console
|
||||
@@ -207,3 +251,111 @@ class DriverTest(Driver):
|
||||
|
||||
def stop_application_mode(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# It seems that we have to give _way more_ time to `asyncio` on Windows in order to see our different awaiters
|
||||
# properly triggered when we pause our own "move clock forward" loop.
|
||||
# It could be caused by the fact that the time resolution for `asyncio` on this platform seems rather low:
|
||||
# > The resolution of the monotonic clock on Windows is usually around 15.6 msec.
|
||||
# > The best resolution is 0.5 msec.
|
||||
# @link https://docs.python.org/3/library/asyncio-platforms.html:
|
||||
ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD = 0.025 if WINDOWS else 0.005
|
||||
|
||||
|
||||
async def let_asyncio_process_some_events() -> None:
|
||||
await asyncio.sleep(ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD)
|
||||
|
||||
|
||||
class ClockMock(_Clock):
|
||||
# To avoid issues with floats we will store the current time as an integer internally.
|
||||
# Tenths of microseconds should be a good enough granularity:
|
||||
TIME_RESOLUTION = 10_000_000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ticks_granularity_fps: int = 60,
|
||||
):
|
||||
self._ticks_granularity_fps = ticks_granularity_fps
|
||||
self._single_tick_duration = int(self.TIME_RESOLUTION / ticks_granularity_fps)
|
||||
self._start_time: int = -1
|
||||
self._current_time: int = -1
|
||||
# For each call to our `sleep` method we will store an asyncio.Event
|
||||
# and the time at which we should trigger it:
|
||||
self._pending_sleep_events: dict[int, list[asyncio.Event]] = {}
|
||||
|
||||
def get_time_no_wait(self) -> float:
|
||||
if self._current_time == -1:
|
||||
self._start_clock()
|
||||
|
||||
return self._current_time / self.TIME_RESOLUTION
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
event = asyncio.Event()
|
||||
internal_waiting_duration = int(seconds * self.TIME_RESOLUTION)
|
||||
target_event_monotonic_time = self._current_time + internal_waiting_duration
|
||||
self._pending_sleep_events.setdefault(target_event_monotonic_time, []).append(
|
||||
event
|
||||
)
|
||||
# Ok, let's wait for this Event
|
||||
# (which can only be "unlocked" by calls to `advance_clock()`)
|
||||
await event.wait()
|
||||
|
||||
async def advance_clock(self, seconds: float) -> None:
|
||||
"""
|
||||
Artificially advance the Textual clock forward.
|
||||
|
||||
Args:
|
||||
seconds: for each second we will artificially tick `ticks_granularity_fps` times
|
||||
"""
|
||||
if self._current_time == -1:
|
||||
self._start_clock()
|
||||
|
||||
ticks_count = ceil(seconds * self._ticks_granularity_fps)
|
||||
activated_timers_count_total = 0 # useful when debugging this code :-)
|
||||
for tick_counter in range(ticks_count):
|
||||
self._current_time += self._single_tick_duration
|
||||
activated_timers_count = self._check_sleep_timers_to_activate()
|
||||
activated_timers_count_total += activated_timers_count
|
||||
# Now that we likely unlocked some occurrences of `await sleep(duration)`,
|
||||
# let's give an opportunity to asyncio-related stuff to happen:
|
||||
if activated_timers_count:
|
||||
await let_asyncio_process_some_events()
|
||||
|
||||
await let_asyncio_process_some_events()
|
||||
|
||||
def _start_clock(self) -> None:
|
||||
# N.B. `start_time` is not actually used, but it is useful to have when we set breakpoints there :-)
|
||||
self._start_time = self._current_time = int(monotonic() * self.TIME_RESOLUTION)
|
||||
|
||||
def _check_sleep_timers_to_activate(self) -> int:
|
||||
activated_timers_count = 0
|
||||
activated_events_times_to_clear: list[int] = []
|
||||
for (monotonic_time, target_events) in self._pending_sleep_events.items():
|
||||
if self._current_time < monotonic_time:
|
||||
continue # not time for you yet, dear awaiter...
|
||||
# Right, let's release these waiting events!
|
||||
for event in target_events:
|
||||
event.set()
|
||||
activated_timers_count += len(target_events)
|
||||
# ...and let's mark it for removal:
|
||||
activated_events_times_to_clear.append(monotonic_time)
|
||||
|
||||
if activated_events_times_to_clear:
|
||||
for event_time_to_clear in activated_events_times_to_clear:
|
||||
del self._pending_sleep_events[event_time_to_clear]
|
||||
|
||||
return activated_timers_count
|
||||
|
||||
|
||||
def mock_textual_timers(
|
||||
*,
|
||||
ticks_granularity_fps: int = 60,
|
||||
) -> ContextManager[ClockMock]:
|
||||
@contextlib.contextmanager
|
||||
def mock_textual_timers_context_manager():
|
||||
clock_mock = ClockMock(ticks_granularity_fps=ticks_granularity_fps)
|
||||
with mock.patch("textual._clock._clock", new=clock_mock):
|
||||
yield clock_mock
|
||||
|
||||
return mock_textual_timers_context_manager()
|
||||
|
||||
Reference in New Issue
Block a user