From 7786fc1586625082e364eb0734d0aea16f2f43b8 Mon Sep 17 00:00:00 2001 From: Patrick Totzke Date: Sun, 25 Oct 2026 04:11:47 +0000 Subject: [PATCH] Draw no ArrowTree connector or bar towards empty nested subtrees In a NestedTree an empty subtree takes no line, but ArrowTree asked its own tree for the next sibling and so drew a T-shaped connector and a vertical bar towards it when it was the last entry. ArrowTree now skips siblings that are trees holding nothing but empty trees. Co-Authored-By: Claude Opus 5.5 --- tests/test_nested.py | 23 +++++++++++++++++++++++ urwidtrees/decoration.py | 40 ++++++++++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/tests/test_nested.py b/tests/test_nested.py index 8aded84..0dfed02 100644 --- a/tests/test_nested.py +++ b/tests/test_nested.py @@ -57,6 +57,29 @@ def test_indented(self): ['R', ' c', ' x', ' y']) + def test_arrows_skip_empty_subtrees(self): + """an empty subtree after the last node draws no connector or bar""" + empty = SimpleTree([]) + tree = NestedTree(ArrowTree(SimpleTree([ + (Node('R'), [(Node('a'), [(Node('a1'), None)]), (empty, None), + (SimpleTree([(empty, None)]), None)]), + ]))) + self.assertEqual(render_tree(tree), ['R', '└─➤a', ' └─➤a1']) + a1 = ((0, 0, 0),) + self.assertEqual(render(tree.decorate(a1, Node('m'), False), 8)[0], + ' m ') + + def test_arrows_skip_deeply_nested_empty_subtree(self): + # decoration only: NestedTree navigation still recurses once per + # nesting level + empty = SimpleTree([]) + for _ in range(1500): + empty = SimpleTree([(empty, None)]) + tree = NestedTree(ArrowTree(SimpleTree([ + (Node('R'), [(Node('a'), None), (empty, None)])]))) + self.assertEqual(render(tree.get_decorated(((0, 0),)), 8)[0], + '└─➤a ') + def test_decoration_two_levels(self): inner = ArrowTree(SimpleTree([(Node('x'), [(Node('y'), None)])])) middle = ArrowTree(SimpleTree([(inner, None)])) diff --git a/urwidtrees/decoration.py b/urwidtrees/decoration.py index 668d766..0a203e2 100644 --- a/urwidtrees/decoration.py +++ b/urwidtrees/decoration.py @@ -11,6 +11,23 @@ class NoSpaceError(Exception): """too little space for requested decoration""" +def _is_empty_tree(entry): + """ + whether `entry` is a tree holding nothing but empty trees, which takes + no line in a NestedTree (compare NestedTree._sanitize_position) + """ + todo = [entry] + while todo: + entry = todo.pop() + if not isinstance(entry, Tree): + return False + pos = entry.root + while pos is not None: + todo.append(entry[pos]) + pos = entry.next_sibling_position(pos) + return True + + class DecoratedTree(Tree): """ :class:`Tree` that wraps around another :class:`Tree` and allows to read @@ -331,6 +348,19 @@ def __init__(self, walker, self._arrow_tip_att = arrow_tip_att self._arrow_att = arrow_att + def _has_next_sibling(self, pos): + """ + whether a line follows `pos` at its level; an empty tree nested in + place of a node takes no line in a :class:`NestedTree` + """ + # ponytail: walks the run of empty subtrees on every call; a long run + # costs as much per rendered line below it, which the walker's line + # cache absorbs + pos = self._tree.next_sibling_position(pos) + while pos is not None and _is_empty_tree(self._tree[pos]): + pos = self._tree.next_sibling_position(pos) + return pos is not None + def _construct_spacer(self, pos, acc): """ build a spacer that occupies the horizontally indented space between @@ -341,8 +371,7 @@ def _construct_spacer(self, pos, acc): while parent is not None: grandparent = self._tree.parent_position(parent) if self._indent > 0 and grandparent is not None: - parent_sib = self._tree.next_sibling_position(parent) - draw_vbar = parent_sib is not None + draw_vbar = self._has_next_sibling(parent) space_width = self._indent - 1 * (draw_vbar) - self._childbar_offset if space_width > 0: void = urwid.AttrMap(urwid.SolidFill(' '), self._arrow_att) @@ -364,7 +393,7 @@ def _construct_connector(self, pos): # connector symbol, either L or |- shaped. connectorw = None connector = None - if self._tree.next_sibling_position(pos) is not None: # |- shaped + if self._has_next_sibling(pos): # |- shaped if self._arrow_connector_tchar is not None: connectorw = urwid.Text(self._arrow_connector_tchar) else: # L shaped @@ -402,7 +431,7 @@ def _construct_first_indent(self, pos): if width > available_width: raise NoSpaceError() available_width -= width - if self._tree.next_sibling_position(pos) is not None: + if self._has_next_sibling(pos): barw = urwid.SolidFill(self._arrow_vbar_char) below = urwid.AttrMap(barw, self._arrow_vbar_att or self._arrow_att) @@ -453,8 +482,7 @@ def decorate(self, pos, widget, is_first=True): else: parent = self._tree.parent_position(pos) if self._indent > 0 and parent is not None: - parent_sib = self._tree.next_sibling_position(pos) - draw_vbar = parent_sib is not None + draw_vbar = self._has_next_sibling(pos) void = urwid.AttrMap(urwid.SolidFill(' '), self._arrow_att) if draw_vbar: