scalars in CSS

This commit is contained in:
Will McGugan
2021-11-26 14:13:01 +00:00
parent 6706746548
commit 079d41473f
9 changed files with 89 additions and 54 deletions

View File

@@ -11,6 +11,7 @@ App > DockView {
height: 1fr; height: 1fr;
layer: panels; layer: panels;
border-right: outer #09312e; border-right: outer #09312e;
offset-x: -50%;
} }
#header { #header {

View File

@@ -16,4 +16,4 @@ class BasicApp(App):
) )
BasicApp.run(css_file="basic.css") BasicApp.run(log="textual.log", css_file="basic.css")

View File

@@ -6,7 +6,14 @@ import rich.repr
from rich.color import Color from rich.color import Color
from rich.style import Style from rich.style import Style
from .scalar import get_symbols, UNIT_SYMBOL, Unit, Scalar, ScalarParseError from .scalar import (
get_symbols,
UNIT_SYMBOL,
Unit,
Scalar,
ScalarOffset,
ScalarParseError,
)
from ..geometry import Offset, Spacing, SpacingDimensions from ..geometry import Offset, Spacing, SpacingDimensions
from .constants import NULL_SPACING, VALID_EDGE from .constants import NULL_SPACING, VALID_EDGE
from .errors import StyleTypeError, StyleValueError from .errors import StyleTypeError, StyleValueError
@@ -42,7 +49,7 @@ class ScalarProperty:
if value is None: if value is None:
new_value = None new_value = None
elif isinstance(value, float): elif isinstance(value, float):
new_value = Scalar(value, Unit.CELLS) new_value = Scalar(value, Unit.CELLS, Unit.WIDTH)
elif isinstance(value, Scalar): elif isinstance(value, Scalar):
new_value = value new_value = value
elif isinstance(value, str): elif isinstance(value, str):
@@ -57,7 +64,7 @@ class ScalarProperty:
f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}" 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: if new_value is not None and new_value.is_percent:
new_value = Scalar(new_value.value, self.percent_unit) new_value = Scalar(new_value.value, self.percent_unit, Unit.WIDTH)
setattr(obj, self.internal_name, new_value) setattr(obj, self.internal_name, new_value)
return value return value
@@ -258,11 +265,26 @@ class OffsetProperty:
def __set_name__(self, owner: Styles, name: str) -> None: def __set_name__(self, owner: Styles, name: str) -> None:
self._internal_name = f"_rule_{name}" self._internal_name = f"_rule_{name}"
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Offset: def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> ScalarOffset:
return getattr(obj, self._internal_name) or Offset() return getattr(obj, self._internal_name) or ScalarOffset(
Scalar.from_number(0), Scalar.from_number(0)
)
def __set__(self, obj: Styles, offset: tuple[int, int]) -> tuple[int, int]: def __set__(
_offset = Offset(*offset) self, obj: Styles, offset: tuple[int | str, int | str]
) -> tuple[int | str, int | str]:
x, y = offset
scalar_x = (
Scalar.parse(x, Unit.WIDTH)
if isinstance(x, str)
else Scalar(x, Unit.CELLS, Unit.WIDTH)
)
scalar_y = (
Scalar.parse(y, Unit.HEIGHT)
if isinstance(y, str)
else Scalar(y, Unit.CELLS, Unit.HEIGHT)
)
_offset = ScalarOffset(scalar_x, scalar_y)
setattr(obj, self._internal_name, _offset) setattr(obj, self._internal_name, _offset)
return offset return offset

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import cast from typing import cast, NoReturn
import rich.repr import rich.repr
from rich.color import ANSI_COLOR_NAMES, Color from rich.color import ANSI_COLOR_NAMES, Color
@@ -11,7 +11,7 @@ from .errors import DeclarationError, StyleValueError
from ._error_tools import friendly_list from ._error_tools import friendly_list
from ..geometry import Offset, Spacing, SpacingDimensions from ..geometry import Offset, Spacing, SpacingDimensions
from .model import Declaration from .model import Declaration
from .scalar import Scalar from .scalar import Scalar, ScalarOffset, Unit
from .styles import DockGroup, Styles from .styles import DockGroup, Styles
from .types import Edge, Display, Visibility from .types import Edge, Display, Visibility
from .tokenize import Token from .tokenize import Token
@@ -27,7 +27,7 @@ class StylesBuilder:
def __repr__(self) -> str: def __repr__(self) -> str:
return "StylesBuilder()" return "StylesBuilder()"
def error(self, name: str, token: Token, message: str) -> None: def error(self, name: str, token: Token, message: str) -> NoReturn:
raise DeclarationError(name, token, message) raise DeclarationError(name, token, message)
def add_declaration(self, declaration: Declaration) -> None: def add_declaration(self, declaration: Declaration) -> None:
@@ -210,13 +210,14 @@ class StylesBuilder:
self.error(name, tokens[0], "expected two numbers in declaration") self.error(name, tokens[0], "expected two numbers in declaration")
else: else:
token1, token2 = tokens token1, token2 = tokens
if token1.name != "number":
self.error(name, token1, f"expected a number (found {token1.value!r})") if token1.name != "scalar":
if token2.name != "number": self.error(name, token1, f"expected a scalar; found {token1.value!r}")
self.error(name, token2, f"expected a number (found {token1.value!r})") if token2.name != "scalar":
self.styles._rule_offset = Offset( self.error(name, token2, f"expected a scalar; found {token1.value!r}")
int(float(token1.value)), int(float(token2.value)) scalar_x = Scalar.parse(token1.value, Unit.WIDTH)
) scalar_y = Scalar.parse(token2.value, Unit.HEIGHT)
self.styles._rule_offset = ScalarOffset(scalar_x, scalar_y)
def process_offset_x(self, name: str, tokens: list[Token]) -> None: def process_offset_x(self, name: str, tokens: list[Token]) -> None:
if not tokens: if not tokens:
@@ -224,9 +225,12 @@ class StylesBuilder:
if len(tokens) != 1: if len(tokens) != 1:
self.error(name, tokens[0], f"expected a single number") self.error(name, tokens[0], f"expected a single number")
else: else:
x = int(float(tokens[0].value)) token = tokens[0]
if token.name != "scalar":
self.error(name, token, f"expected a scalar; found {token.value!r}")
x = Scalar.parse(token.value, Unit.WIDTH)
y = self.styles.offset.y y = self.styles.offset.y
self.styles._rule_offset = Offset(x, y) self.styles._rule_offset = ScalarOffset(x, y)
def process_offset_y(self, name: str, tokens: list[Token]) -> None: def process_offset_y(self, name: str, tokens: list[Token]) -> None:
if not tokens: if not tokens:
@@ -234,9 +238,12 @@ class StylesBuilder:
if len(tokens) != 1: if len(tokens) != 1:
self.error(name, tokens[0], f"expected a single number") self.error(name, tokens[0], f"expected a single number")
else: else:
y = int(float(tokens[0].value)) token = tokens[0]
if token.name != "scalar":
self.error(name, token, f"expected a scalar; found {token.value!r}")
y = Scalar.parse(token.value, Unit.HEIGHT)
x = self.styles.offset.x x = self.styles.offset.x
self.styles._rule_offset = Offset(x, y) self.styles._rule_offset = ScalarOffset(x, y)
def process_layout(self, name: str, tokens: list[Token]) -> None: def process_layout(self, name: str, tokens: list[Token]) -> None:
if tokens: if tokens:
@@ -247,7 +254,10 @@ class StylesBuilder:
def process_text(self, name: str, tokens: list[Token]) -> None: def process_text(self, name: str, tokens: list[Token]) -> None:
style_definition = " ".join(token.value for token in tokens) style_definition = " ".join(token.value for token in tokens)
try:
style = Style.parse(style_definition) style = Style.parse(style_definition)
except Exception as error:
self.error(name, tokens[0], f"failed to parse style; {error}")
self.styles.text = style self.styles.text = style
def process_text_color(self, name: str, tokens: list[Token]) -> None: def process_text_color(self, name: str, tokens: list[Token]) -> None:

