From 233c2c4075be37f2c1e130789fad0b9e307d971e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Apr 2022 10:20:32 +0100 Subject: [PATCH] fix css reload --- sandbox/uber.css | 2 +- sandbox/uber.py | 2 +- src/textual/app.py | 9 +-- src/textual/css/parse.py | 2 +- src/textual/css/stylesheet.py | 100 +++++++++++++++++++-------------- src/textual/widget.py | 8 +-- src/textual/widgets/_button.py | 6 +- tests/css/test_parse.py | 26 ++++----- tests/css/test_stylesheet.py | 2 +- 9 files changed, 87 insertions(+), 70 deletions(-) diff --git a/sandbox/uber.css b/sandbox/uber.css index fcc11b51d..e41b1ef1b 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -1,7 +1,7 @@ #uber1 { layout: vertical; - background: dark_green; + background: green; overflow: hidden auto; border: heavy white; } diff --git a/sandbox/uber.py b/sandbox/uber.py index a19fe9ba4..d71d56ae7 100644 --- a/sandbox/uber.py +++ b/sandbox/uber.py @@ -37,7 +37,7 @@ class BasicApp(App): await self.dispatch_key(event) def action_quit(self): - self.panic(self.screen.tree) + self.panic(self.app.tree) def action_dump(self): self.panic(str(self.app.registry)) diff --git a/src/textual/app.py b/src/textual/app.py index 38c969a9d..6979f628a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -330,10 +330,10 @@ class App(Generic[ReturnType], DOMNode): async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" if self.css_file is not None: - stylesheet = Stylesheet(variables=self.get_css_variables()) + try: time = perf_counter() - stylesheet.read(self.css_file) + self.stylesheet.read(self.css_file) elapsed = (perf_counter() - time) * 1000 self.log(f"loaded {self.css_file} in {elapsed:.0f}ms") except Exception as error: @@ -342,7 +342,6 @@ class App(Generic[ReturnType], DOMNode): self.log(error) else: self.reset_styles() - self.stylesheet = stylesheet self.stylesheet.update(self) self.screen.refresh(layout=True) @@ -506,7 +505,9 @@ class App(Generic[ReturnType], DOMNode): if self.css_file is not None: self.stylesheet.read(self.css_file) if self.css is not None: - self.stylesheet.parse(self.css, path=f"<{self.__class__.__name__}>") + self.stylesheet.add_source( + self.css, path=f"<{self.__class__.__name__}>" + ) except Exception as error: self.on_exception(error) self._print_error_renderables() diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index d934a2109..6cb47076a 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -340,7 +340,7 @@ if __name__ == "__main__": console = Console() stylesheet = Stylesheet() try: - stylesheet.parse(css) + stylesheet.add_source(css) except StylesheetParseError as e: console.print(e.errors) print(stylesheet) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index f0e6c52ea..7b8606cc1 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -101,21 +101,23 @@ class StylesheetErrors: @rich.repr.auto class Stylesheet: def __init__(self, *, variables: dict[str, str] | None = None) -> None: - self.rules: list[RuleSet] = [] + self._rules: list[RuleSet] | None = None self.variables = variables or {} - self.source: list[tuple[str, str]] = [] + self.source: dict[str, str] = {} 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 rules(self) -> list[RuleSet]: + if self._rules is None: + self.parse() + assert self._rules is not None + return self._rules @property - def any_errors(self) -> bool: - """Check if there are any errors.""" - return any(rule.errors for rule in self.rules) + def css(self) -> str: + return "\n\n".join(rule_set.css for rule_set in self.rules) @property def error_renderable(self) -> StylesheetErrors: @@ -129,6 +131,28 @@ class Stylesheet: """ self.variables = variables + def _parse_rules(self, css: str, path: str) -> list[RuleSet]: + """Parse CSS and return rules. + + + Args: + css (str): String containing Textual CSS. + path (str): Path to CSS or unique identifier + + Raises: + StylesheetError: If the CSS is invalid. + + Returns: + list[RuleSet]: List of RuleSets. + """ + try: + rules = list(parse(css, path, variables=self.variables)) + except TokenizeError: + raise + except Exception as error: + raise StylesheetError(f"failed to parse css; {error}") + return rules + def read(self, filename: str) -> None: """Read Textual CSS file. @@ -146,19 +170,10 @@ class Stylesheet: path = os.path.abspath(filename) except Exception as error: raise StylesheetError(f"unable to read {filename!r}; {error}") - try: - rules = list(parse(css, path, variables=self.variables)) - except TokenizeError: - raise - except Exception as error: - raise StylesheetError(f"failed to parse {filename!r}; {error!r}") - else: - self.source.append((css, path)) - self.rules.extend(rules) - if self.any_errors: - raise StylesheetParseError(self.error_renderable) + self.source[path] = css + self._rules = None - def parse(self, css: str, *, path: str = "") -> None: + def add_source(self, css: str, path: str | None = None) -> None: """Parse CSS from a string. Args: @@ -169,28 +184,30 @@ class Stylesheet: StylesheetError: If the CSS could not be read. StylesheetParseError: If the CSS is invalid. """ - if (css, path) in self.source: + + if path is None: + path = str(hash(css)) + if path in self.source and self.source[path] == css: + # Path already in source, and CSS is identical return - try: - rules = list(parse(css, path, variables=self.variables)) - except TokenizeError: - raise - except Exception as error: - raise StylesheetError(f"failed to parse css; {error}") - else: - self.source.append((css, path)) - self.rules.extend(rules) - if self.any_errors: - raise StylesheetParseError(self.error_renderable) - def _clone(self, stylesheet: Stylesheet) -> None: - """Replace this stylesheet contents with another. + self.source[path] = css + self._rules = None - Args: - stylesheet (Stylesheet): A Stylesheet. + def parse(self) -> None: + """Parse the source in the stylesheet. + + Raises: + StylesheetParseError: If there are any CSS related errors. """ - self.rules = stylesheet.rules.copy() - self.source = stylesheet.source.copy() + rules: list[RuleSet] = [] + add_rules = rules.extend + for path, css in self.source.items(): + css_rules = self._parse_rules(css, path) + if any(rule.errors for rule in css_rules): + raise StylesheetParseError(self.error_renderable) + add_rules(css_rules) + self._rules = rules def reparse(self) -> None: """Re-parse source, applying new variables. @@ -202,9 +219,10 @@ class Stylesheet: """ # Do this in a fresh Stylesheet so if there are errors we don't break self. stylesheet = Stylesheet(variables=self.variables) - for css, path in self.source: - stylesheet.parse(css, path=path) - self._clone(stylesheet) + for path, css in self.source.items(): + stylesheet.add_source(css, path) + self._rules = None + self.source = stylesheet.source @classmethod def _check_rule(cls, rule: RuleSet, node: DOMNode) -> Iterable[Specificity3]: @@ -403,7 +421,7 @@ if __name__ == "__main__": """ stylesheet = Stylesheet() - stylesheet.parse(CSS) + stylesheet.add_source(CSS) print(stylesheet.css) diff --git a/src/textual/widget.py b/src/textual/widget.py index 7976b5818..4f47e620e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -68,10 +68,6 @@ class Widget(DOMNode): can_focus: bool = False - DEFAULT_STYLES = """ - - """ - CSS = """ """ @@ -127,7 +123,9 @@ class Widget(DOMNode): app (App): App instance. """ # Parser the Widget's CSS - self.app.stylesheet.parse(self.CSS, path=f"<{self.__class__.__name__}>") + self.app.stylesheet.add_source( + self.CSS, f"{__file__}:<{self.__class__.__name__}>" + ) def get_box_model(self, container: Size, viewport: Size) -> BoxModel: """Process the box model for this widget. diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 4d08a2796..331b6bdde 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -23,9 +23,9 @@ class Button(Widget, can_focus=True): background: $primary; color: $text-primary; content-align: center middle; - border: tall $primary-lighten-3; - margin: 1; - min-width:16; + border: tall $primary-lighten-3; + + margin: 1 0; text-style: bold; } diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index b5b3179fd..f0c05dc2d 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -864,7 +864,7 @@ class TestParseLayout: css = "#some-widget { layout: dock; }" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles assert isinstance(styles.layout, DockLayout) @@ -874,7 +874,7 @@ class TestParseLayout: stylesheet = Stylesheet() with pytest.raises(StylesheetParseError) as ex: - stylesheet.parse(css) + stylesheet.add_source(css) assert ex.value.errors is not None @@ -886,7 +886,7 @@ class TestParseText: } """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles assert styles.color == Color.parse("green") @@ -897,7 +897,7 @@ class TestParseText: } """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles assert styles.background == Color.parse("red") @@ -933,7 +933,7 @@ class TestParseOffset: }} """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles @@ -972,7 +972,7 @@ class TestParseOffset: }} """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles @@ -1002,7 +1002,7 @@ class TestParseTransition: }} """ stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles @@ -1017,7 +1017,7 @@ class TestParseTransition: def test_no_delay_specified(self): css = f"#some-widget {{ transition: offset-x 1 in_out_cubic; }}" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) styles = stylesheet.rules[0].styles @@ -1032,7 +1032,7 @@ class TestParseTransition: stylesheet = Stylesheet() with pytest.raises(StylesheetParseError) as ex: - stylesheet.parse(css) + stylesheet.add_source(css) stylesheet_errors = stylesheet.rules[0].errors @@ -1056,7 +1056,7 @@ class TestParseOpacity: def test_opacity_to_styles(self, css_value, styles_value): css = f"#some-widget {{ opacity: {css_value} }}" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) assert stylesheet.rules[0].styles.opacity == styles_value assert not stylesheet.rules[0].errors @@ -1066,7 +1066,7 @@ class TestParseOpacity: stylesheet = Stylesheet() with pytest.raises(StylesheetParseError): - stylesheet.parse(css) + stylesheet.add_source(css) assert stylesheet.rules[0].errors @@ -1074,7 +1074,7 @@ class TestParseMargin: def test_margin_partial(self): css = "#foo {margin: 1; margin-top: 2; margin-right: 3; margin-bottom: -1;}" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) assert stylesheet.rules[0].styles.margin == Spacing(2, 3, -1, 1) @@ -1082,5 +1082,5 @@ class TestParsePadding: def test_padding_partial(self): css = "#foo {padding: 1; padding-top: 2; padding-right: 3; padding-bottom: -1;}" stylesheet = Stylesheet() - stylesheet.parse(css) + stylesheet.add_source(css) assert stylesheet.rules[0].styles.padding == Spacing(2, 3, -1, 1) diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py index 87f27845d..b31633504 100644 --- a/tests/css/test_stylesheet.py +++ b/tests/css/test_stylesheet.py @@ -42,7 +42,7 @@ def test_color_property_parsing(css_value, expectation, expected_color): ) with expectation: - stylesheet.parse(css) + stylesheet.add_source(css) if expected_color: css_rule = stylesheet.rules[0]