From e2f6e2f82dfb55efe64769876324dde43e849266 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 19 Nov 2022 11:57:25 +0000 Subject: [PATCH] added directory tree --- examples/tree.py | 4 +- src/textual/widgets/_directory_tree.py | 182 +++++++++++++------------ src/textual/widgets/_tree.py | 97 +++++++++++-- 3 files changed, 181 insertions(+), 102 deletions(-) diff --git a/examples/tree.py b/examples/tree.py index 3662d78f7..688a505d7 100644 --- a/examples/tree.py +++ b/examples/tree.py @@ -1,7 +1,7 @@ import json from textual.app import App, ComposeResult -from textual.widgets import Header, Footer, Tree +from textual.widgets import Header, Footer, Tree, DirectoryTree with open("food.json") as data_file: @@ -23,7 +23,7 @@ class TreeApp(App): def compose(self) -> ComposeResult: yield Header() yield Footer() - yield Tree("Root") + yield DirectoryTree("../") def action_add(self) -> None: tree = self.query_one(Tree) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 4961db81d..b07bb47be 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -1,32 +1,55 @@ from __future__ import annotations from dataclasses import dataclass -from functools import lru_cache -from os import scandir -import os.path - -from rich.console import RenderableType -import rich.repr +from pathlib import Path +from typing import ClassVar +from rich.style import Style from rich.text import Text -from ..message import Message -from .._types import MessageTarget -from ._tree_control import TreeControl, TreeNode +from ._tree import Tree, TreeNode, TOGGLE_STYLE @dataclass class DirEntry: path: str is_dir: bool + loaded: bool = False -class DirectoryTree(TreeControl[DirEntry]): - @rich.repr.auto - class FileClick(Message, bubble=True): - def __init__(self, sender: MessageTarget, path: str) -> None: - self.path = path - super().__init__(sender) +class DirectoryTree(Tree[DirEntry]): + + COMPONENT_CLASSES: ClassVar[set[str]] = { + "tree--label", + "tree--guides", + "tree--guides-hover", + "tree--guides-selected", + "tree--cursor", + "tree--highlight", + "tree--highlight-line", + "directory-tree--folder", + "directory-tree--file", + "directory-tree--extension", + "directory-tree--hidden", + } + + DEFAULT_CSS = """ + DirectoryTree > .directory-tree--folder { + text-style: bold; + } + + DirectoryTree > .directory-tree--file { + + } + + DirectoryTree > .directory-tree--extension { + text-style: italic; + } + + DirectoryTree > .directory-tree--hidden { + color: $text 50%; + } + """ def __init__( self, @@ -36,84 +59,69 @@ class DirectoryTree(TreeControl[DirEntry]): id: str | None = None, classes: str | None = None, ) -> None: - self.path = os.path.expanduser(path.rstrip("/")) - label = os.path.basename(self.path) - data = DirEntry(self.path, True) - super().__init__(label, data, name=name, id=id, classes=classes) - self.root.tree.guide_style = "black" - - def render_node(self, node: TreeNode[DirEntry]) -> RenderableType: - return self.render_tree_label( - node, - node.data.is_dir, - node.expanded, - node.is_cursor, - node.id == self.hover_node, - self.has_focus, + self.path = path + super().__init__( + path, + data=DirEntry(path, True), + name=name, + id=id, + classes=classes, ) - @lru_cache(maxsize=1024 * 32) - def render_tree_label( - self, - node: TreeNode[DirEntry], - is_dir: bool, - expanded: bool, - is_cursor: bool, - is_hover: bool, - has_focus: bool, - ) -> RenderableType: - meta = { - "@click": f"click_label({node.id})", - "tree_node": node.id, - "cursor": node.is_cursor, - } - label = Text(node.label) if isinstance(node.label, str) else node.label - if is_hover: - label.stylize("underline") - if is_dir: - label.stylize("bold") - icon = "📂" if expanded else "📁" + def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style): + node_label = node._label.copy() + node_label.stylize(style) + + if node._allow_expand: + prefix = ("📂 " if node.is_expanded else "📁 ", base_style + TOGGLE_STYLE) + node_label.stylize_before( + self.get_component_rich_style("directory-tree--folder", partial=True) + ) else: - icon = "📄" - label.highlight_regex(r"\..*$", "italic") + prefix = ( + "📄 ", + base_style, + ) + node_label.stylize_before( + self.get_component_rich_style("directory-tree--file", partial=True), + ) + node_label.highlight_regex( + r"\..+$", + self.get_component_rich_style( + "directory-tree--extension", partial=True + ), + ) - if label.plain.startswith("."): - label.stylize("dim") + if node_label.plain.startswith("."): + node_label.stylize_before( + self.get_component_rich_style("directory-tree--hidden") + ) - if is_cursor and has_focus: - cursor_style = self.get_component_styles("tree--cursor").rich_style - label.stylize(cursor_style) + text = Text.assemble(prefix, node_label) + return text - icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label - icon_label.apply_meta(meta) - return icon_label - - def on_styles_updated(self) -> None: - self.render_tree_label.cache_clear() + def load_directory(self, node: TreeNode[DirEntry]) -> None: + assert node.data is not None + dir_path = Path(node.data.path) + node.data.loaded = True + directory = sorted( + list(dir_path.iterdir()), + key=lambda path: (not path.is_dir(), path.name.lower()), + ) + for path in directory: + node.add( + path.name, + data=DirEntry(str(path), path.is_dir()), + allow_expand=path.is_dir(), + ) + node.expand() def on_mount(self) -> None: - self.call_after_refresh(self.load_directory, self.root) + self.load_directory(self.root) - async def load_directory(self, node: TreeNode[DirEntry]): - path = node.data.path - directory = sorted( - list(scandir(path)), key=lambda entry: (not entry.is_dir(), entry.name) - ) - for entry in directory: - node.add(entry.name, DirEntry(entry.path, entry.is_dir())) - node.loaded = True - node.expand() - self.refresh(layout=True) - - async def on_tree_control_node_selected( - self, message: TreeControl.NodeSelected[DirEntry] - ) -> None: - dir_entry = message.node.data - if not dir_entry.is_dir: - await self.emit(self.FileClick(self, dir_entry.path)) - else: - if not message.node.loaded: - await self.load_directory(message.node) - message.node.expand() - else: - message.node.toggle() + def on_tree_node_expanded(self, event: Tree.NodeSelected) -> None: + dir_entry = event.node.data + if dir_entry is None: + return + if dir_entry.is_dir and not dir_entry.loaded: + self.load_directory(event.node) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index cd44eef82..5d48151b3 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -28,7 +28,7 @@ EventTreeDataType = TypeVar("EventTreeDataType") LineCacheKey: TypeAlias = tuple[int | tuple[int, ...], ...] -_TOGGLE_STYLE = Style.from_meta({"toggle": True}) +TOGGLE_STYLE = Style.from_meta({"toggle": True}) @dataclass @@ -70,7 +70,7 @@ class TreeNode(Generic[TreeDataType]): self._parent = parent self._id = id self._label = label - self.data: TreeDataType = data if data is not None else tree._data_factory() + self.data = data self._expanded = expanded self._children: list[TreeNode] = [] @@ -78,6 +78,7 @@ class TreeNode(Generic[TreeDataType]): 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 @@ -88,6 +89,10 @@ class TreeNode(Generic[TreeDataType]): self._selected = False self._updates += 1 + def line(self) -> int: + """Get the line number for this node, or -1 if it is not displayed.""" + return self._line + @property def hover(self) -> bool: return self._hover @@ -101,7 +106,7 @@ class TreeNode(Generic[TreeDataType]): def selected(self) -> bool: return self._selected - @hover.setter + @selected.setter def selected(self, selected: bool) -> None: self._updates += 1 self._selected = selected @@ -158,6 +163,10 @@ class TreeNode(Generic[TreeDataType]): self._parent._children and self._parent._children[-1] == self, ) + @property + def allow_expand(self) -> bool: + return self._allow_expand + def add( self, label: TextType, @@ -281,11 +290,24 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self.node = node super().__init__(sender) + class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True): + def __init__( + self, sender: MessageTarget, node: TreeNode[EventTreeDataType] + ) -> None: + self.node = node + super().__init__(sender) + + class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True): + def __init__( + self, sender: MessageTarget, node: TreeNode[EventTreeDataType] + ) -> None: + self.node = node + super().__init__(sender) + def __init__( self, label: TextType, data: TreeDataType | None = None, - data_factory: Callable[[], TreeDataType] = dict, *, name: str | None = None, id: str | None = None, @@ -295,7 +317,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): text_label = self.process_label(label) - self._data_factory = data_factory self._updates = 0 self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {} self._current_id = 0 @@ -303,6 +324,21 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._line_cache: LRUCache[LineCacheKey, list[Segment]] = 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 node under the cursor, which may be None if no cursor is visible. + + # Returns: + # TreeNode[TreeDataType] | None: A Tree node or None. + # """ + # if self.cursor_line == -1: + # return None + # try: + # self._tree_lines[self.cursor_line].path[-1] + # except IndexError: + # return None @classmethod def process_label(cls, label: TextType): @@ -328,8 +364,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): data: TreeDataType | None, expand: bool = False, ) -> TreeNode[TreeDataType]: - node_data = data if data is not None else self._data_factory() - node = TreeNode(self, parent, self._new_id(), label, node_data, expanded=expand) + node = TreeNode(self, parent, self._new_id(), label, data, expanded=expand) self._nodes[node._id] = node self._updates += 1 return node @@ -351,7 +386,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): if node._allow_expand: prefix = ( "▼ " if node.is_expanded else "▶ ", - base_style + _TOGGLE_STYLE, + base_style + TOGGLE_STYLE, ) else: prefix = ("", base_style) @@ -376,6 +411,14 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._updates += 1 self.refresh() + def select_node(self, node: TreeNode | None) -> None: + """Move the cursor to the given node, or reset cursor. + + Args: + node (TreeNode | None): 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. @@ -477,12 +520,13 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): 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) - self.scroll_to_line(line) node.selected = True + self.cursor_node = node def watch_guide_depth(self, guide_depth: int) -> None: self._invalidate() @@ -499,6 +543,16 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """ self.scroll_to_region(Region(0, line, self.size.width, 1)) + def scroll_to_node(self, node: TreeNode) -> None: + """Scroll to the given node. + + Args: + node (TreeNode): 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. @@ -542,6 +596,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): def add_node(path: list[TreeNode], node: TreeNode, 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): @@ -561,6 +616,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): width = self.size.width self.virtual_size = Size(width, len(lines)) + if self.cursor_node is not None: + self.cursor_line = self.cursor_node._line if self.cursor_line >= len(lines): self.cursor_line = -1 @@ -693,28 +750,42 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._line_cache.grow(event.size.height) self._invalidate() + def _toggle_node(self, node: TreeNode[TreeDataType]) -> None: + if node.is_expanded: + node.collapse() + self.post_message_no_wait(self.NodeCollapsed(self, node)) + else: + node.expand() + self.post_message_no_wait(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: - node.toggle() + if node is not None and self.auto_expand: + self._toggle_node(node) + else: if self.cursor_line == cursor_line: await self.action("select_cursor") else: self.cursor_line = cursor_line + def _on_styles_updated(self) -> None: + self._invalidate() + def action_cursor_up(self) -> None: if self.cursor_line == -1: self.cursor_line = len(self._tree_lines) - 1 else: self.cursor_line -= 1 + self.scroll_to_line(self.cursor_line) def action_cursor_down(self) -> None: self.cursor_line += 1 + self.scroll_to_line(self.cursor_line) def action_select_cursor(self) -> None: try: @@ -724,5 +795,5 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): else: node = line.path[-1] if self.auto_expand: - node.toggle() - self.emit_no_wait(self.NodeSelected(self, line.path[-1])) + self._toggle_node(node) + self.post_message_no_wait(self.NodeSelected(self, node))