added directory tree

This commit is contained in:
Will McGugan
2022-11-19 11:57:25 +00:00
parent 41e4d0328b
commit e2f6e2f82d
3 changed files with 181 additions and 102 deletions

View File

@@ -1,7 +1,7 @@
import json import json
from textual.app import App, ComposeResult 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: with open("food.json") as data_file:
@@ -23,7 +23,7 @@ class TreeApp(App):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
yield Footer() yield Footer()
yield Tree("Root") yield DirectoryTree("../")
def action_add(self) -> None: def action_add(self) -> None:
tree = self.query_one(Tree) tree = self.query_one(Tree)

View File

@@ -1,32 +1,55 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from pathlib import Path
from os import scandir from typing import ClassVar
import os.path
from rich.console import RenderableType
import rich.repr
from rich.style import Style
from rich.text import Text from rich.text import Text
from ..message import Message from ._tree import Tree, TreeNode, TOGGLE_STYLE
from .._types import MessageTarget
from ._tree_control import TreeControl, TreeNode
@dataclass @dataclass
class DirEntry: class DirEntry:
path: str path: str
is_dir: bool is_dir: bool
loaded: bool = False
class DirectoryTree(TreeControl[DirEntry]): class DirectoryTree(Tree[DirEntry]):
@rich.repr.auto
class FileClick(Message, bubble=True): COMPONENT_CLASSES: ClassVar[set[str]] = {
def __init__(self, sender: MessageTarget, path: str) -> None: "tree--label",
self.path = path "tree--guides",
super().__init__(sender) "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__( def __init__(
self, self,
@@ -36,84 +59,69 @@ class DirectoryTree(TreeControl[DirEntry]):
id: str | None = None, id: str | None = None,
classes: str | None = None, classes: str | None = None,
) -> None: ) -> None:
self.path = os.path.expanduser(path.rstrip("/")) self.path = path
label = os.path.basename(self.path) super().__init__(
data = DirEntry(self.path, True) path,
super().__init__(label, data, name=name, id=id, classes=classes) data=DirEntry(path, True),
self.root.tree.guide_style = "black" name=name,
id=id,
def render_node(self, node: TreeNode[DirEntry]) -> RenderableType: classes=classes,
return self.render_tree_label(
node,
node.data.is_dir,
node.expanded,
node.is_cursor,
node.id == self.hover_node,
self.has_focus,
) )
@lru_cache(maxsize=1024 * 32) def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style):
def render_tree_label( node_label = node._label.copy()
self, node_label.stylize(style)
node: TreeNode[DirEntry],
is_dir: bool, if node._allow_expand:
expanded: bool, prefix = ("📂 " if node.is_expanded else "📁 ", base_style + TOGGLE_STYLE)
is_cursor: bool, node_label.stylize_before(
is_hover: bool, self.get_component_rich_style("directory-tree--folder", partial=True)
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 "📁"
else: else:
icon = "📄" prefix = (
label.highlight_regex(r"\..*$", "italic") "📄 ",
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("."): if node_label.plain.startswith("."):
label.stylize("dim") node_label.stylize_before(
self.get_component_rich_style("directory-tree--hidden")
)
if is_cursor and has_focus: text = Text.assemble(prefix, node_label)
cursor_style = self.get_component_styles("tree--cursor").rich_style return text
label.stylize(cursor_style)
icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label def load_directory(self, node: TreeNode[DirEntry]) -> None:
icon_label.apply_meta(meta) assert node.data is not None
return icon_label dir_path = Path(node.data.path)
node.data.loaded = True
def on_styles_updated(self) -> None: directory = sorted(
self.render_tree_label.cache_clear() 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: 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]): def on_tree_node_expanded(self, event: Tree.NodeSelected) -> None:
path = node.data.path dir_entry = event.node.data
directory = sorted( if dir_entry is None:
list(scandir(path)), key=lambda entry: (not entry.is_dir(), entry.name) return
) if dir_entry.is_dir and not dir_entry.loaded:
for entry in directory: self.load_directory(event.node)
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()

View File

@@ -28,7 +28,7 @@ EventTreeDataType = TypeVar("EventTreeDataType")
LineCacheKey: TypeAlias = tuple[int | tuple[int, ...], ...] LineCacheKey: TypeAlias = tuple[int | tuple[int, ...], ...]
_TOGGLE_STYLE = Style.from_meta({"toggle": True}) TOGGLE_STYLE = Style.from_meta({"toggle": True})
@dataclass @dataclass
@@ -70,7 +70,7 @@ class TreeNode(Generic[TreeDataType]):
self._parent = parent self._parent = parent
self._id = id self._id = id
self._label = label self._label = label
self.data: TreeDataType = data if data is not None else tree._data_factory() self.data = data
self._expanded = expanded self._expanded = expanded
self._children: list[TreeNode] = [] self._children: list[TreeNode] = []
@@ -78,6 +78,7 @@ class TreeNode(Generic[TreeDataType]):
self._selected = False self._selected = False
self._allow_expand = allow_expand self._allow_expand = allow_expand
self._updates: int = 0 self._updates: int = 0
self._line: int = -1
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield self._label.plain yield self._label.plain
@@ -88,6 +89,10 @@ class TreeNode(Generic[TreeDataType]):
self._selected = False self._selected = False
self._updates += 1 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 @property
def hover(self) -> bool: def hover(self) -> bool:
return self._hover return self._hover
@@ -101,7 +106,7 @@ class TreeNode(Generic[TreeDataType]):
def selected(self) -> bool: def selected(self) -> bool:
return self._selected return self._selected
@hover.setter @selected.setter
def selected(self, selected: bool) -> None: def selected(self, selected: bool) -> None:
self._updates += 1 self._updates += 1
self._selected = selected self._selected = selected
@@ -158,6 +163,10 @@ class TreeNode(Generic[TreeDataType]):
self._parent._children and self._parent._children[-1] == self, self._parent._children and self._parent._children[-1] == self,
) )
@property
def allow_expand(self) -> bool:
return self._allow_expand
def add( def add(
self, self,
label: TextType, label: TextType,
@@ -281,11 +290,24 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.node = node self.node = node
super().__init__(sender) 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__( def __init__(
self, self,
label: TextType, label: TextType,
data: TreeDataType | None = None, data: TreeDataType | None = None,
data_factory: Callable[[], TreeDataType] = dict,
*, *,
name: str | None = None, name: str | None = None,
id: 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) text_label = self.process_label(label)
self._data_factory = data_factory
self._updates = 0 self._updates = 0
self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {} self._nodes: dict[NodeID, TreeNode[TreeDataType]] = {}
self._current_id = 0 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._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024)
self._tree_lines_cached: list[_TreeLine] | None = None 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 @classmethod
def process_label(cls, label: TextType): def process_label(cls, label: TextType):
@@ -328,8 +364,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
data: TreeDataType | None, data: TreeDataType | None,
expand: bool = False, expand: bool = False,
) -> TreeNode[TreeDataType]: ) -> TreeNode[TreeDataType]:
node_data = data if data is not None else self._data_factory() node = TreeNode(self, parent, self._new_id(), label, data, expanded=expand)
node = TreeNode(self, parent, self._new_id(), label, node_data, expanded=expand)
self._nodes[node._id] = node self._nodes[node._id] = node
self._updates += 1 self._updates += 1
return node return node
@@ -351,7 +386,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
if node._allow_expand: if node._allow_expand:
prefix = ( prefix = (
"" if node.is_expanded else "", "" if node.is_expanded else "",
base_style + _TOGGLE_STYLE, base_style + TOGGLE_STYLE,
) )
else: else:
prefix = ("", base_style) prefix = ("", base_style)
@@ -376,6 +411,14 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self._updates += 1 self._updates += 1
self.refresh() 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: def get_node_at_line(self, line_no: int) -> TreeNode[TreeDataType] | None:
"""Get the node for a given line. """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: if previous_node is not None:
self._refresh_node(previous_node) self._refresh_node(previous_node)
previous_node.selected = False previous_node.selected = False
self.cursor_node = None
node = self._get_node(line) node = self._get_node(line)
if node is not None: if node is not None:
self._refresh_node(node) self._refresh_node(node)
self.scroll_to_line(line)
node.selected = True node.selected = True
self.cursor_node = node
def watch_guide_depth(self, guide_depth: int) -> None: def watch_guide_depth(self, guide_depth: int) -> None:
self._invalidate() 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)) 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: def refresh_line(self, line: int) -> None:
"""Refresh (repaint) a given line in the tree. """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: def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None:
child_path = [*path, node] child_path = [*path, node]
node._line = len(lines)
add_line(_TreeLine(child_path, last)) add_line(_TreeLine(child_path, last))
if node._expanded: if node._expanded:
for last, child in loop_last(node._children): for last, child in loop_last(node._children):
@@ -561,6 +616,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
width = self.size.width width = self.size.width
self.virtual_size = Size(width, len(lines)) 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): if self.cursor_line >= len(lines):
self.cursor_line = -1 self.cursor_line = -1
@@ -693,28 +750,42 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self._line_cache.grow(event.size.height) self._line_cache.grow(event.size.height)
self._invalidate() 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: async def _on_click(self, event: events.Click) -> None:
meta = event.style.meta meta = event.style.meta
if "line" in meta: if "line" in meta:
cursor_line = meta["line"] cursor_line = meta["line"]
if meta.get("toggle", False): if meta.get("toggle", False):
node = self.get_node_at_line(cursor_line) node = self.get_node_at_line(cursor_line)
if node is not None: if node is not None and self.auto_expand:
node.toggle() self._toggle_node(node)
else: else:
if self.cursor_line == cursor_line: if self.cursor_line == cursor_line:
await self.action("select_cursor") await self.action("select_cursor")
else: else:
self.cursor_line = cursor_line self.cursor_line = cursor_line
def _on_styles_updated(self) -> None:
self._invalidate()
def action_cursor_up(self) -> None: def action_cursor_up(self) -> None:
if self.cursor_line == -1: if self.cursor_line == -1:
self.cursor_line = len(self._tree_lines) - 1 self.cursor_line = len(self._tree_lines) - 1
else: else:
self.cursor_line -= 1 self.cursor_line -= 1
self.scroll_to_line(self.cursor_line)
def action_cursor_down(self) -> None: def action_cursor_down(self) -> None:
self.cursor_line += 1 self.cursor_line += 1
self.scroll_to_line(self.cursor_line)
def action_select_cursor(self) -> None: def action_select_cursor(self) -> None:
try: try:
@@ -724,5 +795,5 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
else: else:
node = line.path[-1] node = line.path[-1]
if self.auto_expand: if self.auto_expand:
node.toggle() self._toggle_node(node)
self.emit_no_wait(self.NodeSelected(self, line.path[-1])) self.post_message_no_wait(self.NodeSelected(self, node))