diff --git a/examples/simple.py b/examples/simple.py index c3389441a..ac621befe 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -35,4 +35,4 @@ class MyApp(App): await self.call_later(get_markdown, "richreadme.md") -MyApp.run(title="Simple App", log="textual.log") +MyApp.run(title="Simple App", log="textual.log", css_file="theme.css") diff --git a/examples/theme.css b/examples/theme.css new file mode 100644 index 000000000..3f852c16f --- /dev/null +++ b/examples/theme.css @@ -0,0 +1,7 @@ +Header { + border: solid #122233; +} + +App > View > Widget { + display: none; +} diff --git a/src/textual/app.py b/src/textual/app.py index 331c31563..fcea8f18d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2,7 +2,7 @@ from __future__ import annotations import os import asyncio -from functools import partial + from typing import Any, Callable, ClassVar, Type, TypeVar import warnings @@ -21,6 +21,7 @@ from .geometry import Offset, Region from . import log from ._callback import invoke from ._context import active_app +from .css.stylesheet import Stylesheet from ._event_broker import extract_handler_actions, NoHandler from .driver import Driver from .layouts.dock import DockLayout, Dock @@ -66,6 +67,7 @@ class App(MessagePump): log: str = "", log_verbosity: int = 1, title: str = "Textual Application", + css_file: str | None = None, ): """The Textual Application base class @@ -104,6 +106,10 @@ class App(MessagePump): self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) self._refresh_required = False + self.stylesheet = Stylesheet() + + self.css_file = css_file + super().__init__() title: Reactive[str] = Reactive("Textual") @@ -124,6 +130,9 @@ class App(MessagePump): def view(self) -> DockView: return self._view_stack[-1] + def load_css(self, filename: str) -> None: + pass + def log(self, *args: Any, verbosity: int = 1, **kwargs) -> None: """Write to logs. @@ -269,6 +278,13 @@ class App(MessagePump): log("---") log(f"driver={self.driver_class}") + try: + if self.css_file is not None: + self.stylesheet.read(self.css_file) + print(self.stylesheet.css) + except Exception: + self.panic() + load_event = events.Load(sender=self) await self.dispatch_message(load_event) await self.post_message(events.Mount(self)) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index ea8d44391..71a208060 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -36,6 +36,18 @@ class Selector: selector: SelectorType = SelectorType.TYPE pseudo_classes: list[str] = field(default_factory=list) + @property + def css(self) -> str: + psuedo_suffix = "".join(f":{name}" for name in self.pseudo_classes) + if self.selector == SelectorType.UNIVERSAL: + return "*" + elif self.selector == SelectorType.TYPE: + return f"{self.name}{psuedo_suffix}" + elif self.selector == SelectorType.CLASS: + return f".{self.name}{psuedo_suffix}" + else: + return f"#{self.name}{psuedo_suffix}" + @dataclass class Declaration: @@ -50,3 +62,23 @@ class Declaration: class RuleSet: selectors: list[list[Selector]] = field(default_factory=list) styles: Styles = field(default_factory=Styles) + + @classmethod + def selector_to_css(cls, selectors: list[Selector]) -> str: + tokens: list[str] = [] + for selector in selectors: + if selector.combinator == CombinatorType.DESCENDENT: + tokens.append(" ") + elif selector.combinator == CombinatorType.CHILD: + tokens.append(" > ") + tokens.append(selector.css) + return "".join(tokens).strip() + + @property + def css(self) -> str: + selectors = ", ".join( + self.selector_to_css(selector) for selector in self.selectors + ) + declarations = "\n".join(f" {line}" for line in self.styles.css_lines) + css = f"{selectors} {{\n{declarations}\n}}" + return css diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 8d824bb75..5b489406e 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -76,7 +76,6 @@ def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]: if declaration.tokens: rule_set.styles.add_declaration(declaration) - print(rule_set) yield rule_set @@ -87,16 +86,24 @@ def parse(css: str) -> Iterable[RuleSet]: token = next(tokens, None) if token is None: break - if token.name.startswith("selector_start_"): + if token.name.startswith("selector_start"): yield from parse_rule_set(tokens, token) if __name__ == "__main__": test = """ .foo.bar baz:focus, #egg { + display: block visibility: visible; border: solid green !important; - outline: red + outline: red; + padding: 1 2; + margin: 5 }""" - for obj in parse(test): - print(obj) + + from .stylesheet import Stylesheet + + stylesheet = Stylesheet() + stylesheet.parse(test) + print(stylesheet) + print(stylesheet.css) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 0b8eafdbf..e5ada20d8 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1,14 +1,16 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import cast, TYPE_CHECKING +from typing import cast, Sequence, TYPE_CHECKING from rich import print +import rich.repr from rich.color import ANSI_COLOR_NAMES, Color from ._error_tools import friendly_list +from ..geometry import Spacing, SpacingDimensions from .tokenize import Token -from .types import Visibility +from .types import Display, Visibility if TYPE_CHECKING: from .model import Declaration @@ -21,68 +23,347 @@ class DeclarationError(Exception): VALID_VISIBILITY = {"visible", "hidden"} +VALID_DISPLAY = {"block", "none"} VALID_BORDER = {"rounded", "solid", "double", "dashed", "heavy", "inner", "outer"} +NULL_SPACING = Spacing(0, 0, 0, 0) + + +class _BoxSetter: + def __set_name__(self, owner, name): + self.internal_name = f"_{name}" + _type, edge = name.split("_") + self._type = _type + self.edge = edge + + def __get__(self, obj: Styles, objtype=None) -> tuple[str, str] | None: + value = getattr(obj, self.internal_name) + if value is None: + return None + else: + _type, color = value + return (_type, color.name) + + def __set__( + self, obj: Styles, border: tuple[str, str] | None + ) -> tuple[str, Color] | None: + if border is None: + new_value = None + else: + _type, color = border + if isinstance(color, Color): + new_value = (_type, color) + else: + new_value = (_type, Color.parse(color)) + setattr(obj, self.internal_name, new_value) + return new_value + + @dataclass class Styles: - visibility: Visibility | None = None + _display: Display | None = None + _visibility: Visibility | None = None - border_top: tuple[str, Color] | None = None - border_right: tuple[str, Color] | None = None - border_bottom: tuple[str, Color] | None = None - border_left: tuple[str, Color] | None = None + _padding: Spacing | None = None + _margin: Spacing | None = None - outline_top: tuple[str, Color] | None = None - outline_right: tuple[str, Color] | None = None - outline_bottom: tuple[str, Color] | None = None - outline_left: tuple[str, Color] | None = None + _border_top: tuple[str, Color] | None = None + _border_right: tuple[str, Color] | None = None + _border_bottom: tuple[str, Color] | None = None + _border_left: tuple[str, Color] | None = None - important: set[str] = field(default_factory=set) + _outline_top: tuple[str, Color] | None = None + _outline_right: tuple[str, Color] | None = None + _outline_bottom: tuple[str, Color] | None = None + _outline_left: tuple[str, Color] | None = None + + _important: set[str] = field(default_factory=set) + + @property + def display(self) -> Display: + return self._display or "block" + + @display.setter + def display(self, display: Display) -> None: + if display not in VALID_DISPLAY: + raise ValueError(f"display must be one of {friendly_list(VALID_DISPLAY)}") + self._display = display + + @property + def visibility(self) -> Visibility: + return self._visibility or "visible" + + @visibility.setter + def visibility(self, visibility: Visibility) -> None: + if visibility not in VALID_VISIBILITY: + raise ValueError( + f"visibility must be one of {friendly_list(VALID_VISIBILITY)}" + ) + self._visibility = visibility + + @property + def padding(self) -> Spacing: + return self._padding or NULL_SPACING + + @padding.setter + def padding(self, padding: SpacingDimensions) -> None: + self._padding = Spacing.unpack(padding) + + @property + def margin(self) -> Spacing: + return self._margin or NULL_SPACING + + @margin.setter + def margin(self, padding: SpacingDimensions) -> None: + self._margin = Spacing.unpack(padding) + + @property + def border( + self, + ) -> tuple[ + tuple[str, str] | None, + tuple[str, str] | None, + tuple[str, str] | None, + tuple[str, str] | None, + ]: + return ( + self.border_top, + self.border_right, + self.border_bottom, + self.border_left, + ) + + @border.setter + def border( + self, border: Sequence[tuple[str, str] | None] | tuple[str, str] | None + ) -> None: + if border is None: + self._border_top = ( + self._border_right + ) = self._border_bottom = self._border_left = None + return + if isinstance(border, tuple): + self.border_top = ( + self.border_right + ) = self.border_bottom = self.border_left = border + return + count = len(border) + if count == 1: + self.border_top = ( + self.border_right + ) = self.border_bottom = self.border_left = border[0] + elif count == 2: + self.border_top = self.border_right = border[0] + self.border_bottom = self.border_left = border[1] + elif count == 4: + top, right, bottom, left = border + self.border_top = top + self.border_right = right + self.border_bottom = bottom + self.border_left = left + else: + raise ValueError("expected 1, 2, or 4 values") + + border_top = _BoxSetter() + border_right = _BoxSetter() + border_bottom = _BoxSetter() + border_left = _BoxSetter() + + outline_top = _BoxSetter() + outline_right = _BoxSetter() + outline_bottom = _BoxSetter() + outline_left = _BoxSetter() + + def __rich_repr__(self) -> rich.repr.Result: + yield "display", self.display, "block" + yield "visibility", self.visibility, "visible" + yield "padding", self.padding, NULL_SPACING + yield "margin", self.padding, NULL_SPACING + + yield "border_top", self.border_top, None + yield "border_right", self.border_right, None + yield "border_bottom", self.border_bottom, None + yield "border_left", self.border_left, None + + yield "outline_top", self.outline_top, None + yield "outline_right", self.outline_right, None + yield "outline_bottom", self.outline_bottom, None + yield "outline_left", self.outline_left, None + + @property + def css_lines(self) -> list[str]: + lines: list[str] = [] + append = lines.append + + def append_declaration(name: str, value: str) -> None: + if name in self._important: + append(f"{name}: {value} !important;") + else: + append(f"{name}: {value};") + + if self._display is not None: + append_declaration("display", self._display) + + if self._visibility is not None: + append_declaration("visibility", self._visibility) + + if self._padding is not None: + append_declaration("padding", self._padding.packed) + + if self._margin is not None: + append_declaration("margin", self._margin.packed) + + if ( + self._border_top != None + and self._border_top == self._border_right + and self._border_right == self._border_bottom + and self._border_bottom == self._border_left + ): + _type, color = self._border_top + append_declaration("border", f"{_type} {color.name}") + else: + + if self._border_top is not None: + _type, color = self._border_top + append_declaration("border-top", f"{_type} {color.name}") + + if self._border_right is not None: + _type, color = self._border_right + append_declaration("border-right", f"{_type} {color.name}") + + if self._border_bottom is not None: + _type, color = self._border_bottom + append_declaration("border-bottom", f"{_type} {color.name}") + + if self._border_left is not None: + _type, color = self._border_left + append_declaration("border-left", f"{_type} {color.name}") + + if ( + self._outline_top != None + and self._outline_top == self._outline_right + and self._outline_right == self._outline_bottom + and self._outline_bottom == self._outline_left + ): + _type, color = self._outline_top + append_declaration("outline", f"{_type} {color.name}") + else: + + if self._outline_top is not None: + _type, color = self._outline_top + append_declaration("outline-top", f"{_type} {color.name}") + + if self._outline_right is not None: + _type, color = self._outline_right + append_declaration("outline-right", f"{_type} {color.name}") + + if self._outline_bottom is not None: + _type, color = self._outline_bottom + append_declaration("outline-bottom", f"{_type} {color.name}") + + if self._outline_left is not None: + _type, color = self._outline_left + append_declaration("outline-left", f"{_type} {color.name}") + + return lines def error(self, name: str, token: Token, msg: str) -> None: - raise DeclarationError(name, token, msg) + line, col = token.location + raise DeclarationError(name, token, f"{msg} (line {line + 1}, col {col + 1})") def add_declaration(self, declaration: Declaration) -> None: - print(declaration) if not declaration.tokens: return process_method = getattr(self, f"process_{declaration.name.replace('-', '_')}") tokens = declaration.tokens if tokens[-1].name == "important": tokens = tokens[:-1] - self.important.add(declaration.name) + self._important.add(declaration.name) if process_method is not None: process_method(declaration.name, tokens) - def _parse_border(self, tokens: list[Token]) -> tuple[str, Color]: - color = Color.default() - border_type = "solid" + def process_display(self, name: str, tokens: list[Token]) -> None: for token in tokens: location, name, value = token if name == "token": + value = value.lower() + if value in VALID_DISPLAY: + self._display = cast(Display, value) + else: + self.error( + name, + token, + f"invalid value for display (received {value!r}, expected {friendly_list(VALID_DISPLAY)})", + ) + else: + self.error(name, token, f"invalid token {value!r} in this context") + + def process_visibility(self, name: str, tokens: list[Token]) -> None: + for token in tokens: + location, name, value = token + if name == "token": + value = value.lower() + if value in VALID_VISIBILITY: + self._visibility = cast(Visibility, value) + else: + self.error( + name, + token, + f"invalid value for visibility (received {value!r}, expected {friendly_list(VALID_VISIBILITY)})", + ) + else: + self.error(name, token, f"invalid token {value!r} in this context") + + def _process_space(self, name: str, tokens: list[Token]) -> None: + space: list[int] = [] + append = space.append + for token in tokens: + location, toke_name, value = token + if toke_name == "number": + append(int(value)) + else: + self.error(name, token, f"unexpected token {value!r} in declaration") + if len(space) not in (1, 2, 4): + self.error( + name, tokens[0], f"1, 2, or 4 values expected (received {len(space)})" + ) + setattr(self, f"_{name}", Spacing.unpack(cast(SpacingDimensions, tuple(space)))) + + def process_padding(self, name: str, tokens: list[Token]) -> None: + self._process_space(name, tokens) + + def process_margin(self, name: str, tokens: list[Token]) -> None: + self._process_space(name, tokens) + + def _parse_border(self, name: str, tokens: list[Token]) -> tuple[str, Color]: + color = Color.default() + border_type = "solid" + for token in tokens: + location, token_name, value = token + if token_name == "token": if value in ANSI_COLOR_NAMES: color = Color.parse(value) elif value in VALID_BORDER: border_type = value else: self.error(name, token, f"unknown token {value!r} in declaration") - elif name == "color": + elif token_name == "color": color = Color.parse(value) else: self.error(name, token, f"unexpected token {value!r} in declaration") return (border_type, color) def _process_border(self, edge: str, name: str, tokens: list[Token]) -> None: - border = self._parse_border(tokens) - setattr(self, f"border_{edge}", border) + border = self._parse_border("border", tokens) + setattr(self, f"_border_{edge}", border) def process_border(self, name: str, tokens: list[Token]) -> None: - border = self._parse_border(tokens) - self.border_top = self.border_right = border - self.border_bottom = self.border_left = border + border = self._parse_border("border", tokens) + self._border_top = self._border_right = border + self._border_bottom = self._border_left = border def process_border_top(self, name: str, tokens: list[Token]) -> None: self._process_border("top", name, tokens) @@ -97,13 +378,13 @@ class Styles: self._process_border("left", name, tokens) def _process_outline(self, edge: str, name: str, tokens: list[Token]) -> None: - border = self._parse_border(tokens) - setattr(self, f"outline_{edge}", border) + border = self._parse_border("outline", tokens) + setattr(self, f"_outline_{edge}", border) def process_outline(self, name: str, tokens: list[Token]) -> None: - border = self._parse_border(tokens) - self.outline_top = self.outline_right = border - self.outline_bottom = self.outline_left = border + border = self._parse_border("outline", tokens) + self._outline_top = self._outline_right = border + self._outline_bottom = self._outline_left = border def process_outline_top(self, name: str, tokens: list[Token]) -> None: self._process_outline("top", name, tokens) @@ -117,18 +398,15 @@ class Styles: def process_outline_left(self, name: str, tokens: list[Token]) -> None: self._process_outline("left", name, tokens) - def process_visibility(self, name: str, tokens: list[Token]) -> None: - for token in tokens: - location, name, value = token - if name == "token": - value = value.lower() - if value in VALID_VISIBILITY: - self.visibility = cast(Visibility, value) - else: - self.error( - name, - token, - f"invalid value for visibility (received {value!r}, expected {friendly_list(VALID_VISIBILITY)})", - ) - else: - self.error(name, token, f"invalid token {value!r} in this context") + +if __name__ == "__main__": + styles = Styles() + + styles.display = "none" + styles.visibility = "hidden" + styles.border = ("solid", "rgb(10,20,30)") + styles.outline_right = ("solid", "red") + + from rich import print + + print(styles) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py new file mode 100644 index 000000000..72e15204f --- /dev/null +++ b/src/textual/css/stylesheet.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import rich.repr + +from .model import RuleSet +from .parse import parse + + +class StylesheetError(Exception): + pass + + +@rich.repr.auto +class Stylesheet: + def __init__(self) -> None: + self.rules: list[RuleSet] = [] + + def __rich_repr__(self) -> rich.repr.Result: + yield self.rules + + @property + def css(self) -> str: + return "\n\n".join(rule_set.css for rule_set in self.rules) + + def read(self, filename: str) -> None: + try: + with open(filename, "rt") as css_file: + css = css_file.read() + del css_file + except Exception as error: + raise StylesheetError(f"unable to read {filename!r}; {error}") from None + try: + rules = list(parse(css)) + except Exception as error: + raise StylesheetError(f"failed to parse {filename!r}; {error}") from None + self.rules.extend(rules) + + def parse(self, css: str) -> None: + try: + rules = list(parse(css)) + except Exception as error: + raise StylesheetError(f"failed to parse css; {error}") from None + self.rules.extend(rules) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 8b06521ef..f517e5679 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -45,7 +45,7 @@ expect_declaration_content = Expect( whitespace=r"\s+", comment_start=r"\/\*", percentage=r"\d+\%", - number=r"\d+\.?\d+", + number=r"\d+\.?\d*", color=r"\#[0-9a-f]{6}|color\[0-9]{1,3}\|rgb\([\d\s,]+\)", token="[a-zA-Z_-]+", string=r"\".*?\"", @@ -82,7 +82,6 @@ def tokenize(code: str) -> Iterable[Token]: elif name == "eof": break expect = get_state(name, expect) - print(token) yield token diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index 8cddd1a88..83caf480c 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -74,15 +74,14 @@ class Tokenizer: if value is not None: break - try: - return Token((line_no, col_no), name, value) - finally: - col_no += len(value) - if col_no >= len(line): - line_no += 1 - col_no = 0 - self.line_no = line_no - self.col_no = col_no + token = Token((line_no, col_no), name, value) + col_no += len(value) + if col_no >= len(line): + line_no += 1 + col_no = 0 + self.line_no = line_no + self.col_no = col_no + return token def skip_to(self, expect: Expect) -> Token: line_no = self.line_no diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 234bc75e0..cfe44a46a 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -9,3 +9,4 @@ else: Visibility = Literal["visible", "hidden", "initial", "inherit"] +Display = Literal["block", "none"] diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 861e11fc0..73123a3fe 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -457,6 +457,16 @@ class Spacing(NamedTuple): """Bottom right space.""" return (self.right, self.bottom) + @property + def packed(self) -> str: + top, right, bottom, left = self + if top == right == bottom == left: + return f"{top}" + if (top, right) == (bottom, left): + return f"{top}, {right}" + else: + return f"{top}, {right}, {bottom}, {left}" + @classmethod def unpack(cls, pad: SpacingDimensions) -> Spacing: """Unpack padding specified in CSS style."""