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

223 statements  

1"""hods - home directory synchronization. 

2 

3Copyright (C) 2016-2020 Mathias Stelzer <knoppo@rolln.de> 

4 

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. 

9 

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. 

14 

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 

19 

20import urwid 

21import urwidtrees 

22 

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) 

34 

35logger = logging.getLogger(__name__) 

36 

37 

38class BaseObjectTree(urwidtrees.Tree): 

39 """Base tree of objects.""" 

40 

41 child_attr_name = 'children' 

42 parent_attr_name = 'parent' 

43 

44 def __init__(self, root): 

45 """Initialize base tree of objects. 

46 

47 :param root: `hods.path.RootPathNode` - Root node of the tree 

48 """ 

49 self.root = root 

50 

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] 

57 

58 # Tree API 

59 

60 def __getitem__(self, pos): # pragma: no cover 

61 """Return the given position to decorate.""" 

62 return pos 

63 

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) 

68 

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] 

74 

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] 

80 

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] 

88 

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] 

96 

97 # ObjectTree API 

98 

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 

102 

103 def get_parent_attr_name(self): 

104 """Get the attribute name of the parent of a node.""" 

105 return self.parent_attr_name 

106 

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, []) 

111 

112 

113class PathTree(AppItemMixin, BaseObjectTree): 

114 """Base PathNode tree.""" 

115 

116 pass 

117 

118 

119class SourceTree(PathTree): 

120 """SourceFile tree in configuration/synchronization directory.""" 

121 

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 

127 

128 return super().parent_position(pos) 

129 

130 def get_children(self, pos): 

131 """Get the children list of the given position/item. 

132 

133 Scan position children (if not already). 

134 

135 :param pos: `hods.tui.config.tree.TreeItem` 

136 :return: `list` 

137 """ 

138 if isinstance(pos, SourceDirectory) and pos.scan_aged(): 

139 pos.scan() 

140 

141 return super().get_children(pos) 

142 

143 

144class TreeItem(ObjectItem): 

145 """A tree node item used to display a node.""" 

146 

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) 

152 

153 def initially_collapsed(self): 

154 """Return a boolean whether the node is collapsed by default.""" 

155 return False 

156 

157 

158class PathTreeItem(TreeItem): 

159 """A tree node item used to display a path node.""" 

160 

161 def __init__(self, *args, **kwargs): 

162 """Initialize tree item.""" 

163 super().__init__(*args, **kwargs) 

164 

165 def get_label(self): 

166 """Get the paths basename as label.""" 

167 return self.object.basename 

168 

169 

170class SourceTreeItem(PathTreeItem): 

171 """Base class for tree node items to display a source node.""" 

172 

173 item_attr = 'directory' 

174 

175 def get_attr(self): 

176 """Get an according urwid attribute for the item. 

177 

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 

183 

184 def get_label(self): 

185 """Get the paths basename as label. 

186 

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 

197 

198 

199class SourceFileTreeItem(AppItemMixin, SourceTreeItem): 

200 """A tree node item used to display a source file.""" 

201 

202 item_attr = 'file' 

203 

204 def initially_collapsed(self): 

205 """Whether the item is initially collapsed.""" 

206 return True 

207 

208 def on_press(self, user_data=None): 

209 """Callback called when selecting the item. 

210 

211 Show the edit window for the item. 

212 """ 

213 SourceFileEditWindow(self.app, self.object).show() 

214 

215 

216class SourceDirectoryTreeItem(AppItemMixin, SourceTreeItem): 

217 """A tree node item to display a `~hods.config.sourcefile.SourceDirectory` instance.""" 

218 

219 def get_label(self): 

220 """Get the paths basename as label. 

221 

222 Append a slash if its a directory. 

223 """ 

224 return super().get_label() + '/' 

225 

226 def initially_collapsed(self): 

227 """Whether the item is initially collapsed.""" 

228 return False 

229 

230 def on_press(self, user_data=None): 

231 """Callback called when selecting the item. 

232 

233 Show the edit window for the item. 

234 """ 

235 SourceDirectoryEditWindow(self.app, self.object).show() 

236 

237 

238class GitRepositoryTreeItem(SourceDirectoryTreeItem): 

239 """Tree node for a `hods.sources.GitRepository` object.""" 

240 

241 item_attr = 'source' 

242 

243 def on_press(self, user_data=None): 

244 """Callback called when selecting the item. 

245 

246 Show the edit window for the item. 

247 """ 

248 GitRepositoryEditWindow(self.app, self.object).show() 

249 

250 

251class ServerDirectoryTreeItem(SourceDirectoryTreeItem): 

252 """Tree node for a `hods.sources.ServerDirectory` instance.""" 

253 

254 item_attr = 'source' 

255 

256 def on_press(self, user_data=None): 

257 """Callback called when selecting the item. 