View File

@@ -40,15 +40,15 @@ UNIT_SYMBOL = {
SYMBOL_UNIT = {v: k for k, v in UNIT_SYMBOL.items()} SYMBOL_UNIT = {v: k for k, v in UNIT_SYMBOL.items()}
_MATCH_SCALAR = re.compile(r"^(\d+\.?\d*)(fr|%|w|h|vw|vh)?$").match _MATCH_SCALAR = re.compile(r"^(\-?\d+\.?\d*)(fr|%|w|h|vw|vh)?$").match
RESOLVE_MAP = { RESOLVE_MAP = {
Unit.CELLS: lambda value, size, viewport: value, Unit.CELLS: lambda value, size, viewport: value,
Unit.WIDTH: lambda value, size, viewport: size[0], Unit.WIDTH: lambda value, size, viewport: size[0] * value / 100,
Unit.HEIGHT: lambda value, size, viewport: size[1], Unit.HEIGHT: lambda value, size, viewport: size[1] * value / 100,
Unit.VIEW_WIDTH: lambda value, size, viewport: viewport[0], Unit.VIEW_WIDTH: lambda value, size, viewport: viewport[0] * value / 100,
Unit.VIEW_HEIGHT: lambda value, size, viewport: viewport[1], Unit.VIEW_HEIGHT: lambda value, size, viewport: viewport[1] * value / 100,
} }
@@ -69,9 +69,10 @@ class Scalar(NamedTuple):
value: float value: float
unit: Unit unit: Unit
percent_unit: Unit
def __str__(self) -> str: def __str__(self) -> str:
value, _unit = self value, _unit, _ = self
return f"{int(value) if value.is_integer() else value}{self.symbol}" return f"{int(value) if value.is_integer() else value}{self.symbol}"
@property @property
@@ -80,12 +81,12 @@ class Scalar(NamedTuple):
@property @property
def cells(self) -> int | None: def cells(self) -> int | None:
value, unit = self value, unit, _ = self
return int(value) if unit == Unit.CELLS else None return int(value) if unit == Unit.CELLS else None
@property @property
def fraction(self) -> int | None: def fraction(self) -> int | None:
value, unit = self value, unit, _ = self
return int(value) if unit == Unit.FRACTION else None return int(value) if unit == Unit.FRACTION else None
@property @property
@@ -93,7 +94,11 @@ class Scalar(NamedTuple):
return UNIT_SYMBOL[self.unit] return UNIT_SYMBOL[self.unit]
@classmethod @classmethod
def parse(cls, token: str) -> Scalar: def from_number(cls, value: float) -> Scalar:
return cls(value, Unit.CELLS, Unit.WIDTH)
@classmethod
def parse(cls, token: str, percent_unit: Unit = Unit.WIDTH) -> Scalar:
"""Parse a string in to a Scalar """Parse a string in to a Scalar
Args: Args:
@@ -109,16 +114,11 @@ class Scalar(NamedTuple):
if match is None: if match is None:
raise ScalarParseError(f"{token!r} is not a valid scalar") raise ScalarParseError(f"{token!r} is not a valid scalar")
value, unit_name = match.groups() value, unit_name = match.groups()
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""]) scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
return scalar return scalar
def resolve( def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> float:
self, value, unit, percent_unit = self
size: tuple[int, int],
viewport: tuple[int, int],
percent_unit: Unit = Unit.WIDTH,
) -> float:
value, unit = self
if unit == Unit.PERCENT: if unit == Unit.PERCENT:
unit = percent_unit unit = percent_unit
try: try:
@@ -139,8 +139,7 @@ class ScalarOffset(NamedTuple):
def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> Offset: def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> Offset:
x, y = self x, y = self
return Offset( return Offset(
round(x.resolve(size, viewport, percent_unit=Unit.WIDTH)), round(x.resolve(size, viewport)), round(y.resolve(size, viewport))
round(y.resolve(size, viewport, percent_unit=Unit.HEIGHT)),
) )

