Coverage for src/file_tree/app.py: 89%
106 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-17 13:27 +0000
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-17 13:27 +0000
1"""
2Set up and runs the textual app for some FileTree.
4It is not recommended to run any of the functions in this module.
5Instead load a :class:`FileTree <file_tree.file_tree.FileTree>` and
6then run :meth:`FileTree.run_app <file_tree.file_tree.FileTree.run_app>` and
7"""
8import itertools
9from argparse import ArgumentParser
10from functools import lru_cache
12try:
13 from rich.style import Style
14 from rich.table import Table
15 from rich.text import Text
16 from rich.console import Group
17 from textual.app import App, ComposeResult
18 from textual.message import Message
19 from textual.widgets import Header, Footer, Tree, Static
20 from textual.widgets.tree import TreeNode
21 from textual.containers import Horizontal
22 from textual.binding import Binding
23except ImportError:
24 raise ImportError("Running the file-tree app requires rich and textual to be installed. Please install these using `pip/conda install textual`.")
26from .file_tree import FileTree, Template
29class TemplateSelect(Message, bubble=True):
30 """Message sent when a template in the sidebar gets selected."""
32 def __init__(self, sender, template: Template):
33 """Create template selector."""
34 self.template = template
35 super().__init__(sender)
38class TemplateTreeControl(Tree):
39 """Sidebar containing all template definitions in FileTree."""
40 current_node = None
41 BINDINGS = [
42 Binding("space", "toggle_node", "Collapse/Expand Node", show=True),
43 Binding("up", "cursor_up", "Move Up", show=True),
44 Binding("down", "cursor_down", "Move Down", show=True),
45 ]
47 def __init__(self, file_tree: FileTree, renderer, name: str = None):
48 """
49 Create a new template sidebar based on given FileTree.
51 Args:
52 tree: FileTree to interact with
53 name: name of the sidebar within textual
54 """
55 self.file_tree = file_tree
56 super().__init__("-", name=name)
57 self.show_root = False
58 self.find_children(self.root, self.file_tree.get_template(""))
59 self.root.expand_all()
60 self.renderer = renderer
61 self.select_node(self.get_node_at_line(0))
63 def on_mount(self, ):
64 self.focus()
66 def find_children(self, parent_node: TreeNode, template:Template):
67 """
68 Find all the children of a template and add them to the node.
70 Calls itself recursively.
71 """
72 all_children = template.children(self.file_tree._templates.values())
73 if len(all_children) == 0:
74 parent_node.add_leaf(template.unique_part, template)
75 else:
76 this_node = parent_node.add(template.unique_part, template)
77 children = set()
78 for child in all_children:
79 if child not in children:
80 self.find_children(this_node, child)
81 children.add(child)
83 def render_label(self, node: TreeNode[Template], base_style, style):
84 if node.data is None:
85 return node.label
86 label = _render_node_helper(self.file_tree, node).copy()
87 if node is self.cursor_node:
88 label.stylize("reverse")
89 if not node.is_expanded and len(node.children) > 0:
90 label = Text("📁 ") + label
91 return label
93 def on_tree_node_highlighted(self):
94 if self.current_node is not self.cursor_node:
95 self.current_node = self.cursor_node
96 self.renderer.render_template(self.current_node.data)
99@lru_cache(None)
100def _render_node_helper(tree: FileTree, node: TreeNode[Template]):
101 meta = {
102 "@click": f"click_label({node.id})",
103 "tree_node": node.id,
104 #"cursor": node.is_cursor,
105 }
106 paths = node.data.format_mult(
107 tree.placeholders, filter=True, glob=True
108 ).data.flatten()
109 existing = [p for p in paths if p != ""]
110 color = "blue" if len(existing) == len(paths) else "yellow"
111 if len(existing) == 0:
112 color = "red"
113 counter = f" [{color}][{len(existing)}/{len(paths)}][/{color}]"
114 res = Text.from_markup(
115 node.data.rich_line(tree._iter_templates) + counter, overflow="ellipsis"
116 )
117 res.apply_meta(meta)
118 return res
121class FileTreeViewer(App):
122 """FileTree viewer app."""
124 TITLE = "FileTree viewer"
125 CSS_PATH = "css/app.css"
127 def __init__(self, file_tree: FileTree):
128 self.file_tree = file_tree.fill().update_glob(file_tree.template_keys(only_leaves=True))
129 super().__init__()
131 def compose(self) -> ComposeResult:
132 renderer = TemplateRenderer(self.file_tree)
133 controller = TemplateTreeControl(self.file_tree, renderer)
134 yield Header()
135 yield Horizontal(
136 controller,
137 renderer,
138 )
139 yield Footer()
141 async def handle_template_select(self, message: TemplateSelect):
142 """User has selected a template."""
143 template = message.template
144 self.app.sub_title = template.as_string
145 await self.body.update(TemplateRenderer(template, self.file_tree))
148class TemplateRenderer(Static):
149 """
150 Helper class to create a Rich rendering of a template.
152 There are two parts:
154 - a text file with the template
155 - a table with the possible placeholder value combinations
156 (shaded red for non-existing files)
157 """
159 def __init__(self, file_tree: FileTree):
160 """Create new renderer for template."""
161 self.file_tree = file_tree
162 super().__init__()
164 def on_mount(self):
165 self.render_template(self.file_tree.get_template(""))
167 def render_template(self, template: Template):
168 """Render the template as rich text."""
169 xr = template.format_mult(
170 self.file_tree.placeholders, filter=True, glob=True
171 )
172 coords = sorted(xr.coords.keys())
173 single_var_table = Table(*coords)
174 for values in itertools.product(*[xr.coords[c].data for c in coords]):
175 path = xr.sel(**{c: v for c, v in zip(coords, values)}).item()
176 style = Style(bgcolor=None if path != "" else "red")
177 single_var_table.add_row(*[str(v) for v in values], style=style)
178 self.update(Group(
179 template.as_string,
180 single_var_table,
181 ))
184def run():
185 """Start CLI interface to app."""
186 parser = ArgumentParser(
187 description="Interactive terminal-based interface with file-trees"
188 )
189 parser.add_argument("file_tree", help="Which file-tree to visualise")
190 parser.add_argument("-d", "--directory", default=".", help="top-level directory")
191 args = parser.parse_args()
192 FileTree.read(args.file_tree, args.directory).run_app()