mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' into tab-focus
This commit is contained in:
4
.github/workflows/pythonpackage.yml
vendored
4
.github/workflows/pythonpackage.yml
vendored
@@ -39,6 +39,10 @@ jobs:
|
||||
run: |
|
||||
source $VENV
|
||||
pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing
|
||||
- name: Quick e2e smoke test
|
||||
run: |
|
||||
source $VENV
|
||||
python e2e_tests/sandbox_basic_test.py basic 2.0
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@v1.0.10
|
||||
with:
|
||||
|
||||
55
e2e_tests/sandbox_basic_test.py
Normal file
55
e2e_tests/sandbox_basic_test.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
target_script_name = "basic"
|
||||
script_time_to_live = 2.0 # in seconds
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
target_script_name = sys.argv[1]
|
||||
if len(sys.argv) > 2:
|
||||
script_time_to_live = float(sys.argv[2])
|
||||
|
||||
e2e_root = Path(__file__).parent
|
||||
|
||||
completed_process = None
|
||||
|
||||
|
||||
def launch_sandbox_script(python_file_name: str) -> None:
|
||||
global completed_process
|
||||
|
||||
command = f"{sys.executable} ./test_apps/{shlex.quote(python_file_name)}.py"
|
||||
print(f"Launching command '{command}'...")
|
||||
try:
|
||||
completed_process = subprocess.run(
|
||||
command, shell=True, check=True, capture_output=True, cwd=str(e2e_root)
|
||||
)
|
||||
except subprocess.CalledProcessError as err:
|
||||
print(f"Process error: {err.stderr}")
|
||||
raise
|
||||
|
||||
|
||||
thread = threading.Thread(
|
||||
target=launch_sandbox_script, args=(target_script_name,), daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
print(
|
||||
f"Launching Python script in a sub-thread; we'll wait for it for {script_time_to_live} seconds..."
|
||||
)
|
||||
thread.join(timeout=script_time_to_live)
|
||||
print("The wait is over.")
|
||||
|
||||
process_still_running = completed_process is None
|
||||
process_was_able_to_run_without_errors = process_still_running
|
||||
|
||||
if process_was_able_to_run_without_errors:
|
||||
print("Python script is still running :-)")
|
||||
else:
|
||||
print("Python script is no longer running :-/")
|
||||
|
||||
sys.exit(0 if process_was_able_to_run_without_errors else 1)
|
||||
227
e2e_tests/test_apps/basic.css
Normal file
227
e2e_tests/test_apps/basic.css
Normal file
@@ -0,0 +1,227 @@
|
||||
/* CSS file for basic.py */
|
||||
|
||||
|
||||
|
||||
* {
|
||||
transition: color 300ms linear, background 300ms linear;
|
||||
}
|
||||
|
||||
|
||||
* {
|
||||
scrollbar-background: $panel-darken-2;
|
||||
scrollbar-background-hover: $panel-darken-3;
|
||||
scrollbar-color: $system;
|
||||
scrollbar-color-active: $accent-darken-1;
|
||||
}
|
||||
|
||||
App > Screen {
|
||||
layout: dock;
|
||||
docks: side=left/1;
|
||||
background: $surface;
|
||||
color: $text-surface;
|
||||
}
|
||||
|
||||
|
||||
#sidebar {
|
||||
color: $text-primary;
|
||||
background: $primary;
|
||||
dock: side;
|
||||
width: 30;
|
||||
offset-x: -100%;
|
||||
layout: dock;
|
||||
transition: offset 500ms in_out_cubic;
|
||||
}
|
||||
|
||||
#sidebar.-active {
|
||||
offset-x: 0;
|
||||
}
|
||||
|
||||
#sidebar .title {
|
||||
height: 3;
|
||||
background: $primary-darken-2;
|
||||
color: $text-primary-darken-2 ;
|
||||
border-right: outer $primary-darken-3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#sidebar .user {
|
||||
height: 8;
|
||||
background: $primary-darken-1;
|
||||
color: $text-primary-darken-1;
|
||||
border-right: outer $primary-darken-3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#sidebar .content {
|
||||
background: $primary;
|
||||
color: $text-primary;
|
||||
border-right: outer $primary-darken-3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#header {
|
||||
color: $text-primary-darken-1;
|
||||
background: $primary-darken-1;
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#content {
|
||||
color: $text-background;
|
||||
background: $background;
|
||||
layout: vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
|
||||
Tweet {
|
||||
height: 12;
|
||||
width: 80;
|
||||
|
||||
margin: 1 3;
|
||||
background: $panel;
|
||||
color: $text-panel;
|
||||
layout: vertical;
|
||||
/* border: outer $primary; */
|
||||
padding: 1;
|
||||
border: wide $panel-darken-2;
|
||||
overflow-y: scroll;
|
||||
align-horizontal: center;
|
||||
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
width: 80;
|
||||
overflow-y: scroll;
|
||||
max-width:80;
|
||||
height: 20;
|
||||
align-horizontal: center;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
.code {
|
||||
|
||||
height: 34;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
|
||||
TweetHeader {
|
||||
height:1;
|
||||
background: $accent;
|
||||
color: $text-accent
|
||||
}
|
||||
|
||||
TweetBody {
|
||||
width: 100%;
|
||||
background: $panel;
|
||||
color: $text-panel;
|
||||
height:20;
|
||||
padding: 0 1 0 0;
|
||||
|
||||
}
|
||||
|
||||
.button {
|
||||
background: $accent;
|
||||
color: $text-accent;
|
||||
width:20;
|
||||
height: 3;
|
||||
/* border-top: hidden $accent-darken-3; */
|
||||
border: tall $accent-darken-2;
|
||||
/* border-left: tall $accent-darken-1; */
|
||||
|
||||
|
||||
/* padding: 1 0 0 0 ; */
|
||||
|
||||
transition: background 200ms in_out_cubic, color 300ms in_out_cubic;
|
||||
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: $accent-lighten-1;
|
||||
color: $text-accent-lighten-1;
|
||||
width: 20;
|
||||
height: 3;
|
||||
border: tall $accent-darken-1;
|
||||
/* border-left: tall $accent-darken-3; */
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#footer {
|
||||
color: $text-accent;
|
||||
background: $accent;
|
||||
height: 1;
|
||||
border-top: hkey $accent-darken-2;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
|
||||
#sidebar .content {
|
||||
layout: vertical
|
||||
}
|
||||
|
||||
OptionItem {
|
||||
height: 3;
|
||||
background: $primary;
|
||||
transition: background 100ms linear;
|
||||
border-right: outer $primary-darken-2;
|
||||
border-left: hidden;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
OptionItem:hover {
|
||||
height: 3;
|
||||
color: $accent;
|
||||
background: $primary-darken-1;
|
||||
/* border-top: hkey $accent2-darken-3;
|
||||
border-bottom: hkey $accent2-darken-3; */
|
||||
text-style: bold;
|
||||
border-left: outer $accent-darken-2;
|
||||
}
|
||||
|
||||
Error {
|
||||
width: 80;
|
||||
height:3;
|
||||
background: $error;
|
||||
color: $text-error;
|
||||
border-top: hkey $error-darken-2;
|
||||
border-bottom: hkey $error-darken-2;
|
||||
margin: 1 3;
|
||||
|
||||
text-style: bold;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
Warning {
|
||||
width: 80;
|
||||
height:3;
|
||||
background: $warning;
|
||||
color: $text-warning-fade-1;
|
||||
border-top: hkey $warning-darken-2;
|
||||
border-bottom: hkey $warning-darken-2;
|
||||
margin: 1 2;
|
||||
text-style: bold;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
Success {
|
||||
width: 80;
|
||||
height:3;
|
||||
box-sizing: border-box;
|
||||
background: $success-lighten-3;
|
||||
color: $text-success-lighten-3-fade-1;
|
||||
border-top: hkey $success;
|
||||
border-bottom: hkey $success;
|
||||
margin: 1 2;
|
||||
text-style: bold;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
|
||||
.horizontal {
|
||||
layout: horizontal
|
||||
}
|
||||
150
e2e_tests/test_apps/basic.py
Normal file
150
e2e_tests/test_apps/basic.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from pathlib import Path
|
||||
|
||||
from rich.align import Align
|
||||
from rich.console import RenderableType
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
CODE = '''
|
||||
class Offset(NamedTuple):
|
||||
"""A point defined by x and y coordinates."""
|
||||
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
|
||||
@property
|
||||
def is_origin(self) -> bool:
|
||||
"""Check if the point is at the origin (0, 0)"""
|
||||
return self == (0, 0)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self != (0, 0)
|
||||
|
||||
def __add__(self, other: object) -> Offset:
|
||||
if isinstance(other, tuple):
|
||||
_x, _y = self
|
||||
x, y = other
|
||||
return Offset(_x + x, _y + y)
|
||||
return NotImplemented
|
||||
|
||||
def __sub__(self, other: object) -> Offset:
|
||||
if isinstance(other, tuple):
|
||||
_x, _y = self
|
||||
x, y = other
|
||||
return Offset(_x - x, _y - y)
|
||||
return NotImplemented
|
||||
|
||||
def __mul__(self, other: object) -> Offset:
|
||||
if isinstance(other, (float, int)):
|
||||
x, y = self
|
||||
return Offset(int(x * other), int(y * other))
|
||||
return NotImplemented
|
||||
'''
|
||||
|
||||
|
||||
lorem = Text.from_markup(
|
||||
"""Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
|
||||
"""Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
|
||||
)
|
||||
|
||||
|
||||
class TweetHeader(Widget):
|
||||
def render(self) -> RenderableType:
|
||||
return Text("Lorem Impsum", justify="center")
|
||||
|
||||
|
||||
class TweetBody(Widget):
|
||||
def render(self) -> Text:
|
||||
return lorem
|
||||
|
||||
|
||||
class Tweet(Widget):
|
||||
pass
|
||||
|
||||
|
||||
class OptionItem(Widget):
|
||||
def render(self) -> Text:
|
||||
return Text("Option")
|
||||
|
||||
|
||||
class Error(Widget):
|
||||
def render(self) -> Text:
|
||||
return Text("This is an error message", justify="center")
|
||||
|
||||
|
||||
class Warning(Widget):
|
||||
def render(self) -> Text:
|
||||
return Text("This is a warning message", justify="center")
|
||||
|
||||
|
||||
class Success(Widget):
|
||||
def render(self) -> Text:
|
||||
return Text("This is a success message", justify="center")
|
||||
|
||||
|
||||
class BasicApp(App):
|
||||
"""A basic app demonstrating CSS"""
|
||||
|
||||
def on_load(self):
|
||||
"""Bind keys here."""
|
||||
self.bind("tab", "toggle_class('#sidebar', '-active')")
|
||||
|
||||
def on_mount(self):
|
||||
"""Build layout here."""
|
||||
self.mount(
|
||||
header=Static(
|
||||
Text.from_markup(
|
||||
"[b]This is a [u]Textual[/u] app, running in the terminal"
|
||||
),
|
||||
),
|
||||
content=Widget(
|
||||
Tweet(
|
||||
TweetBody(),
|
||||
# Widget(
|
||||
# Widget(classes={"button"}),
|
||||
# Widget(classes={"button"}),
|
||||
# classes={"horizontal"},
|
||||
# ),
|
||||
),
|
||||
Widget(
|
||||
Static(Syntax(CODE, "python"), classes="code"),
|
||||
classes="scrollable",
|
||||
),
|
||||
Error(),
|
||||
Tweet(TweetBody()),
|
||||
Warning(),
|
||||
Tweet(TweetBody()),
|
||||
Success(),
|
||||
),
|
||||
footer=Widget(),
|
||||
sidebar=Widget(
|
||||
Widget(classes="title"),
|
||||
Widget(classes="user"),
|
||||
OptionItem(),
|
||||
OptionItem(),
|
||||
OptionItem(),
|
||||
Widget(classes="content"),
|
||||
),
|
||||
)
|
||||
|
||||
async def on_key(self, event) -> None:
|
||||
await self.dispatch_key(event)
|
||||
|
||||
def key_d(self):
|
||||
self.dark = not self.dark
|
||||
|
||||
def key_x(self):
|
||||
self.panic(self.tree)
|
||||
|
||||
|
||||
css_file = Path(__file__).parent / "basic.css"
|
||||
app = BasicApp(
|
||||
css_file=str(css_file), watch_css=True, log="textual.log", log_verbosity=0
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
57
poetry.lock
generated
57
poetry.lock
generated
@@ -205,7 +205,7 @@ python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "ghp-import"
|
||||
version = "2.0.2"
|
||||
version = "2.1.0"
|
||||
description = "Copy your docs directly to the gh-pages branch."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -219,7 +219,7 @@ dev = ["twine", "markdown", "flake8", "wheel"]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.4.12"
|
||||
version = "2.5.0"
|
||||
description = "File identification library for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -263,7 +263,7 @@ python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.1"
|
||||
version = "3.1.2"
|
||||
description = "A very fast and expressive template engine."
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -496,22 +496,22 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.11.2"
|
||||
version = "2.12.0"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "9.3"
|
||||
version = "9.4"
|
||||
description = "Extension pack for Python Markdown."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
Markdown = ">=3.2"
|
||||
markdown = ">=3.2"
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
@@ -641,16 +641,16 @@ pyyaml = "*"
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "12.1.0"
|
||||
version = "12.3.0"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6.2,<4.0.0"
|
||||
python-versions = ">=3.6.3,<4.0.0"
|
||||
|
||||
[package.dependencies]
|
||||
commonmark = ">=0.9.0,<0.10.0"
|
||||
pygments = ">=2.6.0,<3.0.0"
|
||||
typing-extensions = {version = ">=3.7.4,<5.0", markers = "python_version < \"3.9\""}
|
||||
typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""}
|
||||
|
||||
[package.extras]
|
||||
jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
|
||||
@@ -700,11 +700,11 @@ python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "3.10.0.2"
|
||||
description = "Backported and Experimental Type Hints for Python 3.5+"
|
||||
version = "4.2.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.7+"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
@@ -764,7 +764,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "256c1d6571a11bf4b80d0eba16d9e39bf2965c4436281c3ec40033cca54aa098"
|
||||
content-hash = "2f50b8219bfdf683dabf54b0a33636f27712a6ecccc1f8ce2695e1f7793f9969"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = [
|
||||
@@ -1027,12 +1027,12 @@ frozenlist = [
|
||||
{file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"},
|
||||
]
|
||||
ghp-import = [
|
||||
{file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"},
|
||||
{file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"},
|
||||
{file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"},
|
||||
{file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
|
||||
]
|
||||
identify = [
|
||||
{file = "identify-2.4.12-py2.py3-none-any.whl", hash = "sha256:5f06b14366bd1facb88b00540a1de05b69b310cbc2654db3c7e07fa3a4339323"},
|
||||
{file = "identify-2.4.12.tar.gz", hash = "sha256:3f3244a559290e7d3deb9e9adc7b33594c1bc85a9dd82e0f1be519bf12a1ec17"},
|
||||
{file = "identify-2.5.0-py2.py3-none-any.whl", hash = "sha256:3acfe15a96e4272b4ec5662ee3e231ceba976ef63fd9980ed2ce9cc415df393f"},
|
||||
{file = "identify-2.5.0.tar.gz", hash = "sha256:c83af514ea50bf2be2c4a3f2fb349442b59dc87284558ae9ff54191bff3541d2"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
|
||||
@@ -1047,8 +1047,8 @@ iniconfig = [
|
||||
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
|
||||
]
|
||||
jinja2 = [
|
||||
{file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"},
|
||||
{file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"},
|
||||
{file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
|
||||
{file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
|
||||
]
|
||||
markdown = [
|
||||
{file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"},
|
||||
@@ -1236,12 +1236,12 @@ py = [
|
||||
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
|
||||
]
|
||||
pygments = [
|
||||
{file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
|
||||
{file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
|
||||
{file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"},
|
||||
{file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"},
|
||||
]
|
||||
pymdown-extensions = [
|
||||
{file = "pymdown-extensions-9.3.tar.gz", hash = "sha256:a80553b243d3ed2d6c27723bcd64ca9887e560e6f4808baa96f36e93061eaf90"},
|
||||
{file = "pymdown_extensions-9.3-py3-none-any.whl", hash = "sha256:b37461a181c1c8103cfe1660081726a0361a8294cbfda88e5b02cefe976f0546"},
|
||||
{file = "pymdown_extensions-9.4-py3-none-any.whl", hash = "sha256:5b7432456bf555ce2b0ab3c2439401084cda8110f24f6b3ecef952b8313dfa1b"},
|
||||
{file = "pymdown_extensions-9.4.tar.gz", hash = "sha256:1baa22a60550f731630474cad28feb0405c8101f1a7ddc3ec0ed86ee510bcc43"},
|
||||
]
|
||||
pyparsing = [
|
||||
{file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"},
|
||||
@@ -1312,8 +1312,8 @@ pyyaml-env-tag = [
|
||||
{file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
|
||||
]
|
||||
rich = [
|
||||
{file = "rich-12.1.0-py3-none-any.whl", hash = "sha256:b60ff99f4ff7e3d1d37444dee2b22fdd941c622dbc37841823ec1ce7f058b263"},
|
||||
{file = "rich-12.1.0.tar.gz", hash = "sha256:198ae15807a7c1bf84ceabf662e902731bf8f874f9e775e2289cab02bb6a4e30"},
|
||||
{file = "rich-12.3.0-py3-none-any.whl", hash = "sha256:0eb63013630c6ee1237e0e395d51cb23513de6b5531235e33889e8842bdf3a6f"},
|
||||
{file = "rich-12.3.0.tar.gz", hash = "sha256:7e8700cda776337036a712ff0495b04052fb5f957c7dfb8df997f88350044b64"},
|
||||
]
|
||||
six = [
|
||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||
@@ -1396,9 +1396,8 @@ typed-ast = [
|
||||
{file = "typed_ast-1.5.3.tar.gz", hash = "sha256:27f25232e2dd0edfe1f019d6bfaaf11e86e657d9bdb7b0956db95f560cceb2b3"},
|
||||
]
|
||||
typing-extensions = [
|
||||
{file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
|
||||
{file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
|
||||
{file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
|
||||
{file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"},
|
||||
{file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"},
|
||||
]
|
||||
virtualenv = [
|
||||
{file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"},
|
||||
|
||||
@@ -22,12 +22,12 @@ textual = "textual.cli.cli:run"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.7"
|
||||
rich = "^12.0.0"
|
||||
rich = "^12.3.0"
|
||||
|
||||
#rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"}
|
||||
typing-extensions = { version = "^3.10.0", python = "<3.8" }
|
||||
click = "8.1.2"
|
||||
importlib-metadata = "^4.11.3"
|
||||
typing-extensions = { version = "^4.0.0", python = "<3.8" }
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^6.2.3"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
from rich.align import Align
|
||||
from rich.console import RenderableType
|
||||
from rich.syntax import Syntax
|
||||
@@ -139,7 +141,8 @@ class BasicApp(App):
|
||||
self.panic(self.tree)
|
||||
|
||||
|
||||
app = BasicApp(css_file="basic.css", watch_css=True, log="textual.log")
|
||||
css_file = Path(__file__).parent / "basic.css"
|
||||
app = BasicApp(css_file=str(css_file), watch_css=True, log="textual.log")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
|
||||
.list-item {
|
||||
height: 8;
|
||||
background: darkblue;
|
||||
color: #12a0;
|
||||
background: #ffffff00;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class BasicApp(App):
|
||||
self.focused.display = not self.focused.display
|
||||
|
||||
def action_toggle_border(self):
|
||||
self.focused.styles.border = ("solid", "red")
|
||||
self.focused.styles.border_top = ("solid", "invalid-color")
|
||||
|
||||
|
||||
app = BasicApp(css_file="uber.css", log="textual.log", log_verbosity=1)
|
||||
|
||||
@@ -448,7 +448,7 @@ class Compositor:
|
||||
segment_lines: list[list[Segment]] = [
|
||||
sum(
|
||||
[line for line in bucket.values() if line is not None],
|
||||
start=[],
|
||||
[],
|
||||
)
|
||||
for bucket in chops
|
||||
]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import weakref
|
||||
from asyncio import (
|
||||
get_event_loop,
|
||||
CancelledError,
|
||||
Event,
|
||||
sleep,
|
||||
@@ -84,7 +84,7 @@ class Timer:
|
||||
Returns:
|
||||
Task: A Task instance for the timer.
|
||||
"""
|
||||
self._task = get_event_loop().create_task(self._run())
|
||||
self._task = asyncio.create_task(self._run())
|
||||
return self._task
|
||||
|
||||
def stop_no_wait(self) -> None:
|
||||
|
||||
@@ -6,7 +6,6 @@ import os
|
||||
import platform
|
||||
import sys
|
||||
import warnings
|
||||
from asyncio import AbstractEventLoop
|
||||
from contextlib import redirect_stdout
|
||||
from time import perf_counter
|
||||
from typing import (
|
||||
@@ -38,10 +37,9 @@ from ._animator import Animator
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
from ._event_broker import extract_handler_actions, NoHandler
|
||||
from ._profile import timer
|
||||
from ._timer import Timer
|
||||
from .binding import Bindings, NoBinding
|
||||
from .css.stylesheet import Stylesheet, StylesheetError
|
||||
from .css.stylesheet import Stylesheet
|
||||
from .design import ColorSystem
|
||||
from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog
|
||||
from .devtools.redirect_output import StdoutRedirector
|
||||
@@ -65,6 +63,9 @@ WINDOWS = PLATFORM == "Windows"
|
||||
# asyncio will warn against resources not being cleared
|
||||
warnings.simplefilter("always", ResourceWarning)
|
||||
|
||||
# `asyncio.get_event_loop()` is deprecated since Python 3.10:
|
||||
_ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0)
|
||||
|
||||
LayoutDefinition = "dict[str, Any]"
|
||||
|
||||
|
||||
@@ -122,6 +123,11 @@ class App(Generic[ReturnType], DOMNode):
|
||||
css (str | None, optional): CSS code to parse, or ``None`` for no literal CSS. Defaults to None.
|
||||
watch_css (bool, optional): Watch CSS for changes. Defaults to True.
|
||||
"""
|
||||
# N.B. This must be done *before* we call the parent constructor, because MessagePump's
|
||||
# constructor instantiates a `asyncio.PriorityQueue` and in Python versions older than 3.10
|
||||
# this will create some first references to an asyncio loop.
|
||||
_init_uvloop()
|
||||
|
||||
self.console = Console(
|
||||
file=sys.__stdout__, markup=False, highlight=False, emoji=False
|
||||
)
|
||||
@@ -428,25 +434,19 @@ class App(Generic[ReturnType], DOMNode):
|
||||
keys, action, description, show=show, key_display=key_display
|
||||
)
|
||||
|
||||
def run(self, loop: AbstractEventLoop | None = None) -> ReturnType | None:
|
||||
def run(self) -> ReturnType | None:
|
||||
"""The entry point to run a Textual app."""
|
||||
|
||||
async def run_app() -> None:
|
||||
await self.process_messages()
|
||||
|
||||
if loop:
|
||||
asyncio.set_event_loop(loop)
|
||||
else:
|
||||
try:
|
||||
import uvloop
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
asyncio.set_event_loop(uvloop.new_event_loop())
|
||||
|
||||
event_loop = asyncio.get_event_loop()
|
||||
try:
|
||||
if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
|
||||
# N.B. This doesn't work with Python<3.10, as we end up with 2 event loops:
|
||||
asyncio.run(run_app())
|
||||
finally:
|
||||
event_loop.close()
|
||||
else:
|
||||
# However, this works with Python<3.10:
|
||||
event_loop = asyncio.get_event_loop()
|
||||
event_loop.run_until_complete(run_app())
|
||||
|
||||
return self._return_value
|
||||
|
||||
@@ -631,6 +631,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._set_active()
|
||||
log("---")
|
||||
log(f"driver={self.driver_class}")
|
||||
log(f"asyncio running loop={asyncio.get_running_loop()!r}")
|
||||
|
||||
if self.devtools_enabled:
|
||||
try:
|
||||
@@ -1011,3 +1012,26 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
|
||||
self.stylesheet.update(self, animate=True)
|
||||
|
||||
|
||||
_uvloop_init_done: bool = False
|
||||
|
||||
|
||||
def _init_uvloop() -> None:
|
||||
"""
|
||||
Import and install the `uvloop` asyncio policy, if available.
|
||||
This is done only once, even if the function is called multiple times.
|
||||
"""
|
||||
global _uvloop_init_done
|
||||
|
||||
if _uvloop_init_done:
|
||||
return
|
||||
|
||||
try:
|
||||
import uvloop
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
uvloop.install()
|
||||
|
||||
_uvloop_init_done = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Manages Color in Textual.
|
||||
|
||||
All instances where the developer is presented with a color should use this class. The only
|
||||
All instances where the developer is presented with a color should use this class. The only
|
||||
exception should be when passing things to a Rich renderable, which will need to use the
|
||||
`rich_color` attribute to perform a conversion.
|
||||
|
||||
@@ -54,6 +54,8 @@ class Lab(NamedTuple):
|
||||
|
||||
RE_COLOR = re.compile(
|
||||
r"""^
|
||||
\#([0-9a-fA-F]{3})$|
|
||||
\#([0-9a-fA-F]{4})$|
|
||||
\#([0-9a-fA-F]{6})$|
|
||||
\#([0-9a-fA-F]{8})$|
|
||||
rgb\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$|
|
||||
@@ -62,7 +64,7 @@ rgba\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# Fast way to split a string of 8 characters in to 3 pairs of 2 characters
|
||||
# Fast way to split a string of 6 characters in to 3 pairs of 2 characters
|
||||
split_pairs3: Callable[[str], tuple[str, str, str]] = itemgetter(
|
||||
slice(0, 2), slice(2, 4), slice(4, 6)
|
||||
)
|
||||
@@ -270,9 +272,27 @@ class Color(NamedTuple):
|
||||
color_match = RE_COLOR.match(color_text)
|
||||
if color_match is None:
|
||||
raise ColorParseError(f"failed to parse {color_text!r} as a color")
|
||||
rgb_hex, rgba_hex, rgb, rgba = color_match.groups()
|
||||
(
|
||||
rgb_hex_triple,
|
||||
rgb_hex_quad,
|
||||
rgb_hex,
|
||||
rgba_hex,
|
||||
rgb,
|
||||
rgba,
|
||||
) = color_match.groups()
|
||||
|
||||
if rgb_hex is not None:
|
||||
if rgb_hex_triple is not None:
|
||||
r, g, b = rgb_hex_triple
|
||||
color = cls(int(f"{r}{r}", 16), int(f"{g}{g}", 16), int(f"{b}{b}", 16))
|
||||
elif rgb_hex_quad is not None:
|
||||
r, g, b, a = rgb_hex_quad
|
||||
color = cls(
|
||||
int(f"{r}{r}", 16),
|
||||
int(f"{g}{g}", 16),
|
||||
int(f"{b}{b}", 16),
|
||||
int(f"{a}{a}", 16) / 255.0,
|
||||
)
|
||||
elif rgb_hex is not None:
|
||||
r, g, b = [int(pair, 16) for pair in split_pairs3(rgb_hex)]
|
||||
color = cls(r, g, b, 1.0)
|
||||
elif rgba_hex is not None:
|
||||
|
||||
@@ -128,7 +128,32 @@ def _spacing_examples(property_name: str) -> ContextSpecificBullets:
|
||||
)
|
||||
|
||||
|
||||
def spacing_wrong_number_of_values(
|
||||
def property_invalid_value_help_text(
|
||||
property_name: str, context: StylingContext, *, suggested_property_name: str = None
|
||||
) -> HelpText:
|
||||
"""Help text to show when the user supplies an invalid value for CSS property
|
||||
property.
|
||||
|
||||
Args:
|
||||
property_name (str): The name of the property
|
||||
context (StylingContext | None): The context the spacing property is being used in.
|
||||
Keyword Args:
|
||||
suggested_property_name (str | None): A suggested name for the property (e.g. "width" for "wdth"). Defaults to None.
|
||||
|
||||
Returns:
|
||||
HelpText: Renderable for displaying the help text for this property
|
||||
"""
|
||||
property_name = _contextualize_property_name(property_name, context)
|
||||
bullets = []
|
||||
if suggested_property_name:
|
||||
suggested_property_name = _contextualize_property_name(
|
||||
suggested_property_name, context
|
||||
)
|
||||
bullets.append(Bullet(f'Did you mean "{suggested_property_name}"?'))
|
||||
return HelpText(f"Invalid CSS property [i]{property_name}[/]", bullets=bullets)
|
||||
|
||||
|
||||
def spacing_wrong_number_of_values_help_text(
|
||||
property_name: str,
|
||||
num_values_supplied: int,
|
||||
context: StylingContext,
|
||||
@@ -159,7 +184,7 @@ def spacing_wrong_number_of_values(
|
||||
)
|
||||
|
||||
|
||||
def spacing_invalid_value(
|
||||
def spacing_invalid_value_help_text(
|
||||
property_name: str,
|
||||
context: StylingContext,
|
||||
) -> HelpText:
|
||||
|
||||
@@ -22,7 +22,7 @@ from ._help_text import (
|
||||
style_flags_property_help_text,
|
||||
)
|
||||
from ._help_text import (
|
||||
spacing_wrong_number_of_values,
|
||||
spacing_wrong_number_of_values_help_text,
|
||||
scalar_help_text,
|
||||
string_enum_help_text,
|
||||
color_property_help_text,
|
||||
@@ -103,6 +103,7 @@ class ScalarProperty:
|
||||
StyleValueError: If the value is of an invalid type, uses an invalid unit, or
|
||||
cannot be parsed for any other reason.
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
if value is None:
|
||||
obj.clear_rule(self.name)
|
||||
obj.refresh(layout=True)
|
||||
@@ -186,6 +187,7 @@ class BoxProperty:
|
||||
Raises:
|
||||
StyleSyntaxError: If the string supplied for the color has invalid syntax.
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
if border is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh()
|
||||
@@ -310,6 +312,7 @@ class BorderProperty:
|
||||
Raises:
|
||||
StyleValueError: When the supplied ``tuple`` is not of valid length (1, 2, or 4).
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
top, right, bottom, left = self._properties
|
||||
if border is None:
|
||||
clear_rule = obj.clear_rule
|
||||
@@ -405,7 +408,7 @@ class SpacingProperty:
|
||||
ValueError: When the value is malformed, e.g. a ``tuple`` with a length that is
|
||||
not 1, 2, or 4.
|
||||
"""
|
||||
|
||||
_rich_traceback_omit = True
|
||||
if spacing is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh(layout=True)
|
||||
@@ -415,7 +418,7 @@ class SpacingProperty:
|
||||
except ValueError as error:
|
||||
raise StyleValueError(
|
||||
str(error),
|
||||
help_text=spacing_wrong_number_of_values(
|
||||
help_text=spacing_wrong_number_of_values_help_text(
|
||||
property_name=self.name,
|
||||
num_values_supplied=len(spacing),
|
||||
context="inline",
|
||||
@@ -455,6 +458,7 @@ class DocksProperty:
|
||||
obj (Styles): The ``Styles`` object.
|
||||
docks (Iterable[DockGroup]): Iterable of DockGroups
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
if docks is None:
|
||||
if obj.clear_rule("docks"):
|
||||
obj.refresh(layout=True)
|
||||
@@ -489,6 +493,7 @@ class DockProperty:
|
||||
obj (Styles): The ``Styles`` object
|
||||
dock_name (str | None): The name of the dock to attach this widget to
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
if obj.set_rule("dock", dock_name):
|
||||
obj.refresh(layout=True)
|
||||
|
||||
@@ -525,6 +530,7 @@ class LayoutProperty:
|
||||
MissingLayout,
|
||||
) # Prevents circular import
|
||||
|
||||
_rich_traceback_omit = True
|
||||
if layout is None:
|
||||
if obj.clear_rule("layout"):
|
||||
obj.refresh(layout=True)
|
||||
@@ -583,6 +589,7 @@ class OffsetProperty:
|
||||
ScalarParseError: If any of the string values supplied in the 2-tuple cannot
|
||||
be parsed into a Scalar. For example, if you specify a non-existent unit.
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
if offset is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh(layout=True)
|
||||
@@ -649,7 +656,7 @@ class StringEnumProperty:
|
||||
Raises:
|
||||
StyleValueError: If the value is not in the set of valid values.
|
||||
"""
|
||||
|
||||
_rich_traceback_omit = True
|
||||
if value is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh(layout=self._layout)
|
||||
@@ -695,7 +702,7 @@ class NameProperty:
|
||||
Raises:
|
||||
StyleTypeError: If the value is not a ``str``.
|
||||
"""
|
||||
|
||||
_rich_traceback_omit = True
|
||||
if name is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh(layout=True)
|
||||
@@ -716,7 +723,7 @@ class NameListProperty:
|
||||
return cast("tuple[str, ...]", obj.get_rule(self.name, ()))
|
||||
|
||||
def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None):
|
||||
|
||||
_rich_traceback_omit = True
|
||||
if names is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh(layout=True)
|
||||
@@ -765,7 +772,7 @@ class ColorProperty:
|
||||
Raises:
|
||||
ColorParseError: When the color string is invalid.
|
||||
"""
|
||||
|
||||
_rich_traceback_omit = True
|
||||
if color is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh()
|
||||
@@ -817,6 +824,7 @@ class StyleFlagsProperty:
|
||||
Raises:
|
||||
StyleValueError: If the value is an invalid style flag
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
if style_flags is None:
|
||||
if obj.clear_rule(self.name):
|
||||
obj.refresh()
|
||||
@@ -859,6 +867,7 @@ class TransitionsProperty:
|
||||
return obj.get_rule("transitions", {})
|
||||
|
||||
def __set__(self, obj: Styles, transitions: dict[str, Transition] | None) -> None:
|
||||
_rich_traceback_omit = True
|
||||
if transitions is None:
|
||||
obj.clear_rule("transitions")
|
||||
else:
|
||||
@@ -896,6 +905,7 @@ class FractionalProperty:
|
||||
value (float|str|None): The value to set as a float between 0 and 1, or
|
||||
as a percentage string such as '10%'.
|
||||
"""
|
||||
_rich_traceback_omit = True
|
||||
name = self.name
|
||||
if value is None:
|
||||
if obj.clear_rule(name):
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast, Iterable, NoReturn
|
||||
from functools import lru_cache
|
||||
from typing import cast, Iterable, NoReturn, Sequence
|
||||
|
||||
import rich.repr
|
||||
|
||||
from ._error_tools import friendly_list
|
||||
from ._help_renderables import HelpText
|
||||
from ._help_text import (
|
||||
spacing_invalid_value,
|
||||
spacing_wrong_number_of_values,
|
||||
spacing_invalid_value_help_text,
|
||||
spacing_wrong_number_of_values_help_text,
|
||||
scalar_help_text,
|
||||
color_property_help_text,
|
||||
string_enum_help_text,
|
||||
@@ -21,6 +22,7 @@ from ._help_text import (
|
||||
offset_property_help_text,
|
||||
offset_single_axis_help_text,
|
||||
style_flags_property_help_text,
|
||||
property_invalid_value_help_text,
|
||||
)
|
||||
from .constants import (
|
||||
VALID_ALIGN_HORIZONTAL,
|
||||
@@ -44,6 +46,7 @@ from ..color import Color, ColorParseError
|
||||
from .._duration import _duration_as_seconds
|
||||
from .._easing import EASING
|
||||
from ..geometry import Spacing, SpacingDimensions, clamp
|
||||
from ..suggestions import get_suggestion
|
||||
|
||||
|
||||
def _join_tokens(tokens: Iterable[Token], joiner: str = "") -> str:
|
||||
@@ -84,26 +87,47 @@ class StylesBuilder:
|
||||
process_method = getattr(self, f"process_{rule_name}", None)
|
||||
|
||||
if process_method is None:
|
||||
suggested_property_name = self._get_suggested_property_name_for_rule(
|
||||
declaration.name
|
||||
)
|
||||
self.error(
|
||||
declaration.name,
|
||||
declaration.token,
|
||||
f"unknown declaration {declaration.name!r}",
|
||||
property_invalid_value_help_text(
|
||||
declaration.name,
|
||||
"css",
|
||||
suggested_property_name=suggested_property_name,
|
||||
),
|
||||
)
|
||||
else:
|
||||
tokens = declaration.tokens
|
||||
return
|
||||
|
||||
important = tokens[-1].name == "important"
|
||||
if important:
|
||||
tokens = tokens[:-1]
|
||||
self.styles.important.add(rule_name)
|
||||
try:
|
||||
process_method(declaration.name, tokens)
|
||||
except DeclarationError:
|
||||
raise
|
||||
except Exception as error:
|
||||
self.error(declaration.name, declaration.token, str(error))
|
||||
tokens = declaration.tokens
|
||||
|
||||
def _process_enum_multiple(
|
||||
important = tokens[-1].name == "important"
|
||||
if important:
|
||||
tokens = tokens[:-1]
|
||||
self.styles.important.add(rule_name)
|
||||
try:
|
||||
process_method(declaration.name, tokens)
|
||||
except DeclarationError:
|
||||
raise
|
||||
except Exception as error:
|
||||
self.error(declaration.name, declaration.token, str(error))
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _get_processable_rule_names(self) -> Sequence[str]:
|
||||
"""
|
||||
Returns the list of CSS properties we can manage -
|
||||
i.e. the ones for which we have a `process_[property name]` method
|
||||
|
||||
Returns:
|
||||
Sequence[str]: All the "Python-ised" CSS property names this class can handle.
|
||||
|
||||
Example: ("width", "background", "offset_x", ...)
|
||||
"""
|
||||
return [attr[8:] for attr in dir(self) if attr.startswith("process_")]
|
||||
|
||||
def _get_process_enum_multiple(
|
||||
self, name: str, tokens: list[Token], valid_values: set[str], count: int
|
||||
) -> tuple[str, ...]:
|
||||
"""Generic code to process a declaration with two enumerations, like overflow: auto auto"""
|
||||
@@ -332,14 +356,20 @@ class StylesBuilder:
|
||||
try:
|
||||
append(int(value))
|
||||
except ValueError:
|
||||
self.error(name, token, spacing_invalid_value(name, context="css"))
|
||||
self.error(
|
||||
name,
|
||||
token,
|
||||
spacing_invalid_value_help_text(name, context="css"),
|
||||
)
|
||||
else:
|
||||
self.error(name, token, spacing_invalid_value(name, context="css"))
|
||||
self.error(
|
||||
name, token, spacing_invalid_value_help_text(name, context="css")
|
||||
)
|
||||
if len(space) not in (1, 2, 4):
|
||||
self.error(
|
||||
name,
|
||||
tokens[0],
|
||||
spacing_wrong_number_of_values(
|
||||
spacing_wrong_number_of_values_help_text(
|
||||
name, num_values_supplied=len(space), context="css"
|
||||
),
|
||||
)
|
||||
@@ -348,7 +378,9 @@ class StylesBuilder:
|
||||
def _process_space_partial(self, name: str, tokens: list[Token]) -> None:
|
||||
"""Process granular margin / padding declarations."""
|
||||
if len(tokens) != 1:
|
||||
self.error(name, tokens[0], spacing_invalid_value(name, context="css"))
|
||||
self.error(
|
||||
name, tokens[0], spacing_invalid_value_help_text(name, context="css")
|
||||
)
|
||||
|
||||
_EDGE_SPACING_MAP = {"top": 0, "right": 1, "bottom": 2, "left": 3}
|
||||
token = tokens[0]
|
||||
@@ -356,7 +388,9 @@ class StylesBuilder:
|
||||
if token_name == "number":
|
||||
space = int(value)
|
||||
else:
|
||||
self.error(name, token, spacing_invalid_value(name, context="css"))
|
||||
self.error(
|
||||
name, token, spacing_invalid_value_help_text(name, context="css")
|
||||
)
|
||||
style_name, _, edge = name.replace("-", "_").partition("_")
|
||||
|
||||
current_spacing = cast(
|
||||
@@ -733,3 +767,18 @@ class StylesBuilder:
|
||||
process_content_align = process_align
|
||||
process_content_align_horizontal = process_align_horizontal
|
||||
process_content_align_vertical = process_align_vertical
|
||||
|
||||
def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
|
||||
"""
|
||||
Returns a valid CSS property "Python" name, or None if no close matches could be found.
|
||||
|
||||
Args:
|
||||
rule_name (str): An invalid "Python-ised" CSS property (i.e. "offst_x" rather than "offst-x")
|
||||
|
||||
Returns:
|
||||
str | None: The closest valid "Python-ised" CSS property.
|
||||
Returns `None` if no close matches could be found.
|
||||
|
||||
Example: returns "background" for rule_name "bkgrund", "offset_x" for "ofset_x"
|
||||
"""
|
||||
return get_suggestion(rule_name, self._get_processable_rule_names())
|
||||
|
||||
@@ -9,7 +9,7 @@ COMMENT_START = r"\/\*"
|
||||
SCALAR = r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)"
|
||||
DURATION = r"\d+\.?\d*(?:ms|s)"
|
||||
NUMBER = r"\-?\d+\.?\d*"
|
||||
COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|rgb\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)|rgba\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)"
|
||||
COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|\#[0-9a-fA-F]{4}|\#[0-9a-fA-F]{3}|rgb\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)|rgba\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)"
|
||||
KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
|
||||
TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*"
|
||||
STRING = r"\".*?\""
|
||||
|
||||
@@ -21,7 +21,7 @@ class Driver(ABC):
|
||||
self.console = console
|
||||
self._target = target
|
||||
self._debug = debug
|
||||
self._loop = asyncio.get_event_loop()
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._mouse_down_time = time()
|
||||
|
||||
def send_event(self, event: events.Event) -> None:
|
||||
|
||||
@@ -73,7 +73,7 @@ class LinuxDriver(Driver):
|
||||
|
||||
def start_application_mode(self):
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def send_size_event():
|
||||
terminal_size = self._get_terminal_size()
|
||||
@@ -121,7 +121,7 @@ class LinuxDriver(Driver):
|
||||
self.console.file.write("\033[?1003h\n")
|
||||
self.console.file.flush()
|
||||
self._key_thread = Thread(
|
||||
target=self.run_input_thread, args=(asyncio.get_event_loop(),)
|
||||
target=self.run_input_thread, args=(asyncio.get_running_loop(),)
|
||||
)
|
||||
send_size_event()
|
||||
self._key_thread.start()
|
||||
|
||||
@@ -46,7 +46,7 @@ class WindowsDriver(Driver):
|
||||
|
||||
def start_application_mode(self) -> None:
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
self._restore_console = win32.enable_application_mode()
|
||||
|
||||
|
||||
38
src/textual/suggestions.py
Normal file
38
src/textual/suggestions.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from difflib import get_close_matches
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
def get_suggestion(word: str, possible_words: Sequence[str]) -> str | None:
|
||||
"""
|
||||
Returns a close match of `word` amongst `possible_words`.
|
||||
|
||||
Args:
|
||||
word (str): The word we want to find a close match for
|
||||
possible_words (Sequence[str]): The words amongst which we want to find a close match
|
||||
|
||||
Returns:
|
||||
str | None: The closest match amongst the `possible_words`. Returns `None` if no close matches could be found.
|
||||
|
||||
Example: returns "red" for word "redu" and possible words ("yellow", "red")
|
||||
"""
|
||||
possible_matches = get_close_matches(word, possible_words, n=1)
|
||||
return None if not possible_matches else possible_matches[0]
|
||||
|
||||
|
||||
def get_suggestions(word: str, possible_words: Sequence[str], count: int) -> list[str]:
|
||||
"""
|
||||
Returns a list of up to `count` matches of `word` amongst `possible_words`.
|
||||
|
||||
Args:
|
||||
word (str): The word we want to find a close match for
|
||||
possible_words (Sequence[str]): The words amongst which we want to find close matches
|
||||
|
||||
Returns:
|
||||
list[str]: The closest matches amongst the `possible_words`, from the closest to the least close.
|
||||
Returns an empty list if no close matches could be found.
|
||||
|
||||
Example: returns ["yellow", "ellow"] for word "yllow" and possible words ("yellow", "red", "ellow")
|
||||
"""
|
||||
return get_close_matches(word, possible_words, n=count)
|
||||
@@ -1,10 +1,22 @@
|
||||
import pytest
|
||||
|
||||
from tests.utilities.render import render
|
||||
from textual.css._help_text import spacing_wrong_number_of_values, spacing_invalid_value, scalar_help_text, \
|
||||
string_enum_help_text, color_property_help_text, border_property_help_text, layout_property_help_text, \
|
||||
docks_property_help_text, dock_property_help_text, fractional_property_help_text, offset_property_help_text, \
|
||||
align_help_text, offset_single_axis_help_text, style_flags_property_help_text
|
||||
from textual.css._help_text import (
|
||||
spacing_wrong_number_of_values_help_text,
|
||||
spacing_invalid_value_help_text,
|
||||
scalar_help_text,
|
||||
string_enum_help_text,
|
||||
color_property_help_text,
|
||||
border_property_help_text,
|
||||
layout_property_help_text,
|
||||
docks_property_help_text,
|
||||
dock_property_help_text,
|
||||
fractional_property_help_text,
|
||||
offset_property_help_text,
|
||||
align_help_text,
|
||||
offset_single_axis_help_text,
|
||||
style_flags_property_help_text,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(params=["css", "inline"])
|
||||
@@ -15,22 +27,24 @@ def styling_context(request):
|
||||
def test_help_text_examples_are_contextualized():
|
||||
"""Ensure that if the user is using CSS, they see CSS-specific examples
|
||||
and if they're using inline styles they see inline-specific examples."""
|
||||
rendered_inline = render(spacing_invalid_value("padding", "inline"))
|
||||
rendered_inline = render(spacing_invalid_value_help_text("padding", "inline"))
|
||||
assert "widget.styles.padding" in rendered_inline
|
||||
|
||||
rendered_css = render(spacing_invalid_value("padding", "css"))
|
||||
rendered_css = render(spacing_invalid_value_help_text("padding", "css"))
|
||||
assert "padding:" in rendered_css
|
||||
|
||||
|
||||
def test_spacing_wrong_number_of_values(styling_context):
|
||||
rendered = render(spacing_wrong_number_of_values("margin", 3, styling_context))
|
||||
rendered = render(
|
||||
spacing_wrong_number_of_values_help_text("margin", 3, styling_context)
|
||||
)
|
||||
assert "Invalid number of values" in rendered
|
||||
assert "margin" in rendered
|
||||
assert "3" in rendered
|
||||
|
||||
|
||||
def test_spacing_invalid_value(styling_context):
|
||||
rendered = render(spacing_invalid_value("padding", styling_context))
|
||||
rendered = render(spacing_invalid_value_help_text("padding", styling_context))
|
||||
assert "Invalid value for" in rendered
|
||||
assert "padding" in rendered
|
||||
|
||||
@@ -47,7 +61,9 @@ def test_scalar_help_text(styling_context):
|
||||
|
||||
|
||||
def test_string_enum_help_text(styling_context):
|
||||
rendered = render(string_enum_help_text("display", ["none", "hidden"], styling_context))
|
||||
rendered = render(
|
||||
string_enum_help_text("display", ["none", "hidden"], styling_context)
|
||||
)
|
||||
assert "Invalid value for" in rendered
|
||||
|
||||
# Ensure property name is mentioned
|
||||
@@ -113,7 +129,9 @@ def test_offset_single_axis_help_text():
|
||||
|
||||
|
||||
def test_style_flags_property_help_text(styling_context):
|
||||
rendered = render(style_flags_property_help_text("text-style", "notavalue b", styling_context))
|
||||
rendered = render(
|
||||
style_flags_property_help_text("text-style", "notavalue b", styling_context)
|
||||
)
|
||||
assert "Invalid value" in rendered
|
||||
assert "notavalue" in rendered
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from contextlib import nullcontext as does_not_raise
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from textual.color import Color
|
||||
from textual.css._help_renderables import HelpText
|
||||
from textual.css.stylesheet import Stylesheet, StylesheetParseError
|
||||
from textual.css.tokenizer import TokenizeError
|
||||
|
||||
@@ -29,8 +32,6 @@ from textual.css.tokenizer import TokenizeError
|
||||
["red 4", pytest.raises(StylesheetParseError), None], # space in it
|
||||
["1", pytest.raises(StylesheetParseError), None], # invalid value
|
||||
["()", pytest.raises(TokenizeError), None], # invalid tokens
|
||||
# TODO: implement hex colors with 3 chars? @link https://devdocs.io/css/color_value
|
||||
["#09f", pytest.raises(TokenizeError), None],
|
||||
# TODO: allow spaces in rgb/rgba expressions?
|
||||
["rgb(200, 90, 30)", pytest.raises(TokenizeError), None],
|
||||
["rgba(200,90,30, 0.4)", pytest.raises(TokenizeError), None],
|
||||
@@ -53,3 +54,50 @@ def test_color_property_parsing(css_value, expectation, expected_color):
|
||||
if expected_color:
|
||||
css_rule = stylesheet.rules[0]
|
||||
assert css_rule.styles.background == expected_color
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"css_property_name,expected_property_name_suggestion",
|
||||
[
|
||||
["backgroundu", "background"],
|
||||
["bckgroundu", "background"],
|
||||
["ofset-x", "offset-x"],
|
||||
["ofst_y", "offset-y"],
|
||||
["colr", "color"],
|
||||
["colour", "color"],
|
||||
["wdth", "width"],
|
||||
["wth", "width"],
|
||||
["wh", None],
|
||||
["xkcd", None],
|
||||
],
|
||||
)
|
||||
def test_did_you_mean_for_css_property_names(
|
||||
css_property_name: str, expected_property_name_suggestion
|
||||
):
|
||||
stylesheet = Stylesheet()
|
||||
css = """
|
||||
* {
|
||||
border: blue;
|
||||
${PROPERTY}: red;
|
||||
}
|
||||
""".replace(
|
||||
"${PROPERTY}", css_property_name
|
||||
)
|
||||
|
||||
stylesheet.add_source(css)
|
||||
with pytest.raises(StylesheetParseError) as err:
|
||||
stylesheet.parse()
|
||||
|
||||
_, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText
|
||||
displayed_css_property_name = css_property_name.replace("_", "-")
|
||||
assert (
|
||||
help_text.summary == f"Invalid CSS property [i]{displayed_css_property_name}[/]"
|
||||
)
|
||||
|
||||
expected_bullets_length = 1 if expected_property_name_suggestion else 0
|
||||
assert len(help_text.bullets) == expected_bullets_length
|
||||
if expected_property_name_suggestion is not None:
|
||||
expected_suggestion_message = (
|
||||
f'Did you mean "{expected_property_name_suggestion}"?'
|
||||
)
|
||||
assert help_text.bullets[0].markup == expected_suggestion_message
|
||||
|
||||
@@ -93,6 +93,8 @@ def test_color_blend():
|
||||
("#000000", Color(0, 0, 0, 1.0)),
|
||||
("#ffffff", Color(255, 255, 255, 1.0)),
|
||||
("#FFFFFF", Color(255, 255, 255, 1.0)),
|
||||
("#fab", Color(255, 170, 187, 1.0)), # #ffaabb
|
||||
("#fab0", Color(255, 170, 187, .0)), # #ffaabb00
|
||||
("#020304ff", Color(2, 3, 4, 1.0)),
|
||||
("#02030400", Color(2, 3, 4, 0.0)),
|
||||
("#0203040f", Color(2, 3, 4, 0.058823529411764705)),
|
||||
|
||||
35
tests/test_suggestions.py
Normal file
35
tests/test_suggestions.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from textual.suggestions import get_suggestion, get_suggestions
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"word, possible_words, expected_result",
|
||||
(
|
||||
["background", ("background",), "background"],
|
||||
["backgroundu", ("background",), "background"],
|
||||
["bkgrund", ("background",), "background"],
|
||||
["llow", ("background",), None],
|
||||
["llow", ("background", "yellow"), "yellow"],
|
||||
["yllow", ("background", "yellow", "ellow"), "yellow"],
|
||||
),
|
||||
)
|
||||
def test_get_suggestion(word, possible_words, expected_result):
|
||||
assert get_suggestion(word, possible_words) == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"word, possible_words, count, expected_result",
|
||||
(
|
||||
["background", ("background",), 1, ["background"]],
|
||||
["backgroundu", ("background",), 1, ["background"]],
|
||||
["bkgrund", ("background",), 1, ["background"]],
|
||||
["llow", ("background",), 1, []],
|
||||
["llow", ("background", "yellow"), 1, ["yellow"]],
|
||||
["yllow", ("background", "yellow", "ellow"), 1, ["yellow"]],
|
||||
["yllow", ("background", "yellow", "ellow"), 2, ["yellow", "ellow"]],
|
||||
["yllow", ("background", "yellow", "red"), 2, ["yellow"]],
|
||||
),
|
||||
)
|
||||
def test_get_suggestions(word, possible_words, count, expected_result):
|
||||
assert get_suggestions(word, possible_words, count) == expected_result
|
||||
Reference in New Issue
Block a user