This commit is contained in:
Will McGugan
2021-10-03 21:22:51 +01:00
parent 67af34bf8a
commit 01bb02c84f
11 changed files with 454 additions and 62 deletions

View File

@@ -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")

7
examples/theme.css Normal file
View File

@@ -0,0 +1,7 @@
Header {
border: solid #122233;
}
App > View > Widget {
display: none;
}

View File

@@ -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))

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -74,15 +74,14 @@ class Tokenizer:
if value is not None:
break
try:
return Token((line_no, col_no), name, value)
finally:
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

View File

@@ -9,3 +9,4 @@ else:
Visibility = Literal["visible", "hidden", "initial", "inherit"]
Display = Literal["block", "none"]

View File

@@ -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."""