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

1""" 

2Set up and runs the textual app for some FileTree. 

3 

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 

11 

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`.") 

25 

26from .file_tree import FileTree, Template 

27 

28 

29class TemplateSelect(Message, bubble=True): 

30 """Message sent when a template in the sidebar gets selected.""" 

31 

32 def __init__(self, sender, template: Template): 

33 """Create template selector.""" 

34 self.template = template 

35 super().__init__(sender) 

36 

37 

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 ] 

46 

47 def __init__(self, file_tree: FileTree, renderer, name: str = None): 

48 """ 

49 Create a new template sidebar based on given FileTree. 

50 

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

62 

63 def on_mount(self, ): 

64 self.focus() 

65 

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. 

69 

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) 

82 

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 

92 

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) 

97 

98 

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 

119 

120 

121class FileTreeViewer(App): 

122 """FileTree viewer app.""" 

123 

124 TITLE = "FileTree viewer" 

125 CSS_PATH = "css/app.css" 

126 

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

130 

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

140 

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

146 

147 

148class TemplateRenderer(Static): 

149 """ 

150 Helper class to create a Rich rendering of a template. 

151 

152 There are two parts: 

153 

154 - a text file with the template 

155 - a table with the possible placeholder value combinations 

156 (shaded red for non-existing files) 

157 """ 

158 

159 def __init__(self, file_tree: FileTree): 

160 """Create new renderer for template.""" 

161 self.file_tree = file_tree 

162 super().__init__() 

163 

164 def on_mount(self): 

165 self.render_template(self.file_tree.get_template("")) 

166 

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

182 

183 

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