Merge branch 'css' into tab-focus

This commit is contained in:
Will McGugan
2022-05-04 11:04:38 +01:00
committed by GitHub
25 changed files with 815 additions and 107 deletions

View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -7,5 +7,6 @@
.list-item {
height: 8;
background: darkblue;
color: #12a0;
background: #ffffff00;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"\".*?\""

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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