Coverage for src/hods/tui/base/list.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

243 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 

21 

22from hods.tui.base.widgets import FocusMap 

23 

24logger = logging.getLogger(__name__) 

25 

26 

27def button_list(buttons, column=False): 

28 """Build a listbox of buttons. 

29 

30 :param buttons: list of `urwid.Button` objects or tuples containing 

31 the button and the urwid attributes to wrap the item widget with. 

32 :param column: Return a tuple of the maximum item label length and the 

33 listbox to use in a column widget. (Default: False) 

34 :return: `urwid.ListBox` object or `tuple` object 

35 """ 

36 max_label_len = 0 

37 button_items = [] 

38 for tupl in buttons: 

39 if isinstance(tupl, urwid.Divider): 

40 continue 

41 if isinstance(tupl, tuple): 

42 label = tupl[0].label # button label 

43 else: 

44 label = tupl.label 

45 

46 label_len = len(label) + 4 

47 if label_len > max_label_len: 

48 max_label_len = label_len 

49 

50 if not isinstance(tupl, tuple): 

51 button_items.append(tupl) 

52 continue 

53 

54 button_items.append(FocusMap(*tupl)) 

55 

56 listbox = urwid.ListBox(urwid.SimpleListWalker(button_items)) 

57 

58 if column: 

59 return max_label_len, listbox 

60 return listbox 

61 

62 

63class Item(urwid.WidgetWrap): 

64 """A ItemListBox item. 

65 

66 It knows its width and wraps its contents with an urwid attribute. 

67 

68 Signals supported: ``'click'`` 

69 

70 Register signal handler with:: 

71 

72 urwid.connect_signal(button, 'click', callback, user_data) 

73 

74 where callback is callback(button [,user_data]). 

75 

76 Unregister signal handlers with:: 

77 

78 urwid.disconnect_signal(button, 'click', callback, user_data) 

79 

80 >>> Item('Ok', 'attr_map') 

81 <Button selectable flow widget 'Ok'> 

82 >>> b = Item('Cancel', 'attr_map') 

83 >>> b.render((15,), focus=True).text # ... = b in Python 3 

84 [...' Cancel '] 

85 """ 

86 

87 signals = ['click'] 

88 

89 def __init__(self, label, attr, text_right=None, on_press=None, user_data=None, padding=1, 

90 require_doubleclick=False, wrap=urwid.ANY): 

91 """Initialize widget. 

92 

93 :param label: `str` - Markup for button label 

94 :param attr: `str` - style attribute. 

95 :param text_right: `str` - Additional label markup aligned to the right. (Default: None) 

96 :param on_press: Optional function to call when *pressing* the button. (Default: None) 

97 shorthand for connect_signal(): function call for a single callback 

98 :param user_data: Optional data to pass to `on_press`. (Default: None) 

99 """ 

100 self.label_text = label 

101 self.wrap = wrap 

102 self.label_widget = self.get_label_widget() 

103 self.label_width = self.get_label_width() 

104 self.padding = padding 

105 self._on_press = on_press 

106 self.require_doubleclick = require_doubleclick 

107 

108 items = [] 

109 

110 pad = ('fixed', padding, urwid.Text('')) 

111 if padding: 

112 items.append(pad) 

113 

114 items.append(self.label_widget) 

115 

116 self.text_right_width = 0 

117 if text_right is not None: 

118 items.append(('fixed', 1, urwid.Text(''))) 

119 self.text_right_width = len(text_right) 

120 items.append(('fixed', self.text_right_width, urwid.Text(text_right, align=urwid.RIGHT))) 

121 self.text_right_width += 1 

122 

123 self.width = self.label_width + self.text_right_width + (2 * self.padding) 

124 

125 if padding: 

126 items.append(pad) 

127 

128 columns = urwid.Columns(items, dividechars=0) 

129 

130 # decoration 

131 focus_attr = '{} focus'.format(attr) 

132 select_attr = '{} select'.format(attr) 

133 self.columns = urwid.AttrMap(columns, attr, focus_attr) 

134 self.columns_selected = urwid.AttrMap(columns, select_attr, focus_attr) 

135 self.selected = False 

136 

137 self.__super.__init__(self.columns) 

138 

139 urwid.connect_signal(self, 'click', self.on_press, user_data) 

140 

141 def get_label_widget(self): 

