mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
scalars in CSS
This commit is contained in:
@@ -11,6 +11,7 @@ App > DockView {
|
||||
height: 1fr;
|
||||
layer: panels;
|
||||
border-right: outer #09312e;
|
||||
offset-x: -50%;
|
||||
}
|
||||
|
||||
#header {
|
||||
|
||||
@@ -16,4 +16,4 @@ class BasicApp(App):
|
||||
)
|
||||
|
||||
|
||||
BasicApp.run(css_file="basic.css")
|
||||
BasicApp.run(log="textual.log", css_file="basic.css")
|
||||
|
||||
@@ -6,7 +6,14 @@ import rich.repr
|
||||
from rich.color import Color
|
||||
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 .constants import NULL_SPACING, VALID_EDGE
|
||||
from .errors import StyleTypeError, StyleValueError
|
||||
@@ -42,7 +49,7 @@ class ScalarProperty:
|
||||
if value is None:
|
||||
new_value = None
|
||||
elif isinstance(value, float):
|
||||
new_value = Scalar(value, Unit.CELLS)
|
||||
new_value = Scalar(value, Unit.CELLS, Unit.WIDTH)
|
||||
elif isinstance(value, Scalar):
|
||||
new_value = value
|
||||
elif isinstance(value, str):
|
||||
@@ -57,7 +64,7 @@ class ScalarProperty:
|
||||
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(new_value.value, self.percent_unit)
|
||||
new_value = Scalar(new_value.value, self.percent_unit, Unit.WIDTH)
|
||||
setattr(obj, self.internal_name, new_value)
|
||||
return value
|
||||
|
||||
@@ -258,11 +265,26 @@ class OffsetProperty:
|
||||
def __set_name__(self, owner: Styles, name: str) -> None:
|
||||
self._internal_name = f"_rule_{name}"
|
||||
|
||||
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Offset:
|
||||
return getattr(obj, self._internal_name) or Offset()
|
||||
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> ScalarOffset:
|
||||
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]:
|
||||
_offset = Offset(*offset)
|
||||
def __set__(
|
||||
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)
|
||||
return offset
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
from typing import cast, NoReturn
|
||||
|
||||
import rich.repr
|
||||
from rich.color import ANSI_COLOR_NAMES, Color
|
||||
@@ -11,7 +11,7 @@ from .errors import DeclarationError, StyleValueError
|
||||
from ._error_tools import friendly_list
|
||||
from ..geometry import Offset, Spacing, SpacingDimensions
|
||||
from .model import Declaration
|
||||
from .scalar import Scalar
|
||||
from .scalar import Scalar, ScalarOffset, Unit
|
||||
from .styles import DockGroup, Styles
|
||||
from .types import Edge, Display, Visibility
|
||||
from .tokenize import Token
|
||||
@@ -27,7 +27,7 @@ class StylesBuilder:
|
||||
def __repr__(self) -> str:
|
||||
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)
|
||||
|
||||
def add_declaration(self, declaration: Declaration) -> None:
|
||||
@@ -210,13 +210,14 @@ class StylesBuilder:
|
||||
self.error(name, tokens[0], "expected two numbers in declaration")
|
||||
else:
|
||||
token1, token2 = tokens
|
||||
if token1.name != "number":
|
||||
self.error(name, token1, f"expected a number (found {token1.value!r})")
|
||||
if token2.name != "number":
|
||||
self.error(name, token2, f"expected a number (found {token1.value!r})")
|
||||
self.styles._rule_offset = Offset(
|
||||
int(float(token1.value)), int(float(token2.value))
|
||||
)
|
||||
|
||||
if token1.name != "scalar":
|
||||
self.error(name, token1, f"expected a scalar; found {token1.value!r}")
|
||||
if token2.name != "scalar":
|
||||
self.error(name, token2, f"expected a scalar; found {token1.value!r}")
|
||||
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:
|
||||
if not tokens:
|
||||
@@ -224,9 +225,12 @@ class StylesBuilder:
|
||||
if len(tokens) != 1:
|
||||
self.error(name, tokens[0], f"expected a single number")
|
||||
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
|
||||
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:
|
||||
if not tokens:
|
||||
@@ -234,9 +238,12 @@ class StylesBuilder:
|
||||
if len(tokens) != 1:
|
||||
self.error(name, tokens[0], f"expected a single number")
|
||||
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
|
||||
self.styles._rule_offset = Offset(x, y)
|
||||
self.styles._rule_offset = ScalarOffset(x, y)
|
||||
|
||||
def process_layout(self, name: str, tokens: list[Token]) -> None:
|
||||
if tokens:
|
||||
@@ -247,7 +254,10 @@ class StylesBuilder:
|
||||
|
||||
def process_text(self, name: str, tokens: list[Token]) -> None:
|
||||
style_definition = " ".join(token.value for token in tokens)
|
||||
try:
|
||||
style = Style.parse(style_definition)
|
||||
except Exception as error:
|
||||
self.error(name, tokens[0], f"failed to parse style; {error}")
|
||||
self.styles.text = style
|
||||
|
||||
def process_text_color(self, name: str, tokens: list[Token]) -> None:
|
||||
|
||||
@@ -40,15 +40,15 @@ UNIT_SYMBOL = {
|
||||
|
||||
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 = {
|
||||
Unit.CELLS: lambda value, size, viewport: value,
|
||||
Unit.WIDTH: lambda value, size, viewport: size[0],
|
||||
Unit.HEIGHT: lambda value, size, viewport: size[1],
|
||||
Unit.VIEW_WIDTH: lambda value, size, viewport: viewport[0],
|
||||
Unit.VIEW_HEIGHT: lambda value, size, viewport: viewport[1],
|
||||
Unit.WIDTH: lambda value, size, viewport: size[0] * value / 100,
|
||||
Unit.HEIGHT: lambda value, size, viewport: size[1] * value / 100,
|
||||
Unit.VIEW_WIDTH: lambda value, size, viewport: viewport[0] * value / 100,
|
||||
Unit.VIEW_HEIGHT: lambda value, size, viewport: viewport[1] * value / 100,
|
||||
}
|
||||
|
||||
|
||||
@@ -69,9 +69,10 @@ class Scalar(NamedTuple):
|
||||
|
||||
value: float
|
||||
unit: Unit
|
||||
percent_unit: Unit
|
||||
|
||||
def __str__(self) -> str:
|
||||
value, _unit = self
|
||||
value, _unit, _ = self
|
||||
return f"{int(value) if value.is_integer() else value}{self.symbol}"
|
||||
|
||||
@property
|
||||
@@ -80,12 +81,12 @@ class Scalar(NamedTuple):
|
||||
|
||||
@property
|
||||
def cells(self) -> int | None:
|
||||
value, unit = self
|
||||
value, unit, _ = self
|
||||
return int(value) if unit == Unit.CELLS else None
|
||||
|
||||
@property
|
||||
def fraction(self) -> int | None:
|
||||
value, unit = self
|
||||
value, unit, _ = self
|
||||
return int(value) if unit == Unit.FRACTION else None
|
||||
|
||||
@property
|
||||
@@ -93,7 +94,11 @@ class Scalar(NamedTuple):
|
||||
return UNIT_SYMBOL[self.unit]
|
||||
|
||||
@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
|
||||
|
||||
Args:
|
||||
@@ -109,16 +114,11 @@ class Scalar(NamedTuple):
|
||||
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 ""])
|
||||
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
|
||||
return scalar
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
size: tuple[int, int],
|
||||
viewport: tuple[int, int],
|
||||
percent_unit: Unit = Unit.WIDTH,
|
||||
) -> float:
|
||||
value, unit = self
|
||||
def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> float:
|
||||
value, unit, percent_unit = self
|
||||
if unit == Unit.PERCENT:
|
||||
unit = percent_unit
|
||||
try:
|
||||
@@ -139,8 +139,7 @@ class ScalarOffset(NamedTuple):
|
||||
def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> Offset:
|
||||
x, y = self
|
||||
return Offset(
|
||||
round(x.resolve(size, viewport, percent_unit=Unit.WIDTH)),
|
||||
round(y.resolve(size, viewport, percent_unit=Unit.HEIGHT)),
|
||||
round(x.resolve(size, viewport)), round(y.resolve(size, viewport))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from .constants import (
|
||||
NULL_SPACING,
|
||||
)
|
||||
from ..geometry import NULL_OFFSET, Offset, Spacing
|
||||
from .scalar import Scalar, Unit
|
||||
from .scalar import Scalar, ScalarOffset, Unit
|
||||
from ._style_properties import (
|
||||
BorderProperty,
|
||||
BoxProperty,
|
||||
@@ -63,7 +63,7 @@ class Styles:
|
||||
|
||||
_rule_padding: 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_right: tuple[str, Style] | None = None
|
||||
@@ -144,6 +144,10 @@ class Styles:
|
||||
"""Check if an outline is present."""
|
||||
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(
|
||||
self, specificity: tuple[int, int, int]
|
||||
) -> list[tuple[str, tuple[int, int, int, int], Any]]:
|
||||
|
||||
@@ -44,8 +44,7 @@ expect_declaration_content = Expect(
|
||||
declaration_end=r"\n|;",
|
||||
whitespace=r"\s+",
|
||||
comment_start=r"\/\*",
|
||||
percentage=r"\d+\%",
|
||||
scalar=r"\-?\d+\.?\d*(?:fr|%)?",
|
||||
scalar=r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)?",
|
||||
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_\-\/]+",
|
||||
token="[a-zA-Z_-]+",
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from typing import ItemsView, KeysView, ValuesView, NamedTuple
|
||||
|
||||
from . import log
|
||||
from .geometry import Region, Size
|
||||
from .geometry import Offset, Region, Size
|
||||
from operator import attrgetter
|
||||
from .widget import Widget
|
||||
|
||||
@@ -47,7 +47,13 @@ class LayoutMap:
|
||||
if widget in self.widgets:
|
||||
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):
|
||||
view: View = widget
|
||||
|
||||
@@ -185,12 +185,6 @@ class Widget(DOMNode):
|
||||
assert self._animate is not None
|
||||
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
|
||||
def gutter(self) -> Spacing:
|
||||
mt, mr, mb, bl = self.margin or (0, 0, 0, 0)
|
||||
|
||||
Reference in New Issue
Block a user