Merge branch 'css' into auto-dimensions

This commit is contained in:
Will McGugan
2022-05-20 12:00:50 +01:00
committed by GitHub
9 changed files with 277 additions and 50 deletions

67
sandbox/borders.py Normal file
View File

@@ -0,0 +1,67 @@
from rich.console import RenderableType
from rich.text import Text
from textual.app import App, ComposeResult
from textual.css.types import EdgeType
from textual.widget import Widget
from textual.widgets import Placeholder
class VerticalContainer(Widget):
CSS = """
VerticalContainer {
layout: vertical;
overflow: hidden auto;
background: darkblue;
}
VerticalContainer Placeholder {
margin: 1 0;
height: auto;
align: center top;
}
"""
class Introduction(Widget):
CSS = """
Introduction {
background: indigo;
color: white;
height: 3;
padding: 1 0;
}
"""
def render(self, styles) -> RenderableType:
return Text("Here are the color edge types we support.", justify="center")
class BorderDemo(Widget):
def __init__(self, name: str):
super().__init__(name=name)
def render(self, style) -> RenderableType:
return Text(self.name, style="black on yellow", justify="center")
class MyTestApp(App):
def compose(self) -> ComposeResult:
border_demo_widgets = []
for border_edge_type in EdgeType.__args__:
border_demo = BorderDemo(f'"border: {border_edge_type} white"')
border_demo.styles.height = "auto"
border_demo.styles.margin = (1, 0)
border_demo.styles.border = (border_edge_type, "white")
border_demo_widgets.append(border_demo)
yield VerticalContainer(Introduction(), *border_demo_widgets, id="root")
def on_mount(self):
self.bind("q", "quit")
app = MyTestApp()
if __name__ == "__main__":
app.run()

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import sys
from functools import lru_cache
from typing import cast, Tuple, Union
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
import rich.repr
@@ -10,15 +12,23 @@ from rich.style import Style
from .color import Color
from .css.types import EdgeStyle, EdgeType
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
INNER = 1
OUTER = 2
BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = {
# TODO: in "browsers' CSS" `none` and `hidden` both set the border width to zero. Should we do the same?
# Each string of the tuple represents a sub-tuple itself:
# - 1st string represents `(top1, top2, top3)`
# - 2nd string represents (mid1, mid2, mid3)
# - 3rd string represents (bottom1, bottom2, bottom3)
"": (" ", " ", " "),
"none": (" ", " ", " "),
"hidden": (" ", " ", " "),
"blank": (" ", " ", " "),
"round": ("╭─╮", "│ │", "╰─╯"),
"solid": ("┌─┐", "│ │", "└─┘"),
"double": ("╔═╗", "║ ║", "╚═╝"),
@@ -40,6 +50,7 @@ BORDER_LOCATIONS: dict[
"": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"none": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"hidden": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"blank": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"round": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"solid": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"double": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
@@ -53,6 +64,10 @@ BORDER_LOCATIONS: dict[
"wide": ((1, 1, 1), (0, 1, 0), (1, 1, 1)),
}
INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidden")))
BorderValue: TypeAlias = Tuple[EdgeType, Union[str, Color, Style]]
@lru_cache(maxsize=1024)
def get_box(
@@ -135,7 +150,12 @@ class Border:
(bottom, bottom_color),
(left, left_color),
) = edge_styles
self._sides = (top or "none", right or "none", bottom or "none", left or "none")
self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType] = (
top or "none",
right or "none",
bottom or "none",
left or "none",
)
from_color = Style.from_color
self._styles = (
@@ -159,10 +179,11 @@ class Border:
width (int): Desired width.
"""
top, right, bottom, left = self._sides
has_left = left != "none"
has_right = right != "none"
has_top = top != "none"
has_bottom = bottom != "none"
# the 4 following lines rely on the fact that we normalise "none" and "hidden" to en empty string
has_left = bool(left)
has_right = bool(right)
has_top = bool(top)
has_bottom = bool(bottom)
if has_top:
lines.pop(0)
@@ -188,10 +209,11 @@ class Border:
outer_style = console.get_style(self.outer_style)
top_style, right_style, bottom_style, left_style = self._styles
has_left = left != "none"
has_right = right != "none"
has_top = top != "none"
has_bottom = bottom != "none"
# ditto than in `_crop_renderable` ☝
has_left = bool(left)
has_right = bool(right)
has_top = bool(top)
has_bottom = bool(bottom)
width = options.max_width - has_left - has_right
@@ -261,6 +283,18 @@ class Border:
yield new_line
_edge_type_normalization_table: dict[EdgeType, EdgeType] = {
# i.e. we normalize "border: none;" to "border: ;".
# As a result our layout-related calculations that include borders are simpler (and have better performance)
"none": "",
"hidden": "",
}
def normalize_border_value(value: BorderValue) -> BorderValue:
return _edge_type_normalization_table.get(value[0], value[0]), value[1]
if __name__ == "__main__":
from rich import print
from rich.text import Text

View File

@@ -27,6 +27,7 @@ from ._help_text import (
string_enum_help_text,
color_property_help_text,
)
from .._border import INVISIBLE_EDGE_TYPES, normalize_border_value
from ..color import Color, ColorPair, ColorParseError
from ._error_tools import friendly_list
from .constants import NULL_SPACING, VALID_STYLE_FLAGS
@@ -321,28 +322,37 @@ class BorderProperty:
clear_rule(bottom)
clear_rule(left)
return
if isinstance(border, tuple):
setattr(obj, top, border)
setattr(obj, right, border)
setattr(obj, bottom, border)
setattr(obj, left, border)
if isinstance(border, tuple) and len(border) == 2:
_border = normalize_border_value(border)
setattr(obj, top, _border)
setattr(obj, right, _border)
setattr(obj, bottom, _border)
setattr(obj, left, _border)
return
count = len(border)
if count == 1:
_border = border[0]
_border = normalize_border_value(border[0])
setattr(obj, top, _border)
setattr(obj, right, _border)
setattr(obj, bottom, _border)
setattr(obj, left, _border)
elif count == 2:
_border1, _border2 = border
_border1, _border2 = (
normalize_border_value(border[0]),
normalize_border_value(border[1]),
)
setattr(obj, top, _border1)
setattr(obj, bottom, _border1)
setattr(obj, right, _border2)
setattr(obj, left, _border2)
elif count == 4:
_border1, _border2, _border3, _border4 = border
_border1, _border2, _border3, _border4 = (
normalize_border_value(border[0]),
normalize_border_value(border[1]),
normalize_border_value(border[3]),
normalize_border_value(border[4]),
)
setattr(obj, top, _border1)
setattr(obj, right, _border2)
setattr(obj, bottom, _border3)

View File

@@ -42,7 +42,8 @@ from .scalar import Scalar, ScalarOffset, Unit, ScalarError, ScalarParseError
from .styles import DockGroup, Styles
from .tokenize import Token
from .transition import Transition
from .types import BoxSizing, Edge, Display, Overflow, Visibility
from .types import BoxSizing, Edge, Display, Overflow, Visibility, EdgeType
from .._border import normalize_border_value, BorderValue
from ..color import Color, ColorParseError
from .._duration import _duration_as_seconds
from .._easing import EASING
@@ -417,8 +418,8 @@ class StylesBuilder:
process_padding_bottom = _process_space_partial
process_padding_left = _process_space_partial
def _parse_border(self, name: str, tokens: list[Token]) -> tuple[str, Color]:
border_type = "solid"
def _parse_border(self, name: str, tokens: list[Token]) -> BorderValue:
border_type: EdgeType = "solid"
border_color = Color(0, 255, 0)
def border_value_error():
@@ -444,7 +445,7 @@ class StylesBuilder:
else:
border_value_error()
return (border_type, border_color)
return normalize_border_value((border_type, border_color))
def _process_border_edge(self, edge: str, name: str, tokens: list[Token]) -> None:
border = self._parse_border(name, tokens)

View File

@@ -1,4 +1,6 @@
from __future__ import annotations
import sys
import typing
if sys.version_info >= (3, 8):
from typing import Final
@@ -7,12 +9,16 @@ else:
from ..geometry import Spacing
if typing.TYPE_CHECKING:
from .types import EdgeType
VALID_VISIBILITY: Final = {"visible", "hidden"}
VALID_DISPLAY: Final = {"block", "none"}
VALID_BORDER: Final = {
VALID_BORDER: Final[set[EdgeType]] = {
"none",
"hidden",
"round",
"blank",
"solid",
"double",
"dashed",

View File

@@ -16,6 +16,7 @@ EdgeType = Literal[
"",
"none",
"hidden",
"blank",
"round",
"solid",
"double",
@@ -35,6 +36,6 @@ AlignVertical = Literal["top", "middle", "bottom"]
ScrollbarGutter = Literal["auto", "stable"]
BoxSizing = Literal["border-box", "content-box"]
Overflow = Literal["scroll", "hidden", "auto"]
EdgeStyle = Tuple[str, Color]
EdgeStyle = Tuple[EdgeType, Color]
Specificity3 = Tuple[int, int, int]
Specificity4 = Tuple[int, int, int, int]

View File

@@ -19,6 +19,19 @@ class Placeholder(Widget, can_focus=True):
has_focus: Reactive[bool] = Reactive(False)
mouse_over: Reactive[bool] = Reactive(False)
def __init__(
# parent class constructor signature:
self,
*children: Widget,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
# ...and now for our own class specific params:
title: str | None = None,
) -> None:
super().__init__(*children, name=name, id=id, classes=classes)
self.title = title
def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__()
yield "has_focus", self.has_focus, False
@@ -32,7 +45,7 @@ class Placeholder(Widget, can_focus=True):
Pretty(self, no_wrap=True, overflow="ellipsis"),
vertical="middle",
),
title=self.__class__.__name__,
title=self.title or self.__class__.__name__,
border_style="green" if self.mouse_over else "blue",
box=box.HEAVY if self.has_focus else box.ROUNDED,
)

View File

@@ -3,9 +3,12 @@ import asyncio
from typing import cast, List
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
@@ -31,30 +34,20 @@ PLACEHOLDERS_DEFAULT_H = 3 # the default height for our Placeholder widgets
"expected_placeholders_offset_x",
),
(
[
SCREEN_SIZE,
1,
"border: ;", # #root has no 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,
],
[
# "none" borders still allocate a space for the (invisible) border
SCREEN_SIZE,
1,
"border: none;", # #root has an invisible 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,
*[
[
SCREEN_SIZE,
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")
],
[
SCREEN_SIZE,
@@ -169,3 +162,75 @@ async def test_composition_of_vertical_container_with_children(
assert placeholder.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.integration_test
@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",
"tall",
"wide",
]
],
),
)
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, style) -> 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.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 == expected_top_left_edge_color
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

View File

@@ -8,7 +8,7 @@ from typing import AsyncContextManager, cast
from rich.console import Console
from textual import events
from textual import events, errors
from textual.app import App, ReturnType, ComposeResult
from textual.driver import Driver
from textual.geometry import Size
@@ -123,6 +123,36 @@ class AppTest(App):
last_display_start_index = total_capture.rindex(CLEAR_SCREEN_SEQUENCE)
return total_capture[last_display_start_index:]
def get_char_at(self, x: int, y: int) -> str:
"""Get the character at the given cell or empty string
Args:
x (int): X position within the Layout
y (int): Y position within the Layout
Returns:
str: The character at the cell (x, y) within the Layout
"""
# N.B. Basically a copy-paste-and-slightly-adapt of `Compositor.get_style_at()`
try:
widget, region = self.get_widget_at(x, y)
except errors.NoWidget:
return ""
if widget not in self.screen._compositor.regions:
return ""
x -= region.x
y -= region.y
lines = widget.get_render_lines(y, y + 1)
if not lines:
return ""
end = 0
for segment in lines[0]:
end += segment.cell_length
if x < end:
return segment.text[0]
return ""
@property
def console(self) -> ConsoleTest:
return self._console