142 """Return the widget to display the label.""" 

143 return urwid.Text(self.label_text, wrap=self.wrap) 

144 

145 def get_label_width(self): 

146 """Return the width of the label.""" 

147 return len(self.label_text) 

148 

149 def on_press(self, user_data=None): 

150 """Call the items on_press callback.""" 

151 if callable(self._on_press): # pragma: no branch 

152 self._on_press(user_data=user_data) 

153 

154 def render(self, size, focus=False): 

155 """Fix focus style attribute. 

156 

157 The focus attribute for the inner `urwid.Columns` does 

158 not switch properly. To fix this we replace the complete row. 

159 """ 

160 w = self.columns 

161 if self.selected: 

162 w = self.columns_selected 

163 self._w = w 

164 return super().render(size, focus) 

165 

166 def sizing(self): # pragma: no cover 

167 """Mark the widget as flow widget.""" 

168 return frozenset([urwid.FLOW]) 

169 

170 def selectable(self): 

171 """Return ``True`` to mark the widget selectable.""" 

172 return True 

173 

174 def _repr_words(self): # pragma: no cover 

175 # include button.label in repr(button) 

176 return self.__super._repr_words() + [urwid.wimp.python3_repr(self.label_widget)] 

177 

178 def keypress(self, size, key): 

179 """Key event callback. 

180 

181 Check if the key is a valid selection key. If so, emit the 

182 click event. 

183 """ 

184 logger.debug('Item %s: %s.keypress(%s, %s)', self, self.__class__.__name__, repr(size), repr(key)) 

185 

186 if self._command_map[key] != urwid.ACTIVATE: 

187 return key 

188 

189 self._emit('click') 

190 

191 def mouse_event(self, size, event, button, x, y, focus): 

192 """Check if doubleclick is required emit the click event.""" 

193 if button != 1 or not urwid.wimp.is_mouse_press(event): 

194 return False 

195 

196 if self.require_doubleclick and not focus: 

197 return False 

198 

199 self._emit('click') 

200 return True 

201 

202 

203class CheckBoxItemMixin: 

204 """A mixin to add a checkbox to a list item widget.""" 

205 

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

207 """Initialize checkbox. 

208 

209 :param args: Pass to mixed class. 

210 :param kwargs: Pass to mixed class. 

211 """ 

212 self.items = kwargs.pop('items', {}) 

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

214 

215 @property 

216 def checkbox(self): 

217 """The checkbox widget.""" 

218 return self.label_widget 

219 

220 def get_label_widget(self): 

221 """Return a checkbox widget to display the label.""" 

222 return urwid.CheckBox(self.label_text, state=self.get_initial_state()) 

223 

224 def _toggle_checkbox(self, state=None): 

225 if state is None: 

226 self.checkbox.toggle_state() 

227 state = self.checkbox.state 

228 else: 

229 self.checkbox.set_state(state) 

230 return state 

231 

232 def get_parent_item(self): 

233 """Retrieve the parent tree item for this child.""" 

234 if self.object.parent.is_root: 

235 return 

236 return self.items.get(self.object.parent, None) 

237 

238 def _reset_parent(self): 

239 item = self.get_parent_item() 

240 if item is None: 

241 return 

242 item.reset_state() 

243 

244 def toggle(self, state=None, reset_parent=True): 

245 """Toggle checkbox state.""" 

246 state = self._toggle_checkbox(state) 

247 if reset_parent: 

248 self._reset_parent() 

249 return state 

250 

251 def on_press(self, user_data=None): 

252 """Callback to handle item selection. 

253 

254 Change the checkbox state and update the checked list. 

255 

256 :param user_data: Unused 

257 """ 

258 self.toggle() 

259 

260 def get_label_width(self): 

261 """Add 4 for the checkbox to the label text width.""" 

262 return super().get_label_width() + 4 

263 

264 def get_initial_state(self): 

265 """Return the initial state of the checkbox.""" 

266 item = self.items.get(self.object, None) 

267 if item in (True, False): 

268 # state was set by parent but item is not rendered, yet 

269 return item 

270 

271 if item is not None: # pragma: no cover 

272 raise ValueError('{} is already rendered but asking for initial state'.format(item)) 

273 

274 # Each parent only controls its direct children, so the grand-children 

275 # need to ask their parents again what state they have. Their states 

276 # are not in `items`, yet. 