View File

@@ -18,7 +18,7 @@ from .constants import (
NULL_SPACING, NULL_SPACING,
) )
from ..geometry import NULL_OFFSET, Offset, Spacing from ..geometry import NULL_OFFSET, Offset, Spacing
from .scalar import Scalar, Unit from .scalar import Scalar, ScalarOffset, Unit
from ._style_properties import ( from ._style_properties import (
BorderProperty, BorderProperty,
BoxProperty, BoxProperty,
@@ -63,7 +63,7 @@ class Styles:
_rule_padding: Spacing | None = None _rule_padding: Spacing | None = None
_rule_margin: Spacing | None = None _rule_margin: Spacing | None = None
_rule_offset: Offset | None = None _rule_offset: ScalarOffset | None = None
_rule_border_top: tuple[str, Style] | None = None _rule_border_top: tuple[str, Style] | None = None
_rule_border_right: tuple[str, Style] | None = None _rule_border_right: tuple[str, Style] | None = None
@@ -144,6 +144,10 @@ class Styles:
"""Check if an outline is present.""" """Check if an outline is present."""
return any(edge for edge, _style in self.outline) return any(edge for edge, _style in self.outline)
@property
def has_offset(self) -> bool:
return self._rule_offset is not None
def extract_rules( def extract_rules(
self, specificity: tuple[int, int, int] self, specificity: tuple[int, int, int]
) -> list[tuple[str, tuple[int, int, int, int], Any]]: ) -> list[tuple[str, tuple[int, int, int, int], Any]]:

View File

@@ -44,8 +44,7 @@ expect_declaration_content = Expect(
declaration_end=r"\n|;", declaration_end=r"\n|;",
whitespace=r"\s+", whitespace=r"\s+",
comment_start=r"\/\*", comment_start=r"\/\*",
percentage=r"\d+\%", scalar=r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)?",
scalar=r"\-?\d+\.?\d*(?:fr|%)?",
color=r"\#[0-9a-fA-F]{6}|color\([0-9]{1,3}\)|rgb\(\d{1,3}\,\s?\d{1,3}\,\s?\d{1,3}\)", color=r"\#[0-9a-fA-F]{6}|color\([0-9]{1,3}\)|rgb\(\d{1,3}\,\s?\d{1,3}\,\s?\d{1,3}\)",
key_value=r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+", key_value=r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+",
token="[a-zA-Z_-]+", token="[a-zA-Z_-]+",

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import ItemsView, KeysView, ValuesView, NamedTuple from typing import ItemsView, KeysView, ValuesView, NamedTuple
from . import log from . import log
from .geometry import Region, Size from .geometry import Offset, Region, Size
from operator import attrgetter from operator import attrgetter
from .widget import Widget from .widget import Widget
@@ -47,7 +47,13 @@ class LayoutMap:
if widget in self.widgets: if widget in self.widgets:
return return
self.widgets[widget] = RenderRegion(region + widget.layout_offset, order, clip) layout_offset = Offset(0, 0)
if widget.styles.has_offset:
log("r", region, "c", clip.size)
layout_offset = widget.styles.offset.resolve(region.size, clip.size)
log("layout_offset", layout_offset)
self.widgets[widget] = RenderRegion(region + layout_offset, order, clip)
if isinstance(widget, View): if isinstance(widget, View):
view: View = widget view: View = widget

View File

@@ -185,12 +185,6 @@ class Widget(DOMNode):
assert self._animate is not None assert self._animate is not None
return self._animate return self._animate
@property
def layout_offset(self) -> tuple[int, int]:
"""Get the layout offset as a tuple."""
x, y = self.styles.offset
return round(x), round(y)
@property @property
def gutter(self) -> Spacing: def gutter(self) -> Spacing:
mt, mr, mb, bl = self.margin or (0, 0, 0, 0) mt, mr, mb, bl = self.margin or (0, 0, 0, 0)