490 lines
16 KiB
Python
490 lines
16 KiB
Python
|
#!/usr/bin/python
|
||
|
#
|
||
|
# Generic TreeWidget/TreeWalker class
|
||
|
# Copyright (c) 2010 Rob Lanphier
|
||
|
# Copyright (C) 2004-2010 Ian Ward
|
||
|
#
|
||
|
# This library is free software; you can redistribute it and/or
|
||
|
# modify it under the terms of the GNU Lesser General Public
|
||
|
# License as published by the Free Software Foundation; either
|
||
|
# version 2.1 of the License, or (at your option) any later version.
|
||
|
#
|
||
|
# This library is distributed in the hope that it will be useful,
|
||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||
|
# Lesser General Public License for more details.
|
||
|
#
|
||
|
# You should have received a copy of the GNU Lesser General Public
|
||
|
# License along with this library; if not, write to the Free Software
|
||
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||
|
#
|
||
|
# Urwid web site: https://urwid.org/
|
||
|
|
||
|
|
||
|
"""
|
||
|
Urwid tree view
|
||
|
|
||
|
Features:
|
||
|
- custom selectable widgets for trees
|
||
|
- custom list walker for displaying widgets in a tree fashion
|
||
|
"""
|
||
|
|
||
|
|
||
|
from __future__ import annotations
|
||
|
|
||
|
import urwid
|
||
|
from urwid.wimp import SelectableIcon
|
||
|
|
||
|
|
||
|
class TreeWidgetError(RuntimeError):
|
||
|
pass
|
||
|
|
||
|
|
||
|
class TreeWidget(urwid.WidgetWrap):
|
||
|
"""A widget representing something in a nested tree display."""
|
||
|
indent_cols = 3
|
||
|
unexpanded_icon = SelectableIcon('+', 0)
|
||
|
expanded_icon = SelectableIcon('-', 0)
|
||
|
|
||
|
def __init__(self, node):
|
||
|
self._node = node
|
||
|
self._innerwidget = None
|
||
|
self.is_leaf = not hasattr(node, 'get_first_child')
|
||
|
self.expanded = True
|
||
|
widget = self.get_indented_widget()
|
||
|
super().__init__(widget)
|
||
|
|
||
|
def selectable(self):
|
||
|
"""
|
||
|
Allow selection of non-leaf nodes so children may be (un)expanded
|
||
|
"""
|
||
|
return not self.is_leaf
|
||
|
|
||
|
def get_indented_widget(self):
|
||
|
widget = self.get_inner_widget()
|
||
|
if not self.is_leaf:
|
||
|
widget = urwid.Columns([('fixed', 1,
|
||
|
[self.unexpanded_icon, self.expanded_icon][self.expanded]),
|
||
|
widget], dividechars=1)
|
||
|
indent_cols = self.get_indent_cols()
|
||
|
return urwid.Padding(widget, width=('relative', 100), left=indent_cols)
|
||
|
|
||
|
def update_expanded_icon(self):
|
||
|
"""Update display widget text for parent widgets"""
|
||
|
# icon is first element in columns indented widget
|
||
|
self._w.base_widget.widget_list[0] = [
|
||
|
self.unexpanded_icon, self.expanded_icon][self.expanded]
|
||
|
|
||
|
def get_indent_cols(self):
|
||
|
return self.indent_cols * self.get_node().get_depth()
|
||
|
|
||
|
def get_inner_widget(self):
|
||
|
if self._innerwidget is None:
|
||
|
self._innerwidget = self.load_inner_widget()
|
||
|
return self._innerwidget
|
||
|
|
||
|
def load_inner_widget(self):
|
||
|
return urwid.Text(self.get_display_text())
|
||
|
|
||
|
def get_node(self):
|
||
|
return self._node
|
||
|
|
||
|
def get_display_text(self):
|
||
|
return (f"{self.get_node().get_key()}: {str(self.get_node().get_value())}")
|
||
|
|
||
|
def next_inorder(self):
|
||
|
"""Return the next TreeWidget depth first from this one."""
|
||
|
# first check if there's a child widget
|
||
|
firstchild = self.first_child()
|
||
|
if firstchild is not None:
|
||
|
return firstchild
|
||
|
|
||
|
# now we need to hunt for the next sibling
|
||
|
thisnode = self.get_node()
|
||
|
nextnode = thisnode.next_sibling()
|
||
|
depth = thisnode.get_depth()
|
||
|
while nextnode is None and depth > 0:
|
||
|
# keep going up the tree until we find an ancestor next sibling
|
||
|
thisnode = thisnode.get_parent()
|
||
|
nextnode = thisnode.next_sibling()
|
||
|
depth -= 1
|
||
|
assert depth == thisnode.get_depth()
|
||
|
if nextnode is None:
|
||
|
# we're at the end of the tree
|
||
|
return None
|
||
|
else:
|
||
|
return nextnode.get_widget()
|
||
|
|
||
|
def prev_inorder(self):
|
||
|
"""Return the previous TreeWidget depth first from this one."""
|
||
|
thisnode = self._node
|
||
|
prevnode = thisnode.prev_sibling()
|
||
|
if prevnode is not None:
|
||
|
# we need to find the last child of the previous widget if its
|
||
|
# expanded
|
||
|
prevwidget = prevnode.get_widget()
|
||
|
lastchild = prevwidget.last_child()
|
||
|
if lastchild is None:
|
||
|
return prevwidget
|
||
|
else:
|
||
|
return lastchild
|
||
|
else:
|
||
|
# need to hunt for the parent
|
||
|
depth = thisnode.get_depth()
|
||
|
if prevnode is None and depth == 0:
|
||
|
return None
|
||
|
elif prevnode is None:
|
||
|
prevnode = thisnode.get_parent()
|
||
|
return prevnode.get_widget()
|
||
|
|
||
|
def keypress(self, size, key: str) -> str | None:
|
||
|
"""Handle expand & collapse requests (non-leaf nodes)"""
|
||
|
if self.is_leaf:
|
||
|
return key
|
||
|
|
||
|
if key in ("+", "right"):
|
||
|
self.expanded = True
|
||
|
self.update_expanded_icon()
|
||
|
elif key == "-":
|
||
|
self.expanded = False
|
||
|
self.update_expanded_icon()
|
||
|
elif self._w.selectable():
|
||
|
return super().keypress(size, key)
|
||
|
else:
|
||
|
return key
|
||
|
|
||
|
def mouse_event(self, size, event, button: int, col: int, row: int, focus: bool) -> bool:
|
||
|
if self.is_leaf or event != 'mouse press' or button !=1:
|
||
|
return False
|
||
|
|
||
|
if row == 0 and col == self.get_indent_cols():
|
||
|
self.expanded = not self.expanded
|
||
|
self.update_expanded_icon()
|
||
|
return True
|
||
|
|
||
|
return False
|
||
|
|
||
|
def first_child(self):
|
||
|
"""Return first child if expanded."""
|
||
|
if self.is_leaf or not self.expanded:
|
||
|
return None
|
||
|
else:
|
||
|
if self._node.has_children():
|
||
|
firstnode = self._node.get_first_child()
|
||
|
return firstnode.get_widget()
|
||
|
else:
|
||
|
return None
|
||
|
|
||
|
def last_child(self):
|
||
|
"""Return last child if expanded."""
|
||
|
if self.is_leaf or not self.expanded:
|
||
|
return None
|
||
|
else:
|
||
|
if self._node.has_children():
|
||
|
lastchild = self._node.get_last_child().get_widget()
|
||
|
else:
|
||
|
return None
|
||
|
# recursively search down for the last descendant
|
||
|
lastdescendant = lastchild.last_child()
|
||
|
if lastdescendant is None:
|
||
|
return lastchild
|
||
|
else:
|
||
|
return lastdescendant
|
||
|
|
||
|
|
||
|
class TreeNode:
|
||
|
"""
|
||
|
Store tree contents and cache TreeWidget objects.
|
||
|
A TreeNode consists of the following elements:
|
||
|
* key: accessor token for parent nodes
|
||
|
* value: subclass-specific data
|
||
|
* parent: a TreeNode which contains a pointer back to this object
|
||
|
* widget: The widget used to render the object
|
||
|
"""
|
||
|
def __init__(self, value, parent=None, key=None, depth=None):
|
||
|
self._key = key
|
||
|
self._parent = parent
|
||
|
self._value = value
|
||
|
self._depth = depth
|
||
|
self._widget = None
|
||
|
|
||
|
def get_widget(self, reload=False):
|
||
|
""" Return the widget for this node."""
|
||
|
if self._widget is None or reload == True:
|
||
|
self._widget = self.load_widget()
|
||
|
return self._widget
|
||
|
|
||
|
def load_widget(self):
|
||
|
return TreeWidget(self)
|
||
|
|
||
|
def get_depth(self):
|
||
|
if self._depth is None and self._parent is None:
|
||
|
self._depth = 0
|
||
|
elif self._depth is None:
|
||
|
self._depth = self._parent.get_depth() + 1
|
||
|
return self._depth
|
||
|
|
||
|
def get_index(self):
|
||
|
if self.get_depth() == 0:
|
||
|
return None
|
||
|
else:
|
||
|
key = self.get_key()
|
||
|
parent = self.get_parent()
|
||
|
return parent.get_child_index(key)
|
||
|
|
||
|
def get_key(self):
|
||
|
return self._key
|
||
|
|
||
|
def set_key(self, key):
|
||
|
self._key = key
|
||
|
|
||
|
def change_key(self, key):
|
||
|
self.get_parent().change_child_key(self._key, key)
|
||
|
|
||
|
def get_parent(self):
|
||
|
if self._parent is None and self.get_depth() > 0:
|
||
|
self._parent = self.load_parent()
|
||
|
return self._parent
|
||
|
|
||
|
def load_parent(self):
|
||
|
"""Provide TreeNode with a parent for the current node. This function
|
||
|
is only required if the tree was instantiated from a child node
|
||
|
(virtual function)"""
|
||
|
raise TreeWidgetError("virtual function. Implement in subclass")
|
||
|
|
||
|
def get_value(self):
|
||
|
return self._value
|
||
|
|
||
|
def is_root(self):
|
||
|
return self.get_depth() == 0
|
||
|
|
||
|
def next_sibling(self):
|
||
|
if self.get_depth() > 0:
|
||
|
return self.get_parent().next_child(self.get_key())
|
||
|
else:
|
||
|
return None
|
||
|
|
||
|
def prev_sibling(self):
|
||
|
if self.get_depth() > 0:
|
||
|
return self.get_parent().prev_child(self.get_key())
|
||
|
else:
|
||
|
return None
|
||
|
|
||
|
def get_root(self):
|
||
|
root = self
|
||
|
while root.get_parent() is not None:
|
||
|
root = root.get_parent()
|
||
|
return root
|
||
|
|
||
|
|
||
|
class ParentNode(TreeNode):
|
||
|
"""Maintain sort order for TreeNodes."""
|
||
|
def __init__(self, value, parent=None, key=None, depth=None):
|
||
|
super().__init__(value, parent=parent, key=key, depth=depth)
|
||
|
|
||
|
self._child_keys = None
|
||
|
self._children = {}
|
||
|
|
||
|
def get_child_keys(self, reload=False):
|
||
|
"""Return a possibly ordered list of child keys"""
|
||
|
if self._child_keys is None or reload == True:
|
||
|
self._child_keys = self.load_child_keys()
|
||
|
return self._child_keys
|
||
|
|
||
|
def load_child_keys(self):
|
||
|
"""Provide ParentNode with an ordered list of child keys (virtual
|
||
|
function)"""
|
||
|
raise TreeWidgetError("virtual function. Implement in subclass")
|
||
|
|
||
|
def get_child_widget(self, key):
|
||
|
"""Return the widget for a given key. Create if necessary."""
|
||
|
|
||
|
child = self.get_child_node(key)
|
||
|
return child.get_widget()
|
||
|
|
||
|
def get_child_node(self, key, reload=False):
|
||
|
"""Return the child node for a given key. Create if necessary."""
|
||
|
if key not in self._children or reload == True:
|
||
|
self._children[key] = self.load_child_node(key)
|
||
|
return self._children[key]
|
||
|
|
||
|
def load_child_node(self, key):
|
||
|
"""Load the child node for a given key (virtual function)"""
|
||
|
raise TreeWidgetError("virtual function. Implement in subclass")
|
||
|
|
||
|
def set_child_node(self, key, node):
|
||
|
"""Set the child node for a given key. Useful for bottom-up, lazy
|
||
|
population of a tree."""
|
||
|
self._children[key] = node
|
||
|
|
||
|
def change_child_key(self, oldkey, newkey):
|
||
|
if newkey in self._children:
|
||
|
raise TreeWidgetError(f"{newkey} is already in use")
|
||
|
self._children[newkey] = self._children.pop(oldkey)
|
||
|
self._children[newkey].set_key(newkey)
|
||
|
|
||
|
def get_child_index(self, key):
|
||
|
try:
|
||
|
return self.get_child_keys().index(key)
|
||
|
except ValueError:
|
||
|
errorstring = f"Can't find key %s in ParentNode %s\nParentNode items: %s"
|
||
|
raise TreeWidgetError(errorstring % (key, self.get_key(),
|
||
|
str(self.get_child_keys())))
|
||
|
|
||
|
def next_child(self, key):
|
||
|
"""Return the next child node in index order from the given key."""
|
||
|
|
||
|
index = self.get_child_index(key)
|
||
|
# the given node may have just been deleted
|
||
|
if index is None:
|
||
|
return None
|
||
|
index += 1
|
||
|
|
||
|
child_keys = self.get_child_keys()
|
||
|
if index < len(child_keys):
|
||
|
# get the next item at same level
|
||
|
return self.get_child_node(child_keys[index])
|
||
|
else:
|
||
|
return None
|
||
|
|
||
|
def prev_child(self, key):
|
||
|
"""Return the previous child node in index order from the given key."""
|
||
|
index = self.get_child_index(key)
|
||
|
if index is None:
|
||
|
return None
|
||
|
|
||
|
child_keys = self.get_child_keys()
|
||
|
index -= 1
|
||
|
|
||
|
if index >= 0:
|
||
|
# get the previous item at same level
|
||
|
return self.get_child_node(child_keys[index])
|
||
|
else:
|
||
|
return None
|
||
|
|
||
|
def get_first_child(self):
|
||
|
"""Return the first TreeNode in the directory."""
|
||
|
child_keys = self.get_child_keys()
|
||
|
return self.get_child_node(child_keys[0])
|
||
|
|
||
|
def get_last_child(self):
|
||
|
"""Return the last TreeNode in the directory."""
|
||
|
child_keys = self.get_child_keys()
|
||
|
return self.get_child_node(child_keys[-1])
|
||
|
|
||
|
def has_children(self):
|
||
|
"""Does this node have any children?"""
|
||
|
return len(self.get_child_keys())>0
|
||
|
|
||
|
|
||
|
class TreeWalker(urwid.ListWalker):
|
||
|
"""ListWalker-compatible class for displaying TreeWidgets
|
||
|
|
||
|
positions are TreeNodes."""
|
||
|
|
||
|
def __init__(self, start_from):
|
||
|
"""start_from: TreeNode with the initial focus."""
|
||
|
self.focus = start_from
|
||
|
|
||
|
def get_focus(self):
|
||
|
widget = self.focus.get_widget()
|
||
|
return widget, self.focus
|
||
|
|
||
|
def set_focus(self, focus):
|
||
|
self.focus = focus
|
||
|
self._modified()
|
||
|
|
||
|
def get_next(self, start_from):
|
||
|
widget = start_from.get_widget()
|
||
|
target = widget.next_inorder()
|
||
|
if target is None:
|
||
|
return None, None
|
||
|
else:
|
||
|
return target, target.get_node()
|
||
|
|
||
|
def get_prev(self, start_from):
|
||
|
widget = start_from.get_widget()
|
||
|
target = widget.prev_inorder()
|
||
|
if target is None:
|
||
|
return None, None
|
||
|
else:
|
||
|
return target, target.get_node()
|
||
|
|
||
|
|
||
|
class TreeListBox(urwid.ListBox):
|
||
|
"""A ListBox with special handling for navigation and
|
||
|
collapsing of TreeWidgets"""
|
||
|
|
||
|
def keypress(self, size: tuple[int, int], key: str) -> str | None:
|
||
|
key = super().keypress(size, key)
|
||
|
return self.unhandled_input(size, key)
|
||
|
|
||
|
def unhandled_input(self, size: tuple[int, int], input: str) -> str | None:
|
||
|
"""Handle macro-navigation keys"""
|
||
|
if input == 'left':
|
||
|
self.move_focus_to_parent(size)
|
||
|
elif input == '-':
|
||
|
self.collapse_focus_parent(size)
|
||
|
else:
|
||
|
return input
|
||
|
|
||
|
def collapse_focus_parent(self, size: tuple[int, int]) -> None:
|
||
|
"""Collapse parent directory."""
|
||
|
|
||
|
widget, pos = self.body.get_focus()
|
||
|
self.move_focus_to_parent(size)
|
||
|
|
||
|
pwidget, ppos = self.body.get_focus()
|
||
|
if pos != ppos:
|
||
|
self.keypress(size, "-")
|
||
|
|
||
|
def move_focus_to_parent(self, size: tuple[int, int]) -> None:
|
||
|
"""Move focus to parent of widget in focus."""
|
||
|
|
||
|
widget, pos = self.body.get_focus()
|
||
|
|
||
|
parentpos = pos.get_parent()
|
||
|
|
||
|
if parentpos is None:
|
||
|
return
|
||
|
|
||
|
middle, top, bottom = self.calculate_visible(size)
|
||
|
|
||
|
row_offset, focus_widget, focus_pos, focus_rows, cursor = middle
|
||
|
trim_top, fill_above = top
|
||
|
|
||
|
for widget, pos, rows in fill_above:
|
||
|
row_offset -= rows
|
||
|
if pos == parentpos:
|
||
|
self.change_focus(size, pos, row_offset)
|
||
|
return
|
||
|
|
||
|
self.change_focus(size, pos.get_parent())
|
||
|
|
||
|
def _keypress_max_left(self, size: tuple[int, int]) -> None:
|
||
|
return self.focus_home(size)
|
||
|
|
||
|
def _keypress_max_right(self, size: tuple[int, int]) -> None:
|
||
|
return self.focus_end(size)
|
||
|
|
||
|
def focus_home(self, size: tuple[int, int]) -> None:
|
||
|
"""Move focus to very top."""
|
||
|
|
||
|
widget, pos = self.body.get_focus()
|
||
|
rootnode = pos.get_root()
|
||
|
self.change_focus(size, rootnode)
|
||
|
|
||
|
def focus_end(self, size: tuple[int, int]) -> None:
|
||
|
"""Move focus to far bottom."""
|
||
|
|
||
|
maxrow, maxcol = size
|
||
|
widget, pos = self.body.get_focus()
|
||
|
rootnode = pos.get_root()
|
||
|
rootwidget = rootnode.get_widget()
|
||
|
lastwidget = rootwidget.last_child()
|
||
|
if lastwidget:
|
||
|
lastnode = lastwidget.get_node()
|
||
|
|
||
|
self.change_focus(size, lastnode, maxrow-1)
|
||
|
|