diff --git a/docs/examples/introduction/stopwatch.css b/docs/examples/introduction/stopwatch.css index 472dccc89..c7e0fbb92 100644 --- a/docs/examples/introduction/stopwatch.css +++ b/docs/examples/introduction/stopwatch.css @@ -52,3 +52,6 @@ Button { visibility: hidden } +/* #timers:hover { + background: blue; +} */ diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index 15dff3a1c..ba31a7e26 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -88,6 +88,7 @@ class StopwatchApp(App): new_stopwatch = Stopwatch() self.query_one("#timers").mount(new_stopwatch) new_stopwatch.scroll_visible() + self.sub_title = str(len(self.query("Stopwatch"))) def action_remove_stopwatch(self) -> None: """Called to remove a timer.""" diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 503706a3a..8d5c299b7 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -487,9 +487,8 @@ class Compositor: """Get the widget under the given point or None.""" # TODO: Optimize with some line based lookup contains = Region.contains - is_visible = self.visible_widgets.__contains__ for widget, cropped_region, region, *_ in self: - if is_visible(widget) and contains(cropped_region, x, y): + if contains(cropped_region, x, y): return widget, region raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})") diff --git a/src/textual/app.py b/src/textual/app.py index b1a99b0ff..0be12cd51 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -224,7 +224,7 @@ class App(Generic[ReturnType], DOMNode): self.design = DEFAULT_COLORS self.stylesheet = Stylesheet(variables=self.get_css_variables()) - self._require_stylesheet_update = False + self._require_stylesheet_update: set[DOMNode] = set() self.css_path = css_path or self.CSS_PATH self._registry: WeakSet[DOMNode] = WeakSet() @@ -720,15 +720,13 @@ class App(Generic[ReturnType], DOMNode): Should be called whenever CSS classes / pseudo classes change. """ - if node is None: - self._require_stylesheet_update = True - self.check_idle() - else: - self.stylesheet.update(node, animate=True) + self._require_stylesheet_update.add(self.screen if node is None else node) + self.check_idle() def update_visible_styles(self) -> None: """Update visible styles only.""" - self.stylesheet.update_nodes(self.screen.visible_widgets) + self._require_stylesheet_update.update(self.screen.visible_widgets) + self.check_idle() def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: """Mount widgets. Widgets specified as positional args, or keywords args. If supplied @@ -1144,16 +1142,21 @@ class App(Generic[ReturnType], DOMNode): self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") - def on_mount(self) -> None: + def _on_mount(self) -> None: widgets = self.compose() if widgets: self.mount_all(widgets) - async def on_idle(self) -> None: + def _on_idle(self) -> None: """Perform actions when there are no messages in the queue.""" if self._require_stylesheet_update: - self._require_stylesheet_update = False - self.stylesheet.update(self, animate=True) + nodes: set[DOMNode] = { + child + for node in self._require_stylesheet_update + for child in node.walk_children() + } + self._require_stylesheet_update.clear() + self.stylesheet.update_nodes(nodes, animate=True) def _register_child(self, parent: DOMNode, child: Widget) -> bool: if child not in self._registry: diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 7c3440ece..c5efebc15 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -160,11 +160,13 @@ class RuleSet: styles: Styles = field(default_factory=Styles) errors: list[tuple[Token, str]] = field(default_factory=list) - ids: set[str] = field(default_factory=set) is_default_rules: bool = False tie_breaker: int = 0 selector_names: set[str] = field(default_factory=set) + def __hash__(self): + return id(self) + @classmethod def _selector_to_css(cls, selectors: list[Selector]) -> str: tokens: list[str] = [] @@ -200,10 +202,22 @@ class RuleSet: class_type = SelectorType.CLASS id_type = SelectorType.ID + type_type = SelectorType.TYPE + universal_type = SelectorType.UNIVERSAL update_selectors = self.selector_names.update for selector_set in self.selector_set: + update_selectors( + "*" + for selector in selector_set.selectors + if selector.type == universal_type + ) + update_selectors( + selector.name + for selector in selector_set.selectors + if selector.type == type_type + ) update_selectors( f".{selector.name}" for selector in selector_set.selectors diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 564ec9deb..c33600ea4 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -312,7 +312,13 @@ class Stylesheet: if _check_selectors(selector_set.selectors, css_path_nodes): yield selector_set.specificity - def apply(self, node: DOMNode, animate: bool = False) -> None: + def apply( + self, + node: DOMNode, + *, + limit_rules: set[RuleSet] | None = None, + animate: bool = False, + ) -> None: """Apply the stylesheet to a DOM node. Args: @@ -334,12 +340,18 @@ class Stylesheet: _check_rule = self._check_rule css_path_nodes = node.css_path_nodes + selector_names = { selector for node in css_path_nodes for selector in node._selector_names } + rules: Iterable[RuleSet] + if limit_rules: + rules = [rule for rule in reversed(self.rules) if rule in limit_rules] + else: + rules = reversed(self.rules) # Collect the rules defined in the stylesheet - for rule in reversed(self.rules): + for rule in rules: if rule.selector_names and not rule.selector_names.issubset(selector_names): continue @@ -457,10 +469,17 @@ class Stylesheet: nodes (DOMNode): Nodes to update. animate (bool, optional): Enable CSS animation. Defaults to False. """ + + rules_map: defaultdict[str, list[RuleSet]] = defaultdict(list) + for rule in self.rules: + for name in rule.selector_names: + rules_map[name].append(rule) + apply = self.apply - nodes = list(nodes) + for node in nodes: - apply(node, animate=animate) + rules = {rule for name in node._selector_names for rule in rules_map[name]} + apply(node, limit_rules=rules, animate=animate) if isinstance(node, Widget) and node.is_scrollable: if node.show_vertical_scrollbar: apply(node.vertical_scrollbar) diff --git a/src/textual/dom.py b/src/textual/dom.py index 1218820fd..babc62f2b 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -320,6 +320,7 @@ class DOMNode(MessagePump): set[str]: Set of selector names. """ selectors: list[str] = [ + "*", *(f".{class_name}" for class_name in self._classes), *(f":{class_name}" for class_name in self.get_pseudo_classes()), *self._css_types, @@ -716,7 +717,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self, animate=True) + self.app.update_styles(self) except NoActiveAppError: pass @@ -732,7 +733,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self, animate=True) + self.app.update_styles(self) except NoActiveAppError: pass @@ -748,7 +749,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self, animate=True) + self.app.update_styles(self) except NoActiveAppError: pass