ai-station/.venv/lib/python3.12/site-packages/textual/dom.py

1876 lines
61 KiB
Python

"""
The module contains `DOMNode`, the base class for any object within the Textual Document Object Model,
which includes all Widgets, Screens, and Apps.
"""
from __future__ import annotations
import re
import threading
from functools import lru_cache, partial
from inspect import getfile
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Iterable,
Sequence,
Type,
TypeVar,
cast,
overload,
)
import rich.repr
from rich.highlighter import ReprHighlighter
from rich.style import Style
from rich.text import Text
from rich.tree import Tree
from textual._context import NoActiveAppError, active_message_pump
from textual._node_list import NodeList
from textual._types import WatchCallbackType
from textual.binding import Binding, BindingsMap, BindingType
from textual.cache import LRUCache
from textual.color import BLACK, WHITE, Color
from textual.css._error_tools import friendly_list
from textual.css.constants import VALID_DISPLAY, VALID_VISIBILITY
from textual.css.errors import DeclarationError, StyleValueError
from textual.css.match import match
from textual.css.parse import is_id_selector, parse_declarations, parse_selectors
from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches, WrongType
from textual.css.styles import RenderStyles, Styles
from textual.css.tokenize import IDENTIFIER
from textual.css.tokenizer import TokenError
from textual.message_pump import MessagePump
from textual.reactive import Reactive, ReactiveError, _Mutated, _watch
from textual.style import Style as VisualStyle
from textual.timer import Timer
from textual.walk import walk_breadth_first, walk_breadth_search_id, walk_depth_first
from textual.worker_manager import WorkerManager
if TYPE_CHECKING:
from typing_extensions import Self, TypeAlias
from _typeshed import SupportsRichComparison
from rich.console import RenderableType
from textual.app import App
from textual.css.query import DOMQuery, QueryType
from textual.css.types import CSSLocation
from textual.message import Message
from textual.screen import Screen
from textual.widget import Widget
from textual.worker import Worker, WorkType, ResultType
from typing_extensions import Literal
_re_identifier = re.compile(IDENTIFIER)
WalkMethod: TypeAlias = Literal["depth", "breadth"]
"""Valid walking methods for the [`DOMNode.walk_children` method][textual.dom.DOMNode.walk_children]."""
ReactiveType = TypeVar("ReactiveType")
QueryOneCacheKey: TypeAlias = "tuple[int, str, Type[Widget] | None]"
"""The key used to cache query_one results."""
class BadIdentifier(Exception):
"""Exception raised if you supply a `id` attribute or class name in the wrong format."""
def check_identifiers(description: str, *names: str) -> None:
"""Validate identifier and raise an error if it fails.
Args:
description: Description of where identifier is used for error message.
*names: Identifiers to check.
"""
match = _re_identifier.fullmatch
for name in names:
if match(name) is None:
raise BadIdentifier(
f"{name!r} is an invalid {description}; "
"identifiers must contain only letters, numbers, underscores, or hyphens, and must not begin with a number."
)
class DOMError(Exception):
"""Base exception class for errors relating to the DOM."""
class NoScreen(DOMError):
"""Raised when the node has no associated screen."""
class _ClassesDescriptor:
"""A descriptor to manage the `classes` property."""
def __get__(
self, obj: DOMNode, objtype: type[DOMNode] | None = None
) -> frozenset[str]:
"""A frozenset of the current classes on the widget."""
return frozenset(obj._classes)
def __set__(self, obj: DOMNode, classes: str | Iterable[str]) -> None:
"""Replaces classes entirely."""
if isinstance(classes, str):
class_names = set(classes.split())
else:
class_names = set(classes)
check_identifiers("class name", *class_names)
obj._classes = class_names
obj._update_styles()
@rich.repr.auto
class DOMNode(MessagePump):
"""The base class for object that can be in the Textual DOM (App and Widget)"""
DEFAULT_CSS: ClassVar[str] = ""
"""Default TCSS."""
DEFAULT_CLASSES: ClassVar[str] = ""
"""Default classes argument if not supplied."""
COMPONENT_CLASSES: ClassVar[set[str]] = set()
"""Virtual DOM nodes, used to expose styles to line API widgets."""
BINDING_GROUP_TITLE: str | None = None
"""Title of widget used where bindings are displayed (such as in the key panel)."""
BINDINGS: ClassVar[list[BindingType]] = []
"""A list of key bindings."""
# Indicates if the CSS should be automatically scoped
SCOPED_CSS: ClassVar[bool] = True
"""Should default css be limited to the widget type?"""
HELP: ClassVar[str | None] = None
"""Optional help text shown in help panel (Markdown format)."""
# True if this node inherits the CSS from the base class.
_inherit_css: ClassVar[bool] = True
# True if this node inherits the component classes from the base class.
_inherit_component_classes: ClassVar[bool] = True
# True to inherit bindings from base class
_inherit_bindings: ClassVar[bool] = True
# List of names of base classes that inherit CSS
_css_type_names: ClassVar[frozenset[str]] = frozenset()
# Name of the widget in CSS
_css_type_name: str = ""
# Generated list of bindings
_merged_bindings: ClassVar[BindingsMap | None] = None
_reactives: ClassVar[dict[str, Reactive]]
_decorated_handlers: dict[type[Message], list[tuple[Callable, str | None]]]
# Names of potential computed reactives
_computes: ClassVar[frozenset[str]]
_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = {}
"""Pseudo class checks."""
def __init__(
self,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
self._classes: set[str] = set()
self._name = name
self._id = None
if id is not None:
check_identifiers("id", id)
self._id = id
_classes = classes.split() if classes else []
check_identifiers("class name", *_classes)
self._classes.update(_classes)
self._nodes: NodeList = NodeList(self)
self._css_styles: Styles = Styles(self)
self._inline_styles: Styles = Styles(self)
self.styles: RenderStyles = RenderStyles(
self, self._css_styles, self._inline_styles
)
# A mapping of class names to Styles set in COMPONENT_CLASSES
self._component_styles: dict[str, RenderStyles] = {}
self._auto_refresh: float | None = None
self._auto_refresh_timer: Timer | None = None
self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)}
self._bindings = (
BindingsMap()
if self._merged_bindings is None
else self._merged_bindings.copy()
)
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`, `:first-child`, `:last-child`)"""
self._has_odd_or_even: bool = False
"""The node has the pseudo class `odd` or `even`."""
self._reactive_connect: (
dict[str, tuple[MessagePump, Reactive[object] | object]] | None
) = None
self._pruning = False
self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024)
self._trap_focus = False
super().__init__()
def _get_dom_base(self) -> DOMNode:
"""Get the DOM base node (typically self).
All DOM queries on this node will use the return value as the root node.
This method allows the App to query the default screen, and not the active screen.
Returns:
DOMNode.
"""
return self
def set_reactive(
self, reactive: Reactive[ReactiveType], value: ReactiveType
) -> None:
"""Sets a reactive value *without* invoking validators or watchers.
Example:
```python
self.set_reactive(App.theme, "textual-light")
```
Args:
reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`).
value: New value of reactive.
Raises:
AttributeError: If the first argument is not a reactive.
"""
name = reactive.name
if not isinstance(reactive, Reactive):
raise TypeError("A Reactive class is required; for example: MyApp.theme")
if name not in self._reactives:
raise AttributeError(
f"No reactive called {name!r}; Have you called super().__init__(...) in the {self.__class__.__name__} constructor?"
)
setattr(self, f"_reactive_{name}", value)
def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None:
"""Force an update to a mutable reactive.
Example:
```python
self.reactive_name_list.append("Jessica")
self.mutate_reactive(MyClass.reactive_name_list)
```
Textual will automatically detect when a reactive is set to a new value, but it is unable
to detect if a value is _mutated_ (such as updating a list, dict, or attribute of an object).
If you do wish to use a collection or other mutable object in a reactive, then you can call
this method after your reactive is updated. This will ensure that all the reactive _superpowers_
work.
!!! note
This method will cause watchers to be called, even if the value hasn't changed.
Args:
reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`).
"""
internal_name = f"_reactive_{reactive.name}"
value = getattr(self, internal_name)
reactive._set(self, value, always=True)
def data_bind(
self,
*reactives: Reactive[Any],
**bind_vars: Reactive[Any] | object,
) -> Self:
"""Bind reactive data so that changes to a reactive automatically change the reactive on another widget.
Reactives may be given as positional arguments or keyword arguments.
See the [guide on data binding](/guide/reactivity#data-binding).
Example:
```python
def compose(self) -> ComposeResult:
yield WorldClock("Europe/London").data_bind(WorldClockApp.time)
yield WorldClock("Europe/Paris").data_bind(WorldClockApp.time)
yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time)
```
Raises:
ReactiveError: If the data wasn't bound.
Returns:
Self.
"""
_rich_traceback_omit = True
parent = active_message_pump.get()
if self._reactive_connect is None:
self._reactive_connect = {}
bind_vars = {**{reactive.name: reactive for reactive in reactives}, **bind_vars}
for name, reactive in bind_vars.items():
if name not in self._reactives:
raise ReactiveError(
f"Unable to bind non-reactive attribute {name!r} on {self}"
)
if isinstance(reactive, Reactive) and not isinstance(
parent, reactive.owner
):
raise ReactiveError(
f"Unable to bind data; {reactive.owner.__name__} is not defined on {parent.__class__.__name__}."
)
self._reactive_connect[name] = (parent, reactive)
if self._is_mounted:
self._initialize_data_bind()
else:
self.call_later(self._initialize_data_bind)
return self
def _initialize_data_bind(self) -> None:
"""initialize a data binding.
Args:
compose_parent: The node doing the binding.
"""
if not self._reactive_connect:
return
for variable_name, (compose_parent, reactive) in self._reactive_connect.items():
def make_setter(variable_name: str) -> Callable[[object], None]:
"""Make a setter for the given variable name.
Args:
variable_name: Name of variable being set.
Returns:
A callable which takes the value to set.
"""
def setter(value: object) -> None:
"""Set bound data."""
_rich_traceback_omit = True
Reactive._initialize_object(self)
# Wrap the value in `_Mutated` so the setter knows to invoke watchers etc.
setattr(self, variable_name, _Mutated(value))
return setter
assert isinstance(compose_parent, DOMNode)
setter = make_setter(variable_name)
if isinstance(reactive, Reactive):
self.watch(
compose_parent,
reactive.name,
setter,
init=True,
)
else:
self.call_later(partial(setter, reactive))
self._reactive_connect = None
def compose_add_child(self, widget: Widget) -> None:
"""Add a node to children.
This is used by the compose process when it adds children.
There is no need to use it directly, but you may want to override it in a subclass
if you want children to be attached to a different node.
Args:
widget: A Widget to add.
"""
self._nodes._append(widget)
@property
def children(self) -> Sequence["Widget"]:
"""A view on to the children.
Returns:
The node's children.
"""
return self._nodes
@property
def displayed_children(self) -> Sequence[Widget]:
"""The displayed children (where `node.display==True`).
Returns:
A sequence of widgets.
"""
return self._nodes.displayed
@property
def displayed_and_visible_children(self) -> Sequence[Widget]:
"""The displayed children (where `node.display==True` and `node.visible==True`).
Returns:
A sequence of widgets.
"""
return self._nodes.displayed_and_visible
@property
def is_empty(self) -> bool:
"""Are there no displayed children?"""
return not any(child.display for child in self._nodes)
def sort_children(
self,
*,
key: Callable[[Widget], SupportsRichComparison] | None = None,
reverse: bool = False,
) -> None:
"""Sort child widgets with an optional key function.
If `key` is not provided then widgets will be sorted in the order they are constructed.
Example:
```python
# Sort widgets by name
screen.sort_children(key=lambda widget: widget.name or "")
```
Args:
key: A callable which accepts a widget and returns something that can be sorted,
or `None` to sort without a key function.
reverse: Sort in descending order.
"""
self._nodes._sort(key=key, reverse=reverse)
self.refresh(layout=True)
@property
def auto_refresh(self) -> float | None:
"""Number of seconds between automatic refresh, or `None` for no automatic refresh."""
return self._auto_refresh
@auto_refresh.setter
def auto_refresh(self, interval: float | None) -> None:
if self._auto_refresh_timer is not None:
self._auto_refresh_timer.stop()
self._auto_refresh_timer = None
if interval is not None:
self._auto_refresh_timer = self.set_interval(
interval, self.automatic_refresh, name=f"auto refresh {self!r}"
)
self._auto_refresh = interval
@property
def workers(self) -> WorkerManager:
"""The app's worker manager. Shortcut for `self.app.workers`."""
return self.app.workers
def trap_focus(self, trap_focus: bool = True) -> None:
"""Trap the focus.
When applied to a container, this will limit tab-to-focus to the children of that
container (once focus is within that container).
This can be useful for widgets that act like modal dialogs, where you want to restrict
the user to the controls within the dialog.
Args:
trap_focus: `True` to trap focus. `False` to restore default behavior.
"""
self._trap_focus = trap_focus
def run_worker(
self,
work: WorkType[ResultType],
name: str | None = "",
group: str = "default",
description: str = "",
exit_on_error: bool = True,
start: bool = True,
exclusive: bool = False,
thread: bool = False,
) -> Worker[ResultType]:
"""Run work in a worker.
A worker runs a function, coroutine, or awaitable, in the *background* as an async task or as a thread.
Args:
work: A function, async function, or an awaitable object to run in a worker.
name: A short string to identify the worker (in logs and debugging).
group: A short string to identify a group of workers.
description: A longer string to store longer information on the worker.
exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions.
start: Start the worker immediately.
exclusive: Cancel all workers in the same group.
thread: Mark the worker as a thread worker.
Returns:
New Worker instance.
"""
# If we're running a worker from inside a secondary thread,
# do so in a thread-safe way.
if self.app._thread_id != threading.get_ident():
creator = partial(self.app.call_from_thread, self.workers._new_worker)
else:
creator = self.workers._new_worker
worker: Worker[ResultType] = creator(
work,
self,
name=name,
group=group,
description=description,
exit_on_error=exit_on_error,
start=start,
exclusive=exclusive,
thread=thread,
)
return worker
@property
def is_modal(self) -> bool:
"""Is the node a modal?"""
return False
@property
def is_on_screen(self) -> bool:
"""Check if the node was displayed in the last screen update."""
return False
def automatic_refresh(self) -> None:
"""Perform an automatic refresh.
This method is called when you set the `auto_refresh` attribute.
You could implement this method if you want to perform additional work
during an automatic refresh.
"""
if self.is_on_screen:
self.refresh()
def __init_subclass__(
cls,
inherit_css: bool = True,
inherit_bindings: bool = True,
inherit_component_classes: bool = True,
) -> None:
super().__init_subclass__()
reactives = cls._reactives = {}
for base in reversed(cls.__mro__):
reactives.update(
{
name: reactive
for name, reactive in base.__dict__.items()
if isinstance(reactive, Reactive)
}
)
cls._inherit_css = inherit_css
cls._inherit_bindings = inherit_bindings
cls._inherit_component_classes = inherit_component_classes
css_type_names: set[str] = set()
bases = cls._css_bases(cls)
cls._css_type_name = bases[0].__name__
for base in bases:
css_type_names.add(base.__name__)
cls._merged_bindings = cls._merge_bindings()
cls._css_type_names = frozenset(css_type_names)
cls._computes = frozenset(
[
name.lstrip("_")[8:]
for name in dir(cls)
if name.startswith(("_compute_", "compute_"))
]
)
def get_component_styles(self, *names: str) -> RenderStyles:
"""Get a "component" styles object (must be defined in COMPONENT_CLASSES classvar).
Args:
names: Names of the components.
Raises:
KeyError: If the component class doesn't exist.
Returns:
A Styles object.
"""
styles = RenderStyles(self, Styles(), Styles())
for name in names:
if name not in self._component_styles:
raise KeyError(f"No {name!r} key in COMPONENT_CLASSES")
component_styles = self._component_styles[name]
styles.node = component_styles.node
styles.base.merge(component_styles.base)
styles.inline.merge(component_styles.inline)
styles._updates += 1
return styles
def _post_mount(self):
"""Called after the object has been mounted."""
_rich_traceback_omit = True
Reactive._initialize_object(self)
def notify_style_update(self) -> None:
"""Called after styles are updated.
Implement this in a subclass if you want to clear any cached data when the CSS is reloaded.
"""
@property
def _node_bases(self) -> Sequence[Type[DOMNode]]:
"""The DOMNode bases classes (including self.__class__)"""
# Node bases are in reversed order so that the base class is lower priority
return self._css_bases(self.__class__)
@classmethod
@lru_cache(maxsize=None)
def _css_bases(cls, base: Type[DOMNode]) -> Sequence[Type[DOMNode]]:
"""Get the DOMNode base classes, which inherit CSS.
Args:
base: A DOMNode class
Returns:
An iterable of DOMNode classes.
"""
classes: list[type[DOMNode]] = []
_class = base
while True:
classes.append(_class)
if not _class._inherit_css:
break
for _base in _class.__bases__:
if issubclass(_base, DOMNode):
_class = _base
break
else:
break
return classes
@classmethod
def _merge_bindings(cls) -> BindingsMap:
"""Merge bindings from base classes.
Returns:
Merged bindings.
"""
bindings: list[BindingsMap] = []
for base in reversed(cls.__mro__):
if issubclass(base, DOMNode):
if not base._inherit_bindings:
bindings.clear()
bindings.append(
BindingsMap(
base.__dict__.get("BINDINGS", []),
)
)
keys: dict[str, list[Binding]] = {}
for bindings_ in bindings:
for key, key_bindings in bindings_.key_to_bindings.items():
keys[key] = key_bindings
new_bindings = BindingsMap.from_keys(keys)
return new_bindings
def _post_register(self, app: App) -> None:
"""Called when the widget is registered
Args:
app: Parent application.
"""
def __rich_repr__(self) -> rich.repr.Result:
# Being a bit defensive here to guard against errors when calling repr before initialization
if hasattr(self, "_name"):
yield "name", self._name, None
if hasattr(self, "_id"):
yield "id", self._id, None
if hasattr(self, "_classes") and self._classes:
yield "classes", " ".join(self._classes)
def _get_default_css(self) -> list[tuple[CSSLocation, str, int, str]]:
"""Gets the CSS for this class and inherited from bases.
Default CSS is inherited from base classes, unless `inherit_css` is set to
`False` when subclassing.
Returns:
A list of tuples containing (LOCATION, SOURCE, SPECIFICITY, SCOPE) for this
class and inherited from base classes.
"""
css_stack: list[tuple[CSSLocation, str, int, str]] = []
def get_location(base: Type[DOMNode]) -> CSSLocation:
"""Get the original location of this DEFAULT_CSS.
Args:
base: The class from which the default css was extracted.
Returns:
The filename where the class was defined (if possible) and the class
variable the CSS was extracted from.
"""
try:
return (getfile(base), f"{base.__name__}.DEFAULT_CSS")
except (TypeError, OSError):
return ("", f"{base.__name__}.DEFAULT_CSS")
for tie_breaker, base in enumerate(self._node_bases):
css: str = base.__dict__.get("DEFAULT_CSS", "")
if css:
scoped: bool = base.__dict__.get("SCOPED_CSS", True)
css_stack.append(
(
get_location(base),
css,
-tie_breaker,
base._css_type_name if scoped else "",
)
)
return css_stack
@classmethod
@lru_cache(maxsize=None)
def _get_component_classes(cls) -> frozenset[str]:
"""Gets the component classes for this class and inherited from bases.
Component classes are inherited from base classes, unless
`inherit_component_classes` is set to `False` when subclassing.
Returns:
A set with all the component classes available.
"""
component_classes: set[str] = set()
for base in cls._css_bases(cls):
component_classes.update(base.__dict__.get("COMPONENT_CLASSES", set()))
if not base.__dict__.get("_inherit_component_classes", True):
break
return frozenset(component_classes)
@property
def parent(self) -> DOMNode | None:
"""The parent node.
All nodes have parent once added to the DOM, with the exception of the App which is the *root* node.
"""
return cast("DOMNode | None", self._parent)
@property
def screen(self) -> "Screen[object]":
"""The screen containing this node.
Returns:
A screen object.
Raises:
NoScreen: If this node isn't mounted (and has no screen).
"""
# Get the node by looking up a chain of parents
# Note that self.screen may not be the same as self.app.screen
from textual.screen import Screen
node: MessagePump | None = self
try:
while node is not None and not isinstance(node, Screen):
node = node._parent
except AttributeError:
raise RuntimeError(
"Widget is missing attributes; have you called the constructor in your widget class?"
) from None
if not isinstance(node, Screen):
raise NoScreen("node has no screen")
return node
@property
def id(self) -> str | None:
"""The ID of this node, or None if the node has no ID."""
return self._id
@id.setter
def id(self, new_id: str) -> str:
"""Sets the ID (may only be done once).
Args:
new_id: ID for this node.
Raises:
ValueError: If the ID has already been set.
"""
check_identifiers("id", new_id)
self._nodes.updated()
if self._id is not None:
raise ValueError(
f"Node 'id' attribute may not be changed once set (current id={self._id!r})"
)
self._id = new_id
return new_id
@property
def name(self) -> str | None:
"""The name of the node."""
return self._name
@property
def css_identifier(self) -> str:
"""A CSS selector that identifies this DOM node."""
tokens = [self.__class__.__name__]
if self.id is not None:
tokens.append(f"#{self.id}")
return "".join(tokens)
@property
def css_identifier_styled(self) -> Text:
"""A syntax highlighted CSS identifier.
Returns:
A Rich Text object.
"""
tokens = Text.styled(self.__class__.__name__)
if self.id is not None:
tokens.append(f"#{self.id}", style="bold")
if self.classes:
tokens.append(".")
tokens.append(".".join(class_name for class_name in self.classes), "italic")
if self.name:
tokens.append(f"[name={self.name}]", style="underline")
return tokens
classes = _ClassesDescriptor()
"""CSS class names for this node."""
@property
def pseudo_classes(self) -> frozenset[str]:
"""A (frozen) set of all pseudo classes."""
return frozenset(self.get_pseudo_classes())
@property
def css_path_nodes(self) -> list[DOMNode]:
"""A list of nodes from the App to this node, forming a "path".
Returns:
A list of nodes, where the first item is the App, and the last is this node.
"""
result: list[DOMNode] = [self]
append = result.append
node: DOMNode = self
while isinstance((node := node._parent), DOMNode):
append(node)
return result[::-1]
@property
def _selector_names(self) -> set[str]:
"""Get a set of selectors applicable to this widget.
Returns:
Set of selector names.
"""
selectors: set[str] = {
"*",
*(f".{class_name}" for class_name in self._classes),
*self._css_types,
}
if self._id is not None:
selectors.add(f"#{self._id}")
return selectors
@property
def display(self) -> bool:
"""Should the DOM node be displayed?
May be set to a boolean to show or hide the node, or to any valid value for the `display` rule.
Example:
```python
my_widget.display = False # Hide my_widget
```
"""
return self.styles.display != "none" and not (
self._closing or self._closed or self._pruning
)
@display.setter
def display(self, new_val: bool | str) -> None:
"""
Args:
new_val: Shortcut to set the ``display`` CSS property.
``False`` will set ``display: none``. ``True`` will set ``display: block``.
A ``False`` value will prevent the DOMNode from consuming space in the layout.
"""
# TODO: This will forget what the original "display" value was, so if a user
# toggles to False then True, we'll reset to the default "block", rather than
# what the user initially specified.
if isinstance(new_val, bool):
self.styles.display = "block" if new_val else "none"
elif new_val in VALID_DISPLAY:
self.styles.display = new_val
else:
raise StyleValueError(
f"invalid value for display (received {new_val!r}, "
f"expected {friendly_list(VALID_DISPLAY)})",
)
@property
def visible(self) -> bool:
"""Is this widget visible in the DOM?
If a widget hasn't had its visibility set explicitly, then it inherits it from its
DOM ancestors.
This may be set explicitly to override inherited values.
The valid values include the valid values for the `visibility` rule and the booleans
`True` or `False`, to set the widget to be visible or invisible, respectively.
When a node is invisible, Textual will reserve space for it, but won't display anything.
"""
own_value = self.styles.get_rule("visibility")
if own_value is not None:
return own_value != "hidden"
return self.parent.visible if self.parent else True
@visible.setter
def visible(self, new_value: bool | str) -> None:
if isinstance(new_value, bool):
self.styles.visibility = "visible" if new_value else "hidden"
elif new_value in VALID_VISIBILITY:
self.styles.visibility = new_value
else:
raise StyleValueError(
f"invalid value for visibility (received {new_value!r}, "
f"expected {friendly_list(VALID_VISIBILITY)})"
)
@property
def tree(self) -> Tree:
"""A Rich tree to display the DOM.
Log this to visualize your app in the textual console.
Example:
```python
self.log(self.tree)
```
Returns:
A Tree renderable.
"""
from rich.pretty import Pretty
def render_info(node: DOMNode) -> Pretty:
"""Render a node for the tree."""
return Pretty(node)
tree = Tree(render_info(self))
def add_children(tree, node):
for child in node.children:
info = render_info(child)
branch = tree.add(info)
if tree.children:
add_children(branch, child)
add_children(tree, self)
return tree
@property
def css_tree(self) -> Tree:
"""A Rich tree to display the DOM, annotated with the node's CSS.
Log this to visualize your app in the textual console.
Example:
```python
self.log(self.css_tree)
```
Returns:
A Tree renderable.
"""
from rich.columns import Columns
from rich.console import Group
from rich.panel import Panel
from rich.pretty import Pretty
from textual.widget import Widget
def render_info(node: DOMNode) -> Columns:
"""Render a node for the tree."""
if isinstance(node, Widget):
info = Columns(
[
Pretty(node),
highlighter(f"region={node.region!r}"),
highlighter(
f"virtual_size={node.virtual_size!r}",
),
]
)
else:
info = Columns([Pretty(node)])
return info
highlighter = ReprHighlighter()
tree = Tree(render_info(self))
def add_children(tree: Tree, node: DOMNode) -> None:
"""Add children to the tree."""
for child in node.children:
info: RenderableType = render_info(child)
css = child.styles.css
if css:
info = Group(
info,
Panel.fit(
Text(child.styles.css),
border_style="dim",
title="css",
title_align="left",
),
)
branch = tree.add(info)
if tree.children:
add_children(branch, child)
add_children(tree, self)
return tree
@property
def text_style(self) -> Style:
"""Get the text style object.
A widget's style is influenced by its parent. for instance if a parent is bold, then
the child will also be bold.
Returns:
A Rich Style.
"""
return Style.combine(
node.styles.text_style for node in reversed(self.ancestors_with_self)
)
@property
def selection_style(self) -> Style:
"""The style of selected text."""
style = self.screen.get_component_rich_style("screen--selection")
return style
@property
def rich_style(self) -> Style:
"""Get a Rich Style object for this DOMNode.
Returns:
A Rich style.
"""
background = Color(0, 0, 0, 0)
color = Color(255, 255, 255, 0)
style = Style()
opacity = 1.0
for node in reversed(self.ancestors_with_self):
styles = node.styles
has_rule = styles.has_rule
opacity *= styles.opacity
if has_rule("background"):
text_background = background + styles.background.tint(
styles.background_tint
)
background += (
styles.background.tint(styles.background_tint)
).multiply_alpha(opacity)
else:
text_background = background
if has_rule("color"):
color = styles.color
style += styles.text_style
if has_rule("auto_color") and styles.auto_color:
color = text_background.get_contrast_text(color.a)
style += Style.from_color(
(background + color).rich_color if (background.a or color.a) else None,
background.rich_color if background.a else None,
)
return style
def check_consume_key(self, key: str, character: str | None) -> bool:
"""Check if the widget may consume the given key.
This should be implemented in widgets that handle [`Key`][textual.events.Key] events and
stop propagation (such as Input and TextArea).
Implementing this method will hide key bindings from the footer and key panel that would
be *consumed* by the focused widget.
Args:
key: A key identifier.
character: A character associated with the key, or `None` if there isn't one.
Returns:
`True` if the widget may capture the key in its `Key` event handler, or `False` if it won't.
"""
return False
def _get_title_style_information(
self, background: Color
) -> tuple[Color, Color, VisualStyle]:
"""Get a Visual Style object for titles.
Args:
background: The background color.
Returns:
A Rich style.
"""
styles = self.styles
if styles.auto_border_title_color:
color = background.get_contrast_text(styles.border_title_color.a)
else:
color = styles.border_title_color
return (
color,
styles.border_title_background,
VisualStyle.from_rich_style(styles.border_title_style),
)
def _get_subtitle_style_information(
self, background: Color
) -> tuple[Color, Color, VisualStyle]:
"""Get a Rich Style object for subtitles.
Args:
background: The background color.
Returns:
A Rich style.
"""
styles = self.styles
if styles.auto_border_subtitle_color:
color = background.get_contrast_text(styles.border_subtitle_color.a)
else:
color = styles.border_subtitle_color
return (
color,
styles.border_subtitle_background,
VisualStyle.from_rich_style(styles.border_subtitle_style),
)
@property
def background_colors(self) -> tuple[Color, Color]:
"""Background colors adjusted for opacity.
Returns:
`(<background color>, <color>)`
"""
base_background = background = Color(0, 0, 0, 0)
opacity = 1.0
for node in reversed(self.ancestors_with_self):
styles = node.styles
base_background = background
opacity *= styles.opacity
background += styles.background.tint(styles.background_tint).multiply_alpha(
opacity
)
return (base_background, background)
@property
def colors(self) -> tuple[Color, Color, Color, Color]:
"""The widget's background and foreground colors, and the parent's background and foreground colors.
Returns:
`(<parent background>, <parent color>, <background>, <color>)`
"""
base_background = background = WHITE
base_color = color = BLACK
for node in reversed(self.ancestors_with_self):
styles = node.styles
base_background = background
background += styles.background.tint(styles.background_tint)
if styles.has_rule("color"):
base_color = color
if styles.auto_color:
color = background.get_contrast_text(color.a)
else:
color = styles.color
return (base_background, base_color, background, color)
@property
def ancestors_with_self(self) -> list[DOMNode]:
"""A list of ancestor nodes found by tracing a path all the way back to App.
Note:
This is inclusive of ``self``.
Returns:
A list of nodes.
"""
nodes: list[MessagePump | None] = [self]
add_node = nodes.append
node: MessagePump | None = self
while (node := node._parent) is not None:
add_node(node)
return cast("list[DOMNode]", nodes)
@property
def ancestors(self) -> list[DOMNode]:
"""A list of ancestor nodes found by tracing a path all the way back to App.
Returns:
A list of nodes.
"""
nodes: list[MessagePump | None] = []
add_node = nodes.append
node: MessagePump | None = self
while (node := node._parent) is not None:
add_node(node)
return cast("list[DOMNode]", nodes)
def watch(
self,
obj: DOMNode,
attribute_name: str,
callback: WatchCallbackType,
init: bool = True,
) -> None:
"""Watches for modifications to reactive attributes on another object.
Example:
```python
def on_theme_change(old_value:str, new_value:str) -> None:
# Called when app.theme changes.
print(f"App.theme went from {old_value} to {new_value}")
self.watch(self.app, "theme", self.on_theme_change, init=False)
```
Args:
obj: Object containing attribute to watch.
attribute_name: Attribute to watch.
callback: A callback to run when attribute changes.
init: Check watchers on first call.
"""
_watch(self, obj, attribute_name, callback, init=init)
def get_pseudo_classes(self) -> set[str]:
"""Pseudo classes for a widget.
Returns:
Names of the pseudo classes.
"""
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."""
from textual.widget import Widget
for node in self.walk_children(with_self=True):
node._css_styles.reset()
if isinstance(node, Widget):
node._set_dirty()
node._layout_required = True
def _add_child(self, node: Widget) -> None:
"""Add a new child node.
!!! note
For tests only.
Args:
node: A DOM node.
"""
self._nodes._append(node)
node._attach(self)
def _add_children(self, *nodes: Widget) -> None:
"""Add multiple children to this node.
!!! note
For tests only.
Args:
*nodes: Positional args should be new DOM nodes.
"""
_append = self._nodes._append
for node in nodes:
node._attach(self)
_append(node)
node._add_children(*node._pending_children)
WalkType = TypeVar("WalkType", bound="DOMNode")
if TYPE_CHECKING:
@overload
def walk_children(
self,
filter_type: type[WalkType],
*,
with_self: bool = False,
method: WalkMethod = "depth",
reverse: bool = False,
) -> list[WalkType]: ...
@overload
def walk_children(
self,
*,
with_self: bool = False,
method: WalkMethod = "depth",
reverse: bool = False,
) -> list[DOMNode]: ...
def walk_children(
self,
filter_type: type[WalkType] | None = None,
*,
with_self: bool = False,
method: WalkMethod = "depth",
reverse: bool = False,
) -> list[DOMNode] | list[WalkType]:
"""Walk the subtree rooted at this node, and return every descendant encountered in a list.
Args:
filter_type: Filter only this type, or None for no filter.
with_self: Also yield self in addition to descendants.
method: One of "depth" or "breadth".
reverse: Reverse the order (bottom up).
Returns:
A list of nodes.
"""
check_type = filter_type or DOMNode
node_generator = (
walk_depth_first(self, check_type, with_root=with_self)
if method == "depth"
else walk_breadth_first(self, check_type, with_root=with_self)
)
# We want a snapshot of the DOM at this point So that it doesn't
# change mid-walk
nodes = list(node_generator)
if reverse:
nodes.reverse()
return cast("list[DOMNode]", nodes)
if TYPE_CHECKING:
@overload
def query(self, selector: str | None = None) -> DOMQuery[Widget]: ...
@overload
def query(self, selector: type[QueryType]) -> DOMQuery[QueryType]: ...
def query(
self, selector: str | type[QueryType] | None = None
) -> DOMQuery[Widget] | DOMQuery[QueryType]:
"""Query the DOM for children that match a selector or widget type.
Args:
selector: A CSS selector, widget type, or `None` for all nodes.
Returns:
A query object.
"""
from textual.css.query import DOMQuery, QueryType
from textual.widget import Widget
node = self._get_dom_base()
if isinstance(selector, str) or selector is None:
return DOMQuery[Widget](node, filter=selector)
else:
return DOMQuery[QueryType](node, filter=selector.__name__)
if TYPE_CHECKING:
@overload
def query_children(self, selector: str | None = None) -> DOMQuery[Widget]: ...
@overload
def query_children(self, selector: type[QueryType]) -> DOMQuery[QueryType]: ...
def query_children(
self, selector: str | type[QueryType] | None = None
) -> DOMQuery[Widget] | DOMQuery[QueryType]:
"""Query the DOM for the immediate children that match a selector or widget type.
Note that this will not return child widgets more than a single level deep.
If you want to a query to potentially match all children in the widget tree,
see [query][textual.dom.DOMNode.query].
Args:
selector: A CSS selector, widget type, or `None` for all nodes.
Returns:
A query object.
"""
from textual.css.query import DOMQuery, QueryType
from textual.widget import Widget
node = self._get_dom_base()
if isinstance(selector, str) or selector is None:
return DOMQuery[Widget](node, deep=False, filter=selector)
else:
return DOMQuery[QueryType](node, deep=False, filter=selector.__name__)
if TYPE_CHECKING:
@overload
def query_one(self, selector: str) -> Widget: ...
@overload
def query_one(self, selector: type[QueryType]) -> QueryType: ...
@overload
def query_one(
self, selector: str, expect_type: type[QueryType]
) -> QueryType: ...
def query_one(
self,
selector: str | type[QueryType],
expect_type: type[QueryType] | None = None,
) -> QueryType | Widget:
"""Get a widget from this widget's children that matches a selector or widget type.
Args:
selector: A selector or widget type.
expect_type: Require the object be of the supplied type, or None for any type.
Raises:
WrongType: If the wrong type was found.
NoMatches: If no node matches the query.
Returns:
A widget matching the selector.
"""
_rich_traceback_omit = True
base_node = self._get_dom_base()
if isinstance(selector, str):
query_selector = selector
else:
query_selector = selector.__name__
if is_id_selector(query_selector):
cache_key = (base_node._nodes._updates, query_selector, expect_type)
cached_result = base_node._query_one_cache.get(cache_key)
if cached_result is not None:
return cached_result
if (
node := walk_breadth_search_id(
base_node, query_selector[1:], with_root=False
)
) is not None:
if expect_type is not None and not isinstance(node, expect_type):
raise WrongType(
f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
)
base_node._query_one_cache[cache_key] = node
return node
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
try:
selector_set = parse_selectors(query_selector)
except TokenError:
raise InvalidQueryFormat(
f"Unable to parse {query_selector!r} as a query; check for syntax errors"
) from None
if all(selectors.is_simple for selectors in selector_set):
cache_key = (base_node._nodes._updates, query_selector, expect_type)
cached_result = base_node._query_one_cache.get(cache_key)
if cached_result is not None:
return cached_result
else:
cache_key = None
for node in walk_breadth_first(base_node, with_root=False):
if not match(selector_set, node):
continue
if expect_type is not None and not isinstance(node, expect_type):
raise WrongType(
f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
)
if cache_key is not None:
base_node._query_one_cache[cache_key] = node
return node
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
if TYPE_CHECKING:
@overload
def query_exactly_one(self, selector: str) -> Widget: ...
@overload
def query_exactly_one(self, selector: type[QueryType]) -> QueryType: ...
@overload
def query_exactly_one(
self, selector: str, expect_type: type[QueryType]
) -> QueryType: ...
def query_exactly_one(
self,
selector: str | type[QueryType],
expect_type: type[QueryType] | None = None,
) -> QueryType | Widget:
"""Get a widget from this widget's children that matches a selector or widget type.
!!! Note
This method is similar to [query_one][textual.dom.DOMNode.query_one].
The only difference is that it will raise `TooManyMatches` if there is more than a single match.
Args:
selector: A selector or widget type.
expect_type: Require the object be of the supplied type, or None for any type.
Raises:
WrongType: If the wrong type was found.
NoMatches: If no node matches the query.
TooManyMatches: If there is more than one matching node in the query (and `exactly_one==True`).
Returns:
A widget matching the selector.
"""
_rich_traceback_omit = True
base_node = self._get_dom_base()
if isinstance(selector, str):
query_selector = selector
else:
query_selector = selector.__name__
try:
selector_set = parse_selectors(query_selector)
except TokenError:
raise InvalidQueryFormat(
f"Unable to parse {query_selector!r} as a query; check for syntax errors"
) from None
if all(selectors.is_simple for selectors in selector_set):
cache_key = (base_node._nodes._updates, query_selector, expect_type)
cached_result = base_node._query_one_cache.get(cache_key)
if cached_result is not None:
return cached_result
else:
cache_key = None
children = walk_breadth_first(base_node, with_root=False)
iter_children = iter(children)
for node in iter_children:
if not match(selector_set, node):
continue
if expect_type is not None and not isinstance(node, expect_type):
raise WrongType(
f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
)
for later_node in iter_children:
if match(selector_set, later_node):
raise TooManyMatches(
"Call to query_one resulted in more than one matched node"
)
if cache_key is not None:
base_node._query_one_cache[cache_key] = node
return node
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
if TYPE_CHECKING:
@overload
def query_ancestor(self, selector: str) -> DOMNode: ...
@overload
def query_ancestor(self, selector: type[QueryType]) -> QueryType: ...
@overload
def query_ancestor(
self, selector: str, expect_type: type[QueryType]
) -> QueryType: ...
def query_ancestor(
self,
selector: str | type[QueryType],
expect_type: type[QueryType] | None = None,
) -> DOMNode:
"""Get an ancestor which matches a query.
Args:
selector: A TCSS selector.
expect_type: Expected type, or `None` for any DOMNode.
Raises:
InvalidQueryFormat: If the selector is invalid.
NoMatches: If there are no matching ancestors.
Returns:
A DOMNode or subclass if `expect_type` is provided.
"""
base_node = self._get_dom_base()
if isinstance(selector, str):
query_selector = selector
else:
query_selector = selector.__name__
try:
selector_set = parse_selectors(query_selector)
except TokenError:
raise InvalidQueryFormat(
f"Unable to parse {query_selector!r} as a query; check for syntax errors"
) from None
if base_node.parent is not None:
for node in base_node.parent.ancestors_with_self:
if not match(selector_set, node):
continue
if expect_type is not None and not isinstance(node, expect_type):
continue
return node
raise NoMatches(f"No ancestor matches {selector!r} on {self!r}")
def set_styles(self, css: str | None = None, **update_styles: Any) -> Self:
"""Set custom styles on this object.
Args:
css: Styles in CSS format.
update_styles: Keyword arguments map style names onto style values.
Returns:
Self.
"""
if css is not None:
try:
new_styles = parse_declarations(css, read_from=("set_styles", ""))
except DeclarationError as error:
raise DeclarationError(error.name, error.token, error.message) from None
self._inline_styles.merge(new_styles)
self.refresh(layout=True)
styles = self.styles
for key, value in update_styles.items():
setattr(styles, key, value)
return self
def has_class(self, *class_names: str) -> bool:
"""Check if the Node has all the given class names.
Args:
*class_names: CSS class names to check.
Returns:
``True`` if the node has all the given class names, otherwise ``False``.
"""
return self._classes.issuperset(class_names)
def set_class(self, add: bool, *class_names: str, update: bool = True) -> Self:
"""Add or remove class(es) based on a condition.
This can condense the four lines required to implement the equivalent branch into a single line.
Example:
```python
#if foo:
# self.add_class("-foo")
#else:
# self.remove_class("-foo")
self.set_class(foo, "-foo")
```
Args:
add: Add the classes if True, otherwise remove them.
update: Also update styles.
Returns:
Self.
"""
if add:
self.add_class(*class_names, update=update)
else:
self.remove_class(*class_names, update=update)
return self
def set_classes(self, classes: str | Iterable[str]) -> Self:
"""Replace all classes.
Args:
classes: A string containing space separated classes, or an
iterable of class names.
Returns:
Self.
"""
self.classes = classes
return self
def _update_styles(self) -> None:
"""Request an update of this node's styles.
Should be called whenever CSS classes / pseudo classes change.
"""
try:
self.app.update_styles(self)
except NoActiveAppError:
pass
def add_class(self, *class_names: str, update: bool = True) -> Self:
"""Add class names to this Node.
Args:
*class_names: CSS class names to add.
update: Also update styles.
Returns:
Self.
"""
check_identifiers("class name", *class_names)
old_classes = self._classes.copy()
self._classes.update(class_names)
if old_classes == self._classes:
return self
if update:
self._update_styles()
return self
def remove_class(self, *class_names: str, update: bool = True) -> Self:
"""Remove class names from this Node.
Args:
*class_names: CSS class names to remove.
update: Also update styles.
Returns:
Self.
"""
check_identifiers("class name", *class_names)
old_classes = self._classes.copy()
self._classes.difference_update(class_names)
if old_classes == self._classes:
return self
if update:
self._update_styles()
return self
def toggle_class(self, *class_names: str) -> Self:
"""Toggle class names on this Node.
Args:
*class_names: CSS class names to toggle.
Returns:
Self.
"""
check_identifiers("class name", *class_names)
old_classes = self._classes.copy()
self._classes.symmetric_difference_update(class_names)
if old_classes == self._classes:
return self
self._update_styles()
return self
def has_pseudo_class(self, class_name: str) -> bool:
"""Check the node has the given pseudo class.
Args:
class_name: The pseudo class to check for.
Returns:
`True` if the DOM node has the pseudo class, `False` if not.
"""
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.
Args:
class_names: Set of class names to check for.
Returns:
`True` if all pseudo class names are present.
"""
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
) -> Self:
return self
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
"""Check whether an action is enabled.
Implement this method to add logic for [dynamic actions](/guide/actions#dynamic-actions) / bindings.
Args:
action: The name of an action.
parameters: A tuple of any action parameters.
Returns:
`True` if the action is enabled+visible,
`False` if the action is disabled+hidden,
`None` if the action is disabled+visible (grayed out in footer)
"""
return True
def refresh_bindings(self) -> None:
"""Call to prompt widgets such as the [Footer][textual.widgets.Footer] to update
the display of key bindings.
See [actions](/guide/actions#dynamic-actions) for how to use this method.
"""
if self._is_mounted:
self.screen.refresh_bindings()
async def action_toggle(self, attribute_name: str) -> None:
"""Toggle an attribute on the node.
Assumes the attribute is a bool.
Args:
attribute_name: Name of the attribute.
"""
value = getattr(self, attribute_name)
setattr(self, attribute_name, not value)