diff --git a/examples/browse.py b/examples/browse.py index 87c235296..704decddd 100755 --- a/examples/browse.py +++ b/examples/browse.py @@ -274,7 +274,7 @@ def __init__(self) -> None: self.listbox.offset_rows = 1 self.footer = urwid.AttrMap(urwid.Text(self.footer_text), "foot") self.view = urwid.Frame( - urwid.AttrMap(self.listbox, "body"), + urwid.AttrMap(urwid.ScrollBar(self.listbox), "body"), header=urwid.AttrMap(self.header, "head"), footer=self.footer, ) diff --git a/tests/test_scrollable.py b/tests/test_scrollable.py index a6fe98f87..d43666eff 100644 --- a/tests/test_scrollable.py +++ b/tests/test_scrollable.py @@ -192,9 +192,28 @@ def test_negative(self): with self.assertRaises(TypeError): urwid.ScrollBar(urwid.SolidFill(" ")) + def test_no_scrollbar(self): + """If widget fit without scroll - no scrollbar needed""" + widget = urwid.ScrollBar( + urwid.Scrollable(urwid.BigText("1", urwid.HalfBlockHeavy6x5Font())), + trough_char=urwid.ScrollBar.Symbols.LITE_SHADE, + thumb_char=urwid.ScrollBar.Symbols.DARK_SHADE, + ) + reduced_size = (8, 5) + self.assertEqual( + ( + " ▐█▌ ", + " ▀█▌ ", + " █▌ ", + " █▌ ", + " ███▌ ", + ), + widget.render(reduced_size).decoded_text, + ) + class TestScrollBarListBox(unittest.TestCase): - def test_non_selectable(self): + def test_relative_non_selectable(self): widget = urwid.ScrollBar( urwid.ListBox(urwid.SimpleListWalker(urwid.Text(line) for line in LGPL_HEADER.splitlines())) ) @@ -245,13 +264,15 @@ def test_non_selectable(self): widget.keypress(reduced_size, "end") + # Here we have ListBox issue: "end" really scroll not to the "dead end" + # in case of the bottom focus position is multiline self.assertEqual( ( + "GNU Lesser General Public ", "License along with this library; if ", "not, write to the Free Software ", "Foundation, Inc., 51 Franklin Street, ", - "Fifth Floor, Boston, MA 02110-1301 ", - "USA █", + "Fifth Floor, Boston, MA 02110-1301 █", ), widget.render(reduced_size).decoded_text, ) diff --git a/urwid/widget/listbox.py b/urwid/widget/listbox.py index 2e045af23..92c48bfaa 100644 --- a/urwid/widget/listbox.py +++ b/urwid/widget/listbox.py @@ -43,7 +43,18 @@ from urwid.canvas import Canvas, CompositeCanvas -__all__ = ("ListBox", "ListBoxError", "ListWalker", "ListWalkerError", "SimpleFocusListWalker", "SimpleListWalker") +__all__ = ( + "ListBox", + "ListBoxError", + "ListWalker", + "ListWalkerError", + "SimpleFocusListWalker", + "SimpleListWalker", + "VisibleInfo", + "VisibleInfoFillItem", + "VisibleInfoMiddle", + "VisibleInfoTopBottom", +) _T = typing.TypeVar("_T") _K = typing.TypeVar("_K") @@ -54,17 +65,13 @@ class ListWalkerError(Exception): @runtime_checkable -class ScrollSupportingBody(Sized, Protocol): - """Protocol for ListWalkers that support Scrolling.""" +class ScrollSupportingBody(Protocol): + """Protocol for ListWalkers.""" def get_focus(self) -> tuple[Widget, _K]: ... def set_focus(self, position: _K) -> None: ... - def __getitem__(self, index: _K) -> _T: ... - - def positions(self, reverse: bool = False) -> Iterable[_K]: ... - def get_next(self, position: _K) -> tuple[Widget, _K] | tuple[None, None]: ... def get_prev(self, position: _K) -> tuple[Widget, _K] | tuple[None, None]: ... @@ -276,7 +283,7 @@ class ListBoxError(Exception): pass -class _Middle(typing.NamedTuple): +class VisibleInfoMiddle(typing.NamedTuple): """Named tuple for ListBox internals.""" offset: int @@ -286,7 +293,7 @@ class _Middle(typing.NamedTuple): cursor: tuple[int, int] | tuple[int] | None -class _FillItem(typing.NamedTuple): +class VisibleInfoFillItem(typing.NamedTuple): """Named tuple for ListBox internals.""" widget: Widget @@ -294,11 +301,45 @@ class _FillItem(typing.NamedTuple): rows: int -class _TopBottom(typing.NamedTuple): +class VisibleInfoTopBottom(typing.NamedTuple): """Named tuple for ListBox internals.""" trim: int - fill: list[_FillItem] + fill: list[VisibleInfoFillItem] + + @classmethod + def from_raw_data( + cls, + trim: int, + fill: Iterable[tuple[Widget, Hashable, int]], + ) -> Self: + """Construct from not typed data. + + Useful for overridden cases.""" + return cls(trim=trim, fill=[VisibleInfoFillItem(*item) for item in fill]) # pragma: no cover + + +class VisibleInfo(typing.NamedTuple): + middle: VisibleInfoMiddle + top: VisibleInfoTopBottom + bottom: VisibleInfoTopBottom + + @classmethod + def from_raw_data( + cls, + middle: tuple[int, Widget, Hashable, int, tuple[int, int] | tuple[int] | None], + top: tuple[int, Iterable[tuple[Widget, Hashable, int]]], + bottom: tuple[int, Iterable[tuple[Widget, Hashable, int]]], + ) -> Self: + """Construct from not typed data. + + Useful for overridden cases. + """ + return cls( # pragma: no cover + middle=VisibleInfoMiddle(*middle), + top=VisibleInfoTopBottom.from_raw_data(*top), + bottom=VisibleInfoTopBottom.from_raw_data(*bottom), + ) class ListBox(Widget, WidgetContainerMixin): @@ -399,7 +440,7 @@ def calculate_visible( self, size: tuple[int, int], focus: bool = False, - ) -> tuple[_Middle, _TopBottom, _TopBottom] | tuple[None, None, None]: + ) -> VisibleInfo | tuple[None, None, None]: """ Returns the widgets that would be displayed in the ListBox given the current *size* and *focus*. @@ -470,7 +511,7 @@ def calculate_visible( p_rows = prev.rows((maxcol,)) if p_rows: # filter out 0-height widgets - fill_above.append(_FillItem(prev, pos, p_rows)) + fill_above.append(VisibleInfoFillItem(prev, pos, p_rows)) if p_rows > fill_lines: # crosses top edge? trim_top = p_rows - fill_lines break @@ -489,7 +530,7 @@ def calculate_visible( n_rows = next_pos.rows((maxcol,)) if n_rows: # filter out 0-height widgets - fill_below.append(_FillItem(next_pos, pos, n_rows)) + fill_below.append(VisibleInfoFillItem(next_pos, pos, n_rows)) if n_rows > fill_lines: # crosses bottom edge? trim_bottom = n_rows - fill_lines fill_lines -= n_rows @@ -515,7 +556,7 @@ def calculate_visible( break p_rows = prev.rows((maxcol,)) - fill_above.append(_FillItem(prev, pos, p_rows)) + fill_above.append(VisibleInfoFillItem(prev, pos, p_rows)) if p_rows > fill_lines: # more than required trim_top = p_rows - fill_lines offset_rows += fill_lines @@ -524,17 +565,31 @@ def calculate_visible( offset_rows += p_rows # 5. return the interesting bits - return ( - _Middle(offset_rows - inset_rows, focus_widget, focus_pos, focus_rows, cursor), - _TopBottom(trim_top, fill_above), - _TopBottom(trim_bottom, fill_below), + return VisibleInfo( + VisibleInfoMiddle(offset_rows - inset_rows, focus_widget, focus_pos, focus_rows, cursor), + VisibleInfoTopBottom(trim_top, fill_above), + VisibleInfoTopBottom(trim_bottom, fill_below), ) - def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False) -> int: - """Current scrolling position.""" + def _check_support_scrolling(self) -> None: + from .treetools import TreeWalker + if not isinstance(self._body, ScrollSupportingBody): raise ListBoxError(f"{self} body do not implement methods required for scrolling protocol") + if not isinstance(self._body, (Sized, TreeWalker)): + raise ListBoxError( + f"{self} body is not a Sized and not a TreeWalker." + f"Scroll is not allowed due to risk of infinite cycle of widgets load." + ) + + if getattr(self._body, "wrap_around", False): + raise ListBoxError("Body is wrapped around. Scroll position calculation is undefined.") + + def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False) -> int: + """Current scrolling position.""" + self._check_support_scrolling() + if not self._body: return 0 @@ -560,22 +615,65 @@ def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int: """Scrollable protocol for sized iterable and not wrapped around contents.""" - if not isinstance(self._body, ScrollSupportingBody): - raise ListBoxError(f"{self} body do not implement methods required for scrolling protocol") - - if getattr(self._body, "wrap_around", False): - raise ListBoxError("Body is wrapped around") + self._check_support_scrolling() if size is not None: self._rendered_size = size if size or not self._rows_max_cached: - self._rows_max_cached = sum( - self._body[position].rows((self._rendered_size[0],), focus) for position in self._body.positions() - ) + cols = self._rendered_size[0] + rows = 0 + + focused_w, idx = self.body.get_focus() + rows += focused_w.rows((cols,), focus) + + prev, pos = self._body.get_prev(idx) + while prev is not None: + rows += prev.rows((cols,), False) + prev, pos = self._body.get_prev(pos) + + next_, pos = self.body.get_next(idx) + while next_ is not None: + rows += next_.rows((cols,), True) + next_, pos = self._body.get_next(pos) + + self._rows_max_cached = rows return self._rows_max_cached + def require_relative_scroll(self, size: tuple[int, int], focus: bool = False) -> bool: + """Widget require relative scroll due to performance limitations of real lines count calculation.""" + return isinstance(self._body, Sized) and (size[1] * 3 < len(self)) + + def get_first_visible_pos(self, size: tuple[int, int], focus: bool = False) -> int: + self._check_support_scrolling() + + if not self._body: + return 0 + + _mid, top, _bottom = self.calculate_visible(size, focus) + if top.fill: + first_pos = top.fill[-1].position + else: + first_pos = self.focus_position + + over = 0 + _widget, first_pos = self.body.get_prev(first_pos) + while first_pos is not None: + over += 1 + _widget, first_pos = self.body.get_prev(first_pos) + + return over + + def get_visible_amount(self, size: tuple[int, int], focus: bool = False) -> int: + self._check_support_scrolling() + + if not self._body: + return 1 + + _mid, top, bottom = self.calculate_visible(size, focus) + return 1 + len(top.fill) + len(bottom.fill) + def render( self, size: tuple[int, int], # type: ignore[override] @@ -594,9 +692,9 @@ def render( if middle is None: return SolidCanvas(" ", maxcol, maxrow) - _ignore, focus_widget, focus_pos, focus_rows, cursor = middle - trim_top, fill_above = top - trim_bottom, fill_below = bottom + _ignore, focus_widget, focus_pos, focus_rows, cursor = middle # pylint: disable=unpacking-non-sequence + trim_top, fill_above = top # pylint: disable=unpacking-non-sequence + trim_bottom, fill_below = bottom # pylint: disable=unpacking-non-sequence combinelist: list[tuple[Canvas, int, bool]] = [] rows = 0 @@ -706,7 +804,7 @@ def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None: if middle is None: return None - offset_inset, _ignore1, _ignore2, _ignore3, cursor = middle + offset_inset, _ignore1, _ignore2, _ignore3, cursor = middle # pylint: disable=unpacking-non-sequence if not cursor: return None @@ -897,9 +995,9 @@ def _set_focus_first_selectable(self, size: tuple[int, int], focus: bool) -> Non if middle is None: return - row_offset, focus_widget, _focus_pos, focus_rows, _cursor = middle - _trim_top, _fill_above = top - trim_bottom, fill_below = bottom + row_offset, focus_widget, _focus_pos, focus_rows, _cursor = middle # pylint: disable=unpacking-non-sequence + _trim_top, _fill_above = top # pylint: disable=unpacking-non-sequence + trim_bottom, fill_below = bottom # pylint: disable=unpacking-non-sequence if focus_widget.selectable(): return @@ -935,9 +1033,9 @@ def _set_focus_complete(self, size: tuple[int, int], focus: bool) -> None: self._body.set_focus(focus_pos) middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus) - focus_offset, _focus_widget, focus_pos, focus_rows, _cursor = middle - _trim_top, fill_above = top - _trim_bottom, fill_below = bottom + focus_offset, _focus_widget, focus_pos, focus_rows, _cursor = middle # pylint: disable=unpacking-non-sequence + _trim_top, fill_above = top # pylint: disable=unpacking-non-sequence + _trim_bottom, fill_below = bottom # pylint: disable=unpacking-non-sequence offset = focus_offset for _widget, pos, rows in fill_above: @@ -1251,8 +1349,8 @@ def _keypress_up(self, size: tuple[int, int]) -> bool | None: if middle is None: return True - focus_row_offset, focus_widget, focus_pos, _ignore, cursor = middle - _trim_top, fill_above = top + focus_row_offset, focus_widget, focus_pos, _ignore, cursor = middle # pylint: disable=unpacking-non-sequence + _trim_top, fill_above = top # pylint: disable=unpacking-non-sequence row_offset = focus_row_offset @@ -1324,8 +1422,8 @@ def _keypress_down(self, size: tuple[int, int]) -> bool | None: if middle is None: return True - focus_row_offset, focus_widget, focus_pos, focus_rows, cursor = middle - _trim_bottom, fill_below = bottom + focus_row_offset, focus_widget, focus_pos, focus_rows, cursor = middle # pylint: disable=unpacking-non-sequence + _trim_bottom, fill_below = bottom # pylint: disable=unpacking-non-sequence row_offset = focus_row_offset + focus_rows rows = focus_rows @@ -1363,9 +1461,6 @@ def _keypress_down(self, size: tuple[int, int]) -> bool | None: if widget is None: self.shift_focus((maxcol, maxrow), row_offset - rows) return None - # FIXME: catch this bug in testcase - # self.change_focus((maxcol,maxrow), pos, - # row_offset+rows, 'above') self.change_focus((maxcol, maxrow), pos, row_offset - rows, "above") return None @@ -1406,8 +1501,8 @@ def _keypress_page_up(self, size: tuple[int, int]) -> bool | None: if middle is None: return True - row_offset, focus_widget, focus_pos, focus_rows, cursor = middle - _trim_top, fill_above = top + row_offset, focus_widget, focus_pos, focus_rows, cursor = middle # pylint: disable=unpacking-non-sequence + _trim_top, fill_above = top # pylint: disable=unpacking-non-sequence # topmost_visible is row_offset rows above top row of # focus (+ve) or -row_offset rows below top row of focus (-ve) @@ -1516,7 +1611,7 @@ def _keypress_page_up(self, size: tuple[int, int]) -> bool | None: # find out where that actually puts us middle, top, _bottom = self.calculate_visible((maxcol, maxrow), True) - act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle + act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle # pylint: disable=unpacking-non-sequence # discard chosen widget if it will reduce scroll amount # because of a fixed cursor (absolute last resort) @@ -1566,7 +1661,7 @@ def _keypress_page_up(self, size: tuple[int, int]) -> bool | None: # final check for pathological case where we may fall short middle, top, _bottom = self.calculate_visible((maxcol, maxrow), True) - act_row_offset, _ign1, pos, _ign2, _ign3 = middle + act_row_offset, _ign1, pos, _ign2, _ign3 = middle # pylint: disable=unpacking-non-sequence if act_row_offset >= row_offset: # no problem return None @@ -1598,8 +1693,8 @@ def _keypress_page_down(self, size: tuple[int, int]) -> bool | None: if middle is None: return True - row_offset, focus_widget, focus_pos, focus_rows, cursor = middle - _trim_bottom, fill_below = bottom + row_offset, focus_widget, focus_pos, focus_rows, cursor = middle # pylint: disable=unpacking-non-sequence + _trim_bottom, fill_below = bottom # pylint: disable=unpacking-non-sequence # bottom_edge is maxrow-focus_pos rows below top row of focus bottom_edge = maxrow - row_offset @@ -1704,7 +1799,7 @@ def _keypress_page_down(self, size: tuple[int, int]) -> bool | None: # find out where that actually puts us middle, _top, bottom = self.calculate_visible((maxcol, maxrow), True) - act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle + act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle # pylint: disable=unpacking-non-sequence # discard chosen widget if it will reduce scroll amount # because of a fixed cursor (absolute last resort) @@ -1750,7 +1845,7 @@ def _keypress_page_down(self, size: tuple[int, int]) -> bool | None: # final check for pathological case where we may fall short middle, _top, bottom = self.calculate_visible((maxcol, maxrow), True) - act_row_offset, _ign1, pos, _ign2, _ign3 = middle + act_row_offset, _ign1, pos, _ign2, _ign3 = middle # pylint: disable=unpacking-non-sequence if act_row_offset <= row_offset: # no problem return None @@ -1795,9 +1890,9 @@ def mouse_event( if middle is None: return False - _ignore, focus_widget, focus_pos, focus_rows, _cursor = middle - trim_top, fill_above = top - _ignore, fill_below = bottom + _ignore, focus_widget, focus_pos, focus_rows, _cursor = middle # pylint: disable=unpacking-non-sequence + trim_top, fill_above = top # pylint: disable=unpacking-non-sequence + _ignore, fill_below = bottom # pylint: disable=unpacking-non-sequence fill_above.reverse() # fill_above is in bottom-up order w_list = [*fill_above, (focus_widget, focus_pos, focus_rows), *fill_below] @@ -1850,11 +1945,11 @@ def ends_visible(self, size: tuple[int, int], focus: bool = False) -> list[Liter middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus=focus) if middle is None: # empty listbox return ["top", "bottom"] - trim_top, above = top - trim_bottom, below = bottom + trim_top, above = top # pylint: disable=unpacking-non-sequence + trim_bottom, below = bottom # pylint: disable=unpacking-non-sequence if trim_bottom == 0: - row_offset, _w, pos, rows, _c = middle + row_offset, _w, pos, rows, _c = middle # pylint: disable=unpacking-non-sequence row_offset += rows for _w, pos, rows in below: # noqa: B007 # magic with scope row_offset += rows @@ -1862,7 +1957,7 @@ def ends_visible(self, size: tuple[int, int], focus: bool = False) -> list[Liter result.append("bottom") if trim_top == 0: - row_offset, _w, pos, _rows, _c = middle + row_offset, _w, pos, _rows, _c = middle # pylint: disable=unpacking-non-sequence for _w, pos, rows in above: # noqa: B007 # magic with scope row_offset -= rows if self._body.get_prev(pos) == (None, None): diff --git a/urwid/widget/scrollable.py b/urwid/widget/scrollable.py index 490f122d6..2d485be81 100644 --- a/urwid/widget/scrollable.py +++ b/urwid/widget/scrollable.py @@ -85,10 +85,10 @@ class ScrollbarSymbols(str, enum.Enum): @runtime_checkable -class SupportsScroll(Protocol): - """Protocol for scroll supporting widget. +class WidgetProto(Protocol): + """Protocol for widget. - Due to protocol can not inherit non-protocol bases, require also several obligatory Widget methods. + Due to protocol cannot inherit non-protocol bases, define several obligatory Widget methods. """ # Base widget methods (from Widget) @@ -116,12 +116,29 @@ def mouse_event( def render(self, size: tuple[int, int], focus: bool = False) -> Canvas: ... - # Scroll specific methods + +@runtime_checkable +class SupportsScroll(WidgetProto, Protocol): + """Scroll specific methods.""" + def get_scrollpos(self, size: tuple[int, int], focus: bool = False) -> int: ... def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int: ... +@runtime_checkable +class SupportsRelativeScroll(WidgetProto, Protocol): + """Relative scroll-specific methods.""" + + def __len__(self) -> int: ... + + def require_relative_scroll(self, size: tuple[int, int], focus: bool = False) -> bool: ... + + def get_first_visible_pos(self, size: tuple[int, int], focus: bool = False) -> int: ... + + def get_visible_amount(self, size: tuple[int, int], focus: bool = False) -> int: ... + + class Scrollable(WidgetDecoration[WrappedWidget]): def sizing(self) -> frozenset[Sizing]: return frozenset((Sizing.BOX,)) @@ -286,7 +303,7 @@ def keypress( ow = self._original_widget ow_size = self._get_original_widget_size(size) - # Remember previous cursor position if possible + # Remember the previous cursor position if possible if hasattr(ow, "get_cursor_coords"): self._old_cursor_coords = ow.get_cursor_coords(ow_size) @@ -294,7 +311,7 @@ def keypress( if key is None: return None - # Handle up/down, page up/down, etc + # Handle up/down, page up/down, etc. command_map = self._command_map if command_map[key] == Command.UP: self._scroll_action = SCROLL_LINE_UP @@ -438,10 +455,10 @@ def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> class ScrollBar(WidgetDecoration[WrappedWidget]): Symbols = ScrollbarSymbols - def sizing(self): + def sizing(self) -> frozenset[Sizing]: return frozenset((Sizing.BOX,)) - def selectable(self): + def selectable(self) -> bool: return True def __init__( @@ -483,30 +500,46 @@ def render( ) -> Canvas: from urwid import canvas + def render_no_scrollbar() -> Canvas: + self._original_widget_size = size + return ow.render(size, focus) + + def render_for_scrollbar() -> Canvas: + self._original_widget_size = ow_size + return ow.render(ow_size, focus) + maxcol, maxrow = size - sb_width = self._scrollbar_width - ow_size = (max(0, maxcol - sb_width), maxrow) + ow_size = (max(0, maxcol - self._scrollbar_width), maxrow) sb_width = maxcol - ow_size[0] ow = self._original_widget ow_base = self.scrolling_base_widget - ow_rows_max = ow_base.rows_max(size, focus) - if ow_rows_max <= maxrow: - # Canvas fits without scrolling - no scrollbar needed - self._original_widget_size = size - return ow.render(size, focus) - ow_rows_max = ow_base.rows_max(ow_size, focus) - ow_canv = ow.render(ow_size, focus) - self._original_widget_size = ow_size + if isinstance(ow, SupportsRelativeScroll) and ow.require_relative_scroll(size, focus): + if len(ow) == ow.get_visible_amount(size, focus): + # Canvas fits without scrolling - no scrollbar needed + return render_no_scrollbar() - pos = ow_base.get_scrollpos(ow_size, focus) - posmax = ow_rows_max - maxrow + ow_canv = render_for_scrollbar() + visible_amount = ow.get_visible_amount(ow_size, focus) + pos = ow_base.get_first_visible_pos(ow_size, focus) + posmax = len(ow) - visible_amount + thumb_weight = min(1.0, visible_amount / max(1, len(ow))) - # Thumb shrinks/grows according to the ratio of - # / - thumb_weight = min(1.0, maxrow / max(1, ow_rows_max)) + else: + ow_rows_max = ow_base.rows_max(size, focus) + if ow_rows_max <= maxrow: + # Canvas fits without scrolling - no scrollbar needed + return render_no_scrollbar() + + ow_canv = render_for_scrollbar() + ow_rows_max = ow_base.rows_max(ow_size, focus) + pos = ow_base.get_scrollpos(ow_size, focus) + posmax = ow_rows_max - maxrow + thumb_weight = min(1.0, maxrow / max(1, ow_rows_max)) + + # Thumb shrinks/grows according to the ratio of / thumb_height = max(1, round(thumb_weight * maxrow)) # Thumb may only touch top/bottom if the first/last row is visible @@ -564,7 +597,7 @@ def scrollbar_side(self, side: Literal["left", "right"]) -> None: self._invalidate() @property - def scrolling_base_widget(self) -> SupportsScroll: + def scrolling_base_widget(self) -> SupportsScroll | SupportsRelativeScroll: """Nearest `original_widget` that is compatible with the scrolling API""" def orig_iter(w: Widget) -> Iterator[Widget]: diff --git a/urwid/widget/treetools.py b/urwid/widget/treetools.py index 40b231bf6..84c5bec29 100644 --- a/urwid/widget/treetools.py +++ b/urwid/widget/treetools.py @@ -496,8 +496,8 @@ def move_focus_to_parent(self, size: tuple[int, int]) -> None: middle, top, _bottom = self.calculate_visible(size) - row_offset, _focus_widget, _focus_pos, _focus_rows, _cursor = middle - _trim_top, fill_above = top + row_offset, _focus_widget, _focus_pos, _focus_rows, _cursor = middle # pylint: disable=unpacking-non-sequence + _trim_top, fill_above = top # pylint: disable=unpacking-non-sequence for _widget, pos, rows in fill_above: row_offset -= rows