"""Provides a tree widget.""" from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, Generic, Iterable, NewType, TypeVar, cast import rich.repr from rich.style import NULL_STYLE, Style from rich.text import Text, TextType from .. import events from .._cache import LRUCache from .._immutable_sequence_view import ImmutableSequenceView from .._loop import loop_last from .._segment_tools import line_pad from ..binding import Binding, BindingType from ..geometry import Region, Size, clamp from ..message import Message from ..reactive import reactive, var from ..scroll_view import ScrollView from ..strip import Strip if TYPE_CHECKING: from typing_extensions import Self, TypeAlias NodeID = NewType("NodeID", int) """The type of an ID applied to a [TreeNode][textual.widgets._tree.TreeNode].""" TreeDataType = TypeVar("TreeDataType") """The type of the data for a given instance of a [Tree][textual.widgets.Tree].""" EventTreeDataType = TypeVar("EventTreeDataType") """The type of the data for a given instance of a [Tree][textual.widgets.Tree]. Similar to [TreeDataType][textual.widgets._tree.TreeDataType] but used for ``Tree`` messages. """ LineCacheKey: TypeAlias = "tuple[int | tuple, ...]" TOGGLE_STYLE = Style.from_meta({"toggle": True}) @dataclass class _TreeLine(Generic[TreeDataType]): path: list[TreeNode[TreeDataType]] last: bool @property def node(self) -> TreeNode[TreeDataType]: """The node associated with this line.""" return self.path[-1] def _get_guide_width(self, guide_depth: int, show_root: bool) -> int: """Get the cell width of the line as rendered. Args: guide_depth: The guide depth (cells in the indentation). Returns: Width in cells. """ if show_root: return 2 + (max(0, len(self.path) - 1)) * guide_depth else: guides = 2 if len(self.path) > 1: guides += (len(self.path) - 1) * guide_depth return guides class TreeNodes(ImmutableSequenceView["TreeNode[TreeDataType]"]): """An immutable collection of `TreeNode`.""" @rich.repr.auto class TreeNode(Generic[TreeDataType]): """An object that represents a "node" in a tree control.""" def __init__( self, tree: Tree[TreeDataType], parent: TreeNode[TreeDataType] | None, id: NodeID, label: Text, data: TreeDataType | None = None, *, expanded: bool = True, allow_expand: bool = True, ) -> None: """Initialise the node. Args: tree: The tree that the node is being attached to. parent: The parent node that this node is being attached to. id: The ID of the node. label: The label for the node. data: Optional data to associate with the node. expand: Should the node be attached in an expanded state? allow_expand: Should the node allow being expanded by the user? """ self._tree = tree self._parent = parent self._id = id self._label = tree.process_label(label) self.data = data """Optional data associated with the tree node.""" self._expanded = expanded self._children: list[TreeNode[TreeDataType]] = [] self._hover_ = False self._selected_ = False self._allow_expand = allow_expand self._updates: int = 0 self._line: int = -1 def __rich_repr__(self) -> rich.repr.Result: yield self._label.plain yield self.data def _reset(self) -> None: self._hover_ = False self._selected_ = False self._updates += 1 @property def tree(self) -> Tree[TreeDataType]: """The tree that this node is attached to.""" return self._tree @property def children(self) -> TreeNodes[TreeDataType]: """The child nodes of a TreeNode.""" return TreeNodes(self._children) @property def line(self) -> int: """The line number for this node, or -1 if it is not displayed.""" return self._line @property def _hover(self) -> bool: """Check if the mouse is over the node.""" return self._hover_ @_hover.setter def _hover(self, hover: bool) -> None: self._updates += 1 self._hover_ = hover @property def _selected(self) -> bool: """Check if the node is selected.""" return self._selected_ @_selected.setter def _selected(self, selected: bool) -> None: self._updates += 1 self._selected_ = selected @property def id(self) -> NodeID: """The ID of the node.""" return self._id @property def parent(self) -> TreeNode[TreeDataType] | None: """The parent of the node.""" return self._parent @property def is_expanded(self) -> bool: """Is the node expanded?""" return self._expanded @property def is_last(self) -> bool: """Is this the last child node of its parent?""" if self._parent is None: return True return bool( self._parent._children and self._parent._children[-1] == self, ) @property def is_root(self) -> bool: """Is this node the root of the tree?""" return self == self._tree.root @property def allow_expand(self) -> bool: """Is this node allowed to expand?""" return self._allow_expand @allow_expand.setter def allow_expand(self, allow_expand: bool) -> None: self._allow_expand = allow_expand self._updates += 1 def _expand(self, expand_all: bool) -> None: """Mark the node as expanded (its children are shown). Args: expand_all: If `True` expand all offspring at all depths. """ self._expanded = True self._updates += 1 if expand_all: for child in self.children: child._expand(expand_all) def expand(self) -> Self: """Expand the node (show its children). Returns: The `TreeNode` instance. """ self._expand(False) self._tree._invalidate() return self def expand_all(self) -> Self: """Expand the node (show its children) and all those below it. Returns: The `TreeNode` instance. """ self._expand(True) self._tree._invalidate() return self def _collapse(self, collapse_all: bool) -> None: """Mark the node as collapsed (its children are hidden). Args: collapse_all: If `True` collapse all offspring at all depths. """ self._expanded = False self._updates += 1 if collapse_all: for child in self.children: child._collapse(collapse_all) def collapse(self) -> Self: """Collapse the node (hide its children). Returns: The `TreeNode` instance. """ self._collapse(False) self._tree._invalidate() return self def collapse_all(self) -> Self: """Collapse the node (hide its children) and all those below it. Returns: The `TreeNode` instance. """ self._collapse(True) self._tree._invalidate() return self def toggle(self) -> Self: """Toggle the node's expanded state. Returns: The `TreeNode` instance. """ if self._expanded: self.collapse() else: self.expand() return self def toggle_all(self) -> Self: """Toggle the node's expanded state and make all those below it match. Returns: The `TreeNode` instance. """ if self._expanded: self.collapse_all() else: self.expand_all() return self @property def label(self) -> TextType: """The label for the node.""" return self._label @label.setter def label(self, new_label: TextType) -> None: self.set_label(new_label) def set_label(self, label: TextType) -> None: """Set a new label for the node. Args: label: A ``str`` or ``Text`` object with the new label. """ self._updates += 1 text_label = self._tree.process_label(label) self._label = text_label def add( self, label: TextType, data: TreeDataType | None = None, *, expand: bool = False, allow_expand: bool = True, ) -> TreeNode[TreeDataType]: """Add a node to the sub-tree. Args: label: The new node's label. data: Data associated with the new node. expand: Node should be expanded. allow_expand: Allow use to expand the node via keyboard or mouse. Returns: A new Tree node """ text_label = self._tree.process_label(label) node = self._tree._add_node(self, text_label, data) node._expanded = expand node._allow_expand = allow_expand self._updates += 1 self._children.append(node) self._tree._invalidate() return node def add_leaf( self, label: TextType, data: TreeDataType | None = None ) -> TreeNode[TreeDataType]: """Add a 'leaf' node (a node that can not expand). Args: label: Label for the node. data: Optional data. Returns: New node. """ node = self.add(label, data, expand=False, allow_expand=False) return node class RemoveRootError(Exception): """Exception raised when trying to remove a tree's root node.""" def _remove_children(self) -> None: """Remove child nodes of this node. Note: This is the internal support method for `remove_children`. Call `remove_children` to ensure the tree gets refreshed. """ for child in reversed(self._children): child._remove() def _remove(self) -> None: """Remove the current node and all its children. Note: This is the internal support method for `remove`. Call `remove` to ensure the tree gets refreshed. """ self._remove_children() assert self._parent is not None del self._parent._children[self._parent._children.index(self)] del self._tree._tree_nodes[self.id] def remove(self) -> None: """Remove this node from the tree. Raises: TreeNode.RemoveRootError: If there is an attempt to remove the root. """ if self.is_root: raise self.RemoveRootError("Attempt to remove the root node of a Tree.") self._remove() self._tree._invalidate() def remove_children(self) -> None: """Remove any child nodes of this node.""" self._remove_children() self._tree._invalidate() class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """A widget for displaying and navigating data in a tree.""" BINDINGS: ClassVar[list[BindingType]] = [ Binding("enter", "select_cursor", "Select", show=False), Binding("space", "toggle_node", "Toggle", show=False), Binding("up", "cursor_up", "Cursor Up", show=False), Binding("down", "cursor_down", "Cursor Down", show=False), ] """ | Key(s) | Description | | :- | :- | | enter | Select the current item. | | space | Toggle the expand/collapsed space of the current item. | | up | Move the cursor up. | | down | Move the cursor down. | """ COMPONENT_CLASSES: ClassVar[set[str]] = { "tree--cursor", "tree--guides", "tree--guides-hover", "tree--guides-selected", "tree--highlight", "tree--highlight-line", "tree--label", } """ | Class | Description | | :- | :- | | `tree--cursor` | Targets the cursor. | | `tree--guides` | Targets the indentation guides. | | `tree--guides-hover` | Targets the indentation guides under the cursor. | | `tree--guides-selected` | Targets the indentation guides that are selected. | | `tree--highlight` | Targets the highlighted items. | | `tree--highlight-line` | Targets the lines under the cursor. | | `tree--label` | Targets the (text) labels of the items. | """ DEFAULT_CSS = """ Tree { background: $panel; color: $text; } Tree > .tree--label { } Tree > .tree--guides { color: $success-darken-3; } Tree > .tree--guides-hover { color: $success; text-style: bold; } Tree > .tree--guides-selected { color: $warning; text-style: bold; } Tree > .tree--cursor { background: $secondary-darken-2; color: $text; text-style: bold; } Tree:focus > .tree--cursor { background: $secondary; } Tree > .tree--highlight { text-style: underline; } Tree > .tree--highlight-line { background: $boost; } """ show_root = reactive(True) """Show the root of the tree.""" hover_line = var(-1) """The line number under the mouse pointer, or -1 if not under the mouse pointer.""" cursor_line = var(-1, always_update=True) """The line with the cursor, or -1 if no cursor.""" show_guides = reactive(True) """Enable display of tree guide lines.""" guide_depth = reactive(4, init=False) """The indent depth of tree nodes.""" auto_expand = var(True) """Auto expand tree nodes when clicked.""" LINES: dict[str, tuple[str, str, str, str]] = { "default": ( " ", "│ ", "└─", "├─", ), "bold": ( " ", "┃ ", "┗━", "┣━", ), "double": ( " ", "║ ", "╚═", "╠═", ), } class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True): """Event sent when a node is collapsed. Can be handled using `on_tree_node_collapsed` in a subclass of `Tree` or in a parent node in the DOM. """ def __init__( self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType] ) -> None: self.tree = tree """The tree that sent the message.""" self.node: TreeNode[EventTreeDataType] = node """The node that was collapsed.""" super().__init__() @property def control(self) -> Tree[EventTreeDataType]: """The tree that sent the message. This is an alias for [`NodeCollapsed.tree`][textual.widgets.Tree.NodeCollapsed.tree] and is used by the [`on`][textual.on] decorator. """ return self.tree class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True): """Event sent when a node is expanded. Can be handled using `on_tree_node_expanded` in a subclass of `Tree` or in a parent node in the DOM. """ def __init__( self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType] ) -> None: self.tree = tree """The tree that sent the message.""" self.node: TreeNode[EventTreeDataType] = node """The node that was expanded.""" super().__init__() @property def control(self) -> Tree[EventTreeDataType]: """The tree that sent the message. This is an alias for [`NodeExpanded.tree`][textual.widgets.Tree.NodeExpanded.tree] and is used by the [`on`][textual.on] decorator. """ return self.tree class NodeHighlighted(Generic[EventTreeDataType], Message, bubble=True): """Event sent when a node is highlighted. Can be handled using `on_tree_node_highlighted` in a subclass of `Tree` or in a parent node in the DOM. """ def __init__( self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType] ) -> None: self.tree = tree """The tree that sent the message.""" self.node: TreeNode[EventTreeDataType] = node """The node that was highlighted.""" super().__init__() @property def control(self) -> Tree[EventTreeDataType]: """The tree that sent the message. This is an alias for [`NodeHighlighted.tree`][textual.widgets.Tree.NodeHighlighted.tree] and is used by the [`on`][textual.on] decorator. """ return self.tree class NodeSelected(Generic[EventTreeDataType], Message, bubble=True): """Event sent when a node is selected. Can be handled using `on_tree_node_selected` in a subclass of `Tree` or in a parent node in the DOM. """ def __init__( self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType] ) -> None: self.tree = tree """The tree that sent the message.""" self.node: TreeNode[EventTreeDataType] = node """The node that was selected.""" super().__init__() @property def control(self) -> Tree[EventTreeDataType]: """The tree that sent the message. This is an alias for [`NodeSelected.tree`][textual.widgets.Tree.NodeSelected.tree] and is used by the [`on`][textual.on] decorator. """ return self.tree def __init__( self, label: TextType, data: TreeDataType | None = None, *, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, ) -> None: """Initialise a Tree. Args: label: The label of the root node of the tree. data: The optional data to associate with the root node of the tree. name: The name of the Tree. id: The ID of the tree in the DOM. classes: The CSS classes of the tree. disabled: Whether the tree is disabled or not. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) text_label = self.process_label(label) self._updates = 0 self._tree_nodes: dict[NodeID, TreeNode[TreeDataType]] = {} self._current_id = 0 self.root = self._add_node(None, text_label, data) """The root node of the tree.""" self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine] | None = None self._cursor_node: TreeNode[TreeDataType] | None = None @property def cursor_node(self) -> TreeNode[TreeDataType] | None: """The currently selected node, or ``None`` if no selection.""" return self._cursor_node @property def last_line(self) -> int: """The index of the last line.""" return len(self._tree_lines) - 1 def process_label(self, label: TextType) -> Text: """Process a `str` or `Text` value into a label. Maybe overridden in a subclass to change how labels are rendered. Args: label: Label. Returns: A Rich Text object. """ if isinstance(label, str): text_label = Text.from_markup(label) else: text_label = label first_line = text_label.split()[0] return first_line def _add_node( self, parent: TreeNode[TreeDataType] | None, label: Text, data: TreeDataType | None, expand: bool = False, ) -> TreeNode[TreeDataType]: node = TreeNode(self, parent, self._new_id(), label, data, expanded=expand) self._tree_nodes[node._id] = node self._updates += 1 return node def render_label( self, node: TreeNode[TreeDataType], base_style: Style, style: Style ) -> Text: """Render a label for the given node. Override this to modify how labels are rendered. Args: node: A tree node. base_style: The base style of the widget. style: The additional style for the label. Returns: A Rich Text object containing the label. """ node_label = node._label.copy() node_label.stylize(style) if node._allow_expand: prefix = ( "▼ " if node.is_expanded else "▶ ", base_style + TOGGLE_STYLE, ) else: prefix = ("", base_style) text = Text.assemble(prefix, node_label) return text def get_label_width(self, node: TreeNode[TreeDataType]) -> int: """Get the width of the nodes label. The default behavior is to call `render_node` and return the cell length. This method may be overridden in a sub-class if it can be done more efficiently. Args: node: A node. Returns: Width in cells. """ label = self.render_label(node, NULL_STYLE, NULL_STYLE) return label.cell_len def clear(self) -> Self: """Clear all nodes under root. Returns: The `Tree` instance. """ self._line_cache.clear() self._tree_lines_cached = None self._current_id = 0 root_label = self.root._label root_data = self.root.data self.root = TreeNode( self, None, self._new_id(), root_label, root_data, expanded=True, ) self._updates += 1 self.refresh() return self def reset(self, label: TextType, data: TreeDataType | None = None) -> Self: """Clear the tree and reset the root node. Args: label: The label for the root node. data: Optional data for the root node. Returns: The `Tree` instance. """ self.clear() self.root.label = label self.root.data = data return self def select_node(self, node: TreeNode[TreeDataType] | None) -> None: """Move the cursor to the given node, or reset cursor. Args: node: A tree node, or None to reset cursor. """ self.cursor_line = -1 if node is None else node._line def get_node_at_line(self, line_no: int) -> TreeNode[TreeDataType] | None: """Get the node for a given line. Args: line_no: A line number. Returns: A tree node, or ``None`` if there is no node at that line. """ try: line = self._tree_lines[line_no] except IndexError: return None else: return line.node class UnknownNodeID(Exception): """Exception raised when referring to an unknown `TreeNode` ID.""" def get_node_by_id(self, node_id: NodeID) -> TreeNode[TreeDataType]: """Get a tree node by its ID. Args: node_id: The ID of the node to get. Returns: The node associated with that ID. Raises: Tree.UnknownID: Raised if the `TreeNode` ID is unknown. """ try: return self._tree_nodes[node_id] except KeyError: raise self.UnknownNodeID(f"Unknown NodeID ({node_id}) in tree") from None def validate_cursor_line(self, value: int) -> int: """Prevent cursor line from going outside of range. Args: value: The value to test. Return: A valid version of the given value. """ return clamp(value, 0, len(self._tree_lines) - 1) def validate_guide_depth(self, value: int) -> int: """Restrict guide depth to reasonable range. Args: value: The value to test. Return: A valid version of the given value. """ return clamp(value, 2, 10) def _invalidate(self) -> None: """Invalidate caches.""" self._line_cache.clear() self._tree_lines_cached = None self._updates += 1 self.root._reset() self.refresh(layout=True) def _on_mouse_move(self, event: events.MouseMove): meta = event.style.meta if meta and "line" in meta: self.hover_line = meta["line"] else: self.hover_line = -1 def _new_id(self) -> NodeID: """Create a new node ID. Returns: A unique node ID. """ id = self._current_id self._current_id += 1 return NodeID(id) def _get_node(self, line: int) -> TreeNode[TreeDataType] | None: try: tree_line = self._tree_lines[line] except IndexError: return None else: return tree_line.node def _get_label_region(self, line: int) -> Region | None: """Returns the region occupied by the label of the node at line `line`. This can be used, e.g., when scrolling to that line such that the label is visible after the scroll. Args: line: A line number. Returns: The region occupied by the label, or `None` if the line is not in the tree. """ try: tree_line = self._tree_lines[line] except IndexError: return None region_x = tree_line._get_guide_width(self.guide_depth, self.show_root) region_width = self.get_label_width(tree_line.node) return Region(region_x, line, region_width, 1) def watch_hover_line(self, previous_hover_line: int, hover_line: int) -> None: previous_node = self._get_node(previous_hover_line) if previous_node is not None: self._refresh_node(previous_node) previous_node._hover = False node = self._get_node(hover_line) if node is not None: self._refresh_node(node) node._hover = True def watch_cursor_line(self, previous_line: int, line: int) -> None: previous_node = self._get_node(previous_line) if previous_node is not None: self._refresh_node(previous_node) previous_node._selected = False self._cursor_node = None node = self._get_node(line) if node is not None: self._refresh_node(node) node._selected = True self._cursor_node = node if previous_node != node: self.post_message(self.NodeHighlighted(self, node)) else: self._cursor_node = None def watch_guide_depth(self, guide_depth: int) -> None: self._invalidate() def watch_show_root(self, show_root: bool) -> None: self.cursor_line = -1 self._invalidate() def scroll_to_line(self, line: int) -> None: """Scroll to the given line. Args: line: A line number. """ region = self._get_label_region(line) if region is not None: self.scroll_to_region(region) def scroll_to_node(self, node: TreeNode[TreeDataType]) -> None: """Scroll to the given node. Args: node: Node to scroll in to view. """ line = node._line if line != -1: self.scroll_to_line(line) def refresh_line(self, line: int) -> None: """Refresh (repaint) a given line in the tree. Args: line: Line number. """ region = Region(0, line - self.scroll_offset.y, self.size.width, 1) self.refresh(region) def _refresh_node_line(self, line: int) -> None: node = self._get_node(line) if node is not None: self._refresh_node(node) def _refresh_node(self, node: TreeNode[TreeDataType]) -> None: """Refresh a node and all its children. Args: node: A tree node. """ scroll_y = self.scroll_offset.y height = self.size.height visible_lines = self._tree_lines[scroll_y : scroll_y + height] for line_no, line in enumerate(visible_lines, scroll_y): if node in line.path: self.refresh_line(line_no) @property def _tree_lines(self) -> list[_TreeLine]: if self._tree_lines_cached is None: self._build() assert self._tree_lines_cached is not None return self._tree_lines_cached async def _on_idle(self, event: events.Idle) -> None: """Check tree needs a rebuild on idle.""" # Property calls build if required self._tree_lines def _build(self) -> None: """Builds the tree by traversing nodes, and creating tree lines.""" TreeLine = _TreeLine lines: list[_TreeLine] = [] add_line = lines.append root = self.root def add_node( path: list[TreeNode[TreeDataType]], node: TreeNode[TreeDataType], last: bool ) -> None: child_path = [*path, node] node._line = len(lines) add_line(TreeLine(child_path, last)) if node._expanded: for last, child in loop_last(node._children): add_node(child_path, child, last) if self.show_root: add_node([], root, True) else: for node in self.root._children: add_node([], node, True) self._tree_lines_cached = lines guide_depth = self.guide_depth show_root = self.show_root get_label_width = self.get_label_width def get_line_width(line: _TreeLine) -> int: return get_label_width(line.node) + line._get_guide_width( guide_depth, show_root ) if lines: width = max([get_line_width(line) for line in lines]) else: width = self.size.width self.virtual_size = Size(width, len(lines)) if self.cursor_line != -1: if self.cursor_node is not None: self.cursor_line = self.cursor_node._line if self.cursor_line >= len(lines): self.cursor_line = -1 self.refresh() def render_lines(self, crop: Region) -> list[Strip]: self._pseudo_class_state = self.get_pseudo_class_state() return super().render_lines(crop) def render_line(self, y: int) -> Strip: width = self.size.width scroll_x, scroll_y = self.scroll_offset style = self.rich_style return self._render_line( y + scroll_y, scroll_x, scroll_x + width, style, ) def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip: tree_lines = self._tree_lines width = self.size.width if y >= len(tree_lines): return Strip.blank(width, base_style) line = tree_lines[y] is_hover = self.hover_line >= 0 and any(node._hover for node in line.path) cache_key = ( y, is_hover, width, self._updates, self._pseudo_class_state, tuple(node._updates for node in line.path), ) if cache_key in self._line_cache: strip = self._line_cache[cache_key] else: base_guide_style = self.get_component_rich_style( "tree--guides", partial=True ) guide_hover_style = base_guide_style + self.get_component_rich_style( "tree--guides-hover", partial=True ) guide_selected_style = base_guide_style + self.get_component_rich_style( "tree--guides-selected", partial=True ) hover = line.path[0]._hover selected = line.path[0]._selected and self.has_focus def get_guides(style: Style) -> tuple[str, str, str, str]: """Get the guide strings for a given style. Args: style: A Style object. Returns: Strings for space, vertical, terminator and cross. """ lines: tuple[Iterable[str], Iterable[str], Iterable[str], Iterable[str]] if self.show_guides: lines = self.LINES["default"] if style.bold: lines = self.LINES["bold"] elif style.underline2: lines = self.LINES["double"] else: lines = (" ", " ", " ", " ") guide_depth = max(0, self.guide_depth - 2) guide_lines = tuple( f"{characters[0]}{characters[1] * guide_depth} " for characters in lines ) return cast("tuple[str, str, str, str]", guide_lines) if is_hover: line_style = self.get_component_rich_style("tree--highlight-line") else: line_style = base_style guides = Text(style=line_style) guides_append = guides.append guide_style = base_guide_style for node in line.path[1:]: if hover: guide_style = guide_hover_style if selected: guide_style = guide_selected_style space, vertical, _, _ = get_guides(guide_style) guide = space if node.is_last else vertical if node != line.path[-1]: guides_append(guide, style=guide_style) hover = hover or node._hover selected = (selected or node._selected) and self.has_focus if len(line.path) > 1: _, _, terminator, cross = get_guides(guide_style) if line.last: guides.append(terminator, style=guide_style) else: guides.append(cross, style=guide_style) label_style = self.get_component_rich_style("tree--label", partial=True) if self.hover_line == y: label_style += self.get_component_rich_style( "tree--highlight", partial=True ) if self.cursor_line == y: label_style += self.get_component_rich_style( "tree--cursor", partial=False ) label = self.render_label(line.path[-1], line_style, label_style).copy() label.stylize(Style(meta={"node": line.node._id, "line": y})) guides.append(label) segments = list(guides.render(self.app.console)) pad_width = max(self.virtual_size.width, width) segments = line_pad(segments, 0, pad_width - guides.cell_len, line_style) strip = self._line_cache[cache_key] = Strip(segments) strip = strip.crop(x1, x2) return strip def _on_resize(self, event: events.Resize) -> None: self._line_cache.grow(event.size.height) self._invalidate() def _toggle_node(self, node: TreeNode[TreeDataType]) -> None: if not node.allow_expand: return if node.is_expanded: node.collapse() self.post_message(self.NodeCollapsed(self, node)) else: node.expand() self.post_message(self.NodeExpanded(self, node)) async def _on_click(self, event: events.Click) -> None: meta = event.style.meta if "line" in meta: cursor_line = meta["line"] if meta.get("toggle", False): node = self.get_node_at_line(cursor_line) if node is not None: self._toggle_node(node) else: self.cursor_line = cursor_line await self.run_action("select_cursor") def notify_style_update(self) -> None: self._invalidate() def action_cursor_up(self) -> None: """Move the cursor up one node.""" if self.cursor_line == -1: self.cursor_line = self.last_line else: self.cursor_line -= 1 self.scroll_to_line(self.cursor_line) def action_cursor_down(self) -> None: """Move the cursor down one node.""" if self.cursor_line == -1: self.cursor_line = 0 else: self.cursor_line += 1 self.scroll_to_line(self.cursor_line) def action_page_down(self) -> None: """Move the cursor down a page's-worth of nodes.""" if self.cursor_line == -1: self.cursor_line = 0 self.cursor_line += self.scrollable_content_region.height - 1 self.scroll_to_line(self.cursor_line) def action_page_up(self) -> None: """Move the cursor up a page's-worth of nodes.""" if self.cursor_line == -1: self.cursor_line = self.last_line self.cursor_line -= self.scrollable_content_region.height - 1 self.scroll_to_line(self.cursor_line) def action_scroll_home(self) -> None: """Move the cursor to the top of the tree.""" self.cursor_line = 0 self.scroll_to_line(self.cursor_line) def action_scroll_end(self) -> None: """Move the cursor to the bottom of the tree. Note: Here bottom means vertically, not branch depth. """ self.cursor_line = self.last_line self.scroll_to_line(self.cursor_line) def action_toggle_node(self) -> None: """Toggle the expanded state of the target node.""" try: line = self._tree_lines[self.cursor_line] except IndexError: pass else: self._toggle_node(line.path[-1]) def action_select_cursor(self) -> None: """Cause a select event for the target node. Note: If `auto_expand` is `True` use of this action on a non-leaf node will cause both an expand/collapse event to occour, as well as a selected event. """ try: line = self._tree_lines[self.cursor_line] except IndexError: pass else: node = line.path[-1] if self.auto_expand: self._toggle_node(node) self.post_message(self.NodeSelected(self, node))