Coverage for src/hods/tui/config/tree.py: 100.00%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""hods - home directory synchronization.
3Copyright (C) 2016-2020 Mathias Stelzer <knoppo@rolln.de>
5hods is free software: you can redistribute it and/or modify
6it under the terms of the GNU General Public License as published by
7the Free Software Foundation, either version 3 of the License, or
8(at your option) any later version.
10hods is distributed in the hope that it will be useful,
11but WITHOUT ANY WARRANTY; without even the implied warranty of
12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13GNU General Public License for more details.
15You should have received a copy of the GNU General Public License
16along with this program. If not, see <http://www.gnu.org/licenses/>.
17"""
18import logging
20import urwid
21import urwidtrees
23from hods.config.sourcefile import SourceDirectory, SourceFile
24from hods.config.sources import GitRepository, Source
25from hods.node import DirectoryNode, FileNode, RootDirectoryNode
26from hods.path import Directory
27from hods.tui.base.list import AppItemMixin, CheckBoxDirectoryMixin, CheckBoxItemMixin, ObjectItem
28from hods.tui.config.edit import (
29 GitRepositoryEditWindow,
30 ServerDirectoryEditWindow,
31 SourceDirectoryEditWindow,
32 SourceFileEditWindow,
33)
35logger = logging.getLogger(__name__)
38class BaseObjectTree(urwidtrees.Tree):
39 """Base tree of objects."""
41 child_attr_name = 'children'
42 parent_attr_name = 'parent'
44 def __init__(self, root):
45 """Initialize base tree of objects.
47 :param root: `hods.path.RootPathNode` - Root node of the tree
48 """
49 self.root = root
51 def get_siblings(self, pos):
52 """List the parent directory of pos."""
53 parent = self.parent_position(pos)
54 if parent:
55 return self.get_children(parent)
56 return [pos]
58 # Tree API
60 def __getitem__(self, pos): # pragma: no cover
61 """Return the given position to decorate."""
62 return pos
64 def parent_position(self, pos):
65 """Return the parent for the given position."""
66 attr_name = self.get_parent_attr_name()
67 return getattr(pos, attr_name, None)
69 def first_child_position(self, pos):
70 """Get first child of the given position."""
71 children = self.get_children(pos)
72 if children:
73 return children[0]
75 def last_child_position(self, pos):
76 """Get last child of the given position."""
77 children = self.get_children(pos)
78 if children:
79 return children[-1]
81 def next_sibling_position(self, pos):
82 """Get next sibling of the given position."""
83 siblings = self.get_siblings(pos)
84 if pos in siblings: # pragma: no branch
85 myindex = siblings.index(pos)
86 if myindex + 1 < len(siblings): # pos is not the last entry
87 return siblings[myindex + 1]
89 def prev_sibling_position(self, pos):
90 """Get previous sibling of the given position."""
91 siblings = self.get_siblings(pos)
92 if pos in siblings: # pragma: no branch
93 myindex = siblings.index(pos)
94 if myindex > 0: # pos is not the first entry
95 return siblings[myindex - 1]
97 # ObjectTree API
99 def get_child_attr_name(self):
100 """Get the attribute name of the children list of a node."""
101 return self.child_attr_name
103 def get_parent_attr_name(self):
104 """Get the attribute name of the parent of a node."""
105 return self.parent_attr_name
107 def get_children(self, pos):
108 """Get the children list of the given position/item."""
109 attr_name = self.get_child_attr_name()
110 return getattr(pos, attr_name, [])
113class PathTree(AppItemMixin, BaseObjectTree):
114 """Base PathNode tree."""
116 pass
119class SourceTree(PathTree):
120 """SourceFile tree in configuration/synchronization directory."""
122 def parent_position(self, pos):
123 """Return the parent for the given item."""
124 # Make `Source` the root
125 if isinstance(pos, Source):
126 return
128 return super().parent_position(pos)
130 def get_children(self, pos):
131 """Get the children list of the given position/item.
133 Scan position children (if not already).
135 :param pos: `hods.tui.config.tree.TreeItem`
136 :return: `list`
137 """
138 if isinstance(pos, SourceDirectory) and pos.scan_aged():
139 pos.scan()
141 return super().get_children(pos)
144class TreeItem(ObjectItem):
145 """A tree node item used to display a node."""
147 def __init__(self, *args, **kwargs):
148 """Initialize base tree item."""
149 kwargs.setdefault('padding', 0)
150 kwargs.setdefault('require_doubleclick', True)
151 super().__init__(*args, **kwargs)
153 def initially_collapsed(self):
154 """Return a boolean whether the node is collapsed by default."""
155 return False
158class PathTreeItem(TreeItem):
159 """A tree node item used to display a path node."""
161 def __init__(self, *args, **kwargs):
162 """Initialize tree item."""
163 super().__init__(*args, **kwargs)
165 def get_label(self):
166 """Get the paths basename as label."""
167 return self.object.basename
170class SourceTreeItem(PathTreeItem):
171 """Base class for tree node items to display a source node."""
173 item_attr = 'directory'
175 def get_attr(self):
176 """Get an according urwid attribute for the item.
178 Show ignored files as 'disabled' and directories in blue.
179 """
180 if self.object.is_ignored():
181 return 'disabled'
182 return self.item_attr
184 def get_label(self):
185 """Get the paths basename as label.
187 Append a slash if its a directory.
188 """
189 name = super().get_label()
190 if not self.object.is_ignored() and not self.object.destination_is_default():
191 if self.app.enable_unicode:
192 arrow = ' \u25ba ' # pragma: no cover
193 else:
194 arrow = ' -> '
195 name += arrow + self.object.destination_path
196 return name
199class SourceFileTreeItem(AppItemMixin, SourceTreeItem):
200 """A tree node item used to display a source file."""
202 item_attr = 'file'
204 def initially_collapsed(self):
205 """Whether the item is initially collapsed."""
206 return True
208 def on_press(self, user_data=None):
209 """Callback called when selecting the item.
211 Show the edit window for the item.
212 """
213 SourceFileEditWindow(self.app, self.object).show()
216class SourceDirectoryTreeItem(AppItemMixin, SourceTreeItem):
217 """A tree node item to display a `~hods.config.sourcefile.SourceDirectory` instance."""
219 def get_label(self):
220 """Get the paths basename as label.
222 Append a slash if its a directory.
223 """
224 return super().get_label() + '/'
226 def initially_collapsed(self):
227 """Whether the item is initially collapsed."""
228 return False
230 def on_press(self, user_data=None):
231 """Callback called when selecting the item.
233 Show the edit window for the item.
234 """
235 SourceDirectoryEditWindow(self.app, self.object).show()
238class GitRepositoryTreeItem(SourceDirectoryTreeItem):
239 """Tree node for a `hods.sources.GitRepository` object."""
241 item_attr = 'source'
243 def on_press(self, user_data=None):
244 """Callback called when selecting the item.
246 Show the edit window for the item.
247 """
248 GitRepositoryEditWindow(self.app, self.object).show()
251class ServerDirectoryTreeItem(SourceDirectoryTreeItem):
252 """Tree node for a `hods.sources.ServerDirectory` instance."""
254 item_attr = 'source'
256 def on_press(self, user_data=None):
257 """Callback called when selecting the item.
259 Show the edit window for the item.
260 """
261 ServerDirectoryEditWindow(self.app, self.object).show()
264class AppTreeDecoration(urwidtrees.CollapsibleArrowTree):
265 """Base tree decoration used to visualize the tree."""
267 def __init__(self, app, tree, **kwargs):
268 """Initialize tree decoration.
270 Args:
271 app: `hods.tui.base.app.BaseApp`
272 tree: `hods.tui.config.BaseObjectTree`
273 ``**kwargs``: Pass kwargs to `urwidtrees.CollapsibleArrowTree`
274 """
275 self.app = app
276 kwargs.setdefault('indent', 2)
277 kwargs.setdefault('icon_frame_left_char', None)
278 kwargs.setdefault('icon_frame_right_char', None)
279 kwargs.setdefault('arrow_tip_char', None)
280 if app.enable_unicode: # pragma: no cover
281 kwargs.setdefault('icon_collapsed_char', '\u25b6')
282 kwargs.setdefault('icon_expanded_char', '\u25b7')
283 else:
284 kwargs.setdefault('icon_collapsed_char', '+')
285 kwargs.setdefault('icon_expanded_char', '-')
286 kwargs.setdefault('arrow_connector_tchar', None)
287 kwargs.setdefault('arrow_connector_lchar', None)
288 kwargs.setdefault('arrow_hbar_char', ' ')
289 kwargs.setdefault('arrow_vbar_char', None)
291 super().__init__(tree, is_collapsed=self.pos_initially_collapsed, **kwargs)
293 def __getitem__(self, obj): # pragma: no cover
294 """Return the item for the given tree node object."""
295 return TreeItem(obj)
297 def pos_initially_collapsed(self, pos):
298 """Return a boolean whether the node is collapsed by default."""
299 return self[pos].initially_collapsed()
302class SourceTreeDecoration(AppTreeDecoration):
303 """Tree decoration used to visualize a source file tree."""
305 def __getitem__(self, item):
306 """Return the item for the given tree node object."""
307 if isinstance(item, GitRepository):
308 return GitRepositoryTreeItem(self.app, item)
309 if isinstance(item, Source):
310 return ServerDirectoryTreeItem(self.app, item)
311 if isinstance(item, SourceDirectory):
312 return SourceDirectoryTreeItem(self.app, item)
313 if isinstance(item, SourceFile):
314 return SourceFileTreeItem(self.app, item)
315 return super().__getitem__(item) # pragma: no cover
318class PathTreeBox(urwidtrees.TreeBox):
319 """Base file tree widget."""
321 def __init__(self, tree, column_index=1):
322 """Initialize the widget."""
323 super().__init__(tree)
324 self.selected = None
325 urwid.connect_signal(self._walker, 'modified', self.on_focus)
327 self.column_index = column_index
329 def get_item(self):
330 """Get the item from the selected column."""
331 columns = self.get_focus()
332 return columns[self.column_index]
334 def on_focus(self):
335 """Store focus item as selected."""
336 self.selected = self.get_item()
338 def keypress(self, size, key):
339 """Disable left and right keys."""
340 logger.debug('PathTreeBox %s: %s.keypress(%s, %s)', self, self.__class__.__name__, repr(size), repr(key))
341 if key in ['left', 'right']:
342 return key
343 return super().keypress(size, key)
346class ConfigViewMixin:
347 """A configuration view mixin to reset tree positions."""
349 def refresh(self):
350 """Reset all tree children `scanned` attribute."""
351 for pos in self.app.config.tree.collect_children():
352 pos.scanned = None
353 super().refresh()
356class HomeDirectoryNode(DirectoryNode):
357 """A path node representing a file in a home directory."""
359 child_file_class = FileNode
361 def get_child_directory_class(self):
362 """Return the node class for childs."""
363 return HomeDirectoryNode
366class HomeRootNode(RootDirectoryNode):
367 """Base tree node representing a path in the home directory."""
369 ignore_basenames = ['.hods']
371 child_directory_class = HomeDirectoryNode
373 child_file_class = FileNode
376class HomeFileTreeItem(CheckBoxItemMixin, PathTreeItem):
377 """Tree node item to display a file in the home directory."""
379 def get_attr(self):
380 """Return the urwid attribute map to wrap the widget."""
381 return 'file'
384class HomeDirectoryTreeItem(CheckBoxDirectoryMixin, PathTreeItem):
385 """Tree node item to display a directory in the home directory."""
387 def get_label(self):
388 """Get the paths basename as label and append a slash."""
389 label = super().get_label()
390 if not label.endswith('/'): # pragma: no branch
391 label += '/'
392 return label
394 def get_attr(self):
395 """Return the urwid attribute map to wrap the widget."""
396 return 'directory'
398 def initially_collapsed(self):
399 """Return a bool whether the item should be initially collapsed."""
400 return True
403class HomeRootTreeItem(PathTreeItem):
404 """The root node item for the home directory tree."""
406 def get_attr(self):
407 """Return the urwid attribute map to wrap the widget."""
408 return 'directory'
410 def get_label(self):
411 """Return the absolute path to the home directory."""
412 return self.object.basename
414 def initially_collapsed(self):
415 """Don't initially collapse the root node."""
416 return False
419class HomeTree(PathTree):
420 """A path tree representing the home directory."""
422 def __init__(self, app, home_path, *args, **kwargs):
423 """Initialize the tree and its root node.
425 Args:
426 app: The main App instance.
427 home_path: Absolute path of the current user's home directory.
428 `*args`: Additional arguments passed to hods.tui.config.tree.PathTree
429 `**kwargs`: Additional keyword arguments passed to hods.tui.config.tree.PathTree
430 """
431 root = HomeRootNode(home_path)
432 super().__init__(app, root, *args, **kwargs)
434 def get_children(self, item: HomeDirectoryTreeItem) -> list:
435 """Get the children list of the given position/item.
437 Scan position children (if not already) and filter out symlinks.
439 Args:
440 item: The directory node to scan for children.
442 Returns:
443 A list of tree items.
444 """
445 if isinstance(item, Directory) and item.scan_aged():
446 item.scan()
447 return super().get_children(item)
450class HomeTreeDecoration(AppTreeDecoration):
451 """Tree decoration used to visualize a home directory tree."""
453 def __init__(self, *args, **kwargs):
454 """Initialize tree decoration."""
455 super().__init__(*args, **kwargs)
457 # visible items cache
458 self.items = {}
460 def __getitem__(self, obj):
461 """Return the item for the given tree node object."""
462 item = self.items.get(obj, False)
463 if item not in (True, False):
464 return item
466 if isinstance(obj, HomeRootNode):
467 item = HomeRootTreeItem(obj)
468 elif isinstance(obj, HomeDirectoryNode):
469 item = HomeDirectoryTreeItem(obj, items=self.items)
470 else:
471 item = HomeFileTreeItem(obj, items=self.items)
472 self.items[obj] = item
473 return item
475 def clean(self):
476 """Collect unique objects of checked tree nodes."""
477 result = set()
478 checked = list(self.collect_checked())
480 for obj in checked:
481 if not obj.parent.is_root and obj.parent in checked:
482 continue
483 result.add(obj)
485 return result
487 def collect_checked(self):
488 """Generate objects of checked tree nodes."""
489 for obj, item in self.items.items():
490 if isinstance(obj, HomeRootNode):
491 continue
493 if item in (True, False):
494 if item: # pragma: no branch # coverage bug!?
495 yield obj
496 continue
498 if item.checkbox.state is True:
499 yield obj