Merge pull request #398 from Textualize/align

CSS align and box model
This commit is contained in:
Will McGugan
2022-04-22 14:01:26 +01:00
committed by GitHub
23 changed files with 726 additions and 159 deletions

46
sandbox/align.css Normal file
View 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
View 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)

View File

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

View File

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

View File

@@ -8,6 +8,7 @@
.list-item {
height: 8;
min-width: 80;
background: dark_blue;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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