codekingpro/portable-devtools
114k
1# Urwid listbox class2# Copyright (C) 2004-2012 Ian Ward3#4# This library is free software; you can redistribute it and/or5# modify it under the terms of the GNU Lesser General Public6# License as published by the Free Software Foundation; either7# version 2.1 of the License, or (at your option) any later version.8#9# This library is distributed in the hope that it will be useful,10# but WITHOUT ANY WARRANTY; without even the implied warranty of11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU12# Lesser General Public License for more details.13#14# You should have received a copy of the GNU Lesser General Public15# License along with this library; if not, write to the Free Software16# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA17#18# Urwid web site: https://urwid.org/19 20 21from __future__ import annotations22 23import operator24import typing25import warnings26from collections.abc import Iterable, Sized27from contextlib import suppress28 29from urwid import signals30from urwid.canvas import CanvasCombine, SolidCanvas31 32from .constants import Sizing, VAlign, WHSettings, normalize_valign33from .container import WidgetContainerMixin34from .filler import calculate_top_bottom_filler35from .monitored_list import MonitoredFocusList, MonitoredList36from .widget import Widget, nocache_widget_render_instance37 38if typing.TYPE_CHECKING:39 from collections.abc import Callable, Hashable40 41 from typing_extensions import Literal, Self42 43 from urwid.canvas import Canvas, CompositeCanvas44 45__all__ = (46 "ListBox",47 "ListBoxError",48 "ListWalker",49 "ListWalkerError",50 "SimpleFocusListWalker",51 "SimpleListWalker",52 "VisibleInfo",53 "VisibleInfoFillItem",54 "VisibleInfoMiddle",55 "VisibleInfoTopBottom",56)57 58_T = typing.TypeVar("_T")59_K = typing.TypeVar("_K")60 61 62class ListWalkerError(Exception):63 pass64 65 66@typing.runtime_checkable67class ScrollSupportingBody(typing.Protocol):68 """Protocol for ListWalkers."""69 70 def get_focus(self) -> tuple[Widget, _K]: ...71 72 def set_focus(self, position: _K) -> None: ...73 74 def get_next(self, position: _K) -> tuple[Widget, _K] | tuple[None, None]: ...75 76 def get_prev(self, position: _K) -> tuple[Widget, _K] | tuple[None, None]: ...77 78 79@typing.runtime_checkable80class EstimatedSized(typing.Protocol):81 """Widget can estimate it's size.82 83 PEP 424 defines API for memory-efficiency.84 For the ListBox it's a sign of the limited body length.85 The main use-case is lazy-load, where real length calculation is expensive.86 """87 88 def __length_hint__(self) -> int: ...89 90 91class ListWalker(metaclass=signals.MetaSignals): # pylint: disable=no-member, unsubscriptable-object92 # mixin not named as mixin93 signals: typing.ClassVar[list[str]] = ["modified"]94 95 def _modified(self) -> None:96 signals.emit_signal(self, "modified")97 98 def get_focus(self):99 """100 This default implementation relies on a focus attribute and a101 __getitem__() method defined in a subclass.102 103 Override and don't call this method if these are not defined.104 """105 try:106 focus = self.focus107 return self[focus], focus108 except (IndexError, KeyError, TypeError):109 return None, None110 111 def get_next(self, position):112 """113 This default implementation relies on a next_position() method and a114 __getitem__() method defined in a subclass.115 116 Override and don't call this method if these are not defined.117 """118 try:119 position = self.next_position(position)120 return self[position], position121 except (IndexError, KeyError):122 return None, None123 124 def get_prev(self, position):125 """126 This default implementation relies on a prev_position() method and a127 __getitem__() method defined in a subclass.128 129 Override and don't call this method if these are not defined.130 """131 try:132 position = self.prev_position(position)133 return self[position], position134 except (IndexError, KeyError):135 return None, None136 137 138class SimpleListWalker(MonitoredList[_T], ListWalker):139 def __init__(self, contents: Iterable[_T], wrap_around: bool = False) -> None:140 """141 contents -- list to copy into this object142 143 wrap_around -- if true, jumps to beginning/end of list on move144 145 This class inherits :class:`MonitoredList` which means146 it can be treated as a list.147 148 Changes made to this object (when it is treated as a list) are149 detected automatically and will cause ListBox objects using150 this list walker to be updated.151 """152 if not isinstance(contents, Iterable):153 raise ListWalkerError(f"SimpleListWalker expecting list like object, got: {contents!r}")154 MonitoredList.__init__(self, contents)155 self.focus = 0156 self.wrap_around = wrap_around157 158 @property159 def contents(self) -> Self:160 """161 Return self.162 163 Provides compatibility with old SimpleListWalker class.164 """165 return self166 167 def _modified(self) -> None:168 if self.focus >= len(self):169 self.focus = max(0, len(self) - 1)170 ListWalker._modified(self)171 172 def set_modified_callback(self, callback: Callable[[], typing.Any]) -> typing.NoReturn:173 """174 This function inherited from MonitoredList is not implemented in SimpleListWalker.175 176 Use connect_signal(list_walker, "modified", ...) instead.177 """178 raise NotImplementedError('Use connect_signal(list_walker, "modified", ...) instead.')179 180 def set_focus(self, position: int) -> None:181 """Set focus position."""182 183 if not 0 <= position < len(self):184 raise IndexError(f"No widget at position {position}")185 186 self.focus = position187 self._modified()188 189 def next_position(self, position: int) -> int:190 """191 Return position after start_from.192 """193 if len(self) - 1 <= position:194 if self.wrap_around:195 return 0196 raise IndexError197 return position + 1198 199 def prev_position(self, position: int) -> int:200 """201 Return position before start_from.202 """203 if position <= 0:204 if self.wrap_around:205 return len(self) - 1206 raise IndexError207 return position - 1208 209 def positions(self, reverse: bool = False) -> Iterable[int]:210 """211 Optional method for returning an iterable of positions.212 """213 if reverse:214 return range(len(self) - 1, -1, -1)215 return range(len(self))216 217 218class SimpleFocusListWalker(ListWalker, MonitoredFocusList[_T]):219 def __init__(self, contents: Iterable[_T], wrap_around: bool = False) -> None:220 """221 contents -- list to copy into this object222 223 wrap_around -- if true, jumps to beginning/end of list on move224 225 This class inherits :class:`MonitoredList` which means226 it can be treated as a list.227 228 Changes made to this object (when it is treated as a list) are229 detected automatically and will cause ListBox objects using230 this list walker to be updated.231 232 Also, items added or removed before the widget in focus with233 normal list methods will cause the focus to be updated234 intelligently.235 """236 if not isinstance(contents, Iterable):237 raise ListWalkerError(f"SimpleFocusListWalker expecting iterable object, got: {contents!r}")238 MonitoredFocusList.__init__(self, contents)239 self.wrap_around = wrap_around240 241 def set_modified_callback(self, callback: typing.Any) -> typing.NoReturn:242 """243 This function inherited from MonitoredList is not244 implemented in SimpleFocusListWalker.245 246 Use connect_signal(list_walker, "modified", ...) instead.247 """248 raise NotImplementedError('Use connect_signal(list_walker, "modified", ...) instead.')249 250 def set_focus(self, position: int) -> None:251 """Set focus position."""252 self.focus = position253 self._modified()254 255 def next_position(self, position: int) -> int:256 """257 Return position after start_from.258 """259 if len(self) - 1 <= position:260 if self.wrap_around:261 return 0262 raise IndexError263 return position + 1264 265 def prev_position(self, position: int) -> int:266 """267 Return position before start_from.268 """269 if position <= 0:270 if self.wrap_around:271 return len(self) - 1272 raise IndexError273 return position - 1274 275 def positions(self, reverse: bool = False) -> Iterable[int]:276 """277 Optional method for returning an iterable of positions.278 """279 if reverse:280 return range(len(self) - 1, -1, -1)281 return range(len(self))282 283 284class ListBoxError(Exception):285 pass286 287 288class VisibleInfoMiddle(typing.NamedTuple):289 """Named tuple for ListBox internals."""290 291 offset: int292 focus_widget: Widget293 focus_pos: Hashable294 focus_rows: int295 cursor: tuple[int, int] | tuple[int] | None296 297 298class VisibleInfoFillItem(typing.NamedTuple):299 """Named tuple for ListBox internals."""300 301 widget: Widget302 position: Hashable303 rows: int304 305 306class VisibleInfoTopBottom(typing.NamedTuple):307 """Named tuple for ListBox internals."""308 309 trim: int310 fill: list[VisibleInfoFillItem]311 312 @classmethod313 def from_raw_data(314 cls,315 trim: int,316 fill: Iterable[tuple[Widget, Hashable, int]],317 ) -> Self:318 """Construct from not typed data.319 320 Useful for overridden cases."""321 return cls(trim=trim, fill=[VisibleInfoFillItem(*item) for item in fill]) # pragma: no cover322 323 324class VisibleInfo(typing.NamedTuple):325 middle: VisibleInfoMiddle326 top: VisibleInfoTopBottom327 bottom: VisibleInfoTopBottom328 329 @classmethod330 def from_raw_data(331 cls,332 middle: tuple[int, Widget, Hashable, int, tuple[int, int] | tuple[int] | None],333 top: tuple[int, Iterable[tuple[Widget, Hashable, int]]],334 bottom: tuple[int, Iterable[tuple[Widget, Hashable, int]]],335 ) -> Self:336 """Construct from not typed data.337 338 Useful for overridden cases.339 """340 return cls( # pragma: no cover341 middle=VisibleInfoMiddle(*middle),342 top=VisibleInfoTopBottom.from_raw_data(*top),343 bottom=VisibleInfoTopBottom.from_raw_data(*bottom),344 )345 346 347class ListBox(Widget, WidgetContainerMixin):348 """349 Vertically stacked list of widgets350 """351 352 _selectable = True353 _sizing = frozenset([Sizing.BOX])354 355 def __init__(self, body: ListWalker | Iterable[Widget]) -> None:356 """357 :param body: a ListWalker subclass such as :class:`SimpleFocusListWalker`358 that contains widgets to be displayed inside the list box359 :type body: ListWalker360 """361 super().__init__()362 if getattr(body, "get_focus", None):363 self._body: ListWalker = body364 else:365 self._body = SimpleListWalker(body)366 367 self.body = self._body # Initialization hack368 369 # offset_rows is the number of rows between the top of the view370 # and the top of the focused item371 self.offset_rows = 0372 # inset_fraction is used when the focused widget is off the373 # top of the view. it is the fraction of the widget cut off374 # at the top. (numerator, denominator)375 self.inset_fraction = (0, 1)376 377 # pref_col is the preferred column for the cursor when moving378 # between widgets that use the cursor (edit boxes etc.)379 self.pref_col = "left"380 381 # variable for delayed focus change used by set_focus382 self.set_focus_pending = "first selectable"383 384 # variable for delayed valign change used by set_focus_valign385 self.set_focus_valign_pending = None386 387 # used for scrollable protocol388 self._rows_max_cached = 0389 self._rendered_size = 0, 0390 391 @property392 def body(self) -> ListWalker:393 """394 a ListWalker subclass such as :class:`SimpleFocusListWalker` that contains395 widgets to be displayed inside the list box396 """397 return self._body398 399 @body.setter400 def body(self, body: Iterable[Widget] | ListWalker) -> None:401 with suppress(AttributeError):402 signals.disconnect_signal(self._body, "modified", self._invalidate)403 # _body may be not yet assigned404 405 if getattr(body, "get_focus", None):406 self._body = body407 else:408 self._body = SimpleListWalker(body)409 try:410 signals.connect_signal(self._body, "modified", self._invalidate)411 except NameError:412 # our list walker has no modified signal so we must not413 # cache our canvases because we don't know when our414 # content has changed415 self.render = nocache_widget_render_instance(self)416 self._invalidate()417 418 @property419 def __len__(self) -> Callable[[], int]:420 if isinstance(self._body, Sized):421 return self._body.__len__422 raise AttributeError(f"{self._body.__class__.__name__} is not Sized")423 424 @property425 def __length_hint__(self) -> Callable[[], int]: # pylint: disable=invalid-length-hint-returned426 if isinstance(self._body, (Sized, EstimatedSized)):427 return lambda: operator.length_hint(self._body)428 raise AttributeError(f'{self._body.__class__.__name__} is not Sized and do not implement "__length_hint__"')429 430 def calculate_visible(431 self,432 size: tuple[int, int],433 focus: bool = False,434 ) -> VisibleInfo | tuple[None, None, None]:435 """436 Returns the widgets that would be displayed in437 the ListBox given the current *size* and *focus*.438 439 see :meth:`Widget.render` for parameter details440 441 :returns: (*middle*, *top*, *bottom*) or (``None``, ``None``, ``None``)442 443 *middle*444 (*row offset*(when +ve) or *inset*(when -ve),445 *focus widget*, *focus position*, *focus rows*,446 *cursor coords* or ``None``)447 *top*448 (*# lines to trim off top*,449 list of (*widget*, *position*, *rows*) tuples above focus in order from bottom to top)450 *bottom*451 (*# lines to trim off bottom*,452 list of (*widget*, *position*, *rows*) tuples below focus in order from top to bottom)453 """454 (maxcol, maxrow) = size455 456 # 0. set the focus if a change is pending457 if self.set_focus_pending or self.set_focus_valign_pending:458 self._set_focus_complete((maxcol, maxrow), focus)459 460 # 1. start with the focus widget461 focus_widget, focus_pos = self._body.get_focus()462 if focus_widget is None: # list box is empty?463 return None, None, None464 top_pos = focus_pos465 466 offset_rows, inset_rows = self.get_focus_offset_inset((maxcol, maxrow))467 # force at least one line of focus to be visible468 if maxrow and offset_rows >= maxrow:469 offset_rows = maxrow - 1470 471 # adjust position so cursor remains visible472 cursor = None473 if maxrow and focus_widget.selectable() and focus and hasattr(focus_widget, "get_cursor_coords"):474 cursor = focus_widget.get_cursor_coords((maxcol,))475 476 if cursor is not None:477 _cx, cy = cursor478 effective_cy = cy + offset_rows - inset_rows479 480 if effective_cy < 0: # cursor above top?481 inset_rows = cy482 elif effective_cy >= maxrow: # cursor below bottom?483 offset_rows = maxrow - cy - 1484 if offset_rows < 0: # need to trim the top485 inset_rows, offset_rows = -offset_rows, 0486 487 # set trim_top by focus trimmimg488 trim_top = inset_rows489 focus_rows = focus_widget.rows((maxcol,), True)490 491 # 2. collect the widgets above the focus492 pos = focus_pos493 fill_lines = offset_rows494 fill_above = []495 top_pos = pos496 while fill_lines > 0:497 prev, pos = self._body.get_prev(pos)498 if prev is None: # run out of widgets above?499 offset_rows -= fill_lines500 break501 top_pos = pos502 503 p_rows = prev.rows((maxcol,))504 if p_rows: # filter out 0-height widgets505 fill_above.append(VisibleInfoFillItem(prev, pos, p_rows))506 if p_rows > fill_lines: # crosses top edge?507 trim_top = p_rows - fill_lines508 break509 fill_lines -= p_rows510 511 trim_bottom = max(focus_rows + offset_rows - inset_rows - maxrow, 0)512 513 # 3. collect the widgets below the focus514 pos = focus_pos515 fill_lines = maxrow - focus_rows - offset_rows + inset_rows516 fill_below = []517 while fill_lines > 0:518 next_pos, pos = self._body.get_next(pos)519 if next_pos is None: # run out of widgets below?520 break521 522 n_rows = next_pos.rows((maxcol,))523 if n_rows: # filter out 0-height widgets524 fill_below.append(VisibleInfoFillItem(next_pos, pos, n_rows))525 if n_rows > fill_lines: # crosses bottom edge?526 trim_bottom = n_rows - fill_lines527 fill_lines -= n_rows528 break529 fill_lines -= n_rows530 531 # 4. fill from top again if necessary & possible532 fill_lines = max(0, fill_lines)533 534 if fill_lines > 0 and trim_top > 0:535 if fill_lines <= trim_top:536 trim_top -= fill_lines537 offset_rows += fill_lines538 fill_lines = 0539 else:540 fill_lines -= trim_top541 offset_rows += trim_top542 trim_top = 0543 pos = top_pos544 while fill_lines > 0:545 prev, pos = self._body.get_prev(pos)546 if prev is None:547 break548 549 p_rows = prev.rows((maxcol,))550 fill_above.append(VisibleInfoFillItem(prev, pos, p_rows))551 if p_rows > fill_lines: # more than required552 trim_top = p_rows - fill_lines553 offset_rows += fill_lines554 break555 fill_lines -= p_rows556 offset_rows += p_rows557 558 # 5. return the interesting bits559 return VisibleInfo(560 VisibleInfoMiddle(offset_rows - inset_rows, focus_widget, focus_pos, focus_rows, cursor),561 VisibleInfoTopBottom(trim_top, fill_above),562 VisibleInfoTopBottom(trim_bottom, fill_below),563 )564 565 def _check_support_scrolling(self) -> None:566 from .treetools import TreeWalker567 568 if not isinstance(self._body, ScrollSupportingBody):569 raise ListBoxError(f"{self} body do not implement methods required for scrolling protocol")570 571 if not isinstance(self._body, (Sized, EstimatedSized, TreeWalker)):572 raise ListBoxError(573 f"{self} body is not a Sized, can not estimate it's size and not a TreeWalker."574 f"Scroll is not allowed due to risk of infinite cycle of widgets load."575 )576 577 if getattr(self._body, "wrap_around", False):578 raise ListBoxError("Body is wrapped around. Scroll position calculation is undefined.")579 580 def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:581 """Current scrolling position."""582 self._check_support_scrolling()583 584 if not self._body:585 return 0586 587 if size is not None:588 self._rendered_size = size589 590 mid, top, _bottom = self.calculate_visible(self._rendered_size, focus)591 592 start_row = top.trim593 maxcol = self._rendered_size[0]594 595 if top.fill:596 pos = top.fill[-1].position597 else:598 pos = mid.focus_pos599 600 prev, pos = self._body.get_prev(pos)601 while prev is not None:602 start_row += prev.rows((maxcol,))603 prev, pos = self._body.get_prev(pos)604 605 return start_row606 607 def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:608 """Scrollable protocol for sized iterable and not wrapped around contents."""609 self._check_support_scrolling()610 611 if size is not None:612 self._rendered_size = size613 614 if size or not self._rows_max_cached:615 cols = self._rendered_size[0]616 rows = 0617 618 focused_w, idx = self.body.get_focus()619 if focused_w:620 rows += focused_w.rows((cols,), focus)621 622 prev, pos = self._body.get_prev(idx)623 while prev is not None:624 rows += prev.rows((cols,), False)625 prev, pos = self._body.get_prev(pos)626 627 next_, pos = self.body.get_next(idx)628 while next_ is not None:629 rows += next_.rows((cols,), True)630 next_, pos = self._body.get_next(pos)631 632 self._rows_max_cached = rows633 634 return self._rows_max_cached635 636 def require_relative_scroll(self, size: tuple[int, int], focus: bool = False) -> bool:637 """Widget require relative scroll due to performance limitations of real lines count calculation."""638 return isinstance(self._body, (Sized, EstimatedSized)) and (size[1] * 3 < operator.length_hint(self.body))639 640 def get_first_visible_pos(self, size: tuple[int, int], focus: bool = False) -> int:641 self._check_support_scrolling()642 643 if not self._body:644 return 0645 646 _mid, top, _bottom = self.calculate_visible(size, focus)647 if top.fill:648 first_pos = top.fill[-1].position649 else:650 first_pos = self.focus_position651 652 over = 0653 _widget, first_pos = self.body.get_prev(first_pos)654 while first_pos is not None:655 over += 1656 _widget, first_pos = self.body.get_prev(first_pos)657 658 return over659 660 def get_visible_amount(self, size: tuple[int, int], focus: bool = False) -> int:661 self._check_support_scrolling()662 663 if not self._body:664 return 1665 666 _mid, top, bottom = self.calculate_visible(size, focus)667 return 1 + len(top.fill) + len(bottom.fill)668 669 def render(670 self,671 size: tuple[int, int], # type: ignore[override]672 focus: bool = False,673 ) -> CompositeCanvas | SolidCanvas:674 """675 Render ListBox and return canvas.676 677 see :meth:`Widget.render` for details678 """679 (maxcol, maxrow) = size680 681 self._rendered_size = size682 683 middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus=focus)684 if middle is None:685 return SolidCanvas(" ", maxcol, maxrow)686 687 _ignore, focus_widget, focus_pos, focus_rows, cursor = middle # pylint: disable=unpacking-non-sequence688 trim_top, fill_above = top # pylint: disable=unpacking-non-sequence689 trim_bottom, fill_below = bottom # pylint: disable=unpacking-non-sequence690 691 combinelist: list[tuple[Canvas, int, bool]] = []692 rows = 0693 fill_above.reverse() # fill_above is in bottom-up order694 for widget, w_pos, w_rows in fill_above:695 canvas = widget.render((maxcol,))696 if w_rows != canvas.rows():697 raise ListBoxError(698 f"Widget {widget!r} at position {w_pos!r} "699 f"within listbox calculated {w_rows:d} rows "700 f"but rendered {canvas.rows():d}!"701 )702 rows += w_rows703 combinelist.append((canvas, w_pos, False))704 705 focus_canvas = focus_widget.render((maxcol,), focus=focus)706 707 if focus_canvas.rows() != focus_rows:708 raise ListBoxError(709 f"Focus Widget {focus_widget!r} at position {focus_pos!r} "710 f"within listbox calculated {focus_rows:d} rows "711 f"but rendered {focus_canvas.rows():d}!"712 )713 c_cursor = focus_canvas.cursor714 if cursor is not None and cursor != c_cursor:715 raise ListBoxError(716 f"Focus Widget {focus_widget!r} at position {focus_pos!r} "717 f"within listbox calculated cursor coords {cursor!r} "718 f"but rendered cursor coords {c_cursor!r}!"719 )720 721 rows += focus_rows722 combinelist.append((focus_canvas, focus_pos, True))723 724 for widget, w_pos, w_rows in fill_below:725 canvas = widget.render((maxcol,))726 if w_rows != canvas.rows():727 raise ListBoxError(728 f"Widget {widget!r} at position {w_pos!r} "729 f"within listbox calculated {w_rows:d} "730 f"rows but rendered {canvas.rows():d}!"731 )732 rows += w_rows733 combinelist.append((canvas, w_pos, False))734 735 final_canvas = CanvasCombine(combinelist)736 737 if trim_top:738 final_canvas.trim(trim_top)739 rows -= trim_top740 if trim_bottom:741 final_canvas.trim_end(trim_bottom)742 rows -= trim_bottom743 744 if rows > maxrow:745 raise ListBoxError(746 f"Listbox contents too long!\nRender top={top!r}, middle={middle!r}, bottom={bottom!r}\n"747 )748 749 if rows < maxrow:750 if trim_bottom != 0:751 raise ListBoxError(752 f"Listbox contents too short!\n"753 f"Render top={top!r}, middle={middle!r}, bottom={bottom!r}\n"754 f"Trim bottom={trim_bottom!r}"755 )756 757 bottom_pos = focus_pos758 if fill_below:759 bottom_pos = fill_below[-1][1]760 761 rendered_positions = frozenset(idx for _, idx, _ in combinelist)762 widget, next_pos = self._body.get_next(bottom_pos)763 while all(764 (765 widget is not None,766 next_pos is not None,767 next_pos not in rendered_positions,768 )769 ):770 if widget.rows((maxcol,), False):771 raise ListBoxError(772 f"Listbox contents too short!\n"773 f"Render top={top!r}, middle={middle!r}, bottom={bottom!r}\n"774 f"Not rendered not empty widgets available (first is {widget!r} with position {next_pos!r})"775 )776 777 widget, next_next_pos = self._body.get_next(next_pos)778 if next_pos == next_next_pos:779 raise ListBoxError(780 f"Next position after {next_pos!r} is invalid (points to itself)\n"781 f"Looks like bug with {self._body!r}"782 )783 next_pos = next_next_pos784 785 final_canvas.pad_trim_top_bottom(0, maxrow - rows)786 787 return final_canvas788 789 def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None:790 """791 See :meth:`Widget.get_cursor_coords` for details792 """793 (maxcol, maxrow) = size794 795 middle, _top, _bottom = self.calculate_visible((maxcol, maxrow), True)796 if middle is None:797 return None798 799 offset_inset, _ignore1, _ignore2, _ignore3, cursor = middle # pylint: disable=unpacking-non-sequence800 if not cursor:801 return None802 803 x, y = cursor804 y += offset_inset805 if y < 0 or y >= maxrow:806 return None807 return (x, y)808 809 def set_focus_valign(810 self,811 valign: Literal["top", "middle", "bottom"] | VAlign | tuple[Literal["relative", WHSettings.RELATIVE], int],812 ):813 """Set the focus widget's display offset and inset.814 815 :param valign: one of: 'top', 'middle', 'bottom' ('relative', percentage 0=top 100=bottom)816 """817 vt, va = normalize_valign(valign, ListBoxError)818 self.set_focus_valign_pending = vt, va819 820 def set_focus(self, position, coming_from: Literal["above", "below"] | None = None) -> None:821 """822 Set the focus position and try to keep the old focus in view.823 824 :param position: a position compatible with :meth:`self._body.set_focus`825 :param coming_from: set to 'above' or 'below' if you know that826 old position is above or below the new position.827 :type coming_from: str828 """829 if coming_from not in {"above", "below", None}:830 raise ListBoxError(f"coming_from value invalid: {coming_from!r}")831 focus_widget, focus_pos = self._body.get_focus()832 if focus_widget is None:833 raise IndexError("Can't set focus, ListBox is empty")834 835 self.set_focus_pending = coming_from, focus_widget, focus_pos836 self._body.set_focus(position)837 838 def get_focus(self):839 """840 Return a `(focus widget, focus position)` tuple, for backwards841 compatibility. You may also use the new standard container842 properties :attr:`focus` and :attr:`focus_position` to read these values.843 """844 warnings.warn(845 "only for backwards compatibility."846 "You may also use the new standard container property `focus` to get the focus "847 "and property `focus_position` to read these values."848 "API will be removed in version 5.0.",849 DeprecationWarning,850 stacklevel=2,851 )852 return self._body.get_focus()853 854 @property855 def focus(self) -> Widget | None:856 """857 the child widget in focus or None when ListBox is empty.858 859 Return the widget in focus according to our :obj:`list walker <ListWalker>`.860 """861 return self._body.get_focus()[0]862 863 def _get_focus_position(self):864 """865 Return the list walker position of the widget in focus. The type866 of value returned depends on the :obj:`list walker <ListWalker>`.867 868 """869 w, pos = self._body.get_focus()870 if w is None:871 raise IndexError("No focus_position, ListBox is empty")872 return pos873 874 focus_position = property(875 _get_focus_position,876 set_focus,877 doc="""878 the position of child widget in focus. The valid values for this879 position depend on the list walker in use.880 :exc:`IndexError` will be raised by reading this property when the881 ListBox is empty or setting this property to an invalid position.882 """,883 )884 885 def _contents(self):886 # noinspection PyMethodParameters887 class ListBoxContents:888 # pylint: disable=no-self-argument889 890 __getitem__ = self._contents__getitem__891 892 __len__ = self.__len__893 894 def __repr__(inner_self) -> str:895 return f"<{inner_self.__class__.__name__} for {self!r} at 0x{id(inner_self):X}>"896 897 def __call__(inner_self) -> Self:898 warnings.warn(899 "ListBox.contents is a property, not a method",900 DeprecationWarning,901 stacklevel=3,902 )903 return inner_self904 905 return ListBoxContents()906 907 def _contents__getitem__(self, key):908 # try list walker protocol v2 first909 if hasattr(self._body, "__getitem__"):910 try:911 return (self._body[key], None)912 except (IndexError, KeyError) as exc:913 raise KeyError(f"ListBox.contents key not found: {key!r}").with_traceback(exc.__traceback__) from exc914 # fall back to v1915 _w, old_focus = self._body.get_focus()916 917 try:918 self._body.set_focus(key)919 return self._body.get_focus()[0]920 except (IndexError, KeyError) as exc:921 raise KeyError(f"ListBox.contents key not found: {key!r}").with_traceback(exc.__traceback__) from exc922 finally:923 self._body.set_focus(old_focus)924 925 @property926 def contents(self):927 """928 An object that allows reading widgets from the ListBox's list929 walker as a `(widget, options)` tuple. `None` is currently the only930 value for options.931 932 .. warning::933 934 This object may not be used to set or iterate over contents.935 936 You must use the list walker stored as937 :attr:`.body` to perform manipulation and iteration, if supported.938 """939 return self._contents()940 941 def options(self):942 """943 There are currently no options for ListBox contents.944 945 Return None as a placeholder for future options.946 """947 948 def _set_focus_valign_complete(self, size: tuple[int, int], focus: bool) -> None:949 """Finish setting the offset and inset now that we have have a maxcol & maxrow."""950 (maxcol, maxrow) = size951 vt, va = self.set_focus_valign_pending952 self.set_focus_valign_pending = None953 self.set_focus_pending = None954 955 focus_widget, _focus_pos = self._body.get_focus()956 if focus_widget is None:957 return958 959 rows = focus_widget.rows((maxcol,), focus)960 rtop, _rbot = calculate_top_bottom_filler(961 maxrow,962 vt,963 va,964 WHSettings.GIVEN,965 rows,966 None,967 0,968 0,969 )970 971 self.shift_focus((maxcol, maxrow), rtop)972 973 def _set_focus_first_selectable(self, size: tuple[int, int], focus: bool) -> None:974 """Choose the first visible, selectable widget below the current focus as the focus widget."""975 (maxcol, maxrow) = size976 self.set_focus_valign_pending = None977 self.set_focus_pending = None978 middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus=focus)979 if middle is None:980 return981 982 row_offset, focus_widget, _focus_pos, focus_rows, _cursor = middle # pylint: disable=unpacking-non-sequence983 _trim_top, _fill_above = top # pylint: disable=unpacking-non-sequence984 trim_bottom, fill_below = bottom # pylint: disable=unpacking-non-sequence985 986 if focus_widget.selectable():987 return988 989 if trim_bottom:990 fill_below = fill_below[:-1]991 new_row_offset = row_offset + focus_rows992 for widget, pos, rows in fill_below:993 if widget.selectable():994 self._body.set_focus(pos)995 self.shift_focus((maxcol, maxrow), new_row_offset)996 return997 new_row_offset += rows998 999 def _set_focus_complete(self, size: tuple[int, int], focus: bool) -> None:1000 """Finish setting the position now that we have maxcol & maxrow."""1001 (maxcol, maxrow) = size1002 self._invalidate()1003 if self.set_focus_pending == "first selectable":1004 return self._set_focus_first_selectable((maxcol, maxrow), focus)1005 if self.set_focus_valign_pending is not None:1006 return self._set_focus_valign_complete((maxcol, maxrow), focus)1007 coming_from, _focus_widget, focus_pos = self.set_focus_pending1008 self.set_focus_pending = None1009 1010 # new position1011 _new_focus_widget, position = self._body.get_focus()1012 if focus_pos == position:1013 # do nothing1014 return None1015 1016 # restore old focus temporarily1017 self._body.set_focus(focus_pos)1018 1019 middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus)1020 if middle is None:1021 return None1022 1023 focus_offset, _focus_widget, focus_pos, focus_rows, _cursor = middle # pylint: disable=unpacking-non-sequence1024 _trim_top, fill_above = top # pylint: disable=unpacking-non-sequence1025 _trim_bottom, fill_below = bottom # pylint: disable=unpacking-non-sequence1026 1027 offset = focus_offset1028 for _widget, pos, rows in fill_above:1029 offset -= rows1030 if pos == position:1031 self.change_focus((maxcol, maxrow), pos, offset, "below")1032 return None1033 1034 offset = focus_offset + focus_rows1035 for _widget, pos, rows in fill_below:1036 if pos == position:1037 self.change_focus((maxcol, maxrow), pos, offset, "above")1038 return None1039 offset += rows1040 1041 # failed to find widget among visible widgets1042 self._body.set_focus(position)1043 widget, position = self._body.get_focus()1044 rows = widget.rows((maxcol,), focus)1045 1046 if coming_from == "below":1047 offset = 01048 elif coming_from == "above":1049 offset = maxrow - rows1050 else:1051 offset = (maxrow - rows) // 21052 self.shift_focus((maxcol, maxrow), offset)1053 return None1054 1055 def shift_focus(self, size: tuple[int, int], offset_inset: int) -> None:1056 """1057 Move the location of the current focus relative to the top.1058 This is used internally by methods that know the widget's *size*.1059 1060 See also :meth:`.set_focus_valign`.1061 1062 :param size: see :meth:`Widget.render` for details1063 :param offset_inset: either the number of rows between the1064 top of the listbox and the start of the focus widget (+ve1065 value) or the number of lines of the focus widget hidden off1066 the top edge of the listbox (-ve value) or ``0`` if the top edge1067 of the focus widget is aligned with the top edge of the1068 listbox.1069 :type offset_inset: int1070 """1071 (maxcol, maxrow) = size1072 1073 if offset_inset >= 0:1074 if offset_inset >= maxrow:1075 raise ListBoxError(f"Invalid offset_inset: {offset_inset!r}, only {maxrow!r} rows in list box")1076 self.offset_rows = offset_inset1077 self.inset_fraction = (0, 1)1078 else:1079 target, _ignore = self._body.get_focus()1080 tgt_rows = target.rows((maxcol,), True)1081 if offset_inset + tgt_rows <= 0:1082 raise ListBoxError(f"Invalid offset_inset: {offset_inset!r}, only {tgt_rows!r} rows in target!")1083 self.offset_rows = 01084 self.inset_fraction = (-offset_inset, tgt_rows)1085 self._invalidate()1086 1087 def update_pref_col_from_focus(self, size: tuple[int, int]) -> None:1088 """Update self.pref_col from the focus widget."""1089 # TODO: should this not be private?1090 (maxcol, _maxrow) = size1091 1092 widget, _old_pos = self._body.get_focus()1093 if widget is None:1094 return1095 1096 pref_col = None1097 if hasattr(widget, "get_pref_col"):1098 pref_col = widget.get_pref_col((maxcol,))1099 if pref_col is None and hasattr(widget, "get_cursor_coords"):1100 coords = widget.get_cursor_coords((maxcol,))1101 if isinstance(coords, tuple):1102 pref_col, _y = coords1103 if pref_col is not None:1104 self.pref_col = pref_col1105 1106 def change_focus(1107 self,1108 size: tuple[int, int],1109 position,1110 offset_inset: int = 0,1111 coming_from: Literal["above", "below"] | None = None,1112 cursor_coords: tuple[int, int] | None = None,1113 snap_rows: int | None = None,1114 ) -> None:1115 """1116 Change the current focus widget.1117 This is used internally by methods that know the widget's *size*.1118 1119 See also :meth:`.set_focus`.1120 1121 :param size: see :meth:`Widget.render` for details1122 :param position: a position compatible with :meth:`self._body.set_focus`1123 :param offset_inset: either the number of rows between the1124 top of the listbox and the start of the focus widget (+ve1125 value) or the number of lines of the focus widget hidden off1126 the top edge of the listbox (-ve value) or 0 if the top edge1127 of the focus widget is aligned with the top edge of the1128 listbox (default if unspecified)1129 :type offset_inset: int1130 :param coming_from: either 'above', 'below' or unspecified `None`1131 :type coming_from: str1132 :param cursor_coords: (x, y) tuple indicating the desired1133 column and row for the cursor, a (x,) tuple indicating only1134 the column for the cursor, or unspecified1135 :type cursor_coords: (int, int)1136 :param snap_rows: the maximum number of extra rows to scroll1137 when trying to "snap" a selectable focus into the view1138 :type snap_rows: int1139 """1140 (maxcol, maxrow) = size1141 1142 # update pref_col before change1143 if cursor_coords:1144 self.pref_col = cursor_coords[0]1145 else:1146 self.update_pref_col_from_focus((maxcol, maxrow))1147 1148 self._invalidate()1149 self._body.set_focus(position)1150 target, _ignore = self._body.get_focus()1151 tgt_rows = target.rows((maxcol,), True)1152 if snap_rows is None:1153 snap_rows = maxrow - 11154 1155 # "snap" to selectable widgets1156 align_top = 01157 align_bottom = maxrow - tgt_rows1158 1159 if coming_from == "above" and target.selectable() and offset_inset > align_bottom:1160 if snap_rows >= offset_inset - align_bottom:1161 offset_inset = align_bottom1162 elif snap_rows >= offset_inset - align_top:1163 offset_inset = align_top1164 else:1165 offset_inset -= snap_rows1166 1167 if coming_from == "below" and target.selectable() and offset_inset < align_top:1168 if snap_rows >= align_top - offset_inset:1169 offset_inset = align_top1170 elif snap_rows >= align_bottom - offset_inset:1171 offset_inset = align_bottom1172 else:1173 offset_inset += snap_rows1174 1175 # convert offset_inset to offset_rows or inset_fraction1176 if offset_inset >= 0:1177 self.offset_rows = offset_inset1178 self.inset_fraction = (0, 1)1179 else:1180 if offset_inset + tgt_rows <= 0:1181 raise ListBoxError(f"Invalid offset_inset: {offset_inset}, only {tgt_rows} rows in target!")1182 self.offset_rows = 01183 self.inset_fraction = (-offset_inset, tgt_rows)1184 1185 if cursor_coords is None:1186 if coming_from is None:1187 return # must either know row or coming_from1188 cursor_coords = (self.pref_col,)1189 1190 if not hasattr(target, "move_cursor_to_coords"):1191 return1192 1193 attempt_rows = []1194 1195 if len(cursor_coords) == 1:1196 # only column (not row) specified1197 # start from closest edge and move inwards1198 (pref_col,) = cursor_coords1199 if coming_from == "above":1200 attempt_rows = range(tgt_rows)