diff --git a/examples/basic.py b/examples/basic.py index 610927730..35ef90dab 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -6,7 +6,7 @@ class BasicApp(App): """A basic app demonstrating CSS""" def on_load(self): - self.bind("t", "toggle('#sidebar', '-active')") + self.bind("t", "toggle_class('#sidebar', '-active')") def on_mount(self): """Build layout here.""" @@ -18,4 +18,4 @@ class BasicApp(App): ) -BasicApp.run(log="textual.log", css_file="basic.css", log_verbosity=3) +BasicApp.run(log="textual.log", css_file="basic.css", watch_css=True) diff --git a/poetry.lock b/poetry.lock index cd06d1700..e7faaba63 100644 --- a/poetry.lock +++ b/poetry.lock @@ -547,7 +547,7 @@ python-versions = "*" [[package]] name = "rich" -version = "10.15.2" +version = "10.16.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false @@ -995,8 +995,8 @@ regex = [ {file = "regex-2021.8.21.tar.gz", hash = "sha256:faf08b0341828f6a29b8f7dd94d5cf8cc7c39bfc3e67b78514c54b494b66915a"}, ] rich = [ - {file = "rich-10.15.2-py3-none-any.whl", hash = "sha256:43b2c6ad51f46f6c94992aee546f1c177719f4e05aff8f5ea4d2efae3ebdac89"}, - {file = "rich-10.15.2.tar.gz", hash = "sha256:1dded089b79dd042b3ab5cd63439a338e16652001f0c16e73acdcf4997ad772d"}, + {file = "rich-10.16.1-py3-none-any.whl", hash = "sha256:bbe04dd6ac09e4b00d22cb1051aa127beaf6e16c3d8687b026e96d3fca6aad52"}, + {file = "rich-10.16.1.tar.gz", hash = "sha256:4949e73de321784ef6664ebbc854ac82b20ff60b2865097b93f3b9b41e30da27"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, diff --git a/src/textual/app.py b/src/textual/app.py index 32ac975da..d738fe77e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -25,9 +25,11 @@ from .geometry import Offset, Region from . import log from ._callback import invoke from ._context import active_app -from .css.stylesheet import Stylesheet, StylesheetParseError +from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError from ._event_broker import extract_handler_actions, NoHandler from .driver import Driver +from .file_monitor import FileMonitor + from .layouts.dock import DockLayout, Dock from ._linux_driver import LinuxDriver from ._types import MessageTarget @@ -79,6 +81,7 @@ class App(DOMNode): title: str = "Textual Application", css_file: str | None = None, css: str | None = None, + watch_css: bool = True, ): """The Textual Application base class @@ -119,6 +122,11 @@ class App(DOMNode): self.stylesheet = Stylesheet() self.css_file = css_file + self.css_monitor = ( + FileMonitor(css_file, self._on_css_change) + if (watch_css and css_file) + else None + ) if css is not None: self.css = css @@ -148,9 +156,6 @@ class App(DOMNode): def css_type(self) -> str: return "app" - def load_css(self, filename: str) -> None: - pass - def log(self, *args: Any, verbosity: int = 1, **kwargs) -> None: """Write to logs. @@ -214,6 +219,24 @@ class App(DOMNode): asyncio.run(run_app()) + async def _on_css_change(self) -> None: + self.log("CSS changed") + self.log("css_file", self.css_file) + if self.css_file is not None: + stylesheet = Stylesheet() + try: + self.log("loading", self.css_file) + stylesheet.read(self.css_file) + except StylesheetError as error: + self.log(error) + self.console.bell() + else: + self.log("reseting stylesheet") + self.reset_styles() + self.stylesheet = stylesheet + self.stylesheet.update(self) + self.view.refresh(layout=True) + def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: self.register(self.view, *anon_widgets, **widgets) self.view.refresh() @@ -317,6 +340,10 @@ class App(DOMNode): self._print_error_renderables() return + if self.css_monitor: + self.set_interval(0.5, self.css_monitor) + self.log("started", self.css_monitor) + self._running = True try: load_event = events.Load(sender=self) @@ -548,6 +575,15 @@ class App(DOMNode): return False return True + async def handle_update(self, message: messages.Update) -> None: + message.stop() + self.app.refresh() + + async def handle_layout(self, message: messages.Layout) -> None: + message.stop() + await self.view.refresh_layout() + self.app.refresh() + async def on_key(self, event: events.Key) -> None: await self.press(event.key) @@ -570,7 +606,15 @@ class App(DOMNode): async def action_bell(self) -> None: self.console.bell() - async def action_toggle(self, selector: str, class_name: str) -> None: + async def action_add_class_(self, selector: str, class_name: str) -> None: + self.view.query(selector).add_class(class_name) + self.view.refresh(layout=True) + + async def action_remove_class_(self, selector: str, class_name: str) -> None: + self.view.query(selector).remove_class(class_name) + self.view.refresh(layout=True) + + async def action_toggle_class(self, selector: str, class_name: str) -> None: self.view.query(selector).toggle_class(class_name) self.view.refresh(layout=True) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 69430f8d1..ddb0629ae 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -132,6 +132,7 @@ class RuleSet: selector_set: list[SelectorSet] = field(default_factory=list) styles: Styles = field(default_factory=Styles) errors: list[tuple[Token, str]] = field(default_factory=list) + classes: set[str] = field(default_factory=set) @classmethod def _selector_to_css(cls, selectors: list[Selector]) -> str: @@ -161,3 +162,15 @@ class RuleSet: declarations = "\n".join(f" {line}" for line in self.styles.css_lines) css = f"{self.selectors} {{\n{declarations}\n}}" return css + + def _post_parse(self) -> None: + """Called after the RuleSet is parsed.""" + # Build a set of the class names that have been updated + update = self.classes.update + class_type = SelectorType.CLASS + for selector_set in self.selector_set: + update( + selector.name + for selector in selector_set.selectors + if selector.type == class_type + ) diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 1d9ae81c5..c6fd78b14 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -153,6 +153,7 @@ def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]: rule_set = RuleSet( list(SelectorSet.from_selectors(rule_selectors)), styles_builder.styles, errors ) + rule_set._post_parse() yield rule_set diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index b105bce54..0e29f88be 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -83,8 +83,6 @@ class Styles: _rule_min_width: Scalar | None = None _rule_min_height: Scalar | None = None - _rule_layout: str | None = None - _rule_dock_group: str | None = None _rule_docks: tuple[DockGroup, ...] | None = None diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index ad075bda1..6618a8cc6 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -9,9 +9,7 @@ from rich.console import RenderableType import rich.repr from rich.highlighter import ReprHighlighter from rich.panel import Panel -from rich.syntax import Syntax -from rich.table import Table -from rich.text import TextType, Text +from rich.text import Text from rich.console import Group, RenderableType @@ -114,8 +112,9 @@ class Stylesheet: rule_attributes: dict[str, list[tuple[Specificity4, object]]] rule_attributes = defaultdict(list) + _check_rule = self._check_rule for rule in self.rules: - for specificity in self._check_rule(rule, node): + for specificity in _check_rule(rule, node): for key, rule_specificity, value in rule.styles.extract_rules( specificity ): @@ -123,8 +122,6 @@ class Stylesheet: get_first_item = itemgetter(0) - log(rule_attributes.get("offset")) - node_rules = [ (name, max(specificity_rules, key=get_first_item)[1]) for name, specificity_rules in rule_attributes.items() @@ -132,6 +129,12 @@ class Stylesheet: node.styles.apply_rules(node_rules) + def update(self, root: DOMNode) -> None: + """Update a node and its children.""" + apply = self.apply + for node in root.walk_children(): + apply(node) + if __name__ == "__main__": diff --git a/src/textual/dom.py b/src/textual/dom.py index ebee87f33..65ad593e7 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -131,6 +131,14 @@ class DOMNode(MessagePump): add_children(tree, self) return tree + def reset_styles(self) -> None: + from .widget import Widget + + for node in self.walk_children(): + node.styles = Styles() + if isinstance(node, Widget): + node.clear_render_cache() + def add_child(self, node: DOMNode) -> None: self.children._append(node) node.set_parent(self) @@ -172,7 +180,7 @@ class DOMNode(MessagePump): def toggle_class(self, *class_names: str) -> None: """Toggle class names""" self._classes.symmetric_difference_update(class_names) - self.app.stylesheet.apply(self) + self.app.stylesheet.update(self.app) self.log(self.styles.css) def has_psuedo_class(self, *class_names: str) -> bool: diff --git a/src/textual/file_monitor.py b/src/textual/file_monitor.py new file mode 100644 index 000000000..58957a610 --- /dev/null +++ b/src/textual/file_monitor.py @@ -0,0 +1,31 @@ +import os +from typing import Callable + +import rich.repr + +from ._callback import invoke + + +@rich.repr.auto +class FileMonitor: + def __init__(self, path: str, callback: Callable) -> None: + self.path = path + self.callback = callback + self._modified = self._get_modified() + + def _get_modified(self) -> float: + return os.stat(self.path).st_mtime + + def check(self) -> bool: + modified = self._get_modified() + changed = modified != self._modified + self._modified = modified + return changed + + async def __call__(self) -> None: + if self.check(): + await self.on_change() + + async def on_change(self) -> None: + """Called when file changes.""" + await invoke(self.callback) diff --git a/src/textual/widget.py b/src/textual/widget.py index 7c05ef66d..70e1c691f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -84,7 +84,7 @@ class Widget(DOMNode): # layout_offset_x: Reactive[float] = Reactive(0.0, layout=True) # layout_offset_y: Reactive[float] = Reactive(0.0, layout=True) - style: Reactive[str | None] = Reactive(None) + # style: Reactive[str | None] = Reactive(None) padding: Reactive[Spacing | None] = Reactive(None, layout=True) margin: Reactive[Spacing | None] = Reactive(None, layout=True) border: Reactive[str] = Reactive("none", layout=True) @@ -97,12 +97,6 @@ class Widget(DOMNode): def validate_margin(self, margin: SpacingDimensions) -> Spacing: return Spacing.unpack(margin) - # def validate_layout_offset_x(self, value) -> int: - # return int(value) - - # def validate_layout_offset_y(self, value) -> int: - # return int(value) - def __init_subclass__(cls, can_focus: bool = True) -> None: super().__init_subclass__() cls.can_focus = can_focus @@ -285,18 +279,17 @@ class Widget(DOMNode): self.refresh() async def on_idle(self, event: events.Idle) -> None: - self.log("Widget.on_idle") if self.check_layout(): self.log("layout required") self.render_cache = None self.reset_check_repaint() self.reset_check_layout() - await self.post_message(Layout(self)) + await self.emit(Layout(self)) elif self.check_repaint(): self.log("repaint required") self.render_cache = None self.reset_check_repaint() - await self.post_message(Update(self, self)) + await self.emit(Update(self, self)) async def focus(self) -> None: await self.app.set_focus(self)