1298 lines
48 KiB
Python
1298 lines
48 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Iterable, NoReturn, cast
|
|
|
|
import rich.repr
|
|
|
|
from textual._border import BorderValue, normalize_border_value
|
|
from textual._cells import cell_len
|
|
from textual._duration import _duration_as_seconds
|
|
from textual._easing import EASING
|
|
from textual.color import TRANSPARENT, Color, ColorParseError
|
|
from textual.css._error_tools import friendly_list
|
|
from textual.css._help_renderables import HelpText
|
|
from textual.css._help_text import (
|
|
align_help_text,
|
|
border_property_help_text,
|
|
color_property_help_text,
|
|
dock_property_help_text,
|
|
expand_help_text,
|
|
fractional_property_help_text,
|
|
integer_help_text,
|
|
keyline_help_text,
|
|
layout_property_help_text,
|
|
offset_property_help_text,
|
|
offset_single_axis_help_text,
|
|
position_help_text,
|
|
property_invalid_value_help_text,
|
|
scalar_help_text,
|
|
scrollbar_size_property_help_text,
|
|
scrollbar_size_single_axis_help_text,
|
|
spacing_invalid_value_help_text,
|
|
spacing_wrong_number_of_values_help_text,
|
|
split_property_help_text,
|
|
string_enum_help_text,
|
|
style_flags_property_help_text,
|
|
table_rows_or_columns_help_text,
|
|
text_align_help_text,
|
|
)
|
|
from textual.css.constants import (
|
|
HATCHES,
|
|
VALID_ALIGN_HORIZONTAL,
|
|
VALID_ALIGN_VERTICAL,
|
|
VALID_BORDER,
|
|
VALID_BOX_SIZING,
|
|
VALID_CONSTRAIN,
|
|
VALID_DISPLAY,
|
|
VALID_EDGE,
|
|
VALID_EXPAND,
|
|
VALID_HATCH,
|
|
VALID_KEYLINE,
|
|
VALID_OVERFLOW,
|
|
VALID_OVERLAY,
|
|
VALID_POSITION,
|
|
VALID_SCROLLBAR_GUTTER,
|
|
VALID_SCROLLBAR_VISIBILITY,
|
|
VALID_STYLE_FLAGS,
|
|
VALID_TEXT_ALIGN,
|
|
VALID_TEXT_OVERFLOW,
|
|
VALID_TEXT_WRAP,
|
|
VALID_VISIBILITY,
|
|
)
|
|
from textual.css.errors import DeclarationError, StyleValueError
|
|
from textual.css.model import Declaration
|
|
from textual.css.scalar import (
|
|
Scalar,
|
|
ScalarError,
|
|
ScalarOffset,
|
|
ScalarParseError,
|
|
Unit,
|
|
percentage_string_to_float,
|
|
)
|
|
from textual.css.styles import Styles
|
|
from textual.css.tokenize import Token
|
|
from textual.css.transition import Transition
|
|
from textual.css.types import (
|
|
BoxSizing,
|
|
Display,
|
|
EdgeType,
|
|
Overflow,
|
|
ScrollbarVisibility,
|
|
TextOverflow,
|
|
TextWrap,
|
|
Visibility,
|
|
)
|
|
from textual.geometry import Spacing, SpacingDimensions, clamp
|
|
from textual.suggestions import get_suggestion
|
|
|
|
|
|
class StylesBuilder:
|
|
"""
|
|
The StylesBuilder object takes tokens parsed from the CSS and converts
|
|
to the appropriate internal types.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.styles = Styles()
|
|
|
|
def __rich_repr__(self) -> rich.repr.Result:
|
|
yield "styles", self.styles
|
|
|
|
def __repr__(self) -> str:
|
|
return "StylesBuilder()"
|
|
|
|
def error(self, name: str, token: Token, message: str | HelpText) -> NoReturn:
|
|
raise DeclarationError(name, token, message)
|
|
|
|
def add_declaration(self, declaration: Declaration) -> None:
|
|
if not declaration.name:
|
|
return
|
|
rule_name = declaration.name.replace("-", "_")
|
|
|
|
if not declaration.tokens:
|
|
self.error(
|
|
rule_name,
|
|
declaration.token,
|
|
f"Missing property value for '{declaration.name}:'",
|
|
)
|
|
|
|
process_method = getattr(self, f"process_{rule_name}", None)
|
|
|
|
if process_method is None:
|
|
suggested_property_name = self._get_suggested_property_name_for_rule(
|
|
declaration.name
|
|
)
|
|
self.error(
|
|
declaration.name,
|
|
declaration.token,
|
|
property_invalid_value_help_text(
|
|
declaration.name,
|
|
"css",
|
|
suggested_property_name=suggested_property_name,
|
|
),
|
|
)
|
|
|
|
tokens = declaration.tokens
|
|
|
|
important = tokens[-1].name == "important"
|
|
if important:
|
|
tokens = tokens[:-1]
|
|
self.styles.important.add(rule_name)
|
|
|
|
# Check for special token(s)
|
|
if tokens[0].name == "token":
|
|
value = tokens[0].value
|
|
if value == "initial":
|
|
self.styles._rules[rule_name] = None
|
|
return
|
|
try:
|
|
process_method(declaration.name, tokens)
|
|
except DeclarationError:
|
|
raise
|
|
except Exception as error:
|
|
self.error(declaration.name, declaration.token, str(error))
|
|
|
|
def _process_enum_multiple(
|
|
self, name: str, tokens: list[Token], valid_values: set[str], count: int
|
|
) -> tuple[str, ...]:
|
|
"""Generic code to process a declaration with two enumerations, like overflow: auto auto"""
|
|
if len(tokens) > count or not tokens:
|
|
self.error(name, tokens[0], f"expected 1 to {count} tokens here")
|
|
results: list[str] = []
|
|
append = results.append
|
|
for token in tokens:
|
|
token_name, value, _, _, location, _ = token
|
|
if token_name != "token":
|
|
self.error(
|
|
name,
|
|
token,
|
|
f"invalid token {value!r}; expected {friendly_list(valid_values)}",
|
|
)
|
|
append(value)
|
|
|
|
short_results = results[:]
|
|
|
|
while len(results) < count:
|
|
results.extend(short_results)
|
|
results = results[:count]
|
|
|
|
return tuple(results)
|
|
|
|
def _process_enum(
|
|
self, name: str, tokens: list[Token], valid_values: set[str]
|
|
) -> str:
|
|
"""Process a declaration that expects an enum.
|
|
|
|
Args:
|
|
name: Name of declaration.
|
|
tokens: Tokens from parser.
|
|
valid_values: A set of valid values.
|
|
|
|
Returns:
|
|
True if the value is valid or False if it is invalid (also generates an error)
|
|
"""
|
|
|
|
if len(tokens) != 1:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
string_enum_help_text(
|
|
name, valid_values=list(valid_values), context="css"
|
|
),
|
|
)
|
|
|
|
token = tokens[0]
|
|
token_name, value, _, _, location, _ = token
|
|
if token_name != "token":
|
|
self.error(
|
|
name,
|
|
token,
|
|
string_enum_help_text(
|
|
name, valid_values=list(valid_values), context="css"
|
|
),
|
|
)
|
|
if value not in valid_values:
|
|
self.error(
|
|
name,
|
|
token,
|
|
string_enum_help_text(
|
|
name, valid_values=list(valid_values), context="css"
|
|
),
|
|
)
|
|
return value
|
|
|
|
def process_display(self, name: str, tokens: list[Token]) -> None:
|
|
for token in tokens:
|
|
name, value, _, _, location, _ = token
|
|
|
|
if name == "token":
|
|
value = value.lower()
|
|
if value in VALID_DISPLAY:
|
|
self.styles._rules["display"] = cast(Display, value)
|
|
else:
|
|
self.error(
|
|
name,
|
|
token,
|
|
string_enum_help_text(
|
|
"display", valid_values=list(VALID_DISPLAY), context="css"
|
|
),
|
|
)
|
|
else:
|
|
self.error(
|
|
name,
|
|
token,
|
|
string_enum_help_text(
|
|
"display", valid_values=list(VALID_DISPLAY), context="css"
|
|
),
|
|
)
|
|
|
|
def _process_scalar(self, name: str, tokens: list[Token]) -> None:
|
|
def scalar_error():
|
|
self.error(
|
|
name, tokens[0], scalar_help_text(property_name=name, context="css")
|
|
)
|
|
|
|
if not tokens:
|
|
return
|
|
if len(tokens) == 1:
|
|
try:
|
|
self.styles._rules[name.replace("-", "_")] = Scalar.parse( # type: ignore
|
|
tokens[0].value
|
|
)
|
|
except ScalarParseError:
|
|
scalar_error()
|
|
else:
|
|
scalar_error()
|
|
|
|
def _distribute_importance(self, prefix: str, suffixes: tuple[str, ...]) -> None:
|
|
"""Distribute importance amongst all aspects of the given style.
|
|
|
|
Args:
|
|
prefix: The prefix of the style.
|
|
suffixes: The suffixes to distribute amongst.
|
|
|
|
A number of styles can be set with the 'prefix' of the style,
|
|
providing the values as a series of parameters; or they can be set
|
|
with specific suffixes. Think `border` vs `border-left`, etc. This
|
|
method is used to ensure that if the former is set, `!important` is
|
|
distributed amongst all the suffixes.
|
|
"""
|
|
if prefix in self.styles.important:
|
|
self.styles.important.remove(prefix)
|
|
self.styles.important.update(f"{prefix}_{suffix}" for suffix in suffixes)
|
|
|
|
def process_box_sizing(self, name: str, tokens: list[Token]) -> None:
|
|
for token in tokens:
|
|
name, value, _, _, location, _ = token
|
|
|
|
if name == "token":
|
|
value = value.lower()
|
|
if value in VALID_BOX_SIZING:
|
|
self.styles._rules["box_sizing"] = cast(BoxSizing, value)
|
|
else:
|
|
self.error(
|
|
name,
|
|
token,
|
|
string_enum_help_text(
|
|
"box-sizing",
|
|
valid_values=list(VALID_BOX_SIZING),
|
|
context="css",
|
|
),
|
|
)
|
|
else:
|
|
self.error(
|
|
name,
|
|
token,
|
|
string_enum_help_text(
|
|
"box-sizing", valid_values=list(VALID_BOX_SIZING), context="css"
|
|
),
|
|
)
|
|
|
|
def process_width(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_scalar(name, tokens)
|
|
|
|
def process_height(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_scalar(name, tokens)
|
|
|
|
def process_min_width(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_scalar(name, tokens)
|
|
|
|
def process_min_height(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_scalar(name, tokens)
|
|
|
|
def process_max_width(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_scalar(name, tokens)
|
|
|
|
def process_max_height(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_scalar(name, tokens)
|
|
|
|
def process_overflow(self, name: str, tokens: list[Token]) -> None:
|
|
rules = self.styles._rules
|
|
overflow_x, overflow_y = self._process_enum_multiple(
|
|
name, tokens, VALID_OVERFLOW, 2
|
|
)
|
|
rules["overflow_x"] = cast(Overflow, overflow_x)
|
|
rules["overflow_y"] = cast(Overflow, overflow_y)
|
|
self._distribute_importance("overflow", ("x", "y"))
|
|
|
|
def process_overflow_x(self, name: str, tokens: list[Token]) -> None:
|
|
self.styles._rules["overflow_x"] = cast(
|
|
Overflow, self._process_enum(name, tokens, VALID_OVERFLOW)
|
|
)
|
|
|
|
def process_overflow_y(self, name: str, tokens: list[Token]) -> None:
|
|
self.styles._rules["overflow_y"] = cast(
|
|
Overflow, self._process_enum(name, tokens, VALID_OVERFLOW)
|
|
)
|
|
|
|
def process_visibility(self, name: str, tokens: list[Token]) -> None:
|
|
for token in tokens:
|
|
name, value, _, _, location, _ = token
|
|
if name == "token":
|
|
value = value.lower()
|
|
if value in VALID_VISIBILITY:
|
|
self.styles._rules["visibility"] = cast(Visibility, value)
|
|
else:
|
|
self.error(
|
|
name,
|
|
token,
|
|
string_enum_help_text(
|
|
"visibility",
|
|
valid_values=list(VALID_VISIBILITY),
|
|
context="css",
|
|
),
|
|
)
|
|
else:
|
|
string_enum_help_text(
|
|
"visibility", valid_values=list(VALID_VISIBILITY), context="css"
|
|
)
|
|
|
|
def process_text_wrap(self, name: str, tokens: list[Token]) -> None:
|
|
for token in tokens:
|
|
name, value, _, _, location, _ = token
|
|
if name == "token":
|
|
value = value.lower()
|
|
if value in VALID_TEXT_WRAP:
|
|
self.styles._rules["text_wrap"] = cast(TextWrap, value)
|
|
else:
|
|
self.error(
|
|
name,
|
|
token,
|
|
string_enum_help_text(
|
|
"text-wrap",
|
|
valid_values=list(VALID_TEXT_WRAP),
|
|
context="css",
|
|
),
|
|
)
|
|
else:
|
|
string_enum_help_text(
|
|
"text-wrap", valid_values=list(VALID_TEXT_WRAP), context="css"
|
|
)
|
|
|
|
def process_text_overflow(self, name: str, tokens: list[Token]) -> None:
|
|
for token in tokens:
|
|
name, value, _, _, location, _ = token
|
|
if name == "token":
|
|
value = value.lower()
|
|
if value in VALID_TEXT_OVERFLOW:
|
|
self.styles._rules["text_overflow"] = cast(TextOverflow, value)
|
|
else:
|
|
self.error(
|
|
name,
|
|
token,
|
|
string_enum_help_text(
|
|
"text-overflow",
|
|
valid_values=list(VALID_TEXT_OVERFLOW),
|
|
context="css",
|
|
),
|
|
)
|
|
else:
|
|
string_enum_help_text(
|
|
"text-overflow",
|
|
valid_values=list(VALID_TEXT_OVERFLOW),
|
|
context="css",
|
|
)
|
|
|
|
def _process_fractional(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
token = tokens[0]
|
|
error = False
|
|
if len(tokens) != 1:
|
|
error = True
|
|
else:
|
|
token_name = token.name
|
|
value = token.value
|
|
rule_name = name.replace("-", "_")
|
|
if token_name == "scalar" and value.endswith("%"):
|
|
try:
|
|
text_opacity = percentage_string_to_float(value)
|
|
self.styles.set_rule(rule_name, text_opacity)
|
|
except ValueError:
|
|
error = True
|
|
elif token_name == "number":
|
|
try:
|
|
text_opacity = clamp(float(value), 0, 1)
|
|
self.styles.set_rule(rule_name, text_opacity)
|
|
except ValueError:
|
|
error = True
|
|
else:
|
|
error = True
|
|
|
|
if error:
|
|
self.error(name, token, fractional_property_help_text(name, context="css"))
|
|
|
|
process_opacity = _process_fractional
|
|
process_text_opacity = _process_fractional
|
|
|
|
def _process_space(self, name: str, tokens: list[Token]) -> None:
|
|
space: list[int] = []
|
|
append = space.append
|
|
for token in tokens:
|
|
token_name, value, _, _, _, _ = token
|
|
if token_name == "number":
|
|
try:
|
|
append(int(value))
|
|
except ValueError:
|
|
self.error(
|
|
name,
|
|
token,
|
|
spacing_invalid_value_help_text(name, context="css"),
|
|
)
|
|
else:
|
|
self.error(
|
|
name, token, spacing_invalid_value_help_text(name, context="css")
|
|
)
|
|
if len(space) not in (1, 2, 4):
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
spacing_wrong_number_of_values_help_text(
|
|
name, num_values_supplied=len(space), context="css"
|
|
),
|
|
)
|
|
self.styles._rules[name] = Spacing.unpack(cast(SpacingDimensions, tuple(space))) # type: ignore
|
|
|
|
def _process_space_partial(self, name: str, tokens: list[Token]) -> None:
|
|
"""Process granular margin / padding declarations."""
|
|
if len(tokens) != 1:
|
|
self.error(
|
|
name, tokens[0], spacing_invalid_value_help_text(name, context="css")
|
|
)
|
|
|
|
_EDGE_SPACING_MAP = {"top": 0, "right": 1, "bottom": 2, "left": 3}
|
|
token = tokens[0]
|
|
token_name, value, _, _, _, _ = token
|
|
if token_name == "number":
|
|
space = int(value)
|
|
else:
|
|
self.error(
|
|
name, token, spacing_invalid_value_help_text(name, context="css")
|
|
)
|
|
style_name, _, edge = name.replace("-", "_").partition("_")
|
|
|
|
current_spacing = cast(
|
|
"tuple[int, int, int, int]",
|
|
self.styles._rules.get(style_name, (0, 0, 0, 0)),
|
|
)
|
|
|
|
spacing_list = list(current_spacing)
|
|
spacing_list[_EDGE_SPACING_MAP[edge]] = space
|
|
|
|
self.styles._rules[style_name] = Spacing(*spacing_list) # type: ignore
|
|
|
|
process_padding = _process_space
|
|
process_margin = _process_space
|
|
|
|
process_margin_top = _process_space_partial
|
|
process_margin_right = _process_space_partial
|
|
process_margin_bottom = _process_space_partial
|
|
process_margin_left = _process_space_partial
|
|
|
|
process_padding_top = _process_space_partial
|
|
process_padding_right = _process_space_partial
|
|
process_padding_bottom = _process_space_partial
|
|
process_padding_left = _process_space_partial
|
|
|
|
def _parse_border(self, name: str, tokens: list[Token]) -> BorderValue:
|
|
border_type: EdgeType = "solid"
|
|
border_color = Color(0, 255, 0)
|
|
border_alpha: float | None = None
|
|
|
|
def border_value_error():
|
|
self.error(name, token, border_property_help_text(name, context="css"))
|
|
|
|
for token in tokens:
|
|
token_name, value, _, _, _, _ = token
|
|
if token_name == "token":
|
|
if value in VALID_BORDER:
|
|
border_type = value # type: ignore
|
|
else:
|
|
try:
|
|
border_color = Color.parse(value)
|
|
except ColorParseError:
|
|
border_value_error()
|
|
|
|
elif token_name == "color":
|
|
try:
|
|
border_color = Color.parse(value)
|
|
except ColorParseError:
|
|
border_value_error()
|
|
|
|
elif token_name == "scalar":
|
|
alpha_scalar = Scalar.parse(token.value)
|
|
if alpha_scalar.unit != Unit.PERCENT:
|
|
self.error(name, token, "alpha must be given as a percentage.")
|
|
border_alpha = alpha_scalar.value / 100.0
|
|
|
|
else:
|
|
border_value_error()
|
|
|
|
if border_alpha is not None:
|
|
border_color = border_color.multiply_alpha(border_alpha)
|
|
|
|
return normalize_border_value((border_type, border_color))
|
|
|
|
def _process_border_edge(self, edge: str, name: str, tokens: list[Token]) -> None:
|
|
border = self._parse_border(name, tokens)
|
|
self.styles._rules[f"border_{edge}"] = border # type: ignore
|
|
|
|
def process_border(self, name: str, tokens: list[Token]) -> None:
|
|
border = self._parse_border(name, tokens)
|
|
rules = self.styles._rules
|
|
rules["border_top"] = rules["border_right"] = border
|
|
rules["border_bottom"] = rules["border_left"] = border
|
|
self._distribute_importance("border", ("top", "left", "bottom", "right"))
|
|
|
|
def process_border_top(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_border_edge("top", name, tokens)
|
|
|
|
def process_border_right(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_border_edge("right", name, tokens)
|
|
|
|
def process_border_bottom(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_border_edge("bottom", name, tokens)
|
|
|
|
def process_border_left(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_border_edge("left", name, tokens)
|
|
|
|
def _process_outline(self, edge: str, name: str, tokens: list[Token]) -> None:
|
|
border = self._parse_border(name, tokens)
|
|
self.styles._rules[f"outline_{edge}"] = border # type: ignore
|
|
|
|
def process_outline(self, name: str, tokens: list[Token]) -> None:
|
|
border = self._parse_border(name, tokens)
|
|
rules = self.styles._rules
|
|
rules["outline_top"] = rules["outline_right"] = border
|
|
rules["outline_bottom"] = rules["outline_left"] = border
|
|
self._distribute_importance("outline", ("top", "left", "bottom", "right"))
|
|
|
|
def process_outline_top(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_outline("top", name, tokens)
|
|
|
|
def process_outline_right(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_outline("right", name, tokens)
|
|
|
|
def process_outline_bottom(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_outline("bottom", name, tokens)
|
|
|
|
def process_outline_left(self, name: str, tokens: list[Token]) -> None:
|
|
self._process_outline("left", name, tokens)
|
|
|
|
def process_keyline(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
if len(tokens) > 3:
|
|
self.error(name, tokens[0], keyline_help_text())
|
|
keyline_style = "none"
|
|
keyline_color = Color.parse("green")
|
|
keyline_alpha = 1.0
|
|
for token in tokens:
|
|
if token.name == "color":
|
|
try:
|
|
keyline_color = Color.parse(token.value)
|
|
except Exception as error:
|
|
self.error(
|
|
name,
|
|
token,
|
|
color_property_help_text(
|
|
name, context="css", error=error, value=token.value
|
|
),
|
|
)
|
|
elif token.name == "token":
|
|
try:
|
|
keyline_color = Color.parse(token.value)
|
|
except Exception:
|
|
keyline_style = token.value
|
|
if keyline_style not in VALID_KEYLINE:
|
|
self.error(name, token, keyline_help_text())
|
|
|
|
elif token.name == "scalar":
|
|
alpha_scalar = Scalar.parse(token.value)
|
|
if alpha_scalar.unit != Unit.PERCENT:
|
|
self.error(name, token, "alpha must be given as a percentage.")
|
|
keyline_alpha = alpha_scalar.value / 100.0
|
|
|
|
self.styles._rules["keyline"] = (
|
|
keyline_style,
|
|
keyline_color.multiply_alpha(keyline_alpha),
|
|
)
|
|
|
|
def process_offset(self, name: str, tokens: list[Token]) -> None:
|
|
def offset_error(name: str, token: Token) -> None:
|
|
self.error(name, token, offset_property_help_text(context="css"))
|
|
|
|
if not tokens:
|
|
return
|
|
if len(tokens) != 2:
|
|
offset_error(name, tokens[0])
|
|
else:
|
|
token1, token2 = tokens
|
|
|
|
if token1.name not in ("scalar", "number"):
|
|
offset_error(name, token1)
|
|
if token2.name not in ("scalar", "number"):
|
|
offset_error(name, token2)
|
|
|
|
scalar_x = Scalar.parse(token1.value, Unit.WIDTH)
|
|
scalar_y = Scalar.parse(token2.value, Unit.HEIGHT)
|
|
self.styles._rules["offset"] = ScalarOffset(scalar_x, scalar_y)
|
|
|
|
def process_offset_x(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
if len(tokens) != 1:
|
|
self.error(name, tokens[0], offset_single_axis_help_text(name))
|
|
else:
|
|
token = tokens[0]
|
|
if token.name not in ("scalar", "number"):
|
|
self.error(name, token, offset_single_axis_help_text(name))
|
|
x = Scalar.parse(token.value, Unit.WIDTH)
|
|
y = self.styles.offset.y
|
|
self.styles._rules["offset"] = ScalarOffset(x, y)
|
|
|
|
def process_offset_y(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
if len(tokens) != 1:
|
|
self.error(name, tokens[0], offset_single_axis_help_text(name))
|
|
else:
|
|
token = tokens[0]
|
|
if token.name not in ("scalar", "number"):
|
|
self.error(name, token, offset_single_axis_help_text(name))
|
|
y = Scalar.parse(token.value, Unit.HEIGHT)
|
|
x = self.styles.offset.x
|
|
self.styles._rules["offset"] = ScalarOffset(x, y)
|
|
|
|
def process_position(self, name: str, tokens: list[Token]):
|
|
if not tokens:
|
|
return
|
|
if len(tokens) != 1:
|
|
self.error(name, tokens[0], offset_single_axis_help_text(name))
|
|
else:
|
|
token = tokens[0]
|
|
if token.value not in VALID_POSITION:
|
|
self.error(name, tokens[0], position_help_text(name))
|
|
self.styles._rules["position"] = token.value
|
|
|
|
def process_layout(self, name: str, tokens: list[Token]) -> None:
|
|
from textual.layouts.factory import MissingLayout, get_layout
|
|
|
|
if tokens:
|
|
if len(tokens) != 1:
|
|
self.error(
|
|
name, tokens[0], layout_property_help_text(name, context="css")
|
|
)
|
|
else:
|
|
value = tokens[0].value
|
|
layout_name = value
|
|
try:
|
|
self.styles._rules["layout"] = get_layout(layout_name)
|
|
except MissingLayout:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
layout_property_help_text(name, context="css"),
|
|
)
|
|
|
|
def process_color(self, name: str, tokens: list[Token]) -> None:
|
|
"""Processes a simple color declaration."""
|
|
name = name.replace("-", "_")
|
|
|
|
color: Color | None = None
|
|
alpha: float | None = None
|
|
|
|
self.styles._rules[f"auto_{name}"] = False # type: ignore
|
|
for token in tokens:
|
|
if (
|
|
"background" not in name
|
|
and token.name == "token"
|
|
and token.value == "auto"
|
|
):
|
|
self.styles._rules[f"auto_{name}"] = True # type: ignore
|
|
elif token.name == "scalar":
|
|
alpha_scalar = Scalar.parse(token.value)
|
|
if alpha_scalar.unit != Unit.PERCENT:
|
|
self.error(name, token, "alpha must be given as a percentage.")
|
|
alpha = alpha_scalar.value / 100.0
|
|
|
|
elif token.name in ("color", "token"):
|
|
try:
|
|
color = Color.parse(token.value)
|
|
except Exception as error:
|
|
self.error(
|
|
name,
|
|
token,
|
|
color_property_help_text(
|
|
name, context="css", error=error, value=token.value
|
|
),
|
|
)
|
|
else:
|
|
self.error(
|
|
name,
|
|
token,
|
|
color_property_help_text(name, context="css", value=token.value),
|
|
)
|
|
|
|
if color is not None or alpha is not None:
|
|
if alpha is not None:
|
|
color = (color or Color(255, 255, 255)).multiply_alpha(alpha)
|
|
self.styles._rules[name] = color # type: ignore
|
|
|
|
process_tint = process_color
|
|
process_background = process_color
|
|
process_background_tint = process_color
|
|
process_scrollbar_color = process_color
|
|
process_scrollbar_color_hover = process_color
|
|
process_scrollbar_color_active = process_color
|
|
process_scrollbar_corner_color = process_color
|
|
process_scrollbar_background = process_color
|
|
process_scrollbar_background_hover = process_color
|
|
process_scrollbar_background_active = process_color
|
|
|
|
def process_scrollbar_visibility(self, name: str, tokens: list[Token]) -> None:
|
|
"""Process scrollbar visibility rules."""
|
|
self.styles._rules["scrollbar_visibility"] = cast(
|
|
ScrollbarVisibility,
|
|
self._process_enum(name, tokens, VALID_SCROLLBAR_VISIBILITY),
|
|
)
|
|
|
|
process_link_color = process_color
|
|
process_link_background = process_color
|
|
process_link_color_hover = process_color
|
|
process_link_background_hover = process_color
|
|
|
|
process_border_title_color = process_color
|
|
process_border_title_background = process_color
|
|
process_border_subtitle_color = process_color
|
|
process_border_subtitle_background = process_color
|
|
|
|
def process_text_style(self, name: str, tokens: list[Token]) -> None:
|
|
for token in tokens:
|
|
value = token.value
|
|
if value not in VALID_STYLE_FLAGS:
|
|
self.error(
|
|
name,
|
|
token,
|
|
style_flags_property_help_text(name, value, context="css"),
|
|
)
|
|
|
|
style_definition = " ".join(token.value for token in tokens)
|
|
self.styles._rules[name.replace("-", "_")] = style_definition # type: ignore
|
|
|
|
process_link_style = process_text_style
|
|
process_link_style_hover = process_text_style
|
|
|
|
process_border_title_style = process_text_style
|
|
process_border_subtitle_style = process_text_style
|
|
|
|
def process_text_align(self, name: str, tokens: list[Token]) -> None:
|
|
"""Process a text-align declaration"""
|
|
if not tokens:
|
|
return
|
|
|
|
if len(tokens) > 1 or tokens[0].value not in VALID_TEXT_ALIGN:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
text_align_help_text(),
|
|
)
|
|
|
|
self.styles._rules["text_align"] = tokens[0].value # type: ignore
|
|
|
|
def process_dock(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
|
|
if len(tokens) > 1 or tokens[0].value not in VALID_EDGE:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
dock_property_help_text(name, context="css"),
|
|
)
|
|
|
|
dock_value = tokens[0].value
|
|
self.styles._rules["dock"] = dock_value
|
|
|
|
def process_split(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
|
|
if len(tokens) > 1 or tokens[0].value not in VALID_EDGE:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
split_property_help_text(name, context="css"),
|
|
)
|
|
|
|
split_value = tokens[0].value
|
|
self.styles._rules["split"] = split_value
|
|
|
|
def process_layer(self, name: str, tokens: list[Token]) -> None:
|
|
if len(tokens) > 1:
|
|
self.error(name, tokens[1], "unexpected tokens in dock-edge declaration")
|
|
self.styles._rules["layer"] = tokens[0].value
|
|
|
|
def process_layers(self, name: str, tokens: list[Token]) -> None:
|
|
layers: list[str] = []
|
|
for token in tokens:
|
|
if token.name not in {"token", "string"}:
|
|
self.error(name, token, f"{token.name} not expected here")
|
|
layers.append(token.value)
|
|
self.styles._rules["layers"] = tuple(layers)
|
|
|
|
def process_transition(self, name: str, tokens: list[Token]) -> None:
|
|
transitions: dict[str, Transition] = {}
|
|
|
|
def make_groups() -> Iterable[list[Token]]:
|
|
"""Batch tokens into comma-separated groups."""
|
|
group: list[Token] = []
|
|
for token in tokens:
|
|
if token.name == "comma":
|
|
if group:
|
|
yield group
|
|
group = []
|
|
else:
|
|
group.append(token)
|
|
if group:
|
|
yield group
|
|
|
|
valid_duration_token_names = ("duration", "number")
|
|
for tokens in make_groups():
|
|
css_property = ""
|
|
duration = 1.0
|
|
easing = "linear"
|
|
delay = 0.0
|
|
|
|
try:
|
|
iter_tokens = iter(tokens)
|
|
token = next(iter_tokens)
|
|
if token.name != "token":
|
|
self.error(name, token, "expected property")
|
|
|
|
css_property = token.value
|
|
token = next(iter_tokens)
|
|
if token.name not in valid_duration_token_names:
|
|
self.error(name, token, "expected duration or number")
|
|
try:
|
|
duration = _duration_as_seconds(token.value)
|
|
except ScalarError as error:
|
|
self.error(name, token, str(error))
|
|
|
|
token = next(iter_tokens)
|
|
if token.name != "token":
|
|
self.error(name, token, "easing function expected")
|
|
|
|
if token.value not in EASING:
|
|
self.error(
|
|
name,
|
|
token,
|
|
f"expected easing function; found {token.value!r}",
|
|
)
|
|
easing = token.value
|
|
|
|
token = next(iter_tokens)
|
|
if token.name not in valid_duration_token_names:
|
|
self.error(name, token, "expected duration or number")
|
|
try:
|
|
delay = _duration_as_seconds(token.value)
|
|
except ScalarError as error:
|
|
self.error(name, token, str(error))
|
|
except StopIteration:
|
|
pass
|
|
transitions[css_property] = Transition(duration, easing, delay)
|
|
|
|
self.styles._rules["transitions"] = transitions
|
|
|
|
def process_align(self, name: str, tokens: list[Token]) -> None:
|
|
def align_error(name, token):
|
|
self.error(name, token, align_help_text())
|
|
|
|
if len(tokens) != 2:
|
|
self.error(name, tokens[0], align_help_text())
|
|
|
|
token_horizontal = tokens[0]
|
|
token_vertical = tokens[1]
|
|
|
|
if token_horizontal.name != "token":
|
|
align_error(name, token_horizontal)
|
|
elif token_horizontal.value not in VALID_ALIGN_HORIZONTAL:
|
|
align_error(name, token_horizontal)
|
|
|
|
if token_vertical.name != "token":
|
|
align_error(name, token_vertical)
|
|
elif token_vertical.value not in VALID_ALIGN_VERTICAL:
|
|
align_error(name, token_horizontal)
|
|
|
|
name = name.replace("-", "_")
|
|
self.styles._rules[f"{name}_horizontal"] = token_horizontal.value # type: ignore
|
|
self.styles._rules[f"{name}_vertical"] = token_vertical.value # type: ignore
|
|
|
|
self._distribute_importance(name, ("horizontal", "vertical"))
|
|
|
|
def process_align_horizontal(self, name: str, tokens: list[Token]) -> None:
|
|
try:
|
|
value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL)
|
|
except StyleValueError:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
string_enum_help_text(name, VALID_ALIGN_HORIZONTAL, context="css"),
|
|
)
|
|
else:
|
|
self.styles._rules[name.replace("-", "_")] = value # type: ignore
|
|
|
|
def process_align_vertical(self, name: str, tokens: list[Token]) -> None:
|
|
try:
|
|
value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL)
|
|
except StyleValueError:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
string_enum_help_text(name, VALID_ALIGN_VERTICAL, context="css"),
|
|
)
|
|
else:
|
|
self.styles._rules[name.replace("-", "_")] = value # type: ignore
|
|
|
|
process_content_align = process_align
|
|
process_content_align_horizontal = process_align_horizontal
|
|
process_content_align_vertical = process_align_vertical
|
|
|
|
process_border_title_align = process_align_horizontal
|
|
process_border_subtitle_align = process_align_horizontal
|
|
|
|
def process_scrollbar_gutter(self, name: str, tokens: list[Token]) -> None:
|
|
try:
|
|
value = self._process_enum(name, tokens, VALID_SCROLLBAR_GUTTER)
|
|
except StyleValueError:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
string_enum_help_text(name, VALID_SCROLLBAR_GUTTER, context="css"),
|
|
)
|
|
else:
|
|
self.styles._rules[name.replace("-", "_")] = value # type: ignore
|
|
|
|
def process_scrollbar_size(self, name: str, tokens: list[Token]) -> None:
|
|
def scrollbar_size_error(name: str, token: Token) -> None:
|
|
self.error(name, token, scrollbar_size_property_help_text(context="css"))
|
|
|
|
if not tokens:
|
|
return
|
|
if len(tokens) != 2:
|
|
scrollbar_size_error(name, tokens[0])
|
|
else:
|
|
token1, token2 = tokens
|
|
|
|
if token1.name != "number" or not token1.value.isdigit():
|
|
scrollbar_size_error(name, token1)
|
|
if token2.name != "number" or not token2.value.isdigit():
|
|
scrollbar_size_error(name, token2)
|
|
|
|
horizontal = int(token1.value)
|
|
vertical = int(token2.value)
|
|
self.styles._rules["scrollbar_size_horizontal"] = horizontal
|
|
self.styles._rules["scrollbar_size_vertical"] = vertical
|
|
self._distribute_importance("scrollbar_size", ("horizontal", "vertical"))
|
|
|
|
def process_scrollbar_size_vertical(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
if len(tokens) != 1:
|
|
self.error(name, tokens[0], scrollbar_size_single_axis_help_text(name))
|
|
else:
|
|
token = tokens[0]
|
|
if token.name != "number" or not token.value.isdigit():
|
|
self.error(name, token, scrollbar_size_single_axis_help_text(name))
|
|
value = int(token.value)
|
|
self.styles._rules["scrollbar_size_vertical"] = value
|
|
|
|
def process_scrollbar_size_horizontal(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
if len(tokens) != 1:
|
|
self.error(name, tokens[0], scrollbar_size_single_axis_help_text(name))
|
|
else:
|
|
token = tokens[0]
|
|
if token.name != "number" or not token.value.isdigit():
|
|
self.error(name, token, scrollbar_size_single_axis_help_text(name))
|
|
value = int(token.value)
|
|
self.styles._rules["scrollbar_size_horizontal"] = value
|
|
|
|
def _process_grid_rows_or_columns(self, name: str, tokens: list[Token]) -> None:
|
|
scalars: list[Scalar] = []
|
|
percent_unit = Unit.WIDTH if name == "grid-columns" else Unit.HEIGHT
|
|
for token in tokens:
|
|
if token.name == "number":
|
|
scalars.append(Scalar.from_number(float(token.value)))
|
|
elif token.name == "scalar":
|
|
scalars.append(Scalar.parse(token.value, percent_unit=percent_unit))
|
|
elif token.name == "token" and token.value == "auto":
|
|
scalars.append(Scalar.parse("auto"))
|
|
else:
|
|
self.error(
|
|
name,
|
|
token,
|
|
table_rows_or_columns_help_text(name, token.value, context="css"),
|
|
)
|
|
self.styles._rules[name.replace("-", "_")] = scalars # type: ignore
|
|
|
|
process_grid_rows = _process_grid_rows_or_columns
|
|
process_grid_columns = _process_grid_rows_or_columns
|
|
|
|
def _process_integer(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
if len(tokens) != 1:
|
|
self.error(name, tokens[0], integer_help_text(name))
|
|
else:
|
|
token = tokens[0]
|
|
if token.name != "number" or not token.value.isdigit():
|
|
self.error(name, token, integer_help_text(name))
|
|
value = int(token.value)
|
|
if value == 0:
|
|
self.error(name, token, integer_help_text(name))
|
|
self.styles._rules[name.replace("-", "_")] = value # type: ignore
|
|
|
|
process_grid_gutter_horizontal = _process_integer
|
|
process_grid_gutter_vertical = _process_integer
|
|
process_column_span = _process_integer
|
|
process_row_span = _process_integer
|
|
process_grid_size_columns = _process_integer
|
|
process_grid_size_rows = _process_integer
|
|
process_line_pad = _process_integer
|
|
|
|
def process_grid_gutter(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
if len(tokens) == 1:
|
|
token = tokens[0]
|
|
if token.name != "number":
|
|
self.error(name, token, integer_help_text(name))
|
|
value = max(0, int(token.value))
|
|
self.styles._rules["grid_gutter_horizontal"] = value
|
|
self.styles._rules["grid_gutter_vertical"] = value
|
|
|
|
elif len(tokens) == 2:
|
|
token = tokens[0]
|
|
if token.name != "number":
|
|
self.error(name, token, integer_help_text(name))
|
|
value = max(0, int(token.value))
|
|
self.styles._rules["grid_gutter_horizontal"] = value
|
|
token = tokens[1]
|
|
if token.name != "number":
|
|
self.error(name, token, integer_help_text(name))
|
|
value = max(0, int(token.value))
|
|
self.styles._rules["grid_gutter_vertical"] = value
|
|
|
|
else:
|
|
self.error(name, tokens[0], "expected two integers here")
|
|
|
|
def process_grid_size(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
if len(tokens) == 1:
|
|
token = tokens[0]
|
|
if token.name != "number":
|
|
self.error(name, token, integer_help_text(name))
|
|
value = max(0, int(token.value))
|
|
self.styles._rules["grid_size_columns"] = value
|
|
self.styles._rules["grid_size_rows"] = 0
|
|
|
|
elif len(tokens) == 2:
|
|
token = tokens[0]
|
|
if token.name != "number":
|
|
self.error(name, token, integer_help_text(name))
|
|
value = max(0, int(token.value))
|
|
self.styles._rules["grid_size_columns"] = value
|
|
token = tokens[1]
|
|
if token.name != "number":
|
|
self.error(name, token, integer_help_text(name))
|
|
value = max(0, int(token.value))
|
|
self.styles._rules["grid_size_rows"] = value
|
|
|
|
else:
|
|
self.error(name, tokens[0], "expected two integers here")
|
|
|
|
def process_overlay(self, name: str, tokens: list[Token]) -> None:
|
|
try:
|
|
value = self._process_enum(name, tokens, VALID_OVERLAY)
|
|
except StyleValueError:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
string_enum_help_text(name, VALID_OVERLAY, context="css"),
|
|
)
|
|
else:
|
|
self.styles._rules[name] = value # type: ignore
|
|
|
|
def process_constrain(self, name: str, tokens: list[Token]) -> None:
|
|
if len(tokens) == 1:
|
|
try:
|
|
value = self._process_enum(name, tokens, VALID_CONSTRAIN)
|
|
except StyleValueError:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
string_enum_help_text(name, VALID_CONSTRAIN, context="css"),
|
|
)
|
|
else:
|
|
self.styles._rules["constrain_x"] = value # type: ignore
|
|
self.styles._rules["constrain_y"] = value # type: ignore
|
|
elif len(tokens) == 2:
|
|
constrain_x, constrain_y = self._process_enum_multiple(
|
|
name, tokens, VALID_CONSTRAIN, 2
|
|
)
|
|
self.styles._rules["constrain_x"] = constrain_x # type: ignore
|
|
self.styles._rules["constrain_y"] = constrain_y # type: ignore
|
|
else:
|
|
self.error(name, tokens[0], "one or two values expected here")
|
|
|
|
def process_constrain_x(self, name: str, tokens: list[Token]) -> None:
|
|
try:
|
|
value = self._process_enum(name, tokens, VALID_CONSTRAIN)
|
|
except StyleValueError:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
string_enum_help_text(name, VALID_CONSTRAIN, context="css"),
|
|
)
|
|
else:
|
|
self.styles._rules[name] = value # type: ignore
|
|
|
|
def process_constrain_y(self, name: str, tokens: list[Token]) -> None:
|
|
try:
|
|
value = self._process_enum(name, tokens, VALID_CONSTRAIN)
|
|
except StyleValueError:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
string_enum_help_text(name, VALID_CONSTRAIN, context="css"),
|
|
)
|
|
else:
|
|
self.styles._rules[name] = value # type: ignore
|
|
|
|
def process_hatch(self, name: str, tokens: list[Token]) -> None:
|
|
if not tokens:
|
|
return
|
|
character: str | None = None
|
|
color = TRANSPARENT
|
|
opacity = 1.0
|
|
|
|
if len(tokens) == 1 and tokens[0].value == "none":
|
|
self.styles._rules[name] = "none"
|
|
return
|
|
|
|
if len(tokens) not in (2, 3):
|
|
self.error(name, tokens[0], "2 or 3 values expected here")
|
|
|
|
character_token, color_token, *opacity_tokens = tokens
|
|
|
|
if character_token.name == "token":
|
|
if character_token.value not in VALID_HATCH:
|
|
self.error(
|
|
name,
|
|
tokens[0],
|
|
string_enum_help_text(name, VALID_HATCH, context="css"),
|
|
)
|
|
character = HATCHES[character_token.value]
|
|
elif character_token.name == "string":
|
|
character = character_token.value[1:-1]
|
|
if len(character) != 1:
|
|
self.error(
|
|
name,
|
|
character_token,
|
|
f"Hatch type requires a string of length 1; got {character_token.value}",
|
|
)
|
|
if cell_len(character) != 1:
|
|
self.error(
|
|
name,
|
|
character_token,
|
|
f"Hatch type requires a string with a *cell length* of 1; got {character_token.value}",
|
|
)
|
|
|
|
if color_token.name in ("color", "token"):
|
|
try:
|
|
color = Color.parse(color_token.value)
|
|
except Exception as error:
|
|
self.error(
|
|
name,
|
|
color_token,
|
|
color_property_help_text(
|
|
name, context="css", error=error, value=color_token.value
|
|
),
|
|
)
|
|
else:
|
|
self.error(
|
|
name, color_token, f"Expected a color; found {color_token.value!r}"
|
|
)
|
|
|
|
if opacity_tokens:
|
|
opacity_token = opacity_tokens[0]
|
|
if opacity_token.name == "scalar":
|
|
opacity_scalar = opacity = Scalar.parse(opacity_token.value)
|
|
if opacity_scalar.unit != Unit.PERCENT:
|
|
self.error(
|
|
name,
|
|
opacity_token,
|
|
"hatch alpha must be given as a percentage.",
|
|
)
|
|
opacity = clamp(opacity_scalar.value / 100.0, 0, 1.0)
|
|
else:
|
|
self.error(
|
|
name,
|
|
opacity_token,
|
|
f"expected a percentage here; found {opacity_token.value!r}",
|
|
)
|
|
|
|
self.styles._rules[name] = (character or " ", color.multiply_alpha(opacity))
|
|
|
|
def process_expand(self, name: str, tokens: list[Token]):
|
|
if not tokens:
|
|
return
|
|
if len(tokens) != 1:
|
|
self.error(name, tokens[0], offset_single_axis_help_text(name))
|
|
else:
|
|
token = tokens[0]
|
|
if token.value not in VALID_EXPAND:
|
|
self.error(name, tokens[0], expand_help_text(name))
|
|
self.styles._rules["expand"] = token.value
|
|
|
|
def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
|
|
"""
|
|
Returns a valid CSS property "Python" name, or None if no close matches could be found.
|
|
|
|
Args:
|
|
rule_name: An invalid "Python-ised" CSS property (i.e. "offst_x" rather than "offst-x")
|
|
|
|
Returns:
|
|
The closest valid "Python-ised" CSS property.
|
|
Returns `None` if no close matches could be found.
|
|
|
|
Example: returns "background" for rule_name "bkgrund", "offset_x" for "ofset_x"
|
|
"""
|
|
processable_rules_name = [
|
|
attr[8:] for attr in dir(self) if attr.startswith("process_")
|
|
]
|
|
return get_suggestion(rule_name, processable_rules_name)
|