mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' into css-docs-review
This commit is contained in:
4
.github/workflows/pythonpackage.yml
vendored
4
.github/workflows/pythonpackage.yml
vendored
@@ -39,10 +39,6 @@ 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 snapshot report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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 / "test_apps"
|
||||
|
||||
completed_process = None
|
||||
|
||||
|
||||
def launch_sandbox_script(python_file_name: str) -> None:
|
||||
global completed_process
|
||||
|
||||
command = f"{sys.executable} {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=False
|
||||
)
|
||||
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)
|
||||
@@ -1,250 +0,0 @@
|
||||
/* CSS file for basic.py */
|
||||
|
||||
|
||||
|
||||
* {
|
||||
transition: color 300ms linear, background 300ms linear;
|
||||
}
|
||||
|
||||
|
||||
*:hover {
|
||||
/* tint: 30% red;
|
||||
/* outline: heavy red; */
|
||||
}
|
||||
|
||||
App > Screen {
|
||||
|
||||
background: $background;
|
||||
color: $text;
|
||||
layers: base sidebar;
|
||||
layout: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#tree-container {
|
||||
overflow-y: auto;
|
||||
height: 20;
|
||||
margin: 1 2;
|
||||
background: $surface;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
DirectoryTree {
|
||||
padding: 0 1;
|
||||
height: auto;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
DataTable {
|
||||
/*border:heavy red;*/
|
||||
/* tint: 10% green; */
|
||||
/* text-opacity: 50%; */
|
||||
padding: 1;
|
||||
margin: 1 2;
|
||||
height: 24;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
background: $panel;
|
||||
color: $text;
|
||||
dock: left;
|
||||
width: 30;
|
||||
margin-bottom: 1;
|
||||
offset-x: -100%;
|
||||
|
||||
transition: offset 500ms in_out_cubic;
|
||||
layer: sidebar;
|
||||
}
|
||||
|
||||
#sidebar.-active {
|
||||
offset-x: 0;
|
||||
}
|
||||
|
||||
#sidebar .title {
|
||||
height: 1;
|
||||
background: $primary-background-darken-1;
|
||||
color: $text;
|
||||
border-right: wide $background;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#sidebar .user {
|
||||
height: 8;
|
||||
background: $panel-darken-1;
|
||||
color: $text;
|
||||
border-right: wide $background;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#sidebar .content {
|
||||
background: $panel-darken-2;
|
||||
color: $text;
|
||||
border-right: wide $background;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Tweet {
|
||||
height:12;
|
||||
width: 100%;
|
||||
margin: 0 2;
|
||||
|
||||
margin:0 2;
|
||||
background: $panel;
|
||||
color: $text;
|
||||
layout: vertical;
|
||||
/* border: outer $primary; */
|
||||
padding: 1;
|
||||
border: wide $panel;
|
||||
overflow: auto;
|
||||
/* scrollbar-gutter: stable; */
|
||||
align-horizontal: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.scrollable {
|
||||
overflow-x: auto;
|
||||
overflow-y: scroll;
|
||||
margin: 1 2;
|
||||
height: 24;
|
||||
align-horizontal: center;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
.code {
|
||||
height: auto;
|
||||
|
||||
}
|
||||
|
||||
|
||||
TweetHeader {
|
||||
height:1;
|
||||
background: $accent;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
TweetBody {
|
||||
width: 100%;
|
||||
background: $panel;
|
||||
color: $text;
|
||||
height: auto;
|
||||
padding: 0 1 0 0;
|
||||
}
|
||||
|
||||
Tweet.scroll-horizontal TweetBody {
|
||||
width: 350;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: $accent;
|
||||
color: $text;
|
||||
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 400ms in_out_cubic, color 400ms in_out_cubic;
|
||||
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: $accent-lighten-1;
|
||||
color: $text;
|
||||
width: 20;
|
||||
height: 3;
|
||||
border: tall $accent-darken-1;
|
||||
/* border-left: tall $accent-darken-3; */
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
#footer {
|
||||
color: $text;
|
||||
background: $accent;
|
||||
height: 1;
|
||||
|
||||
content-align: center middle;
|
||||
dock:bottom;
|
||||
}
|
||||
|
||||
|
||||
#sidebar .content {
|
||||
layout: vertical
|
||||
}
|
||||
|
||||
OptionItem {
|
||||
height: 3;
|
||||
background: $panel;
|
||||
border-right: wide $background;
|
||||
border-left: blank;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
OptionItem:hover {
|
||||
height: 3;
|
||||
color: $text;
|
||||
background: $primary-darken-1;
|
||||
/* border-top: hkey $accent2-darken-3;
|
||||
border-bottom: hkey $accent2-darken-3; */
|
||||
text-style: bold;
|
||||
border-left: outer $secondary-darken-2;
|
||||
}
|
||||
|
||||
Error {
|
||||
width: 100%;
|
||||
height:3;
|
||||
background: $error;
|
||||
color: $text;
|
||||
border-top: tall $error-darken-2;
|
||||
border-bottom: tall $error-darken-2;
|
||||
|
||||
padding: 0;
|
||||
text-style: bold;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
Warning {
|
||||
width: 100%;
|
||||
height:3;
|
||||
background: $warning;
|
||||
color: $text;
|
||||
border-top: tall $warning-darken-2;
|
||||
border-bottom: tall $warning-darken-2;
|
||||
|
||||
text-style: bold;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
Success {
|
||||
width: 100%;
|
||||
|
||||
height:auto;
|
||||
box-sizing: border-box;
|
||||
background: $success;
|
||||
color: $text;
|
||||
|
||||
border-top: hkey $success-darken-2;
|
||||
border-bottom: hkey $success-darken-2;
|
||||
|
||||
text-style: bold ;
|
||||
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
|
||||
.horizontal {
|
||||
layout: horizontal
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
from rich.console import RenderableType
|
||||
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.reactive import Reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static, DataTable, DirectoryTree, Header, Footer
|
||||
from textual.containers import Container
|
||||
|
||||
CODE = '''
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def loop_first(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
|
||||
"""Iterate and generate a tuple with a flag for first value."""
|
||||
iter_values = iter(values)
|
||||
try:
|
||||
value = next(iter_values)
|
||||
except StopIteration:
|
||||
return
|
||||
yield True, value
|
||||
for value in iter_values:
|
||||
yield False, value
|
||||
|
||||
|
||||
def loop_last(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
|
||||
"""Iterate and generate a tuple with a flag for last value."""
|
||||
iter_values = iter(values)
|
||||
try:
|
||||
previous_value = next(iter_values)
|
||||
except StopIteration:
|
||||
return
|
||||
for value in iter_values:
|
||||
yield False, previous_value
|
||||
previous_value = value
|
||||
yield True, previous_value
|
||||
|
||||
|
||||
def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]:
|
||||
"""Iterate and generate a tuple with a flag for first and last value."""
|
||||
iter_values = iter(values)
|
||||
try:
|
||||
previous_value = next(iter_values)
|
||||
except StopIteration:
|
||||
return
|
||||
first = True
|
||||
for value in iter_values:
|
||||
yield first, False, previous_value
|
||||
first = False
|
||||
previous_value = value
|
||||
yield first, True, previous_value
|
||||
'''
|
||||
|
||||
|
||||
lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, 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."""
|
||||
lorem = (
|
||||
lorem_short
|
||||
+ """ 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_short_text = Text.from_markup(lorem_short)
|
||||
lorem_long_text = Text.from_markup(lorem * 2)
|
||||
|
||||
|
||||
class TweetHeader(Widget):
|
||||
def render(self) -> RenderableType:
|
||||
return Text("Lorem Impsum", justify="center")
|
||||
|
||||
|
||||
class TweetBody(Widget):
|
||||
short_lorem = Reactive(False)
|
||||
|
||||
def render(self) -> Text:
|
||||
return lorem_short_text if self.short_lorem else lorem_long_text
|
||||
|
||||
|
||||
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"""
|
||||
|
||||
CSS_PATH = "basic.css"
|
||||
|
||||
def on_load(self):
|
||||
"""Bind keys here."""
|
||||
self.bind("s", "toggle_class('#sidebar', '-active')", description="Sidebar")
|
||||
self.bind("d", "toggle_dark", description="Dark mode")
|
||||
self.bind("q", "quit", description="Quit")
|
||||
self.bind("f", "query_test", description="Query test")
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
table = DataTable()
|
||||
self.scroll_to_target = Tweet(TweetBody())
|
||||
|
||||
yield Container(
|
||||
Tweet(TweetBody()),
|
||||
Widget(
|
||||
Static(
|
||||
Syntax(CODE, "python", line_numbers=True, indent_guides=True),
|
||||
classes="code",
|
||||
),
|
||||
classes="scrollable",
|
||||
),
|
||||
table,
|
||||
Widget(DirectoryTree("~/"), id="tree-container"),
|
||||
Error(),
|
||||
Tweet(TweetBody(), classes="scrollbar-size-custom"),
|
||||
Warning(),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Success(),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
)
|
||||
yield Widget(
|
||||
Widget(classes="title"),
|
||||
Widget(classes="user"),
|
||||
OptionItem(),
|
||||
OptionItem(),
|
||||
OptionItem(),
|
||||
Widget(classes="content"),
|
||||
id="sidebar",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
table.add_column("Foo", width=20)
|
||||
table.add_column("Bar", width=20)
|
||||
table.add_column("Baz", width=20)
|
||||
table.add_column("Foo", width=20)
|
||||
table.add_column("Bar", width=20)
|
||||
table.add_column("Baz", width=20)
|
||||
table.zebra_stripes = True
|
||||
for n in range(100):
|
||||
table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)])
|
||||
|
||||
def on_mount(self):
|
||||
self.sub_title = "Widget demo"
|
||||
|
||||
async def on_key(self, event) -> None:
|
||||
await self.dispatch_key(event)
|
||||
|
||||
def action_toggle_dark(self):
|
||||
self.dark = not self.dark
|
||||
|
||||
def action_query_test(self):
|
||||
query = self.query("Tweet")
|
||||
self.log(query)
|
||||
self.log(query.nodes)
|
||||
self.log(query)
|
||||
self.log(query.nodes)
|
||||
|
||||
query.set_styles("outline: outer red;")
|
||||
|
||||
query = query.exclude(".scroll-horizontal")
|
||||
self.log(query)
|
||||
self.log(query.nodes)
|
||||
|
||||
# query = query.filter(".rubbish")
|
||||
# self.log(query)
|
||||
# self.log(query.first())
|
||||
|
||||
async def key_q(self):
|
||||
await self.shutdown()
|
||||
|
||||
def key_x(self):
|
||||
self.panic(self.tree)
|
||||
|
||||
def key_escape(self):
|
||||
self.app.bell()
|
||||
|
||||
def key_t(self):
|
||||
# Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one.
|
||||
tweet_body = self.query("TweetBody").first()
|
||||
tweet_body.short_lorem = not tweet_body.short_lorem
|
||||
|
||||
def key_v(self):
|
||||
self.get_child(id="content").scroll_to_widget(self.scroll_to_target)
|
||||
|
||||
def key_space(self):
|
||||
self.bell()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = BasicApp()
|
||||
app.run(quit_after=2)
|
||||
|
||||
# from textual.geometry import Region
|
||||
# from textual.color import Color
|
||||
|
||||
# print(Region.intersection.cache_info())
|
||||
# print(Region.overlaps.cache_info())
|
||||
# print(Region.union.cache_info())
|
||||
# print(Region.split_vertical.cache_info())
|
||||
# print(Region.__contains__.cache_info())
|
||||
# from textual.css.scalar import Scalar
|
||||
|
||||
# print(Scalar.resolve_dimension.cache_info())
|
||||
|
||||
# from rich.style import Style
|
||||
# from rich.cells import cached_cell_len
|
||||
|
||||
# print(Style._add.cache_info())
|
||||
|
||||
# print(cached_cell_len.cache_info())
|
||||
@@ -155,7 +155,7 @@ markdown_extensions:
|
||||
|
||||
theme:
|
||||
name: material
|
||||
custom_dir: custom_theme
|
||||
custom_dir: docs/custom_theme
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.indexes
|
||||
|
||||
@@ -15,7 +15,6 @@ class LineFilter(ABC):
|
||||
@abstractmethod
|
||||
def filter(self, segments: list[Segment]) -> list[Segment]:
|
||||
"""Transform a list of segments."""
|
||||
...
|
||||
|
||||
|
||||
class Monochrome(LineFilter):
|
||||
|
||||
@@ -31,9 +31,9 @@ def import_app(import_name: str) -> App:
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
from textual.app import App
|
||||
from textual.app import App, WINDOWS
|
||||
|
||||
import_name, *argv = shlex.split(import_name)
|
||||
import_name, *argv = shlex.split(import_name, posix=not WINDOWS)
|
||||
lib, _colon, name = import_name.partition(":")
|
||||
|
||||
if lib.endswith(".py"):
|
||||
|
||||
@@ -32,6 +32,5 @@ class EventTarget(Protocol):
|
||||
...
|
||||
|
||||
|
||||
MessageHandler = Callable[["Message"], Awaitable]
|
||||
Lines = List[List[Segment]]
|
||||
CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]]
|
||||
|
||||
@@ -9,12 +9,12 @@ from . import messages
|
||||
from ._ansi_sequences import ANSI_SEQUENCES_KEYS
|
||||
from ._parser import Awaitable, Parser, TokenCallback
|
||||
from ._types import MessageTarget
|
||||
from .keys import KEY_NAME_REPLACEMENTS
|
||||
|
||||
|
||||
# When trying to determine whether the current sequence is a supported/valid
|
||||
# escape sequence, at which length should we give up and consider our search
|
||||
# to be unsuccessful?
|
||||
from .keys import KEY_NAME_REPLACEMENTS
|
||||
|
||||
_MAX_SEQUENCE_SEARCH_THRESHOLD = 20
|
||||
|
||||
_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from typing import Any, Tuple
|
||||
import re
|
||||
|
||||
|
||||
@@ -41,12 +40,3 @@ def parse(action: str) -> tuple[str, tuple[object, ...]]:
|
||||
action_name,
|
||||
action_params if isinstance(action_params, tuple) else (action_params,),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
print(parse("foo"))
|
||||
|
||||
print(parse("view.toggle('side')"))
|
||||
|
||||
print(parse("view.toggle"))
|
||||
|
||||
@@ -11,17 +11,12 @@ from contextlib import redirect_stderr, redirect_stdout
|
||||
from datetime import datetime
|
||||
from pathlib import Path, PurePath
|
||||
from time import perf_counter
|
||||
from typing import Any, Generic, Iterable, Iterator, Type, TypeVar, cast, Union
|
||||
from typing import Any, Generic, Iterable, Type, TypeVar, cast, Union
|
||||
from weakref import WeakSet, WeakValueDictionary
|
||||
|
||||
from ._ansi_sequences import SYNC_END, SYNC_START
|
||||
from ._path import _make_path_object_relative
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from typing import Literal
|
||||
else:
|
||||
from typing_extensions import Literal # pragma: no cover
|
||||
|
||||
import nanoid
|
||||
import rich
|
||||
import rich.repr
|
||||
@@ -131,7 +126,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
title (str | None, optional): Title of the application. If ``None``, the title is set to the name of the ``App`` subclass. Defaults to ``None``.
|
||||
css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None.
|
||||
watch_css (bool, optional): Watch CSS for changes. Defaults to False.
|
||||
|
||||
"""
|
||||
|
||||
# Inline CSS for quick scripts (generally css_path should be preferred.)
|
||||
@@ -702,11 +696,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._require_stylesheet_update.add(self.screen if node is None else node)
|
||||
self.check_idle()
|
||||
|
||||
def update_visible_styles(self) -> None:
|
||||
"""Update visible styles only."""
|
||||
self._require_stylesheet_update.update(self.screen.visible_widgets)
|
||||
self.check_idle()
|
||||
|
||||
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
|
||||
"""Mount widgets. Widgets specified as positional args, or keywords args. If supplied
|
||||
as keyword args they will be assigned an id of the key.
|
||||
|
||||
@@ -528,7 +528,6 @@ class Color(NamedTuple):
|
||||
# Color constants
|
||||
WHITE = Color(255, 255, 255)
|
||||
BLACK = Color(0, 0, 0)
|
||||
TRANSPARENT = Color(0, 0, 0, 0)
|
||||
|
||||
|
||||
def rgb_to_lab(rgb: Color) -> Lab:
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import PurePath
|
||||
from typing import Iterator, Iterable, NoReturn, Sequence
|
||||
from typing import Iterator, Iterable, NoReturn
|
||||
|
||||
from rich import print
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ else:
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._animator import Animation
|
||||
from .._layout import Layout
|
||||
from ..dom import DOMNode
|
||||
|
||||
@@ -608,7 +607,7 @@ class Styles(StylesBase):
|
||||
default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS.
|
||||
|
||||
Returns:
|
||||
list[tuple[str, Specificity5, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.
|
||||
list[tuple[str, Specificity6, Any]]]: A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.
|
||||
"""
|
||||
is_important = self.important.__contains__
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
|
||||
from ._error_tools import friendly_list
|
||||
from .._loop import loop_last
|
||||
|
||||
|
||||
class TokenError(Exception):
|
||||
|
||||
@@ -42,6 +42,4 @@ EdgeStyle = Tuple[EdgeType, Color]
|
||||
TextAlign = Literal["left", "start", "center", "right", "end", "justify"]
|
||||
|
||||
Specificity3 = Tuple[int, int, int]
|
||||
Specificity4 = Tuple[int, int, int, int]
|
||||
Specificity5 = Tuple[int, int, int, int, int]
|
||||
Specificity6 = Tuple[int, int, int, int, int, int]
|
||||
|
||||
@@ -75,10 +75,6 @@ class NoScreen(DOMError):
|
||||
pass
|
||||
|
||||
|
||||
class NoParent(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class DOMNode(MessagePump):
|
||||
"""The base class for object that can be in the Textual DOM (App and Widget)"""
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DrawStyle(Enum):
|
||||
NONE = "none"
|
||||
ASCII = "ascii"
|
||||
SQUARE = "square"
|
||||
HEAVY = "heavy"
|
||||
ROUNDED = "rounded"
|
||||
DOUBLE = "double"
|
||||
@@ -17,12 +17,12 @@ if TYPE_CHECKING:
|
||||
import rich.repr
|
||||
|
||||
from .. import log
|
||||
from .. import events
|
||||
from ..driver import Driver
|
||||
from ..geometry import Size
|
||||
from .._types import MessageTarget
|
||||
from .._xterm_parser import XTermParser
|
||||
from .._profile import timer
|
||||
from .. import events
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -235,7 +235,6 @@ class LinuxDriver(Driver):
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich.console import Console
|
||||
from .. import events
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..geometry import Region, Size
|
||||
from .._layout import ArrangeResult, Layout, WidgetPlacement
|
||||
|
||||
@@ -29,10 +29,6 @@ if TYPE_CHECKING:
|
||||
from .app import App
|
||||
|
||||
|
||||
class NoParent(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CallbackError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import rich.repr
|
||||
from rich.color import Color
|
||||
from rich.console import ConsoleOptions, RenderableType, RenderResult
|
||||
from rich.segment import Segment, Segments
|
||||
from rich.style import NULL_STYLE, Style, StyleType
|
||||
from rich.style import Style, StyleType
|
||||
|
||||
from . import events
|
||||
from ._types import MessageTarget
|
||||
|
||||
@@ -7,7 +7,6 @@ from ..case import camel_to_snake
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..widget import Widget
|
||||
|
||||
|
||||
# ⚠️For any new built-in Widget we create, not only we have to add them to the following list, but also to the
|
||||
# `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't be able to "see" them.
|
||||
__all__ = [
|
||||
@@ -25,9 +24,9 @@ __all__ = [
|
||||
"Welcome",
|
||||
]
|
||||
|
||||
|
||||
_WIDGETS_LAZY_LOADING_CACHE: dict[str, type[Widget]] = {}
|
||||
|
||||
|
||||
# Let's decrease startup time by lazy loading our Widgets:
|
||||
def __getattr__(widget_class: str) -> type[Widget]:
|
||||
try:
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
|
||||
from textual.devtools.server import _make_devtools_aiohttp_app
|
||||
from textual.devtools.client import DevtoolsClient
|
||||
from textual.devtools.server import _make_devtools_aiohttp_app
|
||||
from textual.devtools.service import DevtoolsService
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from datetime import datetime
|
||||
|
||||
import msgpack
|
||||
import pytest
|
||||
import time_machine
|
||||
from rich.align import Align
|
||||
from rich.console import Console
|
||||
from rich.segment import Segment
|
||||
|
||||
import msgpack
|
||||
from tests.utilities.render import wait_for_predicate
|
||||
from textual.devtools.renderables import DevConsoleLog, DevConsoleNotice
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import types
|
||||
from asyncio import Queue
|
||||
from datetime import datetime
|
||||
|
||||
import msgpack
|
||||
import time_machine
|
||||
from aiohttp.web_ws import WebSocketResponse
|
||||
from rich.console import ConsoleDimensions
|
||||
from rich.panel import Panel
|
||||
import msgpack
|
||||
|
||||
from tests.utilities.render import wait_for_predicate
|
||||
from textual.devtools.client import DevtoolsClient
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import json
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import datetime
|
||||
|
||||
import msgpack
|
||||
import time_machine
|
||||
|
||||
import msgpack
|
||||
from textual.devtools.redirect_output import StdoutRedirector
|
||||
|
||||
TIMESTAMP = 1649166819
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from textual.layouts.vertical import VerticalLayout
|
||||
from textual.layouts.factory import get_layout, MissingLayout
|
||||
from textual.layouts.vertical import VerticalLayout
|
||||
|
||||
|
||||
def test_get_layout_valid_layout():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from textual._border import get_box, render_row
|
||||
from textual._border import render_row
|
||||
|
||||
|
||||
def test_border_render_row():
|
||||
|
||||
@@ -8,7 +8,6 @@ import pytest
|
||||
|
||||
from textual._easing import EASING
|
||||
|
||||
|
||||
POINTS = [
|
||||
0.0,
|
||||
0.05,
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
from __future__ import annotations
|
||||
from typing import cast, List, Sequence
|
||||
|
||||
import pytest
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from tests.utilities.test_app import AppTest
|
||||
from textual.app import ComposeResult
|
||||
from textual.css.types import EdgeType
|
||||
from textual.geometry import Size
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
pytestmark = pytest.mark.integration_test
|
||||
|
||||
# Let's allow ourselves some abbreviated names for those tests,
|
||||
# in order to make the test cases a bit easier to read :-)
|
||||
SCREEN_W = 100 # width of our Screens
|
||||
SCREEN_H = 8 # height of our Screens
|
||||
SCREEN_SIZE = Size(SCREEN_W, SCREEN_H)
|
||||
PLACEHOLDERS_DEFAULT_H = 3 # the default height for our Placeholder widgets
|
||||
|
||||
# As per Widget's CSS property, by default Widgets have a horizontal scrollbar of size 1
|
||||
# and a vertical scrollbar of size 2:
|
||||
SCROLL_H_SIZE = 1
|
||||
SCROLL_V_SIZE = 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"placeholders_count",
|
||||
"root_container_style",
|
||||
"placeholders_style",
|
||||
"expected_root_widget_virtual_size",
|
||||
"expected_placeholders_size",
|
||||
"expected_placeholders_offset_x",
|
||||
),
|
||||
(
|
||||
*[
|
||||
[
|
||||
1,
|
||||
f"border: {invisible_border_edge};", # #root has no visible border
|
||||
"", # no specific placeholder style
|
||||
# #root's virtual size=screen size
|
||||
(SCREEN_W, SCREEN_H),
|
||||
# placeholders width=same than screen :: height=default height
|
||||
(SCREEN_W, PLACEHOLDERS_DEFAULT_H),
|
||||
# placeholders should be at offset 0
|
||||
0,
|
||||
]
|
||||
for invisible_border_edge in ("", "none", "hidden")
|
||||
],
|
||||
[
|
||||
1,
|
||||
"border: solid white;", # #root has a visible border
|
||||
"", # no specific placeholder style
|
||||
# #root's virtual size is smaller because of its borders
|
||||
(SCREEN_W - 2, SCREEN_H - 2),
|
||||
# placeholders width=same than screen, minus 2 borders :: height=default height minus 2 borders
|
||||
(SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H),
|
||||
# placeholders should be at offset 1 because of #root's border
|
||||
1,
|
||||
],
|
||||
[
|
||||
4,
|
||||
"border: solid white;", # #root has a visible border
|
||||
"", # no specific placeholder style
|
||||
# #root's virtual height should be as high as its stacked content
|
||||
(SCREEN_W - 2 - 1, PLACEHOLDERS_DEFAULT_H * 4),
|
||||
# placeholders width=same than screen, minus 2 borders, minus scrollbar :: height=default height minus 2 borders
|
||||
(SCREEN_W - 2 - SCROLL_V_SIZE, PLACEHOLDERS_DEFAULT_H),
|
||||
# placeholders should be at offset 1 because of #root's border
|
||||
1,
|
||||
],
|
||||
[
|
||||
1,
|
||||
"border: solid white;", # #root has a visible border
|
||||
"align: center top;", # placeholders are centered horizontally
|
||||
# #root's virtual size=screen size
|
||||
(SCREEN_W, SCREEN_H),
|
||||
# placeholders width=same than screen, minus 2 borders :: height=default height
|
||||
(SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H),
|
||||
# placeholders should be at offset 1 because of #root's border
|
||||
1,
|
||||
],
|
||||
[
|
||||
4,
|
||||
"border: solid white;", # #root has a visible border
|
||||
"align: center top;", # placeholders are centered horizontally
|
||||
# #root's virtual height should be as high as its stacked content
|
||||
(
|
||||
SCREEN_W - 2 - SCROLL_V_SIZE,
|
||||
PLACEHOLDERS_DEFAULT_H * 4,
|
||||
),
|
||||
# placeholders width=same than screen, minus 2 borders, minus scrollbar :: height=default height
|
||||
(SCREEN_W - 2 - SCROLL_V_SIZE, PLACEHOLDERS_DEFAULT_H),
|
||||
# placeholders should be at offset 1 because of #root's border
|
||||
1,
|
||||
],
|
||||
),
|
||||
)
|
||||
async def test_composition_of_vertical_container_with_children(
|
||||
placeholders_count: int,
|
||||
root_container_style: str,
|
||||
placeholders_style: str,
|
||||
expected_placeholders_size: tuple[int, int],
|
||||
expected_root_widget_virtual_size: tuple[int, int],
|
||||
expected_placeholders_offset_x: int,
|
||||
):
|
||||
class VerticalContainer(Widget):
|
||||
DEFAULT_CSS = (
|
||||
"""
|
||||
VerticalContainer {
|
||||
layout: vertical;
|
||||
overflow: hidden auto;
|
||||
${root_container_style}
|
||||
}
|
||||
|
||||
VerticalContainer Placeholder {
|
||||
height: ${placeholders_height};
|
||||
${placeholders_style}
|
||||
}
|
||||
""".replace(
|
||||
"${root_container_style}", root_container_style
|
||||
)
|
||||
.replace("${placeholders_height}", str(PLACEHOLDERS_DEFAULT_H))
|
||||
.replace("${placeholders_style}", placeholders_style)
|
||||
)
|
||||
|
||||
class MyTestApp(AppTest):
|
||||
def compose(self) -> ComposeResult:
|
||||
placeholders = [
|
||||
Placeholder(id=f"placeholder_{i}", name=f"Placeholder #{i}")
|
||||
for i in range(placeholders_count)
|
||||
]
|
||||
|
||||
yield VerticalContainer(*placeholders, id="root")
|
||||
|
||||
app = MyTestApp(size=SCREEN_SIZE, test_name="compositor")
|
||||
|
||||
expected_screen_size = SCREEN_SIZE
|
||||
|
||||
async with app.in_running_state():
|
||||
# root widget checks:
|
||||
root_widget = cast(Widget, app.get_child("root"))
|
||||
assert root_widget.outer_size == expected_screen_size
|
||||
root_widget_region = app.screen.find_widget(root_widget).region
|
||||
assert root_widget_region == (
|
||||
0,
|
||||
0,
|
||||
expected_screen_size.width,
|
||||
expected_screen_size.height,
|
||||
)
|
||||
|
||||
app_placeholders = cast(List[Widget], app.query("Placeholder"))
|
||||
assert len(app_placeholders) == placeholders_count
|
||||
|
||||
# placeholder widgets checks:
|
||||
for placeholder in app_placeholders:
|
||||
assert placeholder.outer_size == expected_placeholders_size
|
||||
assert placeholder.styles.offset.x.value == 0.0
|
||||
assert app.screen.get_offset(placeholder).x == expected_placeholders_offset_x
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"edge_type,expected_box_inner_size,expected_box_size,expected_top_left_edge_color,expects_visible_char_at_top_left_edge",
|
||||
(
|
||||
# These first 3 types of border edge types are synonyms, and display no borders:
|
||||
["", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False],
|
||||
["none", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False],
|
||||
["hidden", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False],
|
||||
# Let's transition to "blank": we still see no visible border, but the size is increased
|
||||
# as the gutter space is reserved the same way it would be with a border:
|
||||
["blank", Size(SCREEN_W - 2, 1), Size(SCREEN_W, 3), "#ffffff", False],
|
||||
# And now for the "normally visible" border edge types:
|
||||
# --> we see a visible border, and the size is increased:
|
||||
*[
|
||||
[edge_style, Size(SCREEN_W - 2, 1), Size(SCREEN_W, 3), "#ffffff", True]
|
||||
for edge_style in [
|
||||
"round",
|
||||
"solid",
|
||||
"double",
|
||||
"dashed",
|
||||
"heavy",
|
||||
"inner",
|
||||
"outer",
|
||||
"hkey",
|
||||
"vkey",
|
||||
]
|
||||
],
|
||||
),
|
||||
)
|
||||
async def test_border_edge_types_impact_on_widget_size(
|
||||
edge_type: EdgeType,
|
||||
expected_box_inner_size: Size,
|
||||
expected_box_size: Size,
|
||||
expected_top_left_edge_color: str,
|
||||
expects_visible_char_at_top_left_edge: bool,
|
||||
):
|
||||
class BorderTarget(Widget):
|
||||
def render(self) -> RenderableType:
|
||||
return Text("border target", style="black on yellow", justify="center")
|
||||
|
||||
border_target = BorderTarget()
|
||||
border_target.styles.height = "auto"
|
||||
border_target.styles.border = (edge_type, "white")
|
||||
|
||||
class MyTestApp(AppTest):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield border_target
|
||||
|
||||
app = MyTestApp(size=SCREEN_SIZE, test_name="border_edge_types")
|
||||
|
||||
await app.boot_and_shutdown()
|
||||
|
||||
box_inner_size = Size(
|
||||
border_target.content_region.width,
|
||||
border_target.content_region.height,
|
||||
)
|
||||
assert box_inner_size == expected_box_inner_size
|
||||
|
||||
assert border_target.outer_size == expected_box_size
|
||||
|
||||
top_left_edge_style = app.screen.get_style_at(0, 0)
|
||||
top_left_edge_color = top_left_edge_style.color.name
|
||||
assert top_left_edge_color.upper() == expected_top_left_edge_color.upper()
|
||||
|
||||
top_left_edge_char = app.get_char_at(0, 0)
|
||||
top_left_edge_char_is_a_visible_one = top_left_edge_char != " "
|
||||
assert top_left_edge_char_is_a_visible_one == expects_visible_char_at_top_left_edge
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"large_widget_size,container_style,expected_large_widget_visible_region_size",
|
||||
(
|
||||
# In these tests we're going to insert a "large widget"
|
||||
# into a container with size (20,20).
|
||||
# ---------------- let's start!
|
||||
# no overflow/scrollbar instructions: no scrollbars
|
||||
[Size(30, 30), "color: red", Size(20, 20)],
|
||||
# explicit hiding of the overflow: no scrollbars either
|
||||
[Size(30, 30), "overflow: hidden", Size(20, 20)],
|
||||
# scrollbar for both directions
|
||||
[
|
||||
Size(30, 30),
|
||||
"overflow: auto",
|
||||
Size(
|
||||
20 - SCROLL_V_SIZE,
|
||||
20 - SCROLL_H_SIZE,
|
||||
),
|
||||
],
|
||||
# horizontal scrollbar
|
||||
[Size(30, 30), "overflow-x: auto", Size(20, 20 - SCROLL_H_SIZE)],
|
||||
# vertical scrollbar
|
||||
[Size(30, 30), "overflow-y: auto", Size(20 - SCROLL_V_SIZE, 20)],
|
||||
# scrollbar for both directions, custom scrollbar size
|
||||
[Size(30, 30), ("overflow: auto", "scrollbar-size: 3 5"), Size(20 - 5, 20 - 3)],
|
||||
# scrollbar for both directions, custom vertical scrollbar size
|
||||
[
|
||||
Size(30, 30),
|
||||
("overflow: auto", "scrollbar-size-vertical: 3"),
|
||||
Size(20 - 3, 20 - SCROLL_H_SIZE),
|
||||
],
|
||||
# scrollbar for both directions, custom horizontal scrollbar size
|
||||
[
|
||||
Size(30, 30),
|
||||
("overflow: auto", "scrollbar-size-horizontal: 3"),
|
||||
Size(20 - SCROLL_V_SIZE, 20 - 3),
|
||||
],
|
||||
# scrollbar needed only horizontally, custom scrollbar size
|
||||
[
|
||||
Size(30, 20),
|
||||
("overflow: auto", "scrollbar-size: 3 3"),
|
||||
Size(20, 20 - 3),
|
||||
],
|
||||
),
|
||||
)
|
||||
async def test_scrollbar_size_impact_on_the_layout(
|
||||
large_widget_size: Size,
|
||||
container_style: str | Sequence[str],
|
||||
expected_large_widget_visible_region_size: Size,
|
||||
):
|
||||
class LargeWidget(Widget):
|
||||
def on_mount(self):
|
||||
self.styles.width = large_widget_size[0]
|
||||
self.styles.height = large_widget_size[1]
|
||||
|
||||
container_style_rules = (
|
||||
[container_style] if isinstance(container_style, str) else container_style
|
||||
)
|
||||
|
||||
class LargeWidgetContainer(Widget):
|
||||
# TODO: Once textual#581 ("Default versus User CSS") is solved the following CSS should just use the
|
||||
# "LargeWidgetContainer" selector, without having to use a more specific one to be able to override Widget's CSS:
|
||||
DEFAULT_CSS = """
|
||||
#large-widget-container {
|
||||
width: 20;
|
||||
height: 20;
|
||||
${container_style};
|
||||
}
|
||||
""".replace(
|
||||
"${container_style}",
|
||||
";\n".join(container_style_rules),
|
||||
)
|
||||
|
||||
large_widget = LargeWidget()
|
||||
large_widget.expand = False
|
||||
container = LargeWidgetContainer(large_widget, id="large-widget-container")
|
||||
|
||||
class MyTestApp(AppTest):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield container
|
||||
|
||||
app = MyTestApp(size=Size(40, 40), test_name="scrollbar_size_impact_on_the_layout")
|
||||
|
||||
await app.boot_and_shutdown()
|
||||
|
||||
compositor = app.screen._compositor
|
||||
widgets_map = compositor.map
|
||||
large_widget_visible_region_size = widgets_map[large_widget].visible_region.size
|
||||
assert large_widget_visible_region_size == expected_large_widget_visible_region_size
|
||||
@@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, cast
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from typing import NamedTuple
|
||||
|
||||
import pytest
|
||||
|
||||
from textual._layout_resolve import layout_resolve
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from textual.geometry import Size
|
||||
from textual.css.scalar import Scalar
|
||||
from textual._resolve import resolve
|
||||
from textual.css.scalar import Scalar
|
||||
from textual.geometry import Size
|
||||
|
||||
|
||||
def test_resolve_empty():
|
||||
|
||||
@@ -5,7 +5,6 @@ import pytest
|
||||
from textual.app import App, ScreenStackError
|
||||
from textual.screen import Screen
|
||||
|
||||
|
||||
skip_py310 = pytest.mark.skipif(
|
||||
sys.version_info.minor == 10 and sys.version_info.major == 3,
|
||||
reason="segfault on py3.10",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
|
||||
from textual._segment_tools import line_crop, line_trim, line_pad
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ from __future__ import annotations
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from textual.color import Color
|
||||
from textual.geometry import Region, Size
|
||||
from textual.css.styles import Styles
|
||||
from textual._styles_cache import StylesCache
|
||||
from textual._types import Lines
|
||||
from textual.color import Color
|
||||
from textual.css.styles import Styles
|
||||
from textual.geometry import Region, Size
|
||||
|
||||
|
||||
def _extract_content(lines: Lines):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import pytest
|
||||
from rich.style import Style
|
||||
|
||||
from textual.app import App
|
||||
from textual.css.errors import StyleValueError
|
||||
|
||||
@@ -14,12 +14,13 @@ from rich.console import Console
|
||||
from textual import events, errors
|
||||
from textual._ansi_sequences import SYNC_START
|
||||
from textual._clock import _Clock
|
||||
from textual.app import WINDOWS
|
||||
from textual._context import active_app
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.app import WINDOWS
|
||||
from textual.driver import Driver
|
||||
from textual.geometry import Size, Region
|
||||
|
||||
|
||||
# N.B. These classes would better be named TestApp/TestConsole/TestDriver/etc,
|
||||
# but it makes pytest emit warning as it will try to collect them as classes containing test cases :-/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user