Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/textual/css/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class WrongType(QueryError):

QueryType = TypeVar("QueryType", bound="Widget")
"""Type variable used to type generic queries."""
AncestorType = TypeVar("AncestorType")
"""A nominal ancestor capability, not necessarily a concrete widget class."""
ExpectType = TypeVar("ExpectType")
"""Type variable used to further restrict queries."""

Expand Down
74 changes: 52 additions & 22 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@
from textual.worker_manager import WorkerManager

if TYPE_CHECKING:
from typing_extensions import Self, TypeAlias
from typing_extensions import Self, TypeAlias, TypeForm
from _typeshed import SupportsRichComparison

from rich.console import RenderableType
from textual.app import App
from textual.css.query import DOMQuery, QueryType
from textual.css.query import AncestorType, DOMQuery, QueryType
from textual.css.types import CSSLocation
from textual.reactive import _StoredReactiveAccess
from textual.screen import Screen
Expand Down Expand Up @@ -170,7 +170,9 @@ def _update_inherited_geometry(self, geometry: bool) -> bool:
geometry = True
return self._style_geometry_updated(geometry)

def _style_rules_updated(self, revision: int, geometry: bool, descendants: bool) -> None:
def _style_rules_updated(
self, revision: int, geometry: bool, descendants: bool
) -> None:
"""Publish the rule owner's mutation to dependent ancestor subtrees.

This is a projection of Styles' mutation epoch, including raw writes
Expand All @@ -195,7 +197,6 @@ def _style_rules_updated(self, revision: int, geometry: bool, descendants: bool)
def _make_component_node(self, component: str) -> DOMNode:
return DOMNode(classes=component)


DEFAULT_CSS: ClassVar[str] = ""
"""Default TCSS."""

Expand Down Expand Up @@ -239,7 +240,6 @@ def _make_component_node(self, component: str) -> DOMNode:
_reactives: ClassVar[dict[str, Reactive]]
_reactive_accessors: ClassVar[dict[str, _StoredReactiveAccess]]


# Names of potential computed reactives
_computes: ClassVar[frozenset[str]]

Expand Down Expand Up @@ -295,10 +295,16 @@ def _scan_relative_children_height(self, native: bool) -> tuple[bool, bool]:
result = True
break
if height.is_auto:
if type(child)._has_relative_children_height is Widget._has_relative_children_height:
if (
type(child)._has_relative_children_height
is Widget._has_relative_children_height
):
relative, child_native = child._relative_children_height()
else:
relative, child_native = child._has_relative_children_height, False
relative, child_native = (
child._has_relative_children_height,
False,
)
native = native and child_native
if relative:
result = True
Expand All @@ -324,7 +330,9 @@ def _render_container(self):
return self.layout.render_keyline(self)
return Blank(self.background_colors[1])

def _measure_content_height(self, container: Size, viewport: Size, width: int) -> int:
def _measure_content_height(
self, container: Size, viewport: Size, width: int
) -> int:
"""Intrinsic native height; the caller owns reuse and publication."""
if not width:
return 0
Expand Down Expand Up @@ -491,7 +499,11 @@ def _component_styles(self) -> dict[str, RenderStyles]:

@cached_property
def _bindings(self) -> BindingsMap:
return BindingsMap() if self._merged_bindings is None else self._merged_bindings.copy()
return (
BindingsMap()
if self._merged_bindings is None
else self._merged_bindings.copy()
)

@cached_property
def _query_one_cache(self) -> LRUCache[QueryOneCacheKey, DOMNode]:
Expand Down Expand Up @@ -1076,8 +1088,11 @@ def screen(self) -> "Screen[object]":

try:
node = next(
(ancestor for ancestor in self.walk_ancestors(with_self=True)
if isinstance(ancestor, Screen)),
(
ancestor
for ancestor in self.walk_ancestors(with_self=True)
if isinstance(ancestor, Screen)
),
None,
)
except AttributeError:
Expand Down Expand Up @@ -1202,8 +1217,10 @@ def display(self) -> bool:
my_widget.display = False # Hide my_widget
```
"""
return not self._display_constraints and self.styles.display != "none" and not (
self._closing or self._closed or self._pruning
return (
not self._display_constraints
and self.styles.display != "none"
and not (self._closing or self._closed or self._pruning)
)

@display.setter
Expand Down Expand Up @@ -1800,7 +1817,7 @@ def query_exactly_one(
def query_ancestor(self, selector: str) -> DOMNode: ...

@overload
def query_ancestor(self, selector: type[QueryType]) -> QueryType: ...
def query_ancestor(self, selector: TypeForm[AncestorType]) -> AncestorType: ...

@overload
def query_ancestor(
Expand All @@ -1809,26 +1826,37 @@ def query_ancestor(

def query_ancestor(
self,
selector: str | type[QueryType],
selector: str | TypeForm[AncestorType],
expect_type: type[QueryType] | None = None,
) -> DOMNode:
"""Get an ancestor which matches a query.
) -> DOMNode | AncestorType:
"""Get an ancestor which matches a query or nominal capability.

Args:
selector: A TCSS selector.
selector: A TCSS selector or ancestor class, including abstract mixins.
expect_type: Expected type, or `None` for any DOMNode.

Raises:
InvalidQueryFormat: If the selector is invalid.
InvalidQueryFormat: If the CSS selector is invalid.
TypeError: If the selector is neither a CSS string nor a class.
NoMatches: If there are no matching ancestors.

Returns:
A DOMNode or subclass if `expect_type` is provided.
"""
base_node = self._get_dom_base()
query = self.query(selector)
if isinstance(selector, str):
matches = self.query(selector)._matches
else:
if not isinstance(selector, type):
raise TypeError("Ancestor selectors must be CSS strings or classes")
ancestor_type = selector

def matches_class(node: DOMNode) -> bool:
return isinstance(node, ancestor_type)

matches = matches_class
for node in base_node.walk_ancestors():
if not query._matches(node):
if not matches(node):
continue
if expect_type is not None and not isinstance(node, expect_type):
continue
Expand All @@ -1855,7 +1883,9 @@ def set_styles(self, css: str | None = None, **update_styles: Any) -> Self:
self._inline_styles.refresh(layout=True)

if update_styles:
self._inline_styles.replace_rules(self._inline_styles.get_rules(), **update_styles)
self._inline_styles.replace_rules(
self._inline_styles.get_rules(), **update_styles
)
return self

def has_class(self, *class_names: str) -> bool:
Expand Down
19 changes: 19 additions & 0 deletions tests/test_query.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from abc import abstractmethod

import pytest

from textual.app import App, ComposeResult
Expand Down Expand Up @@ -159,6 +161,23 @@ class App(Widget):
_ = app.query(".float").last(View)


def test_query_ancestor_abstract_mixin():
class Scope:
@abstractmethod
def scope_value(self) -> str: ...

class ScopeWidget(Widget, Scope):
def scope_value(self) -> str:
return "scope"

parent = ScopeWidget()
child = Widget()
parent._add_child(child)

ancestor: Scope = child.query_ancestor(Scope)
assert ancestor is parent


async def test_query_classes():
class App(Widget):
pass
Expand Down