277 parent_item = self.get_parent_item() 

278 

279 if parent_item is None: 

280 return False # first render 

281 

282 # Use parent state (`mixed` counts as `False` here) 

283 return parent_item.checkbox.state is True 

284 

285 

286class CheckBoxDirectoryMixin(CheckBoxItemMixin): 

287 """Directory tree item with a checkbox prepended. 

288 

289 Each child item can be: 

290 * an instance of `HomeRootNode` 

291 * an instance of `HomeFileTreeItem` 

292 * an instance of `HomeDirectoryTreeItem` 

293 * a `bool` representing the checkbox state (not rendered) 

294 * `None` if the state is still unknown (not rendered and collapsed) 

295 """ 

296 

297 def iter_children(self): 

298 """Generate tuples of children and their corresponding item.""" 

299 if self.object.scan_aged(): 

300 self.object.scan() 

301 

302 for child in self.object.children: 

303 yield child, self.items.get(child, None) 

304 

305 def get_child_states(self): 

306 """Collect checkbox states of children.""" 

307 for obj, item in self.iter_children(): 

308 if item in (True, False, None): 

309 yield item 

310 else: 

311 yield item.checkbox.state 

312 

313 def set_children(self, state): 

314 """Set given checkbox state on children. 

315 

316 Only store its state if the child is not rendered, yet. 

317 """ 

318 for obj, item in self.iter_children(): 

319 if item in (True, False, None): 

320 # child is not rendered, just store the state 

321 self.items[obj] = state 

322 else: 

323 # toggle the child recursively 

324 item.toggle(state, reset_parent=False) 

325 

326 def toggle(self, state=None, reset_parent=True): 

327 """Toggle the checked state.""" 

328 state = super().toggle(state=state, reset_parent=reset_parent) 

329 self.set_children(state) 

330 

331 def _check_state(self): 

332 """Check children states to determine state.""" 

333 states = list(self.get_child_states()) 

334 if all(s is True for s in states): 

335 return True 

336 if all(s is False for s in states): 

337 return False 

338 return 'mixed' 

339 

340 def reset_state(self): 

341 """Check children states and reset own. 

342 

343 This is called when a child is toggled. 

344 """ 

345 self._toggle_checkbox(self._check_state()) 

346 self._reset_parent() 

347 

348 def get_initial_state(self): 

349 """Get initial checkbox state of this item. 

350 

351 If ``True``, check all children, too. 

352 """ 

353 state = super().get_initial_state() 

354 if state is True: 

355 self.set_children(True) 

356 return state 

357 

358 

359class AppItemMixin: 

360 """Mixin to make an item that stores the app as attribute.""" 

361 

362 def __init__(self, app, *args, **kwargs): 

363 """Set the app attribute. 

364 

365 :param app: `hods.tui.__main__.App` object - The app instance. 

366 :param args: Pass to mixed class 

367 :param kwargs: Pass to mixed class 

368 """ 

369 self.app = app 

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

371 

372 

373class ObjectItem(Item): 

374 """A list item widget for an object.""" 

375 

376 def __init__(self, obj, **kwargs): 

377 """Initialize widget. 

378 

379 :param obj: `object` - Any object to store on this item. 

380 :param text_right: `str` - Text to display on the right. 

381 (Default: use get_text_right()) 

382 :param attr: `str` - urwid attr for the widget. 

383 (Default: use get_attr()) 

384 :param focus_attr: `str` - urwid attr to use when the widget is 

385 selected and in focus. (Default: use get_focus_attr()) 

386 :param select_attr: `str` - urwid attr to use when the widget is 

387 selected but not in focus. (Default: use get_select_attr()) 

388 :param kwargs: pass to super 

389 """ 

390 self.object = obj 

391 kwargs.setdefault('text_right', self.get_text_right()) 

392 kwargs.setdefault('attr', self.get_attr()) 

393 super().__init__(self.get_label(), **kwargs) 

394 

395 def get_label(self): 

396 """Overwrite and return the label to display.""" 

397 raise NotImplementedError 

398 

399 def get_text_right(self): 

400 """Return a text to display on the right side.""" 

401 pass 

402 

403 def get_attr(self): 

404 """Return the urwid attribute to wrap the widget.""" 

405 raise NotImplementedError 

406 

407 

408class SortableItemListBoxMixin: 

409 """Mixin to make a item list sortable.""" 

