Merge branch 'css' into css-docs-review

This commit is contained in:
Dave Pearson
2022-10-13 21:38:16 +01:00
40 changed files with 27 additions and 949 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -155,7 +155,7 @@ markdown_extensions:
theme:
name: material
custom_dir: custom_theme
custom_dir: docs/custom_theme
features:
- navigation.tabs
- navigation.indexes

View File

@@ -15,7 +15,6 @@ class LineFilter(ABC):
@abstractmethod
def filter(self, segments: list[Segment]) -> list[Segment]:
"""Transform a list of segments."""
...
class Monochrome(LineFilter):

View File

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

View File

@@ -32,6 +32,5 @@ class EventTarget(Protocol):
...
MessageHandler = Callable[["Message"], Awaitable]
Lines = List[List[Segment]]
CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
from enum import Enum
class DrawStyle(Enum):
NONE = "none"
ASCII = "ascii"
SQUARE = "square"
HEAVY = "heavy"
ROUNDED = "rounded"
DOUBLE = "double"

View File

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

View File

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

View File

@@ -29,10 +29,6 @@ if TYPE_CHECKING:
from .app import App
class NoParent(Exception):
pass
class CallbackError(Exception):
pass

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import pytest
from textual._easing import EASING
POINTS = [
0.0,
0.05,

View File

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

View File

@@ -1,4 +1,5 @@
from __future__ import annotations
from typing import Sequence, cast
import pytest

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import NamedTuple
import pytest
from textual._layout_resolve import layout_resolve

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import pytest
from rich.style import Style
from textual.app import App
from textual.css.errors import StyleValueError

View File

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