mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' into auto-dimensions
This commit is contained in:
67
sandbox/borders.py
Normal file
67
sandbox/borders.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user