Skip to content

Commit 3daf4d7

Browse files
authored
Merge pull request #5139 from Textualize/pseudo-update
Pseudo update
2 parents 9f80226 + be25255 commit 3daf4d7

File tree

11 files changed

+419
-77
lines changed

11 files changed

+419
-77
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1414
### Added
1515

1616
- Added `background-tint` CSS rule https://github.com/Textualize/textual/pull/5117
17+
- Added `:first-of-type`, `:last-of-type`, `:odd`, and `:even` pseudo classes https://github.com/Textualize/textual/pull/5139
1718

1819
## [0.83.0] - 2024-10-10
1920

docs/guide/CSS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,14 @@ Here are some other pseudo classes:
330330
- `:dark` Matches widgets in dark mode (where `App.dark == True`).
331331
- `:disabled` Matches widgets which are in a disabled state.
332332
- `:enabled` Matches widgets which are in an enabled state.
333+
- `:even` Matches a widget at an evenly numbered position within its siblings.
334+
- `:first-of-type` Matches a widget that is the first of its type amongst its siblings.
333335
- `:focus-within` Matches widgets with a focused child widget.
334336
- `:focus` Matches widgets which have input focus.
335337
- `:inline` Matches widgets when the app is running in inline mode.
338+
- `:last-of-type` Matches a widget that is the last of its type amongst its siblings.
336339
- `:light` Matches widgets in dark mode (where `App.dark == False`).
340+
- `:odd` Matches a widget at an oddly numbered position within its siblings.
337341

338342
## Combinators
339343

src/textual/app.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,16 @@ class MyApp(App[None]):
487487
INLINE_PADDING: ClassVar[int] = 1
488488
"""Number of blank lines above an inline app."""
489489

490+
_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App], bool]]] = {
491+
"focus": lambda app: app.app_focus,
492+
"blur": lambda app: not app.app_focus,
493+
"dark": lambda app: app.dark,
494+
"light": lambda app: not app.dark,
495+
"inline": lambda app: app.is_inline,
496+
"ansi": lambda app: app.ansi_color,
497+
"nocolor": lambda app: app.no_color,
498+
} # type: ignore[assignment]
499+
490500
title: Reactive[str] = Reactive("", compute=False)
491501
"""The title of the app, displayed in the header."""
492502
sub_title: Reactive[str] = Reactive("", compute=False)
@@ -892,21 +902,6 @@ def _context(self) -> Generator[None, None, None]:
892902
active_message_pump.reset(message_pump_reset_token)
893903
active_app.reset(app_reset_token)
894904

895-
def get_pseudo_classes(self) -> Iterable[str]:
896-
"""Pseudo classes for a widget.
897-
898-
Returns:
899-
Names of the pseudo classes.
900-
"""
901-
yield "focus" if self.app_focus else "blur"
902-
yield "dark" if self.dark else "light"
903-
if self.is_inline:
904-
yield "inline"
905-
if self.ansi_color:
906-
yield "ansi"
907-
if self.no_color:
908-
yield "nocolor"
909-
910905
def _watch_ansi_color(self, ansi_color: bool) -> None:
911906
"""Enable or disable the truecolor filter when the reactive changes"""
912907
for filter in self._filters:
@@ -3148,17 +3143,21 @@ def _register(
31483143
widget_list = widgets
31493144

31503145
apply_stylesheet = self.stylesheet.apply
3146+
new_widgets: list[Widget] = []
3147+
add_new_widget = new_widgets.append
31513148
for widget in widget_list:
31523149
widget._closing = False
31533150
widget._closed = False
31543151
widget._pruning = False
31553152
if not isinstance(widget, Widget):
31563153
raise AppError(f"Can't register {widget!r}; expected a Widget instance")
31573154
if widget not in self._registry:
3155+
add_new_widget(widget)
31583156
self._register_child(parent, widget, before, after)
31593157
if widget._nodes:
31603158
self._register(widget, *widget._nodes, cache=cache)
3161-
apply_stylesheet(widget, cache=cache)
3159+
for widget in new_widgets:
3160+
apply_stylesheet(widget, cache=cache)
31623161

31633162
if not self._running:
31643163
# If the app is not running, prevent awaiting of the widget tasks

src/textual/css/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@
7272
"inline",
7373
"light",
7474
"nocolor",
75+
"first-of-type",
76+
"last-of-type",
77+
"odd",
78+
"even",
7579
}
7680
VALID_OVERLAY: Final = {"none", "screen"}
7781
VALID_CONSTRAIN: Final = {"inflect", "inside", "none"}

