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;
|
height: 1fr;
|
||||||
layer: panels;
|
layer: panels;
|
||||||
border-right: outer #09312e;
|
border-right: outer #09312e;
|
||||||
|
offset-x: -50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header {
|
#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.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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
style = Style.parse(style_definition)
|
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
|
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:
|
||||||
|
|||||||
@@ -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)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]]:
|
||||||
|
|||||||
@@ -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_-]+",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user