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
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)

View File

@@ -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)

View File

@@ -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))