Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pseudo update #5139

Merged
merged 17 commits into from
Oct 19, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added

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

## [0.83.0] - 2024-10-10

Expand Down
4 changes: 4 additions & 0 deletions docs/guide/CSS.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,10 +330,14 @@ Here are some other pseudo classes:
- `:dark` Matches widgets in dark mode (where `App.dark == True`).
- `:disabled` Matches widgets which are in a disabled state.
- `:enabled` Matches widgets which are in an enabled state.
- `:even` Matches a widget at an evenly numbered position within its siblings.
- `:first-of-type` Matches a widget that is the first of its type amongst its siblings.
- `:focus-within` Matches widgets with a focused child widget.
- `:focus` Matches widgets which have input focus.
- `:inline` Matches widgets when the app is running in inline mode.
- `:last-of-type` Matches a widget that is the last of its type amongst its siblings.
- `:light` Matches widgets in dark mode (where `App.dark == False`).
- `:odd` Matches a widget at an oddly numbered position within its siblings.

## Combinators

Expand Down
31 changes: 15 additions & 16 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,16 @@ class MyApp(App[None]):
INLINE_PADDING: ClassVar[int] = 1
"""Number of blank lines above an inline app."""

_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App], bool]]] = {
"focus": lambda app: app.app_focus,
"blur": lambda app: not app.app_focus,
"dark": lambda app: app.dark,
"light": lambda app: not app.dark,
"inline": lambda app: app.is_inline,
"ansi": lambda app: app.ansi_color,
"nocolor": lambda app: app.no_color,
} # type: ignore[assignment]

title: Reactive[str] = Reactive("", compute=False)
"""The title of the app, displayed in the header."""
sub_title: Reactive[str] = Reactive("", compute=False)
Expand Down Expand Up @@ -892,21 +902,6 @@ def _context(self) -> Generator[None, None, None]:
active_message_pump.reset(message_pump_reset_token)
active_app.reset(app_reset_token)

def get_pseudo_classes(self) -> Iterable[str]:
"""Pseudo classes for a widget.

Returns:
Names of the pseudo classes.
"""
yield "focus" if self.app_focus else "blur"
yield "dark" if self.dark else "light"
if self.is_inline:
yield "inline"
if self.ansi_color:
yield "ansi"
if self.no_color:
yield "nocolor"