258 

259 Show the edit window for the item. 

260 """ 

261 ServerDirectoryEditWindow(self.app, self.object).show() 

262 

263 

264class AppTreeDecoration(urwidtrees.CollapsibleArrowTree): 

265 """Base tree decoration used to visualize the tree.""" 

266 

267 def __init__(self, app, tree, **kwargs): 

268 """Initialize tree decoration. 

269 

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) 

290 

291 super().__init__(tree, is_collapsed=self.pos_initially_collapsed, **kwargs) 

292 

293 def __getitem__(self, obj): # pragma: no cover 

294 """Return the item for the given tree node object.""" 

295 return TreeItem(obj) 

296 

297 def pos_initially_collapsed(self, pos): 

298 """Return a boolean whether the node is collapsed by default.""" 

299 return self[pos].initially_collapsed() 

300 

301 

302class SourceTreeDecoration(AppTreeDecoration): 

303 """Tree decoration used to visualize a source file tree.""" 

304 

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 

316 

317 

318class PathTreeBox(urwidtrees.TreeBox): 

319 """Base file tree widget.""" 

320 

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) 

326 

327 self.column_index = column_index 

328 

329 def get_item(self): 

330 """Get the item from the selected column.""" 

331 columns = self.get_focus() 

332 return columns[self.column_index] 

333 

334 def on_focus(self): 

335 """Store focus item as selected.""" 

336 self.selected = self.get_item() 

337 

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) 

344 

345 

346class ConfigViewMixin: 

347 """A configuration view mixin to reset tree positions.""" 

348 

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

354 

355 

356class HomeDirectoryNode(DirectoryNode): 

357 """A path node representing a file in a home directory.""" 

358 

359 child_file_class = FileNode 

360 

361 def get_child_directory_class(self): 

362 """Return the node class for childs.""" 

363 return HomeDirectoryNode 

364 

365 

366class HomeRootNode(RootDirectoryNode): 

367 """Base tree node representing a path in the home directory.""" 

368 

369 ignore_basenames = ['.hods'] 

370 

371 child_directory_class = HomeDirectoryNode 

372 

373 child_file_class = FileNode 

374 

375 

376class HomeFileTreeItem(CheckBoxItemMixin, PathTreeItem): 

377 """Tree node item to display a file in the home directory.""" 

378 

379 def get_attr(self): 

380 """Return the urwid attribute map to wrap the widget.""" 

381 return 'file' 

382 

383 

384class HomeDirectoryTreeItem(CheckBoxDirectoryMixin, PathTreeItem): 

385 """Tree node item to display a directory in the home directory.""" 

386 

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 

393 

394 def get_attr(self): 

395 """Return the urwid attribute map to wrap the widget.""" 

396 return 'directory' 

397 

398 def initially_collapsed(self): 

399 """Return a bool whether the item should be initially collapsed.""" 

400 return True 

401 

402 

403class HomeRootTreeItem(PathTreeItem): 

404 """The root node item for the home directory tree.""" 

405 

406 def get_attr(self): 

407 """Return the urwid attribute map to wrap the widget.""" 

408 return 'directory' 

409 

410 def get_label(self): 

411 """Return the absolute path to the home directory.""" 

412 return self.object.basename 

413 

414 def initially_collapsed(self): 

415 """Don't initially collapse the root node.""" 

416 return False 

417 

418 

419class HomeTree(PathTree): 

420 """A path tree representing the home directory.""" 

421 

422 def __init__(self, app, home_path, *args, **kwargs): 

423 """Initialize the tree and its root node. 

424 

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) 

433 

434 def get_children(self, item: HomeDirectoryTreeItem) -> list: 

435 """Get the children list of the given position/item. 

436 

437 Scan position children (if not already) and filter out symlinks. 

438 

439 Args: 

440 item: The directory node to scan for children. 

441 

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) 

448 

449 

450class HomeTreeDecoration(AppTreeDecoration): 

451 """Tree decoration used to visualize a home directory tree.""" 

452 

453 def __init__(self, *args, **kwargs): 

454 """Initialize tree decoration.""" 

455 super().__init__(*args, **kwargs) 

456 

457 # visible items cache 

458 self.items = {} 

459 

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 

465 

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 

474 

475 def clean(self): 

476 """Collect unique objects of checked tree nodes.""" 

477 result = set() 

478 checked = list(self.collect_checked()) 

479 

480 for obj in checked: 

481 if not obj.parent.is_root and obj.parent in checked: 

482 continue 

483 result.add(obj) 

484 

485 return result 

486 

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 

492 

493 if item in (True, False): 

494 if item: # pragma: no branch # coverage bug!? 

495 yield obj 

496 continue 

497 

498 if item.checkbox.state is True: 

499 yield obj