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
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
22from hods.tui.base.widgets import FocusMap
24logger = logging.getLogger(__name__)
27def button_list(buttons, column=False):
28 """Build a listbox of buttons.
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
46 label_len = len(label) + 4
47 if label_len > max_label_len:
48 max_label_len = label_len
50 if not isinstance(tupl, tuple):
51 button_items.append(tupl)
52 continue
54 button_items.append(FocusMap(*tupl))
56 listbox = urwid.ListBox(urwid.SimpleListWalker(button_items))
58 if column:
59 return max_label_len, listbox
60 return listbox
63class Item(urwid.WidgetWrap):
64 """A ItemListBox item.
66 It knows its width and wraps its contents with an urwid attribute.
68 Signals supported: ``'click'``
70 Register signal handler with::
72 urwid.connect_signal(button, 'click', callback, user_data)
74 where callback is callback(button [,user_data]).
76 Unregister signal handlers with::
78 urwid.disconnect_signal(button, 'click', callback, user_data)
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 """
87 signals = ['click']
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.
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
108 items = []
110 pad = ('fixed', padding, urwid.Text(''))
111 if padding:
112 items.append(pad)
114 items.append(self.label_widget)
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
123 self.width = self.label_width + self.text_right_width + (2 * self.padding)
125 if padding:
126 items.append(pad)
128 columns = urwid.Columns(items, dividechars=0)
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
137 self.__super.__init__(self.columns)
139 urwid.connect_signal(self, 'click', self.on_press, user_data)
141 def get_label_widget(self):
142 """Return the widget to display the label."""
143 return urwid.Text(self.label_text, wrap=self.wrap)
145 def get_label_width(self):
146 """Return the width of the label."""
147 return len(self.label_text)
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)
154 def render(self, size, focus=False):
155 """Fix focus style attribute.
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)
166 def sizing(self): # pragma: no cover
167 """Mark the widget as flow widget."""
168 return frozenset([urwid.FLOW])
170 def selectable(self):
171 """Return ``True`` to mark the widget selectable."""
172 return True
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)]
178 def keypress(self, size, key):
179 """Key event callback.
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))
186 if self._command_map[key] != urwid.ACTIVATE:
187 return key
189 self._emit('click')
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
196 if self.require_doubleclick and not focus:
197 return False
199 self._emit('click')
200 return True
203class CheckBoxItemMixin:
204 """A mixin to add a checkbox to a list item widget."""
206 def __init__(self, *args, **kwargs):
207 """Initialize checkbox.
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)
215 @property
216 def checkbox(self):
217 """The checkbox widget."""
218 return self.label_widget
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())
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
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)
238 def _reset_parent(self):
239 item = self.get_parent_item()
240 if item is None:
241 return
242 item.reset_state()
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
251 def on_press(self, user_data=None):
252 """Callback to handle item selection.
254 Change the checkbox state and update the checked list.
256 :param user_data: Unused
257 """
258 self.toggle()
260 def get_label_width(self):
261 """Add 4 for the checkbox to the label text width."""
262 return super().get_label_width() + 4
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
271 if item is not None: # pragma: no cover
272 raise ValueError('{} is already rendered but asking for initial state'.format(item))
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()
279 if parent_item is None:
280 return False # first render
282 # Use parent state (`mixed` counts as `False` here)
283 return parent_item.checkbox.state is True
286class CheckBoxDirectoryMixin(CheckBoxItemMixin):
287 """Directory tree item with a checkbox prepended.
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 """
297 def iter_children(self):
298 """Generate tuples of children and their corresponding item."""
299 if self.object.scan_aged():
300 self.object.scan()
302 for child in self.object.children:
303 yield child, self.items.get(child, None)
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
313 def set_children(self, state):
314 """Set given checkbox state on children.
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)
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)
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'
340 def reset_state(self):
341 """Check children states and reset own.
343 This is called when a child is toggled.
344 """
345 self._toggle_checkbox(self._check_state())
346 self._reset_parent()
348 def get_initial_state(self):
349 """Get initial checkbox state of this item.
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
359class AppItemMixin:
360 """Mixin to make an item that stores the app as attribute."""
362 def __init__(self, app, *args, **kwargs):
363 """Set the app attribute.
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)
373class ObjectItem(Item):
374 """A list item widget for an object."""
376 def __init__(self, obj, **kwargs):
377 """Initialize widget.
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)
395 def get_label(self):
396 """Overwrite and return the label to display."""
397 raise NotImplementedError
399 def get_text_right(self):
400 """Return a text to display on the right side."""
401 pass
403 def get_attr(self):
404 """Return the urwid attribute to wrap the widget."""
405 raise NotImplementedError
408class SortableItemListBoxMixin:
409 """Mixin to make a item list sortable."""
411 def __init__(self, app, *args, **kwargs):
412 """Initialize sortable list."""
413 self.app = app
414 super().__init__(*args, **kwargs)
416 def keypress(self, size, key):
417 """Key event callback.
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
441class SelectableItemListMixin:
442 """Mixin to mark and store the selected `Item` in a listbox or columns."""
444 def __init__(self, *args, on_select=None, **kwargs):
445 """Initialize selectable list.
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
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()
463 item = self.get_focus_item()
464 if item is not None:
465 item.selected = True
466 item._invalidate()
467 self.selected = item
469 if callable(self._on_select): # pragma: no branch
470 self._on_select(item)
472 def get_focus_item(self):
473 """Return the focused item."""
474 raise NotImplementedError
477class ItemListBox(SelectableItemListMixin, urwid.ListBox):
478 """A item list box widget."""
480 def __init__(self, items=None, refresh=None, **kwargs):
481 """Initialize widget.
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
493 def get_focus_item(self):
494 """Return the focused item."""
495 return self.get_focus()[0]
497 def refresh(self):
498 """Refresh the list contents."""
499 if callable(self._refresh): # pragma: no branch
500 return self._refresh()
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
509 self.walker[:] = items
510 return self.width
513class ItemColumns(urwid.Columns):
514 """A columns widget containing `Item` instances."""
516 def __init__(self, items=None, *args, **kwargs):
517 """Initialize column widget.
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)
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
533 self.contents = [
534 (item, self.options(urwid.GIVEN, item.width))
535 for item in items
536 ]
538 try:
539 self.focus_position = focus_position
540 except IndexError: # pragma: no cover
541 pass
544class SortableItemListBox(SortableItemListBoxMixin, ItemListBox):
545 """A sortable item list box widget."""
547 pass