def _watch_ansi_color(self, ansi_color: bool) -> None:
"""Enable or disable the truecolor filter when the reactive changes"""
for filter in self._filters:
Expand Down Expand Up @@ -3148,17 +3143,21 @@ def _register(
widget_list = widgets

apply_stylesheet = self.stylesheet.apply
new_widgets: list[Widget] = []
add_new_widget = new_widgets.append
for widget in widget_list:
widget._closing = False
widget._closed = False
widget._pruning = False
if not isinstance(widget, Widget):
raise AppError(f"Can't register {widget!r}; expected a Widget instance")
if widget not in self._registry:
add_new_widget(widget)
self._register_child(parent, widget, before, after)
if widget._nodes:
self._register(widget, *widget._nodes, cache=cache)
apply_stylesheet(widget, cache=cache)
for widget in new_widgets:
apply_stylesheet(widget, cache=cache)

if not self._running:
# If the app is not running, prevent awaiting of the widget tasks
Expand Down
4 changes: 4 additions & 0 deletions src/textual/css/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@
"inline",
"light",
"nocolor",
"first-of-type",
"last-of-type",
"odd",
"even",
}
VALID_OVERLAY: Final = {"none", "screen"}
VALID_CONSTRAIN: Final = {"inflect", "inside", "none"}
Expand Down
32 changes: 21 additions & 11 deletions src/textual/css/stylesheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,15 @@ def _check_rule(
if _check_selectors(selector_set.selectors, css_path_nodes):
yield selector_set.specificity

# pseudo classes which iterate over many nodes
# these have the potential to be slow, and shouldn't be used in a cache key
EXPENSIVE_PSEUDO_CLASSES = {
"first-of-type",
"last-of_type",
"odd",
"even",
}

def apply(
self,
node: DOMNode,
Expand Down Expand Up @@ -467,14 +476,18 @@ def apply(
for rule in rules_map[name]
}
rules = list(filter(limit_rules.__contains__, reversed(self.rules)))

node._has_hover_style = any("hover" in rule.pseudo_classes for rule in rules)
node._has_focus_within = any(
"focus-within" in rule.pseudo_classes for rule in rules
all_pseudo_classes = set().union(*[rule.pseudo_classes for rule in rules])
node._has_hover_style = "hover" in all_pseudo_classes
node._has_focus_within = "focus-within" in all_pseudo_classes
node._has_order_style = not all_pseudo_classes.isdisjoint(
{"first-of-type", "last-of-type", "odd", "even"}
)

cache_key: tuple | None
if cache is not None:
cache_key: tuple | None = None

if cache is not None and all_pseudo_classes.isdisjoint(
self.EXPENSIVE_PSEUDO_CLASSES
):
cache_key = (
node._parent,
(
Expand All @@ -483,16 +496,14 @@ def apply(
else (node._id if f"#{node._id}" in rules_map else None)
),
node.classes,
node.pseudo_classes,
node._pseudo_classes_cache_key,
node._css_type_name,
)
cached_result: RulesMap | None = cache.get(cache_key)
if cached_result is not None:
self.replace_rules(node, cached_result, animate=animate)
self._process_component_classes(node)
return
else:
cache_key = None

_check_rule = self._check_rule
css_path_nodes = node.css_path_nodes
Expand Down Expand Up @@ -561,8 +572,7 @@ def apply(
rule_value = getattr(_DEFAULT_STYLES, initial_rule_name)
node_rules[initial_rule_name] = rule_value # type: ignore[literal-required]

if cache is not None:
assert cache_key is not None
if cache_key is not None:
cache[cache_key] = node_rules
self.replace_rules(node, node_rules, animate=animate)
self._process_component_classes(node)
Expand Down
34 changes: 28 additions & 6 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ class DOMNode(MessagePump):
# Names of potential computed reactives
_computes: ClassVar[frozenset[str]]

_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[object], bool]]] = {}
"""Pseudo class checks."""

def __init__(
self,
*,
Expand Down Expand Up @@ -217,6 +220,8 @@ def __init__(
)
self._has_hover_style: bool = False
self._has_focus_within: bool = False
self._has_order_style: bool = False
"""The node has an ordered dependent pseudo-style (`:odd`, `:even`, `:first-of-type`, `:last-of-type`)"""
self._reactive_connect: (
dict[str, tuple[MessagePump, Reactive[object] | object]] | None
) = None
Expand Down Expand Up @@ -1228,13 +1233,18 @@ def on_dark_change(old_value:bool, new_value:bool) -> None:
"""
_watch(self, obj, attribute_name, callback, init=init)

def get_pseudo_classes(self) -> Iterable[str]:
"""Get any pseudo classes applicable to this Node, e.g. hover, focus.
def get_pseudo_classes(self) -> set[str]:
"""Pseudo classes for a widget.

Returns:
Iterable of strings, such as a generator.
Names of the pseudo classes.
"""
return ()

return {
name
for name, check_class in self._PSEUDO_CLASSES.items()
if check_class(self)
}

def reset_styles(self) -> None:
"""Reset styles back to their initial state."""
Expand Down Expand Up @@ -1658,7 +1668,10 @@ def has_pseudo_class(self, class_name: str) -> bool:
Returns:
`True` if the DOM node has the pseudo class, `False` if not.
"""
return class_name in self.get_pseudo_classes()
try:
return self._PSEUDO_CLASSES[class_name](self)
except KeyError:
return False

def has_pseudo_classes(self, class_names: set[str]) -> bool:
"""Check the node has all the given pseudo classes.
Expand All @@ -1669,7 +1682,16 @@ def has_pseudo_classes(self, class_names: set[str]) -> bool:
Returns:
`True` if all pseudo class names are present.
"""
return class_names.issubset(self.get_pseudo_classes())
PSEUDO_CLASSES = self._PSEUDO_CLASSES
try:
return all(PSEUDO_CLASSES[name](self) for name in class_names)
except KeyError:
return False

@property
def _pseudo_classes_cache_key(self) -> tuple[int, ...]:
"""A cache key used when updating a number of nodes from the stylesheet."""
return ()

def refresh(
self, *, repaint: bool = True, layout: bool = False, recompose: bool = False
Expand Down
Loading
Loading