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") 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 os
import asyncio import asyncio
from functools import partial
from typing import Any, Callable, ClassVar, Type, TypeVar from typing import Any, Callable, ClassVar, Type, TypeVar
import warnings import warnings
@@ -21,6 +21,7 @@ from .geometry import Offset, Region
from . import log from . import log
from ._callback import invoke from ._callback import invoke
from ._context import active_app from ._context import active_app
from .css.stylesheet import Stylesheet
from ._event_broker import extract_handler_actions, NoHandler from ._event_broker import extract_handler_actions, NoHandler
from .driver import Driver from .driver import Driver
from .layouts.dock import DockLayout, Dock from .layouts.dock import DockLayout, Dock
@@ -66,6 +67,7 @@ class App(MessagePump):
log: str = "", log: str = "",
log_verbosity: int = 1, log_verbosity: int = 1,
title: str = "Textual Application", title: str = "Textual Application",
css_file: str | None = None,
): ):
"""The Textual Application base class """The Textual Application base class
@@ -104,6 +106,10 @@ class App(MessagePump):
self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
self._refresh_required = False self._refresh_required = False
self.stylesheet = Stylesheet()
self.css_file = css_file
super().__init__() super().__init__()
title: Reactive[str] = Reactive("Textual") title: Reactive[str] = Reactive("Textual")
@@ -124,6 +130,9 @@ class App(MessagePump):
def view(self) -> DockView: def view(self) -> DockView:
return self._view_stack[-1] return self._view_stack[-1]
def load_css(self, filename: str) -> None:
pass
def log(self, *args: Any, verbosity: int = 1, **kwargs) -> None: def log(self, *args: Any, verbosity: int = 1, **kwargs) -> None:
"""Write to logs. """Write to logs.
@@ -269,6 +278,13 @@ class App(MessagePump):
log("---") log("---")
log(f"driver={self.driver_class}") 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) load_event = events.Load(sender=self)
await self.dispatch_message(load_event) await self.dispatch_message(load_event)
await self.post_message(events.Mount(self)) await self.post_message(events.Mount(self))

View File