410 

411 def __init__(self, app, *args, **kwargs): 

412 """Initialize sortable list.""" 

413 self.app = app 

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

415 

416 def keypress(self, size, key): 

417 """Key event callback. 

418 

419 Move the object of the selected item and refresh the list of items. 

420 Press F7 to move it down and F8 to move it up. 

421 """ 

422 key = super().keypress(size, key) 

423 if self.selected is not None and getattr(self.selected, 'object', None) is not None: 

424 if key == 'f7': 

425 if self.selected.object.move_down(): 

426 self.app.config.save() 

427 index = self.walker.index(self.selected) 

428 self.refresh() 

429 self.focus_position = index + 1 

430 return 

431 if key == 'f8': 

432 if self.selected.object.move_up(): 

433 self.app.config.save() 

434 index = self.walker.index(self.selected) 

435 self.refresh() 

436 self.focus_position = index - 1 

437 return 

438 return key 

439 

440 

441class SelectableItemListMixin: 

442 """Mixin to mark and store the selected `Item` in a listbox or columns.""" 

443 

444 def __init__(self, *args, on_select=None, **kwargs): 

445 """Initialize selectable list. 

446 

447 Args: 

448 *args: Pass all args 

449 on_select: Called when the focus is changed. (Default: None) 

450 **kwargs: Pass all remaining kwargs 

451 """ 

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

453 self._on_select = on_select 

454 self.selected = None 

455 

456 def on_focus_change(self): 

457 """Call this after focus has changed.""" 

458 if self.selected is not None: 

459 self.selected.selected = False 

460 # _invalidate is not automatically called for the *previously* selected item 

461 self.selected._invalidate() 

462 

463 item = self.get_focus_item() 

464 if item is not None: 

465 item.selected = True 

466 item._invalidate() 

467 self.selected = item 

468 

469 if callable(self._on_select): # pragma: no branch 

470 self._on_select(item) 

471 

472 def get_focus_item(self): 

473 """Return the focused item.""" 

474 raise NotImplementedError 

475 

476 

477class ItemListBox(SelectableItemListMixin, urwid.ListBox): 

478 """A item list box widget.""" 

479 

480 def __init__(self, items=None, refresh=None, **kwargs): 

481 """Initialize widget. 

482 

483 :param refresh: `callable` - Called to refresh the list widget. (Default: None) 

484 :param min_width: 

485 """ 

486 # TODO: check if SimpleFocusListWalker can be modified to support a 'focus' signal # noqa 

487 self.walker = urwid.SimpleListWalker([]) 

488 urwid.connect_signal(self.walker, 'modified', self.on_focus_change) 

489 super().__init__(self.walker, **kwargs) 

490 self._refresh = refresh 

491 self.width = 3 

492 

493 def get_focus_item(self): 

494 """Return the focused item.""" 

495 return self.get_focus()[0] 

496 

497 def refresh(self): 

498 """Refresh the list contents.""" 

499 if callable(self._refresh): # pragma: no branch 

500 return self._refresh() 

501 

502 def set_items(self, items): 

503 """Set the given list of items or reset the current.""" 

504 if items: 

505 self.width = max(item.width for item in items) 

506 else: 

507 self.width = 3 

508 

509 self.walker[:] = items 

510 return self.width 

511 

512 

513class ItemColumns(urwid.Columns): 

514 """A columns widget containing `Item` instances.""" 

515 

516 def __init__(self, items=None, *args, **kwargs): 

517 """Initialize column widget. 

518 

519 :param items: child menu items 

520 :param args: pass to parent `urwid.Columns` 

521 :param kwargs: pass to parent `urwid.Columns` 

522 """ 

523 super().__init__([], *args, **kwargs) 

524 self.set_items(items) 

525 

526 def set_items(self, items): 

527 """Set the given list of items.""" 

528 try: 

529 focus_position = self.focus_position 

530 except IndexError: 

531 focus_position = 0 

532 

533 self.contents = [ 

534 (item, self.options(urwid.GIVEN, item.width)) 

535 for item in items 

536 ] 

537 

538 try: 

539 self.focus_position = focus_position 

540 except IndexError: # pragma: no cover 

541 pass 

542 

543 

544class SortableItemListBox(SortableItemListBoxMixin, ItemListBox): 

545 """A sortable item list box widget.""" 

546 

547 pass