src/textual/css/stylesheet.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,15 @@ def _check_rule(
432432
if _check_selectors(selector_set.selectors, css_path_nodes):
433433
yield selector_set.specificity
434434

435+
# pseudo classes which iterate over many nodes
436+
# these have the potential to be slow, and shouldn't be used in a cache key
437+
EXPENSIVE_PSEUDO_CLASSES = {
438+
"first-of-type",
439+
"last-of_type",
440+
"odd",
441+
"even",
442+
}
443+
435444
def apply(
436445
self,
437446
node: DOMNode,
@@ -467,14 +476,18 @@ def apply(
467476
for rule in rules_map[name]
468477
}
469478
rules = list(filter(limit_rules.__contains__, reversed(self.rules)))
470-
471-
node._has_hover_style = any("hover" in rule.pseudo_classes for rule in rules)
472-
node._has_focus_within = any(
473-
"focus-within" in rule.pseudo_classes for rule in rules
479+
all_pseudo_classes = set().union(*[rule.pseudo_classes for rule in rules])
480+
node._has_hover_style = "hover" in all_pseudo_classes
481+
node._has_focus_within = "focus-within" in all_pseudo_classes
482+
node._has_order_style = not all_pseudo_classes.isdisjoint(
483+
{"first-of-type", "last-of-type", "odd", "even"}
474484
)
475485

476-
cache_key: tuple | None
477-
if cache is not None:
486+
cache_key: tuple | None = None
487+
488+
if cache is not None and all_pseudo_classes.isdisjoint(
489+
self.EXPENSIVE_PSEUDO_CLASSES
490+
):
478491
cache_key = (
479492
node._parent,
480493
(
@@ -483,16 +496,14 @@ def apply(
483496
else (node._id if f"#{node._id}" in rules_map else None)
484497
),
485498
node.classes,
486-
node.pseudo_classes,
499+
node._pseudo_classes_cache_key,
487500
node._css_type_name,
488501
)
489502
cached_result: RulesMap | None = cache.get(cache_key)
490503
if cached_result is not None:
491504
self.replace_rules(node, cached_result, animate=animate)
492505
self._process_component_classes(node)
493506
return
494-
else:
495-
cache_key = None
496507

497508
_check_rule = self._check_rule
498509
css_path_nodes = node.css_path_nodes
@@ -561,8 +572,7 @@ def apply(
561572
rule_value = getattr(_DEFAULT_STYLES, initial_rule_name)
562573
node_rules[initial_rule_name] = rule_value # type: ignore[literal-required]
563574

564-
if cache is not None:
565-
assert cache_key is not None
575+
if cache_key is not None:
566576
cache[cache_key] = node_rules
567577
self.replace_rules(node, node_rules, animate=animate)
568578
self._process_component_classes(node)

src/textual/dom.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ class DOMNode(MessagePump):
180180
# Names of potential computed reactives
181181
_computes: ClassVar[frozenset[str]]
182182

183+
_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[object], bool]]] = {}
184+
"""Pseudo class checks."""
185+
183186
def __init__(
184187
self,
185188
*,
@@ -217,6 +220,8 @@ def __init__(
217220
)
218221
self._has_hover_style: bool = False
219222
self._has_focus_within: bool = False
223+
self._has_order_style: bool = False
224+
"""The node has an ordered dependent pseudo-style (`:odd`, `:even`, `:first-of-type`, `:last-of-type`)"""
220225
self._reactive_connect: (
221226
dict[str, tuple[MessagePump, Reactive[object] | object]] | None
222227
) = None
@@ -1228,13 +1233,18 @@ def on_dark_change(old_value:bool, new_value:bool) -> None:
12281233
"""
12291234
_watch(self, obj, attribute_name, callback, init=init)
12301235

1231-
def get_pseudo_classes(self) -> Iterable[str]:
1232-
"""Get any pseudo classes applicable to this Node, e.g. hover, focus.
1236+
def get_pseudo_classes(self) -> set[str]:
1237+
"""Pseudo classes for a widget.
12331238
12341239
Returns:
1235-
Iterable of strings, such as a generator.
1240+
Names of the pseudo classes.
12361241
"""
1237-
return ()
1242+
1243+
return {
1244+
name
1245+
for name, check_class in self._PSEUDO_CLASSES.items()
1246+
if check_class(self)
1247+
}
12381248

12391249
def reset_styles(self) -> None:
12401250
"""Reset styles back to their initial state."""
@@ -1658,7 +1668,10 @@ def has_pseudo_class(self, class_name: str) -> bool:
16581668
Returns:
16591669
`True` if the DOM node has the pseudo class, `False` if not.
16601670
"""
1661-
return class_name in self.get_pseudo_classes()
1671+
try:
1672+
return self._PSEUDO_CLASSES[class_name](self)
1673+
except KeyError:
1674+
return False
16621675

16631676
def has_pseudo_classes(self, class_names: set[str]) -> bool:
16641677
"""Check the node has all the given pseudo classes.
@@ -1669,7 +1682,16 @@ def has_pseudo_classes(self, class_names: set[str]) -> bool:
16691682
Returns:
16701683
`True` if all pseudo class names are present.
16711684
"""
1672-
return class_names.issubset(self.get_pseudo_classes())
1685+
PSEUDO_CLASSES = self._PSEUDO_CLASSES
1686+
try:
1687+
return all(PSEUDO_CLASSES[name](self) for name in class_names)
1688+
except KeyError:
1689+
return False
1690+
1691+
@property
1692+
def _pseudo_classes_cache_key(self) -> tuple[int, ...]:
1693+
"""A cache key used when updating a number of nodes from the stylesheet."""
1694+
return ()
16731695

16741696
def refresh(
16751697
self, *, repaint: bool = True, layout: bool = False, recompose: bool = False

0 commit comments

Comments
 (0)