@@ -36,6 +36,18 @@ class Selector:
selector: SelectorType = SelectorType.TYPE selector: SelectorType = SelectorType.TYPE
pseudo_classes: list[str] = field(default_factory=list) 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 @dataclass
class Declaration: class Declaration:
@@ -50,3 +62,23 @@ class Declaration:
class RuleSet: class RuleSet:
selectors: list[list[Selector]] = field(default_factory=list) selectors: list[list[Selector]] = field(default_factory=list)
styles: Styles = field(default_factory=Styles) 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: if declaration.tokens:
rule_set.styles.add_declaration(declaration) rule_set.styles.add_declaration(declaration)
print(rule_set)
yield rule_set yield rule_set
@@ -87,16 +86,24 @@ def parse(css: str) -> Iterable[RuleSet]:
token = next(tokens, None) token = next(tokens, None)
if token is None: if token is None:
break break
if token.name.startswith("selector_start_"): if token.name.startswith("selector_start"):
yield from parse_rule_set(tokens, token) yield from parse_rule_set(tokens, token)
if __name__ == "__main__": if __name__ == "__main__":
test = """ test = """
.foo.bar baz:focus, #egg { .foo.bar baz:focus, #egg {
display: block
visibility: visible; visibility: visible;
border: solid green !important; 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 __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import cast, TYPE_CHECKING from typing import cast, Sequence, TYPE_CHECKING
from rich import print from rich import print
import rich.repr
from rich.color import ANSI_COLOR_NAMES, Color from rich.color import ANSI_COLOR_NAMES, Color
from ._error_tools import friendly_list from ._error_tools import friendly_list
from ..geometry import Spacing, SpacingDimensions
from .tokenize import Token from .tokenize import Token
from .types import Visibility from .types import Display, Visibility
if TYPE_CHECKING: if TYPE_CHECKING:
from .model import Declaration from .model import Declaration
@@ -21,68 +23,347 @@ class DeclarationError(Exception):
VALID_VISIBILITY = {"visible", "hidden"} VALID_VISIBILITY = {"visible", "hidden"}
VALID_DISPLAY = {"block", "none"}
VALID_BORDER = {"rounded", "solid", "double", "dashed", "heavy", "inner", "outer"} 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 @dataclass
class Styles: class Styles:
visibility: Visibility | None = None _display: Display | None = None
_visibility: Visibility | None = None
border_top: tuple[str, Color] | None = None _padding: Spacing | None = None
border_right: tuple[str, Color] | None = None _margin: Spacing | None = None
border_bottom: tuple[str, Color] | None = None
border_left: tuple[str, Color] | None = None
outline_top: tuple[str, Color] | None = None _border_top: tuple[str, Color] | None = None
outline_right: tuple[str, Color] | None = None _border_right: tuple[str, Color] | None = None
outline_bottom: tuple[str, Color] | None = None _border_bottom: tuple[str, Color] | None = None
outline_left: 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: 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: def add_declaration(self, declaration: Declaration) -> None:
print(declaration)
if not declaration.tokens: if not declaration.tokens:
return return
process_method = getattr(self, f"process_{declaration.name.replace('-', '_')}") process_method = getattr(self, f"process_{declaration.name.replace('-', '_')}")
tokens = declaration.tokens tokens = declaration.tokens
if tokens[-1].name == "important": if tokens[-1].name == "important":
tokens = tokens[:-1] tokens = tokens[:-1]
self.important.add(declaration.name) self._important.add(declaration.name)
if process_method is not None: if process_method is not None:
process_method(declaration.name, tokens) process_method(declaration.name, tokens)
def _parse_border(self, tokens: list[Token]) -> tuple[str, Color]: def process_display(self, name: str, tokens: list[Token]) -> None:
color = Color.default()
border_type = "solid"
for token in tokens: for token in tokens:
location, name, value = token location, name, value = token
if name == "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: if value in ANSI_COLOR_NAMES:
color = Color.parse(value) color = Color.parse(value)
elif value in VALID_BORDER: elif value in VALID_BORDER:
border_type = value border_type = value
else: else:
self.error(name, token, f"unknown token {value!r} in declaration") self.error(name, token, f"unknown token {value!r} in declaration")
elif name == "color": elif token_name == "color":
color = Color.parse(value) color = Color.parse(value)
else: else:
self.error(name, token, f"unexpected token {value!r} in declaration") self.error(name, token, f"unexpected token {value!r} in declaration")
return (border_type, color) return (border_type, color)
def _process_border(self, edge: str, name: str, tokens: list[Token]) -> None: def _process_border(self, edge: str, name: str, tokens: list[Token]) -> None:
border = self._parse_border(tokens) border = self._parse_border("border", tokens)
setattr(self, f"border_{edge}", border) setattr(self, f"_border_{edge}", border)
def process_border(self, name: str, tokens: list[Token]) -> None: def process_border(self, name: str, tokens: list[Token]) -> None:
border = self._parse_border(tokens) border = self._parse_border("border", tokens)
self.border_top = self.border_right = border self._border_top = self._border_right = border
self.border_bottom = self.border_left = border self._border_bottom = self._border_left = border
def process_border_top(self, name: str, tokens: list[Token]) -> None: def process_border_top(self, name: str, tokens: list[Token]) -> None:
self._process_border("top", name, tokens) self._process_border("top", name, tokens)
@@ -97,13 +378,13 @@ class Styles:
self._process_border("left", name, tokens) self._process_border("left", name, tokens)
def _process_outline(self, edge: str, name: str, tokens: list[Token]) -> None: def _process_outline(self, edge: str, name: str, tokens: list[Token]) -> None:
border = self._parse_border(tokens) border = self._parse_border("outline", tokens)
setattr(self, f"outline_{edge}", border) setattr(self, f"_outline_{edge}", border)
def process_outline(self, name: str, tokens: list[Token]) -> None: def process_outline(self, name: str, tokens: list[Token]) -> None:
border = self._parse_border(tokens) border = self._parse_border("outline", tokens)
self.outline_top = self.outline_right = border self._outline_top = self._outline_right = border
self.outline_bottom = self.outline_left = border self._outline_bottom = self._outline_left = border
def process_outline_top(self, name: str, tokens: list[Token]) -> None: def process_outline_top(self, name: str, tokens: list[Token]) -> None:
self._process_outline("top", name, tokens) self._process_outline("top", name, tokens)
@@ -117,18 +398,15 @@ class Styles:
def process_outline_left(self, name: str, tokens: list[Token]) -> None: def process_outline_left(self, name: str, tokens: list[Token]) -> None:
self._process_outline("left", name, tokens) self._process_outline("left", name, tokens)
def process_visibility(self, name: str, tokens: list[Token]) -> None:
for token in tokens: if __name__ == "__main__":
location, name, value = token styles = Styles()
if name == "token":
value = value.lower() styles.display = "none"
if value in VALID_VISIBILITY: styles.visibility = "hidden"
self.visibility = cast(Visibility, value) styles.border = ("solid", "rgb(10,20,30)")
else: styles.outline_right = ("solid", "red")
self.error(
name, from rich import print
token,
f"invalid value for visibility (received {value!r}, expected {friendly_list(VALID_VISIBILITY)})", print(styles)
)
else:
self.error(name, token, f"invalid token {value!r} in this context")

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+", whitespace=r"\s+",
comment_start=r"\/\*", comment_start=r"\/\*",
percentage=r"\d+\%", 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,]+\)", color=r"\#[0-9a-f]{6}|color\[0-9]{1,3}\|rgb\([\d\s,]+\)",
token="[a-zA-Z_-]+", token="[a-zA-Z_-]+",
string=r"\".*?\"", string=r"\".*?\"",
@@ -82,7 +82,6 @@ def tokenize(code: str) -> Iterable[Token]:
elif name == "eof": elif name == "eof":
break break
expect = get_state(name, expect) expect = get_state(name, expect)
print(token)
yield token yield token

View File

@@ -74,15 +74,14 @@ class Tokenizer:
if value is not None: if value is not None:
break break
try: token = Token((line_no, col_no), name, value)
return Token((line_no, col_no), name, value) col_no += len(value)
finally: if col_no >= len(line):
col_no += len(value) line_no += 1
if col_no >= len(line): col_no = 0
line_no += 1 self.line_no = line_no
col_no = 0 self.col_no = col_no
self.line_no = line_no return token
self.col_no = col_no
def skip_to(self, expect: Expect) -> Token: def skip_to(self, expect: Expect) -> Token:
line_no = self.line_no line_no = self.line_no

View File

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

View File

@@ -457,6 +457,16 @@ class Spacing(NamedTuple):
"""Bottom right space.""" """Bottom right space."""
return (self.right, self.bottom) 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 @classmethod
def unpack(cls, pad: SpacingDimensions) -> Spacing: def unpack(cls, pad: SpacingDimensions) -> Spacing:
"""Unpack padding specified in CSS style.""" """Unpack padding specified in CSS style."""