mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
46
sandbox/align.css
Normal file
46
sandbox/align.css
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
|
||||
Screen {
|
||||
layout: vertical;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
Widget {
|
||||
margin:1;
|
||||
}
|
||||
|
||||
#thing {
|
||||
|
||||
width: auto;
|
||||
height: 10;
|
||||
background:magenta;
|
||||
margin: 3;
|
||||
padding: 1;
|
||||
border: solid white;
|
||||
box-sizing: border-box;
|
||||
border: solid white;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
|
||||
#thing2 {
|
||||
border: solid white;
|
||||
/* outline: heavy blue; */
|
||||
height: 10;
|
||||
padding: 1 2;
|
||||
box-sizing: border-box;
|
||||
|
||||
max-height: 100vh;
|
||||
|
||||
background:green;
|
||||
align-horizontal: center;
|
||||
color:white;
|
||||
}
|
||||
|
||||
|
||||
#thing3 {
|
||||
height: 10;
|
||||
margin: 1;
|
||||
background:blue;
|
||||
align-horizontal: center;
|
||||
}
|
||||
25
sandbox/align.py
Normal file
25
sandbox/align.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class Thing(Widget):
|
||||
def render(self):
|
||||
return Text.from_markup("Hello, World. [b magenta]Lorem impsum.")
|
||||
|
||||
|
||||
class AlignApp(App):
|
||||
def on_load(self):
|
||||
self.bind("t", "log_tree")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.log("MOUNTED")
|
||||
self.mount(thing=Thing(), thing2=Static("0123456789"), thing3=Widget())
|
||||
|
||||
def action_log_tree(self):
|
||||
self.log(self.screen.tree)
|
||||
|
||||
|
||||
AlignApp.run(css_file="align.css", log="textual.log", watch_css=True)
|
||||
@@ -17,10 +17,11 @@
|
||||
App > Screen {
|
||||
layout: dock;
|
||||
docks: side=left/1;
|
||||
background: $background;
|
||||
color: $text-background;
|
||||
background: $surface;
|
||||
color: $text-surface;
|
||||
}
|
||||
|
||||
|
||||
#sidebar {
|
||||
color: $text-primary;
|
||||
background: $primary;
|
||||
@@ -66,14 +67,14 @@ App > Screen {
|
||||
color: $text-background;
|
||||
background: $background;
|
||||
layout: vertical;
|
||||
overflow-y:scroll;
|
||||
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
|
||||
Tweet {
|
||||
height: 22;
|
||||
max-width: 80;
|
||||
height: 12;
|
||||
width: 80;
|
||||
|
||||
margin: 1 3;
|
||||
background: $panel;
|
||||
color: $text-panel;
|
||||
@@ -81,7 +82,24 @@ Tweet {
|
||||
/* border: outer $primary; */
|
||||
padding: 1;
|
||||
border: wide $panel-darken-2;
|
||||
overflow-y: scroll
|
||||
overflow-y: scroll;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
width: 80;
|
||||
overflow-y: scroll;
|
||||
max-width:80;
|
||||
height: 20;
|
||||
align-horizontal: center;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
.code {
|
||||
|
||||
height: 34;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +110,7 @@ TweetHeader {
|
||||
}
|
||||
|
||||
TweetBody {
|
||||
width: 100%;
|
||||
background: $panel;
|
||||
color: $text-panel;
|
||||
height:20;
|
||||
@@ -159,7 +178,7 @@ OptionItem:hover {
|
||||
}
|
||||
|
||||
Error {
|
||||
max-width: 80;
|
||||
width: 80;
|
||||
height:3;
|
||||
background: $error;
|
||||
color: $text-error;
|
||||
@@ -168,10 +187,11 @@ Error {
|
||||
margin: 1 3;
|
||||
|
||||
text-style: bold;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
Warning {
|
||||
max-width: 80;
|
||||
width: 80;
|
||||
height:3;
|
||||
background: $warning;
|
||||
color: $text-warning-fade-1;
|
||||
@@ -179,15 +199,23 @@ Warning {
|
||||
border-bottom: hkey $warning-darken-2;
|
||||
margin: 1 2;
|
||||
text-style: bold;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
Success {
|
||||
max-width: 80;
|
||||
height:3;
|
||||
width: 80;
|
||||
height:3;
|
||||
box-sizing: border-box;
|
||||
background: $success-lighten-3;
|
||||
color: $text-success-lighten-3-fade-1;
|
||||
border-top: hkey $success;
|
||||
border-bottom: hkey $success;
|
||||
margin: 1 2;
|
||||
text-style: bold;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
|
||||
.horizontal {
|
||||
layout: horizontal
|
||||
}
|
||||
@@ -1,9 +1,47 @@
|
||||
from rich.align import Align
|
||||
from rich.console import RenderableType
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
CODE = '''
|
||||
class Offset(NamedTuple):
|
||||
"""A point defined by x and y coordinates."""
|
||||
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
|
||||
@property
|
||||
def is_origin(self) -> bool:
|
||||
"""Check if the point is at the origin (0, 0)"""
|
||||
return self == (0, 0)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self != (0, 0)
|
||||
|
||||
def __add__(self, other: object) -> Offset:
|
||||
if isinstance(other, tuple):
|
||||
_x, _y = self
|
||||
x, y = other
|
||||
return Offset(_x + x, _y + y)
|
||||
return NotImplemented
|
||||
|
||||
def __sub__(self, other: object) -> Offset:
|
||||
if isinstance(other, tuple):
|
||||
_x, _y = self
|
||||
x, y = other
|
||||
return Offset(_x - x, _y - y)
|
||||
return NotImplemented
|
||||
|
||||
def __mul__(self, other: object) -> Offset:
|
||||
if isinstance(other, (float, int)):
|
||||
x, y = self
|
||||
return Offset(int(x * other), int(y * other))
|
||||
return NotImplemented
|
||||
'''
|
||||
|
||||
|
||||
lorem = Text.from_markup(
|
||||
@@ -56,9 +94,25 @@ class BasicApp(App):
|
||||
def on_mount(self):
|
||||
"""Build layout here."""
|
||||
self.mount(
|
||||
header=Widget(),
|
||||
header=Static(
|
||||
Align.center(
|
||||
"[b]This is a [u]Textual[/u] app, running in the terminal",
|
||||
vertical="middle",
|
||||
)
|
||||
),
|
||||
content=Widget(
|
||||
Tweet(TweetBody(), Widget(classes={"button"})),
|
||||
Tweet(
|
||||
TweetBody(),
|
||||
# Widget(
|
||||
# Widget(classes={"button"}),
|
||||
# Widget(classes={"button"}),
|
||||
# classes={"horizontal"},
|
||||
# ),
|
||||
),
|
||||
Widget(
|
||||
Static(Syntax(CODE, "python"), classes={"code"}),
|
||||
classes={"scrollable"},
|
||||
),
|
||||
Error(),
|
||||
Tweet(TweetBody()),
|
||||
Warning(),
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
.list-item {
|
||||
height: 8;
|
||||
|
||||
min-width: 80;
|
||||
background: dark_blue;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import warnings
|
||||
from asyncio import AbstractEventLoop
|
||||
from contextlib import redirect_stdout
|
||||
from time import perf_counter
|
||||
from typing import Any, Iterable, Type, TYPE_CHECKING
|
||||
from typing import Any, Iterable, TextIO, Type, TYPE_CHECKING
|
||||
|
||||
import rich
|
||||
import rich.repr
|
||||
@@ -64,6 +64,7 @@ DEFAULT_COLORS = ColorSystem(
|
||||
success="#6d9f71",
|
||||
accent="#ffa62b",
|
||||
system="#5a4599",
|
||||
dark_surface="#292929",
|
||||
)
|
||||
|
||||
|
||||
@@ -100,7 +101,9 @@ class App(DOMNode):
|
||||
driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None.
|
||||
title (str, optional): Title of the application. Defaults to "Textual Application".
|
||||
"""
|
||||
self.console = Console(markup=False, highlight=False, emoji=False)
|
||||
self.console = Console(
|
||||
file=sys.__stdout__, markup=False, highlight=False, emoji=False
|
||||
)
|
||||
self.error_console = Console(markup=False, stderr=True)
|
||||
self._screen = screen
|
||||
self.driver_class = driver_class or self.get_driver_class()
|
||||
@@ -122,6 +125,7 @@ class App(DOMNode):
|
||||
self._title = title
|
||||
|
||||
self._log_console: Console | None = None
|
||||
self._log_file: TextIO | None = None
|
||||
if log:
|
||||
self._log_file = open(log, "wt")
|
||||
self._log_console = Console(
|
||||
@@ -131,9 +135,6 @@ class App(DOMNode):
|
||||
highlight=False,
|
||||
width=100,
|
||||
)
|
||||
else:
|
||||
self._log_console = None
|
||||
self._log_file = None
|
||||
|
||||
self.log_verbosity = log_verbosity
|
||||
|
||||
@@ -459,6 +460,7 @@ class App(DOMNode):
|
||||
Args:
|
||||
error (Exception): An exception instance.
|
||||
"""
|
||||
|
||||
if hasattr(error, "__rich__"):
|
||||
# Exception has a rich method, so we can defer to that for the rendering
|
||||
self.panic(error)
|
||||
@@ -489,15 +491,9 @@ class App(DOMNode):
|
||||
if os.getenv("TEXTUAL_DEVTOOLS") == "1":
|
||||
try:
|
||||
await self.devtools.connect()
|
||||
if self._log_console:
|
||||
self._log_console.print(
|
||||
f"Connected to devtools ({self.devtools.url})"
|
||||
)
|
||||
self.log(f"Connected to devtools ({self.devtools.url})")
|
||||
except DevtoolsConnectionError:
|
||||
if self._log_console:
|
||||
self._log_console.print(
|
||||
f"Couldn't connect to devtools ({self.devtools.url})"
|
||||
)
|
||||
self.log(f"Couldn't connect to devtools ({self.devtools.url})")
|
||||
try:
|
||||
if self.css_file is not None:
|
||||
self.stylesheet.read(self.css_file)
|
||||
@@ -532,11 +528,8 @@ class App(DOMNode):
|
||||
|
||||
with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore
|
||||
await super().process_messages()
|
||||
log("Message processing stopped")
|
||||
with timer("animator.stop()"):
|
||||
await self.animator.stop()
|
||||
with timer("self.close_all()"):
|
||||
await self.close_all()
|
||||
await self.animator.stop()
|
||||
await self.close_all()
|
||||
finally:
|
||||
driver.stop_application_mode()
|
||||
except Exception as error:
|
||||
|
||||
88
src/textual/box_model.py
Normal file
88
src/textual/box_model.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
from operator import is_
|
||||
|
||||
|
||||
from typing import Callable, NamedTuple, TYPE_CHECKING
|
||||
|
||||
from .geometry import Size, Spacing
|
||||
from .css.styles import StylesBase
|
||||
|
||||
|
||||
class BoxModel(NamedTuple):
|
||||
"""The result of `get_box_model`."""
|
||||
|
||||
size: Size # Content + padding + border
|
||||
margin: Spacing # Additional margin
|
||||
|
||||
|
||||
def get_box_model(
|
||||
styles: StylesBase,
|
||||
container: Size,
|
||||
viewport: Size,
|
||||
get_content_width: Callable[[Size, Size], int],
|
||||
get_content_height: Callable[[Size, Size], int],
|
||||
) -> BoxModel:
|
||||
"""Resolve the box model for this Styles.
|
||||
|
||||
Args:
|
||||
styles (StylesBase): Styles object.
|
||||
container (Size): The size of the widget container.
|
||||
viewport (Size): The viewport size.
|
||||
get_auto_width (Callable): A callable which accepts container size and parent size and returns a width.
|
||||
get_auto_height (Callable): A callable which accepts container size and parent size and returns a height.
|
||||
|
||||
Returns:
|
||||
BoxModel: A tuple with the size of the content area and margin.
|
||||
"""
|
||||
|
||||
has_rule = styles.has_rule
|
||||
width, height = container
|
||||
is_content_box = styles.box_sizing == "content-box"
|
||||
gutter = styles.padding + styles.border.spacing
|
||||
|
||||
if not has_rule("width"):
|
||||
width = container.width
|
||||
elif styles.width.is_auto:
|
||||
# When width is auto, we want enough space to always fit the content
|
||||
width = get_content_width(container, viewport)
|
||||
if not is_content_box:
|
||||
# If box sizing is border box we want to enlarge the width so that it
|
||||
# can accommodate padding + border
|
||||
width += gutter.width
|
||||
else:
|
||||
width = styles.width.resolve_dimension(container, viewport)
|
||||
|
||||
if not has_rule("height"):
|
||||
height = container.height
|
||||
elif styles.height.is_auto:
|
||||
height = get_content_height(container, viewport)
|
||||
if not is_content_box:
|
||||
height += gutter.height
|
||||
else:
|
||||
height = styles.height.resolve_dimension(container, viewport)
|
||||
|
||||
if is_content_box:
|
||||
gutter_width, gutter_height = gutter.totals
|
||||
width += gutter_width
|
||||
height += gutter_height
|
||||
|
||||
if has_rule("min_width"):
|
||||
min_width = styles.min_width.resolve_dimension(container, viewport)
|
||||
width = max(width, min_width)
|
||||
|
||||
if has_rule("max_width"):
|
||||
max_width = styles.max_width.resolve_dimension(container, viewport)
|
||||
width = min(width, max_width)
|
||||
|
||||
if has_rule("min_height"):
|
||||
min_height = styles.min_height.resolve_dimension(container, viewport)
|
||||
height = max(height, min_height)
|
||||
|
||||
if has_rule("max_height"):
|
||||
max_height = styles.max_height.resolve_dimension(container, viewport)
|
||||
height = min(height, max_height)
|
||||
|
||||
size = Size(width, height)
|
||||
margin = styles.margin
|
||||
|
||||
return BoxModel(size, margin)
|
||||
@@ -139,6 +139,7 @@ class Color(NamedTuple):
|
||||
@property
|
||||
def rich_color(self) -> RichColor:
|
||||
"""This color encoded in Rich's Color class."""
|
||||
# TODO: This isn't cheap as I'd like - cache in a LRUCache ?
|
||||
r, g, b, _a = self
|
||||
return RichColor.from_rgb(r, g, b)
|
||||
|
||||
|
||||
@@ -47,10 +47,14 @@ class ScalarProperty:
|
||||
"""Descriptor for getting and setting scalar properties. Scalars are numeric values with a unit, e.g. "50vh"."""
|
||||
|
||||
def __init__(
|
||||
self, units: set[Unit] | None = None, percent_unit: Unit = Unit.WIDTH
|
||||
self,
|
||||
units: set[Unit] | None = None,
|
||||
percent_unit: Unit = Unit.WIDTH,
|
||||
allow_auto: bool = True,
|
||||
) -> None:
|
||||
self.units: set[Unit] = units or {*UNIT_SYMBOL}
|
||||
self.percent_unit = percent_unit
|
||||
self.allow_auto = allow_auto
|
||||
super().__init__()
|
||||
|
||||
def __set_name__(self, owner: Styles, name: str) -> None:
|
||||
@@ -71,7 +75,9 @@ class ScalarProperty:
|
||||
value = obj.get_rule(self.name)
|
||||
return value
|
||||
|
||||
def __set__(self, obj: StylesBase, value: float | Scalar | str | None) -> None:
|
||||
def __set__(
|
||||
self, obj: StylesBase, value: float | int | Scalar | str | None
|
||||
) -> None:
|
||||
"""Set the scalar property
|
||||
|
||||
Args:
|
||||
@@ -90,7 +96,7 @@ class ScalarProperty:
|
||||
if value is None:
|
||||
obj.clear_rule(self.name)
|
||||
return
|
||||
if isinstance(value, float):
|
||||
if isinstance(value, (int, float)):
|
||||
new_value = Scalar(float(value), Unit.CELLS, Unit.WIDTH)
|
||||
elif isinstance(value, Scalar):
|
||||
new_value = value
|
||||
@@ -101,12 +107,23 @@ class ScalarProperty:
|
||||
raise StyleValueError("unable to parse scalar from {value!r}")
|
||||
else:
|
||||
raise StyleValueError("expected float, Scalar, or None")
|
||||
if new_value is not None and new_value.unit not in self.units:
|
||||
raise StyleValueError(
|
||||
f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}"
|
||||
)
|
||||
if new_value is not None and new_value.is_percent:
|
||||
new_value = Scalar(float(new_value.value), self.percent_unit, Unit.WIDTH)
|
||||
|
||||
if (
|
||||
new_value is not None
|
||||
and new_value.unit == Unit.AUTO
|
||||
and not self.allow_auto
|
||||
):
|
||||
raise StyleValueError("'auto' not allowed here")
|
||||
|
||||
if new_value.unit != Unit.AUTO:
|
||||
if new_value is not None and new_value.unit not in self.units:
|
||||
raise StyleValueError(
|
||||
f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}"
|
||||
)
|
||||
if new_value is not None and new_value.is_percent:
|
||||
new_value = Scalar(
|
||||
float(new_value.value), self.percent_unit, Unit.WIDTH
|
||||
)
|
||||
if obj.set_rule(self.name, new_value):
|
||||
obj.refresh()
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import rich.repr
|
||||
|
||||
from ._error_tools import friendly_list
|
||||
from .constants import (
|
||||
VALID_ALIGN_HORIZONTAL,
|
||||
VALID_ALIGN_VERTICAL,
|
||||
VALID_BORDER,
|
||||
VALID_BOX_SIZING,
|
||||
VALID_EDGE,
|
||||
@@ -600,3 +602,32 @@ class StylesBuilder:
|
||||
transitions[css_property] = Transition(duration, easing, delay)
|
||||
|
||||
self.styles._rules["transitions"] = transitions
|
||||
|
||||
def process_align(self, name: str, tokens: list[Token]) -> None:
|
||||
if len(tokens) != 2:
|
||||
self.error(name, tokens[0], "expected two tokens")
|
||||
token_horizontal = tokens[0]
|
||||
token_vertical = tokens[1]
|
||||
if token_horizontal.name != "token":
|
||||
self.error(
|
||||
name,
|
||||
token_horizontal,
|
||||
f"invalid token {token_horizontal!r}, expected {friendly_list(VALID_ALIGN_HORIZONTAL)}",
|
||||
)
|
||||
if token_vertical.name != "token":
|
||||
self.error(
|
||||
name,
|
||||
token_vertical,
|
||||
f"invalid token {token_vertical!r}, expected {friendly_list(VALID_ALIGN_VERTICAL)}",
|
||||
)
|
||||
|
||||
self.styles._rules["align_horizontal"] = token_horizontal.value
|
||||
self.styles._rules["align_vertical"] = token_vertical.value
|
||||
|
||||
def process_align_horizontal(self, name: str, tokens: list[Token]) -> None:
|
||||
value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL)
|
||||
self.styles._rules["align_horizontal"] = value
|
||||
|
||||
def process_align_vertical(self, name: str, tokens: list[Token]) -> None:
|
||||
value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL)
|
||||
self.styles._rules["align_vertical"] = value
|
||||
|
||||
@@ -29,6 +29,6 @@ VALID_LAYOUT: Final = {"dock", "vertical", "grid"}
|
||||
|
||||
VALID_BOX_SIZING: Final = {"border-box", "content-box"}
|
||||
VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"}
|
||||
|
||||
|
||||
VALID_ALIGN_HORIZONTAL: Final = {"left", "center", "right"}
|
||||
VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"}
|
||||
NULL_SPACING: Final = Spacing(0, 0, 0, 0)
|
||||
|
||||
@@ -3,11 +3,10 @@ from __future__ import annotations
|
||||
from enum import Enum, unique
|
||||
from functools import lru_cache
|
||||
import re
|
||||
from typing import Iterable, NamedTuple, TYPE_CHECKING
|
||||
from typing import Callable, Iterable, NamedTuple, TYPE_CHECKING
|
||||
|
||||
import rich.repr
|
||||
|
||||
from textual.css.tokenizer import Token
|
||||
|
||||
from .. import log
|
||||
from ..geometry import Offset
|
||||
@@ -34,6 +33,7 @@ class Unit(Enum):
|
||||
HEIGHT = 5
|
||||
VIEW_WIDTH = 6
|
||||
VIEW_HEIGHT = 7
|
||||
AUTO = 8
|
||||
|
||||
|
||||
UNIT_SYMBOL = {
|
||||
@@ -107,6 +107,10 @@ class Scalar(NamedTuple):
|
||||
def symbol(self) -> str:
|
||||
return UNIT_SYMBOL[self.unit]
|
||||
|
||||
@property
|
||||
def is_auto(self) -> bool:
|
||||
return self.unit == Unit.AUTO
|
||||
|
||||
@classmethod
|
||||
def from_number(cls, value: float) -> Scalar:
|
||||
return cls(float(value), Unit.CELLS, Unit.WIDTH)
|
||||
@@ -124,11 +128,14 @@ class Scalar(NamedTuple):
|
||||
Returns:
|
||||
Scalar: New scalar
|
||||
"""
|
||||
match = _MATCH_SCALAR(token)
|
||||
if match is None:
|
||||
raise ScalarParseError(f"{token!r} is not a valid scalar")
|
||||
value, unit_name = match.groups()
|
||||
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
|
||||
if token.lower() == "auto":
|
||||
scalar = cls(1.0, Unit.AUTO, Unit.AUTO)
|
||||
else:
|
||||
match = _MATCH_SCALAR(token)
|
||||
if match is None:
|
||||
raise ScalarParseError(f"{token!r} is not a valid scalar")
|
||||
value, unit_name = match.groups()
|
||||
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
|
||||
return scalar
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
@@ -142,12 +149,13 @@ class Scalar(NamedTuple):
|
||||
viewport (tuple[int, int]): Size of the viewport (typically terminal size)
|
||||
|
||||
Raises:
|
||||
ScalarResolveError: _description_
|
||||
ScalarResolveError: If the unit is unknown.
|
||||
|
||||
Returns:
|
||||
float: _description_
|
||||
int: A size (in cells)
|
||||
"""
|
||||
value, unit, percent_unit = self
|
||||
|
||||
if unit == Unit.PERCENT:
|
||||
unit = percent_unit
|
||||
try:
|
||||
|
||||
@@ -13,7 +13,7 @@ from rich.style import Style
|
||||
from .. import log
|
||||
from .._animator import Animation, EasingFunction
|
||||
from ..color import Color
|
||||
from ..geometry import Size, Spacing
|
||||
from ..geometry import Offset, Size, Spacing
|
||||
from ._style_properties import (
|
||||
BorderProperty,
|
||||
BoxProperty,
|
||||
@@ -32,17 +32,28 @@ from ._style_properties import (
|
||||
TransitionsProperty,
|
||||
FractionalProperty,
|
||||
)
|
||||
from .constants import VALID_BOX_SIZING, VALID_DISPLAY, VALID_VISIBILITY, VALID_OVERFLOW
|
||||
from .constants import (
|
||||
VALID_ALIGN_HORIZONTAL,
|
||||
VALID_ALIGN_VERTICAL,
|
||||
VALID_BOX_SIZING,
|
||||
VALID_DISPLAY,
|
||||
VALID_VISIBILITY,
|
||||
VALID_OVERFLOW,
|
||||
)
|
||||
from .scalar import Scalar, ScalarOffset, Unit
|
||||
from .scalar_animation import ScalarAnimation
|
||||
from .transition import Transition
|
||||
from .types import (
|
||||
BoxSizing,
|
||||
Display,
|
||||
AlignHorizontal,
|
||||
AlignVertical,
|
||||
Edge,
|
||||
AlignHorizontal,
|
||||
Overflow,
|
||||
Specificity3,
|
||||
Specificity4,
|
||||
AlignVertical,
|
||||
Visibility,
|
||||
)
|
||||
|
||||
@@ -116,8 +127,12 @@ class RulesMap(TypedDict, total=False):
|
||||
scrollbar_background_hover: Color
|
||||
scrollbar_background_active: Color
|
||||
|
||||
align_horizontal: AlignHorizontal
|
||||
align_vertical: AlignVertical
|
||||
|
||||
|
||||
RULE_NAMES = list(RulesMap.__annotations__.keys())
|
||||
RULE_NAMES_SET = frozenset(RULE_NAMES)
|
||||
_rule_getter = attrgetter(*RULE_NAMES)
|
||||
|
||||
|
||||
@@ -179,10 +194,10 @@ class StylesBase(ABC):
|
||||
box_sizing = StringEnumProperty(VALID_BOX_SIZING, "border-box")
|
||||
width = ScalarProperty(percent_unit=Unit.WIDTH)
|
||||
height = ScalarProperty(percent_unit=Unit.HEIGHT)
|
||||
min_width = ScalarProperty(percent_unit=Unit.WIDTH)
|
||||
min_height = ScalarProperty(percent_unit=Unit.HEIGHT)
|
||||
max_width = ScalarProperty(percent_unit=Unit.WIDTH)
|
||||
max_height = ScalarProperty(percent_unit=Unit.HEIGHT)
|
||||
min_width = ScalarProperty(percent_unit=Unit.WIDTH, allow_auto=False)
|
||||
min_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False)
|
||||
max_width = ScalarProperty(percent_unit=Unit.WIDTH, allow_auto=False)
|
||||
max_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False)
|
||||
|
||||
dock = DockProperty()
|
||||
docks = DocksProperty()
|
||||
@@ -204,6 +219,9 @@ class StylesBase(ABC):
|
||||
scrollbar_background_hover = ColorProperty("#444444")
|
||||
scrollbar_background_active = ColorProperty("black")
|
||||
|
||||
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
||||
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||
|
||||
def __eq__(self, styles: object) -> bool:
|
||||
"""Check that Styles containts the same rules."""
|
||||
if not isinstance(styles, StylesBase):
|
||||
@@ -345,70 +363,43 @@ class StylesBase(ABC):
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_box_model(
|
||||
self, container_size: Size, parent_size: Size
|
||||
) -> tuple[Size, Spacing]:
|
||||
"""Resolve the box model for this Styles.
|
||||
def align_width(self, width: int, parent_width: int) -> int:
|
||||
"""Align the width dimension.
|
||||
|
||||
Args:
|
||||
container_size (Size): The size of the widget container.
|
||||
parent_size (Size): The size widget's parent.
|
||||
width (int): Width of the content.
|
||||
parent_width (int): Width of the parent container.
|
||||
|
||||
Returns:
|
||||
tuple[Size, Spacing]: A tuple with the size of the content area and margin.
|
||||
int: An offset to add to the X coordinate.
|
||||
"""
|
||||
has_rule = self.has_rule
|
||||
width, height = container_size
|
||||
offset_x = 0
|
||||
align_horizontal = self.align_horizontal
|
||||
if align_horizontal != "left":
|
||||
if align_horizontal == "center":
|
||||
offset_x = (parent_width - width) // 2
|
||||
else:
|
||||
offset_x = parent_width - width
|
||||
return offset_x
|
||||
|
||||
if has_rule("width"):
|
||||
width = self.width.resolve_dimension(container_size, parent_size)
|
||||
else:
|
||||
width = max(0, width - self.margin.width)
|
||||
def align_height(self, height: int, parent_height: int) -> int:
|
||||
"""Align the height dimensions
|
||||
|
||||
if self.min_width:
|
||||
min_width = self.min_width.resolve_dimension(container_size, parent_size)
|
||||
width = max(width, min_width)
|
||||
Args:
|
||||
height (int): Height of the content.
|
||||
parent_height (int): Height of the parent container.
|
||||
|
||||
if self.max_width:
|
||||
max_width = self.max_width.resolve_dimension(container_size, parent_size)
|
||||
width = min(width, max_width)
|
||||
|
||||
if has_rule("height"):
|
||||
height = self.height.resolve_dimension(container_size, parent_size)
|
||||
else:
|
||||
height = max(0, height - self.margin.height)
|
||||
|
||||
if self.min_height:
|
||||
min_height = self.min_height.resolve_dimension(container_size, parent_size)
|
||||
height = max(height, min_height)
|
||||
|
||||
if self.max_height:
|
||||
max_height = self.max_height.resolve_dimension(container_size, parent_size)
|
||||
height = min(width, max_height)
|
||||
|
||||
# TODO: box sizing
|
||||
|
||||
size = Size(width, height)
|
||||
margin = Spacing(0, 0, 0, 0)
|
||||
|
||||
if self.box_sizing == "content-box":
|
||||
|
||||
if has_rule("padding"):
|
||||
size += self.padding.totals
|
||||
if has_rule("border"):
|
||||
size += self.border.spacing.totals
|
||||
if has_rule("margin"):
|
||||
margin = self.margin
|
||||
|
||||
else: # border-box
|
||||
if has_rule("padding"):
|
||||
size -= self.padding.totals
|
||||
if has_rule("border"):
|
||||
size -= self.border.spacing.totals
|
||||
if has_rule("margin"):
|
||||
margin = self.margin
|
||||
|
||||
return size, margin
|
||||
Returns:
|
||||
int: An offset to add to the Y coordinate.
|
||||
"""
|
||||
offset_y = 0
|
||||
align_vertical = self.align_vertical
|
||||
if align_vertical != "top":
|
||||
if align_vertical == "middle":
|
||||
offset_y = (parent_height - height) // 2
|
||||
else:
|
||||
offset_y = parent_height - height
|
||||
return offset_y
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -426,6 +417,7 @@ class Styles(StylesBase):
|
||||
return Styles(node=self.node, _rules=self.get_rules(), important=self.important)
|
||||
|
||||
def has_rule(self, rule: str) -> bool:
|
||||
assert rule in RULE_NAMES_SET, f"no such rule {rule!r}"
|
||||
return rule in self._rules
|
||||
|
||||
def clear_rule(self, rule: str) -> bool:
|
||||
@@ -676,6 +668,15 @@ class Styles(StylesBase):
|
||||
),
|
||||
)
|
||||
|
||||
if has_rule("align_horizontal") and has_rule("align_vertical"):
|
||||
append_declaration(
|
||||
"align", f"{self.align_horizontal} {self.align_vertical}"
|
||||
)
|
||||
elif has_rule("align_horizontal"):
|
||||
append_declaration("align-horizontal", self.align_horizontal)
|
||||
elif has_rule("align_horizontal"):
|
||||
append_declaration("align-vertical", self.align_vertical)
|
||||
|
||||
lines.sort()
|
||||
return lines
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ EdgeType = Literal[
|
||||
]
|
||||
Visibility = Literal["visible", "hidden", "initial", "inherit"]
|
||||
Display = Literal["block", "none"]
|
||||
AlignHorizontal = Literal["left", "center", "right"]
|
||||
AlignVertical = Literal["top", "middle", "bottom"]
|
||||
BoxSizing = Literal["border-box", "content-box"]
|
||||
Overflow = Literal["scroll", "hidden", "auto"]
|
||||
EdgeStyle = Tuple[str, Color]
|
||||
|
||||
@@ -7,7 +7,6 @@ Functions and classes to manage terminal geometry (anything involving coordinate
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from math import sqrt
|
||||
from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar
|
||||
|
||||
|
||||
@@ -97,7 +96,7 @@ class Offset(NamedTuple):
|
||||
"""
|
||||
x1, y1 = self
|
||||
x2, y2 = other
|
||||
distance = sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1))
|
||||
distance = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5
|
||||
return distance
|
||||
|
||||
|
||||
@@ -654,5 +653,14 @@ class Spacing(NamedTuple):
|
||||
)
|
||||
return NotImplemented
|
||||
|
||||
def __sub__(self, other: object) -> Spacing:
|
||||
if isinstance(other, tuple):
|
||||
top1, right1, bottom1, left1 = self
|
||||
top2, right2, bottom2, left2 = other
|
||||
return Spacing(
|
||||
top1 - top2, right1 - right2, bottom1 - bottom2, left1 - left2
|
||||
)
|
||||
return NotImplemented
|
||||
|
||||
|
||||
NULL_OFFSET = Offset(0, 0)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from operator import attrgetter
|
||||
from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence
|
||||
|
||||
from .._layout_resolve import layout_resolve
|
||||
@@ -91,7 +92,7 @@ class DockLayout(Layout):
|
||||
add_placement = placements.append
|
||||
arranged_widgets: set[Widget] = set()
|
||||
|
||||
for edge, widgets, z in docks:
|
||||
for z, (edge, widgets, _z) in enumerate(sorted(docks, key=attrgetter("z"))):
|
||||
|
||||
arranged_widgets.update(widgets)
|
||||
dock_options = [make_dock_options(widget, edge) for widget in widgets]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from textual.geometry import Size, Offset, Region
|
||||
from textual.layout import Layout, WidgetPlacement
|
||||
@@ -24,15 +25,30 @@ class HorizontalLayout(Layout):
|
||||
x = max_width = max_height = 0
|
||||
parent_size = parent.size
|
||||
|
||||
for widget in parent.children:
|
||||
(content_width, content_height), margin = widget.styles.get_box_model(
|
||||
size, parent_size
|
||||
)
|
||||
region = Region(margin.left + x, margin.top, content_width, content_height)
|
||||
max_height = max(max_height, content_height + margin.height)
|
||||
box_models = [
|
||||
widget.get_box_model(size, parent_size)
|
||||
for widget in cast("list[Widget]", parent.children)
|
||||
]
|
||||
|
||||
margins = [
|
||||
max((box1.margin.right, box2.margin.left))
|
||||
for box1, box2 in zip(box_models, box_models[1:])
|
||||
]
|
||||
if box_models:
|
||||
margins.append(box_models[-1].margin.right)
|
||||
|
||||
x = box_models[0].margin.left if box_models else 0
|
||||
|
||||
for widget, box_model, margin in zip(parent.children, box_models, margins):
|
||||
content_width, content_height = box_model.size
|
||||
offset_y = widget.styles.align_height(content_height, parent_size.height)
|
||||
region = Region(x, offset_y, content_width, content_height)
|
||||
max_height = max(max_height, content_height)
|
||||
add_placement(WidgetPlacement(region, widget, 0))
|
||||
x += region.width + margin.left
|
||||
max_width = x + margin.right
|
||||
x += region.width + margin
|
||||
max_width = x
|
||||
|
||||
max_width += margins[-1] if margins else 0
|
||||
|
||||
total_region = Region(0, 0, max_width, max_height)
|
||||
add_placement(WidgetPlacement(total_region, None, 0))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import cast, TYPE_CHECKING
|
||||
|
||||
from .. import log
|
||||
|
||||
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class VerticalLayout(Layout):
|
||||
"""Simple vertical layout."""
|
||||
"""Used to layout Widgets vertically on screen, from top to bottom."""
|
||||
|
||||
name = "vertical"
|
||||
|
||||
@@ -26,15 +26,30 @@ class VerticalLayout(Layout):
|
||||
y = max_width = max_height = 0
|
||||
parent_size = parent.size
|
||||
|
||||
for widget in parent.children:
|
||||
(content_width, content_height), margin = widget.styles.get_box_model(
|
||||
size, parent_size
|
||||
)
|
||||
region = Region(margin.left, y + margin.top, content_width, content_height)
|
||||
max_width = max(max_width, content_width + margin.width)
|
||||
box_models = [
|
||||
widget.get_box_model(size, parent_size)
|
||||
for widget in cast("list[Widget]", parent.children)
|
||||
]
|
||||
|
||||
margins = [
|
||||
max((box1.margin.bottom, box2.margin.top))
|
||||
for box1, box2 in zip(box_models, box_models[1:])
|
||||
]
|
||||
if box_models:
|
||||
margins.append(box_models[-1].margin.bottom)
|
||||
|
||||
y = box_models[0].margin.top if box_models else 0
|
||||
|
||||
for widget, box_model, margin in zip(parent.children, box_models, margins):
|
||||
content_width, content_height = box_model.size
|
||||
offset_x = widget.styles.align_width(content_width, parent_size.width)
|
||||
region = Region(offset_x, y, content_width, content_height)
|
||||
max_height = max(max_height, content_height)
|
||||
add_placement(WidgetPlacement(region, widget, 0))
|
||||
y += region.height + margin.top
|
||||
max_height = y + margin.bottom
|
||||
y += region.height + margin
|
||||
max_height = y
|
||||
|
||||
max_height += margins[-1] if margins else 0
|
||||
|
||||
total_region = Region(0, 0, max_width, max_height)
|
||||
add_placement(WidgetPlacement(total_region, None, 0))
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import ConsoleOptions, Console, RenderResult
|
||||
from rich.color import Color
|
||||
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from ._blend_colors import blend_colors_rgb
|
||||
from ..color import Color
|
||||
|
||||
|
||||
class VerticalGradient:
|
||||
"""Draw a vertical gradient."""
|
||||
|
||||
def __init__(self, color1: str, color2: str) -> None:
|
||||
self._color1 = Color.parse(color1).get_truecolor()
|
||||
self._color2 = Color.parse(color2).get_truecolor()
|
||||
self._color1 = Color.parse(color1)
|
||||
self._color2 = Color.parse(color2)
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
@@ -22,15 +22,20 @@ class VerticalGradient:
|
||||
height = options.height or options.max_height
|
||||
color1 = self._color1
|
||||
color2 = self._color2
|
||||
default_color = Color.default()
|
||||
default_color = Color(0, 0, 0).rich_color
|
||||
from_color = Style.from_color
|
||||
blend = color1.blend
|
||||
rich_color1 = color1.rich_color
|
||||
for y in range(height):
|
||||
yield Segment(
|
||||
f"{width * ' '}\n",
|
||||
from_color(
|
||||
default_color, blend_colors_rgb(color1, color2, y / (height - 1))
|
||||
line_color = from_color(
|
||||
default_color,
|
||||
(
|
||||
blend(color2, y / (height - 1)).rich_color
|
||||
if height > 1
|
||||
else rich_color1
|
||||
),
|
||||
)
|
||||
yield Segment(f"{width * ' '}\n", line_color)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -131,7 +131,8 @@ class Screen(Widget):
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
self.app.panic(error)
|
||||
self.app.on_exception(error)
|
||||
return
|
||||
self.app.refresh()
|
||||
self._dirty_widgets.clear()
|
||||
|
||||
@@ -204,7 +205,11 @@ class Screen(Widget):
|
||||
if isinstance(event, events.MouseDown) and widget.can_focus:
|
||||
await self.app.set_focus(widget)
|
||||
event.style = self.get_style_at(event.screen_x, event.screen_y)
|
||||
await widget.forward_event(event.offset(-region.x, -region.y))
|
||||
if widget is self:
|
||||
event.set_forwarded()
|
||||
await self.post_message(event)
|
||||
else:
|
||||
await widget.forward_event(event.offset(-region.x, -region.y))
|
||||
|
||||
elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):
|
||||
try:
|
||||
@@ -213,6 +218,9 @@ class Screen(Widget):
|
||||
return
|
||||
scroll_widget = widget
|
||||
if scroll_widget is not None:
|
||||
await scroll_widget.forward_event(event)
|
||||
if scroll_widget is self:
|
||||
await self.post_message(event)
|
||||
else:
|
||||
await scroll_widget.forward_event(event)
|
||||
else:
|
||||
await self.post_message(event)
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import (
|
||||
Awaitable,
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Iterable,
|
||||
NamedTuple,
|
||||
cast,
|
||||
@@ -15,22 +14,23 @@ from typing import (
|
||||
import rich.repr
|
||||
from rich.align import Align
|
||||
from rich.console import Console, RenderableType
|
||||
from rich.measure import Measurement
|
||||
from rich.padding import Padding
|
||||
from rich.pretty import Pretty
|
||||
from rich.style import Style
|
||||
from rich.styled import Styled
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
from . import errors, log
|
||||
from . import events
|
||||
from ._animator import BoundAnimator
|
||||
from ._border import Border
|
||||
from .box_model import BoxModel, get_box_model
|
||||
from ._callback import invoke
|
||||
from .color import Color
|
||||
from ._context import active_app
|
||||
from ._types import Lines
|
||||
from .dom import DOMNode
|
||||
from .geometry import clamp, Offset, Region, Size
|
||||
from .geometry import clamp, Offset, Region, Size, Spacing
|
||||
from .message import Message
|
||||
from . import messages
|
||||
from .layout import Layout
|
||||
@@ -96,6 +96,8 @@ class Widget(DOMNode):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.add_children(*children)
|
||||
|
||||
auto_width = Reactive(True)
|
||||
auto_height = Reactive(True)
|
||||
has_focus = Reactive(False)
|
||||
mouse_over = Reactive(False)
|
||||
scroll_x = Reactive(0.0, repaint=False)
|
||||
@@ -105,6 +107,34 @@ class Widget(DOMNode):
|
||||
show_vertical_scrollbar = Reactive(False, layout=True)
|
||||
show_horizontal_scrollbar = Reactive(False, layout=True)
|
||||
|
||||
def get_box_model(self, container: Size, viewport: Size) -> BoxModel:
|
||||
"""Process the box model for this widget.
|
||||
|
||||
Args:
|
||||
container (Size): The size of the container widget (with a layout)
|
||||
viewport (Size): The viewport size.
|
||||
|
||||
Returns:
|
||||
BoxModel: The size and margin for this widget.
|
||||
"""
|
||||
box_model = get_box_model(
|
||||
self.styles,
|
||||
container,
|
||||
viewport,
|
||||
self.get_content_width,
|
||||
self.get_content_height,
|
||||
)
|
||||
return box_model
|
||||
|
||||
def get_content_width(self, container_size: Size, parent_size: Size) -> int:
|
||||
console = self.app.console
|
||||
renderable = self.render()
|
||||
measurement = Measurement.get(console, console.options, renderable)
|
||||
return measurement.maximum
|
||||
|
||||
def get_content_height(self, container_size: Size, parent_size: Size) -> int:
|
||||
return container_size.height
|
||||
|
||||
async def watch_scroll_x(self, new_value: float) -> None:
|
||||
self.horizontal_scrollbar.position = int(new_value)
|
||||
|
||||
@@ -394,10 +424,7 @@ class Widget(DOMNode):
|
||||
if renderable_text_style:
|
||||
renderable = Styled(renderable, renderable_text_style)
|
||||
|
||||
if styles.padding:
|
||||
renderable = Padding(
|
||||
renderable, styles.padding, style=renderable_text_style
|
||||
)
|
||||
renderable = Padding(renderable, styles.padding, style=renderable_text_style)
|
||||
|
||||
if styles.border:
|
||||
renderable = Border(
|
||||
@@ -518,7 +545,9 @@ class Widget(DOMNode):
|
||||
"""Render all lines."""
|
||||
width, height = self.size
|
||||
renderable = self.render_styled()
|
||||
options = self.console.options.update_dimensions(width, height)
|
||||
options = self.console.options.update_dimensions(width, height).update(
|
||||
highlight=False
|
||||
)
|
||||
lines = self.console.render_lines(renderable, options)
|
||||
self._render_cache = RenderCache(self.size, lines)
|
||||
self._dirty_regions.clear()
|
||||
@@ -656,13 +685,13 @@ class Widget(DOMNode):
|
||||
|
||||
def on_mouse_scroll_down(self, event) -> None:
|
||||
if self.is_container:
|
||||
if not self.scroll_down(animate=False):
|
||||
event.stop()
|
||||
self.scroll_down(animate=False)
|
||||
event.stop()
|
||||
|
||||
def on_mouse_scroll_up(self, event) -> None:
|
||||
if self.is_container:
|
||||
if not self.scroll_up(animate=False):
|
||||
event.stop()
|
||||
self.scroll_up(animate=False)
|
||||
event.stop()
|
||||
|
||||
def handle_scroll_to(self, message: ScrollTo) -> None:
|
||||
if self.is_container:
|
||||
|
||||
183
tests/test_box_model.py
Normal file
183
tests/test_box_model.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from textual.box_model import BoxModel, get_box_model
|
||||
from textual.css.styles import Styles
|
||||
from textual.geometry import Size, Spacing
|
||||
|
||||
|
||||
def test_content_box():
|
||||
styles = Styles()
|
||||
styles.width = 10
|
||||
styles.height = 8
|
||||
styles.padding = 1
|
||||
styles.border = ("solid", "red")
|
||||
|
||||
# border-box is default
|
||||
assert styles.box_sizing == "border-box"
|
||||
|
||||
def get_auto_width(container: Size, parent: Size) -> int:
|
||||
assert False, "must not be called"
|
||||
|
||||
def get_auto_height(container: Size, parent: Size) -> int:
|
||||
assert False, "must not be called"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
# Size should be inclusive of padding / border
|
||||
assert box_model == BoxModel(Size(10, 8), Spacing(0, 0, 0, 0))
|
||||
|
||||
# Switch to content-box
|
||||
styles.box_sizing = "content-box"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
# width and height have added padding / border to accommodate content
|
||||
assert box_model == BoxModel(Size(14, 12), Spacing(0, 0, 0, 0))
|
||||
|
||||
|
||||
def test_width():
|
||||
"""Test width settings."""
|
||||
styles = Styles()
|
||||
|
||||
def get_auto_width(container: Size, parent: Size) -> int:
|
||||
return 10
|
||||
|
||||
def get_auto_height(container: Size, parent: Size) -> int:
|
||||
return 10
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(60, 20), Spacing(0, 0, 0, 0))
|
||||
|
||||
# Add a margin and check that it is reported
|
||||
styles.margin = (1, 2, 3, 4)
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4))
|
||||
|
||||
# Set width to auto-detect
|
||||
styles.width = "auto"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
# Setting width to auto should call get_auto_width
|
||||
assert box_model == BoxModel(Size(10, 20), Spacing(1, 2, 3, 4))
|
||||
|
||||
# Set width to 100 vw which should make it the width of the parent
|
||||
styles.width = "100vw"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(80, 20), Spacing(1, 2, 3, 4))
|
||||
|
||||
# Set the width to 100% should make it fill the container size
|
||||
styles.width = "100%"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4))
|
||||
|
||||
styles.width = "100vw"
|
||||
styles.max_width = "50%"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(30, 20), Spacing(1, 2, 3, 4))
|
||||
|
||||
|
||||
def test_height():
|
||||
"""Test width settings."""
|
||||
styles = Styles()
|
||||
|
||||
def get_auto_width(container: Size, parent: Size) -> int:
|
||||
return 10
|
||||
|
||||
def get_auto_height(container: Size, parent: Size) -> int:
|
||||
return 10
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(60, 20), Spacing(0, 0, 0, 0))
|
||||
|
||||
# Add a margin and check that it is reported
|
||||
styles.margin = (1, 2, 3, 4)
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4))
|
||||
|
||||
# Set width to 100 vw which should make it the width of the parent
|
||||
styles.height = "100vh"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(60, 24), Spacing(1, 2, 3, 4))
|
||||
|
||||
# Set the width to 100% should make it fill the container size
|
||||
styles.height = "100%"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4))
|
||||
|
||||
styles.height = "100vh"
|
||||
styles.max_height = "50%"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(60, 10), Spacing(1, 2, 3, 4))
|
||||
|
||||
|
||||
def test_max():
|
||||
"""Check that max_width and max_height are respected."""
|
||||
styles = Styles()
|
||||
styles.width = 100
|
||||
styles.height = 80
|
||||
styles.max_width = 40
|
||||
styles.max_height = 30
|
||||
|
||||
def get_auto_width(container: Size, parent: Size) -> int:
|
||||
assert False, "must not be called"
|
||||
|
||||
def get_auto_height(container: Size, parent: Size) -> int:
|
||||
assert False, "must not be called"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(40, 30), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(40, 30), Spacing(0, 0, 0, 0))
|
||||
|
||||
|
||||
def test_min():
|
||||
"""Check that min_width and min_height are respected."""
|
||||
styles = Styles()
|
||||
styles.width = 10
|
||||
styles.height = 5
|
||||
styles.min_width = 40
|
||||
styles.min_height = 30
|
||||
|
||||
def get_auto_width(container: Size, parent: Size) -> int:
|
||||
assert False, "must not be called"
|
||||
|
||||
def get_auto_height(container: Size, parent: Size) -> int:
|
||||
assert False, "must not be called"
|
||||
|
||||
box_model = get_box_model(
|
||||
styles, Size(40, 30), Size(80, 24), get_auto_width, get_auto_height
|
||||
)
|
||||
assert box_model == BoxModel(Size(40, 30), Spacing(0, 0, 0, 0))
|
||||
@@ -317,6 +317,13 @@ def test_spacing_add():
|
||||
Spacing(1, 2, 3, 4) + "foo"
|
||||
|
||||
|
||||
def test_spacing_sub():
|
||||
assert Spacing(1, 2, 3, 4) - Spacing(5, 6, 7, 8) == Spacing(-4, -4, -4, -4)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
Spacing(1, 2, 3, 4) - "foo"
|
||||
|
||||
|
||||
def test_split():
|
||||
assert Region(10, 5, 22, 15).split(10, 5) == (
|
||||
Region(10, 5, 10, 5),
|
||||
|
||||
Reference in New Issue
Block a user