urwid 2.6.0.post0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of urwid might be problematic. Click here for more details.

Files changed (75) hide show
  1. urwid/__init__.py +333 -0
  2. urwid/canvas.py +1413 -0
  3. urwid/command_map.py +137 -0
  4. urwid/container.py +59 -0
  5. urwid/decoration.py +65 -0
  6. urwid/display/__init__.py +97 -0
  7. urwid/display/_posix_raw_display.py +413 -0
  8. urwid/display/_raw_display_base.py +914 -0
  9. urwid/display/_web.css +12 -0
  10. urwid/display/_web.js +462 -0
  11. urwid/display/_win32.py +171 -0
  12. urwid/display/_win32_raw_display.py +269 -0
  13. urwid/display/common.py +1219 -0
  14. urwid/display/curses.py +690 -0
  15. urwid/display/escape.py +624 -0
  16. urwid/display/html_fragment.py +251 -0
  17. urwid/display/lcd.py +518 -0
  18. urwid/display/raw.py +37 -0
  19. urwid/display/web.py +636 -0
  20. urwid/event_loop/__init__.py +55 -0
  21. urwid/event_loop/abstract_loop.py +175 -0
  22. urwid/event_loop/asyncio_loop.py +231 -0
  23. urwid/event_loop/glib_loop.py +294 -0
  24. urwid/event_loop/main_loop.py +721 -0
  25. urwid/event_loop/select_loop.py +230 -0
  26. urwid/event_loop/tornado_loop.py +206 -0
  27. urwid/event_loop/trio_loop.py +302 -0
  28. urwid/event_loop/twisted_loop.py +269 -0
  29. urwid/event_loop/zmq_loop.py +275 -0
  30. urwid/font.py +695 -0
  31. urwid/graphics.py +96 -0
  32. urwid/highlight.css +19 -0
  33. urwid/listbox.py +1899 -0
  34. urwid/monitored_list.py +522 -0
  35. urwid/numedit.py +376 -0
  36. urwid/signals.py +330 -0
  37. urwid/split_repr.py +130 -0
  38. urwid/str_util.py +358 -0
  39. urwid/text_layout.py +632 -0
  40. urwid/treetools.py +515 -0
  41. urwid/util.py +557 -0
  42. urwid/version.py +16 -0
  43. urwid/vterm.py +1806 -0
  44. urwid/widget/__init__.py +181 -0
  45. urwid/widget/attr_map.py +161 -0
  46. urwid/widget/attr_wrap.py +140 -0
  47. urwid/widget/bar_graph.py +649 -0
  48. urwid/widget/big_text.py +77 -0
  49. urwid/widget/box_adapter.py +126 -0
  50. urwid/widget/columns.py +1145 -0
  51. urwid/widget/constants.py +574 -0
  52. urwid/widget/container.py +227 -0
  53. urwid/widget/divider.py +110 -0
  54. urwid/widget/edit.py +718 -0
  55. urwid/widget/filler.py +403 -0
  56. urwid/widget/frame.py +539 -0
  57. urwid/widget/grid_flow.py +539 -0
  58. urwid/widget/line_box.py +194 -0
  59. urwid/widget/overlay.py +829 -0
  60. urwid/widget/padding.py +597 -0
  61. urwid/widget/pile.py +971 -0
  62. urwid/widget/popup.py +170 -0
  63. urwid/widget/progress_bar.py +141 -0
  64. urwid/widget/scrollable.py +597 -0
  65. urwid/widget/solid_fill.py +44 -0
  66. urwid/widget/text.py +354 -0
  67. urwid/widget/widget.py +852 -0
  68. urwid/widget/widget_decoration.py +166 -0
  69. urwid/widget/wimp.py +792 -0
  70. urwid/wimp.py +23 -0
  71. urwid-2.6.0.post0.dist-info/COPYING +504 -0
  72. urwid-2.6.0.post0.dist-info/METADATA +332 -0
  73. urwid-2.6.0.post0.dist-info/RECORD +75 -0
  74. urwid-2.6.0.post0.dist-info/WHEEL +5 -0
  75. urwid-2.6.0.post0.dist-info/top_level.txt +1 -0
urwid/listbox.py ADDED
@@ -0,0 +1,1899 @@
1
+ # Urwid listbox class
2
+ # Copyright (C) 2004-2012 Ian Ward
3
+ #
4
+ # This library is free software; you can redistribute it and/or
5
+ # modify it under the terms of the GNU Lesser General Public
6
+ # License as published by the Free Software Foundation; either
7
+ # 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 of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12
+ # Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public
15
+ # License along with this library; if not, write to the Free Software
16
+ # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17
+ #
18
+ # Urwid web site: https://urwid.org/
19
+
20
+
21
+ from __future__ import annotations
22
+
23
+ import typing
24
+ import warnings
25
+ from collections.abc import Iterable, Sized
26
+ from contextlib import suppress
27
+
28
+ from typing_extensions import Protocol, runtime_checkable
29
+
30
+ from urwid import signals
31
+ from urwid.canvas import CanvasCombine, SolidCanvas
32
+ from urwid.command_map import Command
33
+ from urwid.monitored_list import MonitoredFocusList, MonitoredList
34
+ from urwid.signals import connect_signal, disconnect_signal
35
+ from urwid.util import is_mouse_press
36
+ from urwid.widget import (
37
+ Sizing,
38
+ VAlign,
39
+ WHSettings,
40
+ Widget,
41
+ WidgetContainerMixin,
42
+ calculate_top_bottom_filler,
43
+ nocache_widget_render_instance,
44
+ normalize_valign,
45
+ )
46
+
47
+ if typing.TYPE_CHECKING:
48
+ from collections.abc import Callable, Hashable
49
+
50
+ from typing_extensions import Literal, Self
51
+
52
+ from urwid.canvas import CompositeCanvas
53
+
54
+ _T = typing.TypeVar("_T")
55
+ _K = typing.TypeVar("_K")
56
+
57
+
58
+ class ListWalkerError(Exception):
59
+ pass
60
+
61
+
62
+ @runtime_checkable
63
+ class ScrollSupportingBody(Sized, Protocol):
64
+ """Protocol for ListWalkers that support Scrolling."""
65
+
66
+ def get_focus(self) -> tuple[Widget, _K]: ...
67
+
68
+ def set_focus(self, position: _K) -> None: ...
69
+
70
+ def __getitem__(self, index: _K) -> _T: ...
71
+
72
+ def positions(self, reverse: bool = False) -> Iterable[_K]: ...
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
+ class ListWalker(metaclass=signals.MetaSignals): # pylint: disable=no-member, unsubscriptable-object
80
+ # mixin not named as mixin
81
+ signals: typing.ClassVar[list[str]] = ["modified"]
82
+
83
+ def _modified(self) -> None:
84
+ signals.emit_signal(self, "modified")
85
+
86
+ def get_focus(self):
87
+ """
88
+ This default implementation relies on a focus attribute and a
89
+ __getitem__() method defined in a subclass.
90
+
91
+ Override and don't call this method if these are not defined.
92
+ """
93
+ try:
94
+ focus = self.focus
95
+ return self[focus], focus
96
+ except (IndexError, KeyError, TypeError):
97
+ return None, None
98
+
99
+ def get_next(self, position):
100
+ """
101
+ This default implementation relies on a next_position() method and a
102
+ __getitem__() method defined in a subclass.
103
+
104
+ Override and don't call this method if these are not defined.
105
+ """
106
+ try:
107
+ position = self.next_position(position)
108
+ return self[position], position
109
+ except (IndexError, KeyError):
110
+ return None, None
111
+
112
+ def get_prev(self, position):
113
+ """
114
+ This default implementation relies on a prev_position() method and a
115
+ __getitem__() method defined in a subclass.
116
+
117
+ Override and don't call this method if these are not defined.
118
+ """
119
+ try:
120
+ position = self.prev_position(position)
121
+ return self[position], position
122
+ except (IndexError, KeyError):
123
+ return None, None
124
+
125
+
126
+ class SimpleListWalker(MonitoredList[_T], ListWalker):
127
+ def __init__(self, contents: Iterable[_T], wrap_around: bool = False) -> None:
128
+ """
129
+ contents -- list to copy into this object
130
+
131
+ wrap_around -- if true, jumps to beginning/end of list on move
132
+
133
+ This class inherits :class:`MonitoredList` which means
134
+ it can be treated as a list.
135
+
136
+ Changes made to this object (when it is treated as a list) are
137
+ detected automatically and will cause ListBox objects using
138
+ this list walker to be updated.
139
+ """
140
+ if not isinstance(contents, Iterable):
141
+ raise ListWalkerError(f"SimpleListWalker expecting list like object, got: {contents!r}")
142
+ MonitoredList.__init__(self, contents)
143
+ self.focus = 0
144
+ self.wrap_around = wrap_around
145
+
146
+ @property
147
+ def contents(self) -> Self:
148
+ """
149
+ Return self.
150
+
151
+ Provides compatibility with old SimpleListWalker class.
152
+ """
153
+ return self
154
+
155
+ def _get_contents(self) -> Self:
156
+ warnings.warn(
157
+ f"Method `{self.__class__.__name__}._get_contents` is deprecated, "
158
+ f"please use property`{self.__class__.__name__}.contents`",
159
+ DeprecationWarning,
160
+ stacklevel=3,
161
+ )
162
+ return self
163
+
164
+ def _modified(self) -> None:
165
+ if self.focus >= len(self):
166
+ self.focus = max(0, len(self) - 1)
167
+ ListWalker._modified(self)
168
+
169
+ def set_modified_callback(self, callback: Callable[[], typing.Any]) -> typing.NoReturn:
170
+ """
171
+ This function inherited from MonitoredList is not implemented in SimpleListWalker.
172
+
173
+ Use connect_signal(list_walker, "modified", ...) instead.
174
+ """
175
+ raise NotImplementedError('Use connect_signal(list_walker, "modified", ...) instead.')
176
+
177
+ def set_focus(self, position: int) -> None:
178
+ """Set focus position."""
179
+
180
+ if not 0 <= position < len(self):
181
+ raise IndexError(f"No widget at position {position}")
182
+
183
+ self.focus = position
184
+ self._modified()
185
+
186
+ def next_position(self, position: int) -> int:
187
+ """
188
+ Return position after start_from.
189
+ """
190
+ if len(self) - 1 <= position:
191
+ if self.wrap_around:
192
+ return 0
193
+ raise IndexError
194
+ return position + 1
195
+
196
+ def prev_position(self, position: int) -> int:
197
+ """
198
+ Return position before start_from.
199
+ """
200
+ if position <= 0:
201
+ if self.wrap_around:
202
+ return len(self) - 1
203
+ raise IndexError
204
+ return position - 1
205
+
206
+ def positions(self, reverse: bool = False) -> Iterable[int]:
207
+ """
208
+ Optional method for returning an iterable of positions.
209
+ """
210
+ if reverse:
211
+ return range(len(self) - 1, -1, -1)
212
+ return range(len(self))
213
+
214
+
215
+ class SimpleFocusListWalker(ListWalker, MonitoredFocusList[_T]):
216
+ def __init__(self, contents: Iterable[_T], wrap_around: bool = False) -> None:
217
+ """
218
+ contents -- list to copy into this object
219
+
220
+ wrap_around -- if true, jumps to beginning/end of list on move
221
+
222
+ This class inherits :class:`MonitoredList` which means
223
+ it can be treated as a list.
224
+
225
+ Changes made to this object (when it is treated as a list) are
226
+ detected automatically and will cause ListBox objects using
227
+ this list walker to be updated.
228
+
229
+ Also, items added or removed before the widget in focus with
230
+ normal list methods will cause the focus to be updated
231
+ intelligently.
232
+ """
233
+ if not isinstance(contents, Iterable):
234
+ raise ListWalkerError(f"SimpleFocusListWalker expecting iterable object, got: {contents!r}")
235
+ MonitoredFocusList.__init__(self, contents)
236
+ self.wrap_around = wrap_around
237
+
238
+ def set_modified_callback(self, callback: typing.Any) -> typing.NoReturn:
239
+ """
240
+ This function inherited from MonitoredList is not
241
+ implemented in SimpleFocusListWalker.
242
+
243
+ Use connect_signal(list_walker, "modified", ...) instead.
244
+ """
245
+ raise NotImplementedError('Use connect_signal(list_walker, "modified", ...) instead.')
246
+
247
+ def set_focus(self, position: int) -> None:
248
+ """Set focus position."""
249
+ self.focus = position
250
+ self._modified()
251
+
252
+ def next_position(self, position: int) -> int:
253
+ """
254
+ Return position after start_from.
255
+ """
256
+ if len(self) - 1 <= position:
257
+ if self.wrap_around:
258
+ return 0
259
+ raise IndexError
260
+ return position + 1
261
+
262
+ def prev_position(self, position: int) -> int:
263
+ """
264
+ Return position before start_from.
265
+ """
266
+ if position <= 0:
267
+ if self.wrap_around:
268
+ return len(self) - 1
269
+ raise IndexError
270
+ return position - 1
271
+
272
+ def positions(self, reverse: bool = False) -> Iterable[int]:
273
+ """
274
+ Optional method for returning an iterable of positions.
275
+ """
276
+ if reverse:
277
+ return range(len(self) - 1, -1, -1)
278
+ return range(len(self))
279
+
280
+
281
+ class ListBoxError(Exception):
282
+ pass
283
+
284
+
285
+ class _Middle(typing.NamedTuple):
286
+ """Named tuple for ListBox internals."""
287
+
288
+ offset: int
289
+ focus_widget: Widget
290
+ focus_pos: Hashable
291
+ focus_rows: int
292
+ cursor: tuple[int, int] | tuple[int] | None
293
+
294
+
295
+ class _FillItem(typing.NamedTuple):
296
+ """Named tuple for ListBox internals."""
297
+
298
+ widget: Widget
299
+ position: Hashable
300
+ rows: int
301
+
302
+
303
+ class _TopBottom(typing.NamedTuple):
304
+ """Named tuple for ListBox internals."""
305
+
306
+ trim: int
307
+ fill: list[_FillItem]
308
+
309
+
310
+ class ListBox(Widget, WidgetContainerMixin):
311
+ """
312
+ Vertically stacked list of widgets
313
+ """
314
+
315
+ _selectable = True
316
+ _sizing = frozenset([Sizing.BOX])
317
+
318
+ def __init__(self, body: ListWalker | Iterable[Widget]) -> None:
319
+ """
320
+ :param body: a ListWalker subclass such as :class:`SimpleFocusListWalker`
321
+ that contains widgets to be displayed inside the list box
322
+ :type body: ListWalker
323
+ """
324
+ super().__init__()
325
+ if getattr(body, "get_focus", None):
326
+ self._body: ListWalker = body
327
+ else:
328
+ self._body = SimpleListWalker(body)
329
+
330
+ self.body = self._body # Initialization hack
331
+
332
+ # offset_rows is the number of rows between the top of the view
333
+ # and the top of the focused item
334
+ self.offset_rows = 0
335
+ # inset_fraction is used when the focused widget is off the
336
+ # top of the view. it is the fraction of the widget cut off
337
+ # at the top. (numerator, denominator)
338
+ self.inset_fraction = (0, 1)
339
+
340
+ # pref_col is the preferred column for the cursor when moving
341
+ # between widgets that use the cursor (edit boxes etc.)
342
+ self.pref_col = "left"
343
+
344
+ # variable for delayed focus change used by set_focus
345
+ self.set_focus_pending = "first selectable"
346
+
347
+ # variable for delayed valign change used by set_focus_valign
348
+ self.set_focus_valign_pending = None
349
+
350
+ # used for scrollable protocol
351
+ self._rows_max_cached = 0
352
+ self._rendered_size = 0, 0
353
+
354
+ @property
355
+ def body(self) -> ListWalker:
356
+ """
357
+ a ListWalker subclass such as :class:`SimpleFocusListWalker` that contains
358
+ widgets to be displayed inside the list box
359
+ """
360
+ return self._body
361
+
362
+ @body.setter
363
+ def body(self, body: Iterable[Widget] | ListWalker) -> None:
364
+ with suppress(AttributeError):
365
+ disconnect_signal(self._body, "modified", self._invalidate)
366
+ # _body may be not yet assigned
367
+
368
+ if getattr(body, "get_focus", None):
369
+ self._body = body
370
+ else:
371
+ self._body = SimpleListWalker(body)
372
+ try:
373
+ connect_signal(self._body, "modified", self._invalidate)
374
+ except NameError:
375
+ # our list walker has no modified signal so we must not
376
+ # cache our canvases because we don't know when our
377
+ # content has changed
378
+ self.render = nocache_widget_render_instance(self)
379
+ self._invalidate()
380
+
381
+ def _get_body(self):
382
+ warnings.warn(
383
+ f"Method `{self.__class__.__name__}._get_body` is deprecated, "
384
+ f"please use property `{self.__class__.__name__}.body`",
385
+ DeprecationWarning,
386
+ stacklevel=3,
387
+ )
388
+ return self.body
389
+
390
+ def _set_body(self, body):
391
+ warnings.warn(
392
+ f"Method `{self.__class__.__name__}._set_body` is deprecated, "
393
+ f"please use property `{self.__class__.__name__}.body`",
394
+ DeprecationWarning,
395
+ stacklevel=3,
396
+ )
397
+ self.body = body
398
+
399
+ def __len__(self) -> int:
400
+ if isinstance(self._body, Sized):
401
+ return len(self._body)
402
+ raise AttributeError(f"{self._body.__class__.__name__} is not Sized")
403
+
404
+ def calculate_visible(
405
+ self,
406
+ size: tuple[int, int],
407
+ focus: bool = False,
408
+ ) -> tuple[_Middle, _TopBottom, _TopBottom] | tuple[None, None, None]:
409
+ """
410
+ Returns the widgets that would be displayed in
411
+ the ListBox given the current *size* and *focus*.
412
+
413
+ see :meth:`Widget.render` for parameter details
414
+
415
+ :returns: (*middle*, *top*, *bottom*) or (``None``, ``None``, ``None``)
416
+
417
+ *middle*
418
+ (*row offset*(when +ve) or *inset*(when -ve),
419
+ *focus widget*, *focus position*, *focus rows*,
420
+ *cursor coords* or ``None``)
421
+ *top*
422
+ (*# lines to trim off top*,
423
+ list of (*widget*, *position*, *rows*) tuples above focus in order from bottom to top)
424
+ *bottom*
425
+ (*# lines to trim off bottom*,
426
+ list of (*widget*, *position*, *rows*) tuples below focus in order from top to bottom)
427
+ """
428
+ (maxcol, maxrow) = size
429
+
430
+ # 0. set the focus if a change is pending
431
+ if self.set_focus_pending or self.set_focus_valign_pending:
432
+ self._set_focus_complete((maxcol, maxrow), focus)
433
+
434
+ # 1. start with the focus widget
435
+ focus_widget, focus_pos = self._body.get_focus()
436
+ if focus_widget is None: # list box is empty?
437
+ return None, None, None
438
+ top_pos = focus_pos
439
+
440
+ offset_rows, inset_rows = self.get_focus_offset_inset((maxcol, maxrow))
441
+ # force at least one line of focus to be visible
442
+ if maxrow and offset_rows >= maxrow:
443
+ offset_rows = maxrow - 1
444
+
445
+ # adjust position so cursor remains visible
446
+ cursor = None
447
+ if maxrow and focus_widget.selectable() and focus and hasattr(focus_widget, "get_cursor_coords"):
448
+ cursor = focus_widget.get_cursor_coords((maxcol,))
449
+
450
+ if cursor is not None:
451
+ _cx, cy = cursor
452
+ effective_cy = cy + offset_rows - inset_rows
453
+
454
+ if effective_cy < 0: # cursor above top?
455
+ inset_rows = cy
456
+ elif effective_cy >= maxrow: # cursor below bottom?
457
+ offset_rows = maxrow - cy - 1
458
+ if offset_rows < 0: # need to trim the top
459
+ inset_rows, offset_rows = -offset_rows, 0
460
+
461
+ # set trim_top by focus trimmimg
462
+ trim_top = inset_rows
463
+ focus_rows = focus_widget.rows((maxcol,), True)
464
+
465
+ # 2. collect the widgets above the focus
466
+ pos = focus_pos
467
+ fill_lines = offset_rows
468
+ fill_above = []
469
+ top_pos = pos
470
+ while fill_lines > 0:
471
+ prev, pos = self._body.get_prev(pos)
472
+ if prev is None: # run out of widgets above?
473
+ offset_rows -= fill_lines
474
+ break
475
+ top_pos = pos
476
+
477
+ p_rows = prev.rows((maxcol,))
478
+ if p_rows: # filter out 0-height widgets
479
+ fill_above.append(_FillItem(prev, pos, p_rows))
480
+ if p_rows > fill_lines: # crosses top edge?
481
+ trim_top = p_rows - fill_lines
482
+ break
483
+ fill_lines -= p_rows
484
+
485
+ trim_bottom = max(focus_rows + offset_rows - inset_rows - maxrow, 0)
486
+
487
+ # 3. collect the widgets below the focus
488
+ pos = focus_pos
489
+ fill_lines = maxrow - focus_rows - offset_rows + inset_rows
490
+ fill_below = []
491
+ while fill_lines > 0:
492
+ next_pos, pos = self._body.get_next(pos)
493
+ if next_pos is None: # run out of widgets below?
494
+ break
495
+
496
+ n_rows = next_pos.rows((maxcol,))
497
+ if n_rows: # filter out 0-height widgets
498
+ fill_below.append(_FillItem(next_pos, pos, n_rows))
499
+ if n_rows > fill_lines: # crosses bottom edge?
500
+ trim_bottom = n_rows - fill_lines
501
+ fill_lines -= n_rows
502
+ break
503
+ fill_lines -= n_rows
504
+
505
+ # 4. fill from top again if necessary & possible
506
+ fill_lines = max(0, fill_lines)
507
+
508
+ if fill_lines > 0 and trim_top > 0:
509
+ if fill_lines <= trim_top:
510
+ trim_top -= fill_lines
511
+ offset_rows += fill_lines
512
+ fill_lines = 0
513
+ else:
514
+ fill_lines -= trim_top
515
+ offset_rows += trim_top
516
+ trim_top = 0
517
+ pos = top_pos
518
+ while fill_lines > 0:
519
+ prev, pos = self._body.get_prev(pos)
520
+ if prev is None:
521
+ break
522
+
523
+ p_rows = prev.rows((maxcol,))
524
+ fill_above.append(_FillItem(prev, pos, p_rows))
525
+ if p_rows > fill_lines: # more than required
526
+ trim_top = p_rows - fill_lines
527
+ offset_rows += fill_lines
528
+ break
529
+ fill_lines -= p_rows
530
+ offset_rows += p_rows
531
+
532
+ # 5. return the interesting bits
533
+ return (
534
+ _Middle(offset_rows - inset_rows, focus_widget, focus_pos, focus_rows, cursor),
535
+ _TopBottom(trim_top, fill_above),
536
+ _TopBottom(trim_bottom, fill_below),
537
+ )
538
+
539
+ def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
540
+ """Current scrolling position."""
541
+ if not isinstance(self._body, ScrollSupportingBody):
542
+ raise ListBoxError(f"{self} body do not implement methods required for scrolling protocol")
543
+
544
+ if not self._body:
545
+ return 0
546
+
547
+ if size is not None:
548
+ self._rendered_size = size
549
+
550
+ mid, top, _bottom = self.calculate_visible(self._rendered_size, focus)
551
+
552
+ start_row = top.trim
553
+ maxcol = self._rendered_size[0]
554
+
555
+ if top.fill:
556
+ pos = top.fill[-1].position
557
+ else:
558
+ pos = mid.focus_pos
559
+
560
+ prev, pos = self._body.get_prev(pos)
561
+ while prev is not None:
562
+ start_row += prev.rows((maxcol,))
563
+ prev, pos = self._body.get_prev(pos)
564
+
565
+ return start_row
566
+
567
+ def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
568
+ """Scrollable protocol for sized iterable and not wrapped around contents."""
569
+ if not isinstance(self._body, ScrollSupportingBody):
570
+ raise ListBoxError(f"{self} body do not implement methods required for scrolling protocol")
571
+
572
+ if getattr(self._body, "wrap_around", False):
573
+ raise ListBoxError("Body is wrapped around")
574
+
575
+ if size is not None:
576
+ self._rendered_size = size
577
+
578
+ if size or not self._rows_max_cached:
579
+ self._rows_max_cached = sum(
580
+ self._body[position].rows((self._rendered_size[0],), focus) for position in self._body.positions()
581
+ )
582
+
583
+ return self._rows_max_cached
584
+
585
+ def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas | SolidCanvas:
586
+ """
587
+ Render ListBox and return canvas.
588
+
589
+ see :meth:`Widget.render` for details
590
+ """
591
+ (maxcol, maxrow) = size
592
+
593
+ self._rendered_size = size
594
+
595
+ middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus=focus)
596
+ if middle is None:
597
+ return SolidCanvas(" ", maxcol, maxrow)
598
+
599
+ _ignore, focus_widget, focus_pos, focus_rows, cursor = middle
600
+ trim_top, fill_above = top
601
+ trim_bottom, fill_below = bottom
602
+
603
+ combinelist = []
604
+ rows = 0
605
+ fill_above.reverse() # fill_above is in bottom-up order
606
+ for widget, w_pos, w_rows in fill_above:
607
+ canvas = widget.render((maxcol,))
608
+ if w_rows != canvas.rows():
609
+ raise ListBoxError(
610
+ f"Widget {widget!r} at position {w_pos!r} "
611
+ f"within listbox calculated {w_rows:d} rows "
612
+ f"but rendered {canvas.rows():d}!"
613
+ )
614
+ rows += w_rows
615
+ combinelist.append((canvas, w_pos, False))
616
+
617
+ focus_canvas = focus_widget.render((maxcol,), focus=focus)
618
+
619
+ if focus_canvas.rows() != focus_rows:
620
+ raise ListBoxError(
621
+ f"Focus Widget {focus_widget!r} at position {focus_pos!r} "
622
+ f"within listbox calculated {focus_rows:d} rows "
623
+ f"but rendered {focus_canvas.rows():d}!"
624
+ )
625
+ c_cursor = focus_canvas.cursor
626
+ if cursor is not None and cursor != c_cursor:
627
+ raise ListBoxError(
628
+ f"Focus Widget {focus_widget!r} at position {focus_pos!r} "
629
+ f"within listbox calculated cursor coords {cursor!r} "
630
+ f"but rendered cursor coords {c_cursor!r}!"
631
+ )
632
+
633
+ rows += focus_rows
634
+ combinelist.append((focus_canvas, focus_pos, True))
635
+
636
+ for widget, w_pos, w_rows in fill_below:
637
+ canvas = widget.render((maxcol,))
638
+ if w_rows != canvas.rows():
639
+ raise ListBoxError(
640
+ f"Widget {widget!r} at position {w_pos!r} "
641
+ f"within listbox calculated {w_rows:d} "
642
+ f"rows but rendered {canvas.rows():d}!"
643
+ )
644
+ rows += w_rows
645
+ combinelist.append((canvas, w_pos, False))
646
+
647
+ final_canvas = CanvasCombine(combinelist)
648
+
649
+ if trim_top:
650
+ final_canvas.trim(trim_top)
651
+ rows -= trim_top
652
+ if trim_bottom:
653
+ final_canvas.trim_end(trim_bottom)
654
+ rows -= trim_bottom
655
+
656
+ if rows > maxrow:
657
+ raise ListBoxError(
658
+ f"Listbox contents too long! Probably urwid's fault (please report): {top, middle, bottom!r}"
659
+ )
660
+
661
+ if rows < maxrow:
662
+ bottom_pos = focus_pos
663
+ if fill_below:
664
+ bottom_pos = fill_below[-1][1]
665
+ if trim_bottom != 0 or self._body.get_next(bottom_pos) != (None, None):
666
+ raise ListBoxError(
667
+ f"Listbox contents too short! Probably urwid's fault (please report): {top, middle, bottom!r}"
668
+ )
669
+ final_canvas.pad_trim_top_bottom(0, maxrow - rows)
670
+
671
+ return final_canvas
672
+
673
+ def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None:
674
+ """
675
+ See :meth:`Widget.get_cursor_coords` for details
676
+ """
677
+ (maxcol, maxrow) = size
678
+
679
+ middle, _top, _bottom = self.calculate_visible((maxcol, maxrow), True)
680
+ if middle is None:
681
+ return None
682
+
683
+ offset_inset, _ignore1, _ignore2, _ignore3, cursor = middle
684
+ if not cursor:
685
+ return None
686
+
687
+ x, y = cursor
688
+ y += offset_inset
689
+ if y < 0 or y >= maxrow:
690
+ return None
691
+ return (x, y)
692
+
693
+ def set_focus_valign(
694
+ self,
695
+ valign: Literal["top", "middle", "bottom"] | VAlign | tuple[Literal["relative", WHSettings.RELATIVE], int],
696
+ ):
697
+ """Set the focus widget's display offset and inset.
698
+
699
+ :param valign: one of: 'top', 'middle', 'bottom' ('relative', percentage 0=top 100=bottom)
700
+ """
701
+ vt, va = normalize_valign(valign, ListBoxError)
702
+ self.set_focus_valign_pending = vt, va
703
+
704
+ def set_focus(self, position, coming_from: Literal["above", "below"] | None = None) -> None:
705
+ """
706
+ Set the focus position and try to keep the old focus in view.
707
+
708
+ :param position: a position compatible with :meth:`self._body.set_focus`
709
+ :param coming_from: set to 'above' or 'below' if you know that
710
+ old position is above or below the new position.
711
+ :type coming_from: str
712
+ """
713
+ if coming_from not in {"above", "below", None}:
714
+ raise ListBoxError(f"coming_from value invalid: {coming_from!r}")
715
+ focus_widget, focus_pos = self._body.get_focus()
716
+ if focus_widget is None:
717
+ raise IndexError("Can't set focus, ListBox is empty")
718
+
719
+ self.set_focus_pending = coming_from, focus_widget, focus_pos
720
+ self._body.set_focus(position)
721
+
722
+ def get_focus(self):
723
+ """
724
+ Return a `(focus widget, focus position)` tuple, for backwards
725
+ compatibility. You may also use the new standard container
726
+ properties :attr:`focus` and :attr:`focus_position` to read these values.
727
+ """
728
+ warnings.warn(
729
+ "only for backwards compatibility."
730
+ "You may also use the new standard container property `focus` to get the focus "
731
+ "and property `focus_position` to read these values.",
732
+ PendingDeprecationWarning,
733
+ stacklevel=2,
734
+ )
735
+ return self._body.get_focus()
736
+
737
+ @property
738
+ def focus(self) -> Widget | None:
739
+ """
740
+ the child widget in focus or None when ListBox is empty.
741
+
742
+ Return the widget in focus according to our :obj:`list walker <ListWalker>`.
743
+ """
744
+ return self._body.get_focus()[0]
745
+
746
+ def _get_focus(self) -> Widget:
747
+ warnings.warn(
748
+ f"method `{self.__class__.__name__}._get_focus` is deprecated, "
749
+ f"please use `{self.__class__.__name__}.focus` property",
750
+ DeprecationWarning,
751
+ stacklevel=3,
752
+ )
753
+ return self.focus
754
+
755
+ def _get_focus_position(self):
756
+ """
757
+ Return the list walker position of the widget in focus. The type
758
+ of value returned depends on the :obj:`list walker <ListWalker>`.
759
+
760
+ """
761
+ w, pos = self._body.get_focus()
762
+ if w is None:
763
+ raise IndexError("No focus_position, ListBox is empty")
764
+ return pos
765
+
766
+ focus_position = property(
767
+ _get_focus_position,
768
+ set_focus,
769
+ doc="""
770
+ the position of child widget in focus. The valid values for this
771
+ position depend on the list walker in use.
772
+ :exc:`IndexError` will be raised by reading this property when the
773
+ ListBox is empty or setting this property to an invalid position.
774
+ """,
775
+ )
776
+
777
+ def _contents(self):
778
+ class ListBoxContents:
779
+ # pylint: disable=no-self-argument
780
+
781
+ __getitem__ = self._contents__getitem__
782
+
783
+ __len__ = self.__len__
784
+
785
+ def __repr__(inner_self) -> str:
786
+ return f"<{inner_self.__class__.__name__} for {self!r} at 0x{id(inner_self):X}>"
787
+
788
+ def __call__(inner_self) -> Self:
789
+ warnings.warn(
790
+ "ListBox.contents is a property, not a method",
791
+ DeprecationWarning,
792
+ stacklevel=3,
793
+ )
794
+ return inner_self
795
+
796
+ return ListBoxContents()
797
+
798
+ def _contents__getitem__(self, key):
799
+ # try list walker protocol v2 first
800
+ if hasattr(self._body, "__getitem__"):
801
+ try:
802
+ return (self._body[key], None)
803
+ except (IndexError, KeyError) as exc:
804
+ raise KeyError(f"ListBox.contents key not found: {key!r}").with_traceback(exc.__traceback__) from exc
805
+ # fall back to v1
806
+ _w, old_focus = self._body.get_focus()
807
+
808
+ try:
809
+ self._body.set_focus(key)
810
+ return self._body.get_focus()[0]
811
+ except (IndexError, KeyError) as exc:
812
+ raise KeyError(f"ListBox.contents key not found: {key!r}").with_traceback(exc.__traceback__) from exc
813
+ finally:
814
+ self._body.set_focus(old_focus)
815
+
816
+ @property
817
+ def contents(self):
818
+ """
819
+ An object that allows reading widgets from the ListBox's list
820
+ walker as a `(widget, options)` tuple. `None` is currently the only
821
+ value for options.
822
+
823
+ .. warning::
824
+
825
+ This object may not be used to set or iterate over contents.
826
+
827
+ You must use the list walker stored as
828
+ :attr:`.body` to perform manipulation and iteration, if supported.
829
+ """
830
+ return self._contents()
831
+
832
+ def options(self):
833
+ """
834
+ There are currently no options for ListBox contents.
835
+
836
+ Return None as a placeholder for future options.
837
+ """
838
+
839
+ def _set_focus_valign_complete(self, size: tuple[int, int], focus: bool) -> None:
840
+ """Finish setting the offset and inset now that we have have a maxcol & maxrow."""
841
+ (maxcol, maxrow) = size
842
+ vt, va = self.set_focus_valign_pending
843
+ self.set_focus_valign_pending = None
844
+ self.set_focus_pending = None
845
+
846
+ focus_widget, _focus_pos = self._body.get_focus()
847
+ if focus_widget is None:
848
+ return
849
+
850
+ rows = focus_widget.rows((maxcol,), focus)
851
+ rtop, _rbot = calculate_top_bottom_filler(
852
+ maxrow,
853
+ vt,
854
+ va,
855
+ WHSettings.GIVEN,
856
+ rows,
857
+ None,
858
+ 0,
859
+ 0,
860
+ )
861
+
862
+ self.shift_focus((maxcol, maxrow), rtop)
863
+
864
+ def _set_focus_first_selectable(self, size: tuple[int, int], focus: bool) -> None:
865
+ """Choose the first visible, selectable widget below the current focus as the focus widget."""
866
+ (maxcol, maxrow) = size
867
+ self.set_focus_valign_pending = None
868
+ self.set_focus_pending = None
869
+ middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus=focus)
870
+ if middle is None:
871
+ return
872
+
873
+ row_offset, focus_widget, _focus_pos, focus_rows, _cursor = middle
874
+ _trim_top, _fill_above = top
875
+ trim_bottom, fill_below = bottom
876
+
877
+ if focus_widget.selectable():
878
+ return
879
+
880
+ if trim_bottom:
881
+ fill_below = fill_below[:-1]
882
+ new_row_offset = row_offset + focus_rows
883
+ for widget, pos, rows in fill_below:
884
+ if widget.selectable():
885
+ self._body.set_focus(pos)
886
+ self.shift_focus((maxcol, maxrow), new_row_offset)
887
+ return
888
+ new_row_offset += rows
889
+
890
+ def _set_focus_complete(self, size: tuple[int, int], focus: bool) -> None:
891
+ """Finish setting the position now that we have maxcol & maxrow."""
892
+ (maxcol, maxrow) = size
893
+ self._invalidate()
894
+ if self.set_focus_pending == "first selectable":
895
+ return self._set_focus_first_selectable((maxcol, maxrow), focus)
896
+ if self.set_focus_valign_pending is not None:
897
+ return self._set_focus_valign_complete((maxcol, maxrow), focus)
898
+ coming_from, _focus_widget, focus_pos = self.set_focus_pending
899
+ self.set_focus_pending = None
900
+
901
+ # new position
902
+ _new_focus_widget, position = self._body.get_focus()
903
+ if focus_pos == position:
904
+ # do nothing
905
+ return None
906
+
907
+ # restore old focus temporarily
908
+ self._body.set_focus(focus_pos)
909
+
910
+ middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus)
911
+ focus_offset, _focus_widget, focus_pos, focus_rows, _cursor = middle
912
+ _trim_top, fill_above = top
913
+ _trim_bottom, fill_below = bottom
914
+
915
+ offset = focus_offset
916
+ for _widget, pos, rows in fill_above:
917
+ offset -= rows
918
+ if pos == position:
919
+ self.change_focus((maxcol, maxrow), pos, offset, "below")
920
+ return None
921
+
922
+ offset = focus_offset + focus_rows
923
+ for _widget, pos, rows in fill_below:
924
+ if pos == position:
925
+ self.change_focus((maxcol, maxrow), pos, offset, "above")
926
+ return None
927
+ offset += rows
928
+
929
+ # failed to find widget among visible widgets
930
+ self._body.set_focus(position)
931
+ widget, position = self._body.get_focus()
932
+ rows = widget.rows((maxcol,), focus)
933
+
934
+ if coming_from == "below":
935
+ offset = 0
936
+ elif coming_from == "above":
937
+ offset = maxrow - rows
938
+ else:
939
+ offset = (maxrow - rows) // 2
940
+ self.shift_focus((maxcol, maxrow), offset)
941
+ return None
942
+
943
+ def shift_focus(self, size: tuple[int, int], offset_inset: int) -> None:
944
+ """
945
+ Move the location of the current focus relative to the top.
946
+ This is used internally by methods that know the widget's *size*.
947
+
948
+ See also :meth:`.set_focus_valign`.
949
+
950
+ :param size: see :meth:`Widget.render` for details
951
+ :param offset_inset: either the number of rows between the
952
+ top of the listbox and the start of the focus widget (+ve
953
+ value) or the number of lines of the focus widget hidden off
954
+ the top edge of the listbox (-ve value) or ``0`` if the top edge
955
+ of the focus widget is aligned with the top edge of the
956
+ listbox.
957
+ :type offset_inset: int
958
+ """
959
+ (maxcol, maxrow) = size
960
+
961
+ if offset_inset >= 0:
962
+ if offset_inset >= maxrow:
963
+ raise ListBoxError(f"Invalid offset_inset: {offset_inset!r}, only {maxrow!r} rows in list box")
964
+ self.offset_rows = offset_inset
965
+ self.inset_fraction = (0, 1)
966
+ else:
967
+ target, _ignore = self._body.get_focus()
968
+ tgt_rows = target.rows((maxcol,), True)
969
+ if offset_inset + tgt_rows <= 0:
970
+ raise ListBoxError(f"Invalid offset_inset: {offset_inset!r}, only {tgt_rows!r} rows in target!")
971
+ self.offset_rows = 0
972
+ self.inset_fraction = (-offset_inset, tgt_rows)
973
+ self._invalidate()
974
+
975
+ def update_pref_col_from_focus(self, size: tuple[int, int]) -> None:
976
+ """Update self.pref_col from the focus widget."""
977
+ # TODO: should this not be private?
978
+ (maxcol, _maxrow) = size
979
+
980
+ widget, _old_pos = self._body.get_focus()
981
+ if widget is None:
982
+ return
983
+
984
+ pref_col = None
985
+ if hasattr(widget, "get_pref_col"):
986
+ pref_col = widget.get_pref_col((maxcol,))
987
+ if pref_col is None and hasattr(widget, "get_cursor_coords"):
988
+ coords = widget.get_cursor_coords((maxcol,))
989
+ if isinstance(coords, tuple):
990
+ pref_col, _y = coords
991
+ if pref_col is not None:
992
+ self.pref_col = pref_col
993
+
994
+ def change_focus(
995
+ self,
996
+ size: tuple[int, int],
997
+ position,
998
+ offset_inset: int = 0,
999
+ coming_from: Literal["above", "below"] | None = None,
1000
+ cursor_coords: tuple[int, int] | None = None,
1001
+ snap_rows: int | None = None,
1002
+ ) -> None:
1003
+ """
1004
+ Change the current focus widget.
1005
+ This is used internally by methods that know the widget's *size*.
1006
+
1007
+ See also :meth:`.set_focus`.
1008
+
1009
+ :param size: see :meth:`Widget.render` for details
1010
+ :param position: a position compatible with :meth:`self._body.set_focus`
1011
+ :param offset_inset: either the number of rows between the
1012
+ top of the listbox and the start of the focus widget (+ve
1013
+ value) or the number of lines of the focus widget hidden off
1014
+ the top edge of the listbox (-ve value) or 0 if the top edge
1015
+ of the focus widget is aligned with the top edge of the
1016
+ listbox (default if unspecified)
1017
+ :type offset_inset: int
1018
+ :param coming_from: either 'above', 'below' or unspecified `None`
1019
+ :type coming_from: str
1020
+ :param cursor_coords: (x, y) tuple indicating the desired
1021
+ column and row for the cursor, a (x,) tuple indicating only
1022
+ the column for the cursor, or unspecified
1023
+ :type cursor_coords: (int, int)
1024
+ :param snap_rows: the maximum number of extra rows to scroll
1025
+ when trying to "snap" a selectable focus into the view
1026
+ :type snap_rows: int
1027
+ """
1028
+ (maxcol, maxrow) = size
1029
+
1030
+ # update pref_col before change
1031
+ if cursor_coords:
1032
+ self.pref_col = cursor_coords[0]
1033
+ else:
1034
+ self.update_pref_col_from_focus((maxcol, maxrow))
1035
+
1036
+ self._invalidate()
1037
+ self._body.set_focus(position)
1038
+ target, _ignore = self._body.get_focus()
1039
+ tgt_rows = target.rows((maxcol,), True)
1040
+ if snap_rows is None:
1041
+ snap_rows = maxrow - 1
1042
+
1043
+ # "snap" to selectable widgets
1044
+ align_top = 0
1045
+ align_bottom = maxrow - tgt_rows
1046
+
1047
+ if coming_from == "above" and target.selectable() and offset_inset > align_bottom:
1048
+ if snap_rows >= offset_inset - align_bottom:
1049
+ offset_inset = align_bottom
1050
+ elif snap_rows >= offset_inset - align_top:
1051
+ offset_inset = align_top
1052
+ else:
1053
+ offset_inset -= snap_rows
1054
+
1055
+ if coming_from == "below" and target.selectable() and offset_inset < align_top:
1056
+ if snap_rows >= align_top - offset_inset:
1057
+ offset_inset = align_top
1058
+ elif snap_rows >= align_bottom - offset_inset:
1059
+ offset_inset = align_bottom
1060
+ else:
1061
+ offset_inset += snap_rows
1062
+
1063
+ # convert offset_inset to offset_rows or inset_fraction
1064
+ if offset_inset >= 0:
1065
+ self.offset_rows = offset_inset
1066
+ self.inset_fraction = (0, 1)
1067
+ else:
1068
+ if offset_inset + tgt_rows <= 0:
1069
+ raise ListBoxError(f"Invalid offset_inset: {offset_inset}, only {tgt_rows} rows in target!")
1070
+ self.offset_rows = 0
1071
+ self.inset_fraction = (-offset_inset, tgt_rows)
1072
+
1073
+ if cursor_coords is None:
1074
+ if coming_from is None:
1075
+ return # must either know row or coming_from
1076
+ cursor_coords = (self.pref_col,)
1077
+
1078
+ if not hasattr(target, "move_cursor_to_coords"):
1079
+ return
1080
+
1081
+ attempt_rows = []
1082
+
1083
+ if len(cursor_coords) == 1:
1084
+ # only column (not row) specified
1085
+ # start from closest edge and move inwards
1086
+ (pref_col,) = cursor_coords
1087
+ if coming_from == "above":
1088
+ attempt_rows = range(0, tgt_rows)
1089
+ else:
1090
+ if coming_from != "below":
1091
+ raise ValueError("must specify coming_from ('above' or 'below') if cursor row is not specified")
1092
+ attempt_rows = range(tgt_rows, -1, -1)
1093
+ else:
1094
+ # both column and row specified
1095
+ # start from preferred row and move back to closest edge
1096
+ (pref_col, pref_row) = cursor_coords
1097
+ if pref_row < 0 or pref_row >= tgt_rows:
1098
+ raise ListBoxError(
1099
+ f"cursor_coords row outside valid range for target. pref_row:{pref_row!r} target_rows:{tgt_rows!r}"
1100
+ )
1101
+
1102
+ if coming_from == "above":
1103
+ attempt_rows = range(pref_row, -1, -1)
1104
+ elif coming_from == "below":
1105
+ attempt_rows = range(pref_row, tgt_rows)
1106
+ else:
1107
+ attempt_rows = [pref_row]
1108
+
1109
+ for row in attempt_rows:
1110
+ if target.move_cursor_to_coords((maxcol,), pref_col, row):
1111
+ break
1112
+
1113
+ def get_focus_offset_inset(self, size: tuple[int, int]) -> tuple[int, int]:
1114
+ """Return (offset rows, inset rows) for focus widget."""
1115
+ (maxcol, _maxrow) = size
1116
+ focus_widget, _pos = self._body.get_focus()
1117
+ focus_rows = focus_widget.rows((maxcol,), True)
1118
+ offset_rows = self.offset_rows
1119
+ inset_rows = 0
1120
+ if offset_rows == 0:
1121
+ inum, iden = self.inset_fraction
1122
+ if inum < 0 or iden < 0 or inum >= iden:
1123
+ raise ListBoxError(f"Invalid inset_fraction: {self.inset_fraction!r}")
1124
+ inset_rows = focus_rows * inum // iden
1125
+ if inset_rows and inset_rows >= focus_rows:
1126
+ raise ListBoxError("urwid inset_fraction error (please report)")
1127
+ return offset_rows, inset_rows
1128
+
1129
+ def make_cursor_visible(self, size: tuple[int, int]) -> None:
1130
+ """Shift the focus widget so that its cursor is visible."""
1131
+ (maxcol, maxrow) = size
1132
+
1133
+ focus_widget, _pos = self._body.get_focus()
1134
+ if focus_widget is None:
1135
+ return
1136
+ if not focus_widget.selectable():
1137
+ return
1138
+ if not hasattr(focus_widget, "get_cursor_coords"):
1139
+ return
1140
+ cursor = focus_widget.get_cursor_coords((maxcol,))
1141
+ if cursor is None:
1142
+ return
1143
+ _cx, cy = cursor
1144
+ offset_rows, inset_rows = self.get_focus_offset_inset((maxcol, maxrow))
1145
+
1146
+ if cy < inset_rows:
1147
+ self.shift_focus((maxcol, maxrow), -(cy))
1148
+ return
1149
+
1150
+ if offset_rows - inset_rows + cy >= maxrow:
1151
+ self.shift_focus((maxcol, maxrow), maxrow - cy - 1)
1152
+ return
1153
+
1154
+ def keypress(self, size: tuple[int, int], key: str) -> str | None:
1155
+ """Move selection through the list elements scrolling when
1156
+ necessary. Keystrokes are first passed to widget in focus
1157
+ in case that widget can handle them.
1158
+
1159
+ Keystrokes handled by this widget are:
1160
+ 'up' up one line (or widget)
1161
+ 'down' down one line (or widget)
1162
+ 'page up' move cursor up one listbox length (or widget)
1163
+ 'page down' move cursor down one listbox length (or widget)
1164
+ """
1165
+ (maxcol, maxrow) = size
1166
+
1167
+ if self.set_focus_pending or self.set_focus_valign_pending:
1168
+ self._set_focus_complete((maxcol, maxrow), focus=True)
1169
+
1170
+ focus_widget, _pos = self._body.get_focus()
1171
+ if focus_widget is None: # empty listbox, can't do anything
1172
+ return key
1173
+
1174
+ if focus_widget.selectable():
1175
+ key = focus_widget.keypress((maxcol,), key)
1176
+ if key is None:
1177
+ self.make_cursor_visible((maxcol, maxrow))
1178
+ return None
1179
+
1180
+ def actual_key(unhandled) -> str | None:
1181
+ if unhandled:
1182
+ return key
1183
+ return None
1184
+
1185
+ # pass off the heavy lifting
1186
+ if self._command_map[key] == Command.UP:
1187
+ return actual_key(self._keypress_up((maxcol, maxrow)))
1188
+
1189
+ if self._command_map[key] == Command.DOWN:
1190
+ return actual_key(self._keypress_down((maxcol, maxrow)))
1191
+
1192
+ if self._command_map[key] == Command.PAGE_UP:
1193
+ return actual_key(self._keypress_page_up((maxcol, maxrow)))
1194
+
1195
+ if self._command_map[key] == Command.PAGE_DOWN:
1196
+ return actual_key(self._keypress_page_down((maxcol, maxrow)))
1197
+
1198
+ if self._command_map[key] == Command.MAX_LEFT:
1199
+ return actual_key(self._keypress_max_left((maxcol, maxrow)))
1200
+
1201
+ if self._command_map[key] == Command.MAX_RIGHT:
1202
+ return actual_key(self._keypress_max_right((maxcol, maxrow)))
1203
+
1204
+ return key
1205
+
1206
+ def _keypress_max_left(self, size: tuple[int, int]) -> None:
1207
+ self.focus_position = next(iter(self.body.positions()))
1208
+ self.set_focus_valign(VAlign.TOP)
1209
+
1210
+ def _keypress_max_right(self, size: tuple[int, int]) -> None:
1211
+ self.focus_position = next(iter(self.body.positions(reverse=True)))
1212
+ self.set_focus_valign(VAlign.BOTTOM)
1213
+
1214
+ def _keypress_up(self, size: tuple[int, int]) -> bool | None:
1215
+ (maxcol, maxrow) = size
1216
+
1217
+ middle, top, _bottom = self.calculate_visible((maxcol, maxrow), True)
1218
+ if middle is None:
1219
+ return True
1220
+
1221
+ focus_row_offset, focus_widget, focus_pos, _ignore, cursor = middle
1222
+ _trim_top, fill_above = top
1223
+
1224
+ row_offset = focus_row_offset
1225
+
1226
+ # look for selectable widget above
1227
+ pos = focus_pos
1228
+ widget = None
1229
+ for widget, pos, rows in fill_above:
1230
+ row_offset -= rows
1231
+ if rows and widget.selectable():
1232
+ # this one will do
1233
+ self.change_focus((maxcol, maxrow), pos, row_offset, "below")
1234
+ return None
1235
+
1236
+ # at this point we must scroll
1237
+ row_offset += 1
1238
+ self._invalidate()
1239
+
1240
+ while row_offset > 0:
1241
+ # need to scroll in another candidate widget
1242
+ widget, pos = self._body.get_prev(pos)
1243
+ if widget is None:
1244
+ # cannot scroll any further
1245
+ return True # keypress not handled
1246
+ rows = widget.rows((maxcol,), True)
1247
+ row_offset -= rows
1248
+ if rows and widget.selectable():
1249
+ # this one will do
1250
+ self.change_focus((maxcol, maxrow), pos, row_offset, "below")
1251
+ return None
1252
+
1253
+ if not focus_widget.selectable() or focus_row_offset + 1 >= maxrow:
1254
+ # just take top one if focus is not selectable
1255
+ # or if focus has moved out of view
1256
+ if widget is None:
1257
+ self.shift_focus((maxcol, maxrow), row_offset)
1258
+ return None
1259
+ self.change_focus((maxcol, maxrow), pos, row_offset, "below")
1260
+ return None
1261
+
1262
+ # check if cursor will stop scroll from taking effect
1263
+ if cursor is not None:
1264
+ _x, y = cursor
1265
+ if y + focus_row_offset + 1 >= maxrow:
1266
+ # cursor position is a problem,
1267
+ # choose another focus
1268
+ if widget is None:
1269
+ # try harder to get prev widget
1270
+ widget, pos = self._body.get_prev(pos)
1271
+ if widget is None:
1272
+ return None # can't do anything
1273
+ rows = widget.rows((maxcol,), True)
1274
+ row_offset -= rows
1275
+
1276
+ if -row_offset >= rows:
1277
+ # must scroll further than 1 line
1278
+ row_offset = -(rows - 1)
1279
+
1280
+ self.change_focus((maxcol, maxrow), pos, row_offset, "below")
1281
+ return None
1282
+
1283
+ # if all else fails, just shift the current focus.
1284
+ self.shift_focus((maxcol, maxrow), focus_row_offset + 1)
1285
+ return None
1286
+
1287
+ def _keypress_down(self, size: tuple[int, int]) -> bool | None:
1288
+ (maxcol, maxrow) = size
1289
+
1290
+ middle, _top, bottom = self.calculate_visible((maxcol, maxrow), True)
1291
+ if middle is None:
1292
+ return True
1293
+
1294
+ focus_row_offset, focus_widget, focus_pos, focus_rows, cursor = middle
1295
+ _trim_bottom, fill_below = bottom
1296
+
1297
+ row_offset = focus_row_offset + focus_rows
1298
+ rows = focus_rows
1299
+
1300
+ # look for selectable widget below
1301
+ pos = focus_pos
1302
+ widget = None
1303
+ for widget, pos, rows in fill_below:
1304
+ if rows and widget.selectable():
1305
+ # this one will do
1306
+ self.change_focus((maxcol, maxrow), pos, row_offset, "above")
1307
+ return None
1308
+ row_offset += rows
1309
+
1310
+ # at this point we must scroll
1311
+ row_offset -= 1
1312
+ self._invalidate()
1313
+
1314
+ while row_offset < maxrow:
1315
+ # need to scroll in another candidate widget
1316
+ widget, pos = self._body.get_next(pos)
1317
+ if widget is None:
1318
+ # cannot scroll any further
1319
+ return True # keypress not handled
1320
+ rows = widget.rows((maxcol,))
1321
+ if rows and widget.selectable():
1322
+ # this one will do
1323
+ self.change_focus((maxcol, maxrow), pos, row_offset, "above")
1324
+ return None
1325
+ row_offset += rows
1326
+
1327
+ if not focus_widget.selectable() or focus_row_offset + focus_rows - 1 <= 0:
1328
+ # just take bottom one if current is not selectable
1329
+ # or if focus has moved out of view
1330
+ if widget is None:
1331
+ self.shift_focus((maxcol, maxrow), row_offset - rows)
1332
+ return None
1333
+ # FIXME: catch this bug in testcase
1334
+ # self.change_focus((maxcol,maxrow), pos,
1335
+ # row_offset+rows, 'above')
1336
+ self.change_focus((maxcol, maxrow), pos, row_offset - rows, "above")
1337
+ return None
1338
+
1339
+ # check if cursor will stop scroll from taking effect
1340
+ if cursor is not None:
1341
+ _x, y = cursor
1342
+ if y + focus_row_offset - 1 < 0:
1343
+ # cursor position is a problem,
1344
+ # choose another focus
1345
+ if widget is None:
1346
+ # try harder to get next widget
1347
+ widget, pos = self._body.get_next(pos)
1348
+ if widget is None:
1349
+ return None # can't do anything
1350
+ else:
1351
+ row_offset -= rows
1352
+
1353
+ if row_offset >= maxrow:
1354
+ # must scroll further than 1 line
1355
+ row_offset = maxrow - 1
1356
+
1357
+ self.change_focus(
1358
+ (maxcol, maxrow),
1359
+ pos,
1360
+ row_offset,
1361
+ "above",
1362
+ )
1363
+ return None
1364
+
1365
+ # if all else fails, keep the current focus.
1366
+ self.shift_focus((maxcol, maxrow), focus_row_offset - 1)
1367
+ return None
1368
+
1369
+ def _keypress_page_up(self, size: tuple[int, int]) -> bool | None:
1370
+ (maxcol, maxrow) = size
1371
+
1372
+ middle, top, _bottom = self.calculate_visible((maxcol, maxrow), True)
1373
+ if middle is None:
1374
+ return True
1375
+
1376
+ row_offset, focus_widget, focus_pos, focus_rows, cursor = middle
1377
+ _trim_top, fill_above = top
1378
+
1379
+ # topmost_visible is row_offset rows above top row of
1380
+ # focus (+ve) or -row_offset rows below top row of focus (-ve)
1381
+ topmost_visible = row_offset
1382
+
1383
+ # scroll_from_row is (first match)
1384
+ # 1. topmost visible row if focus is not selectable
1385
+ # 2. row containing cursor if focus has a cursor
1386
+ # 3. top row of focus widget if it is visible
1387
+ # 4. topmost visible row otherwise
1388
+ if not focus_widget.selectable():
1389
+ scroll_from_row = topmost_visible
1390
+ elif cursor is not None:
1391
+ _x, y = cursor
1392
+ scroll_from_row = -y
1393
+ elif row_offset >= 0:
1394
+ scroll_from_row = 0
1395
+ else:
1396
+ scroll_from_row = topmost_visible
1397
+
1398
+ # snap_rows is maximum extra rows to scroll when
1399
+ # snapping to new a focus
1400
+ snap_rows = topmost_visible - scroll_from_row
1401
+
1402
+ # move row_offset to the new desired value (1 "page" up)
1403
+ row_offset = scroll_from_row + maxrow
1404
+
1405
+ # not used below:
1406
+ scroll_from_row = topmost_visible = None
1407
+
1408
+ # gather potential target widgets and add current focus
1409
+ t = [(row_offset, focus_widget, focus_pos, focus_rows)]
1410
+ pos = focus_pos
1411
+ # include widgets from calculate_visible(..)
1412
+ for widget, pos, rows in fill_above:
1413
+ row_offset -= rows
1414
+ t.append((row_offset, widget, pos, rows))
1415
+ # add newly visible ones, including within snap_rows
1416
+ snap_region_start = len(t)
1417
+ while row_offset > -snap_rows:
1418
+ widget, pos = self._body.get_prev(pos)
1419
+ if widget is None:
1420
+ break
1421
+ rows = widget.rows((maxcol,))
1422
+ row_offset -= rows
1423
+ # determine if one below puts current one into snap rgn
1424
+ if row_offset > 0:
1425
+ snap_region_start += 1
1426
+ t.append((row_offset, widget, pos, rows))
1427
+
1428
+ # if we can't fill the top we need to adjust the row offsets
1429
+ row_offset, _w, _p, _r = t[-1]
1430
+ if row_offset > 0:
1431
+ adjust = -row_offset
1432
+ t = [(ro + adjust, w, p, r) for (ro, w, p, r) in t]
1433
+
1434
+ # if focus_widget (first in t) is off edge, remove it
1435
+ row_offset, _w, _p, _r = t[0]
1436
+ if row_offset >= maxrow:
1437
+ del t[0]
1438
+ snap_region_start -= 1
1439
+
1440
+ # we'll need this soon
1441
+ self.update_pref_col_from_focus((maxcol, maxrow))
1442
+
1443
+ # choose the topmost selectable and (newly) visible widget
1444
+ # search within snap_rows then visible region
1445
+ search_order = list(range(snap_region_start, len(t))) + list(range(snap_region_start - 1, -1, -1))
1446
+ # assert 0, repr((t, search_order))
1447
+ bad_choices = []
1448
+ cut_off_selectable_chosen = 0
1449
+ for i in search_order:
1450
+ row_offset, widget, pos, rows = t[i]
1451
+ if not widget.selectable():
1452
+ continue
1453
+
1454
+ if not rows:
1455
+ continue
1456
+
1457
+ # try selecting this widget
1458
+ pref_row = max(0, -row_offset)
1459
+
1460
+ # if completely within snap region, adjust row_offset
1461
+ if rows + row_offset <= 0:
1462
+ self.change_focus(
1463
+ (maxcol, maxrow),
1464
+ pos,
1465
+ -(rows - 1),
1466
+ "below",
1467
+ (self.pref_col, rows - 1),
1468
+ snap_rows - ((-row_offset) - (rows - 1)),
1469
+ )
1470
+ else:
1471
+ self.change_focus(
1472
+ (maxcol, maxrow),
1473
+ pos,
1474
+ row_offset,
1475
+ "below",
1476
+ (self.pref_col, pref_row),
1477
+ snap_rows,
1478
+ )
1479
+
1480
+ # if we're as far up as we can scroll, take this one
1481
+ if fill_above and self._body.get_prev(fill_above[-1][1]) == (None, None):
1482
+ pass # return
1483
+
1484
+ # find out where that actually puts us
1485
+ middle, top, _bottom = self.calculate_visible((maxcol, maxrow), True)
1486
+ act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle
1487
+
1488
+ # discard chosen widget if it will reduce scroll amount
1489
+ # because of a fixed cursor (absolute last resort)
1490
+ if act_row_offset > row_offset + snap_rows:
1491
+ bad_choices.append(i)
1492
+ continue
1493
+ if act_row_offset < row_offset:
1494
+ bad_choices.append(i)
1495
+ continue
1496
+
1497
+ # also discard if off top edge (second last resort)
1498
+ if act_row_offset < 0:
1499
+ bad_choices.append(i)
1500
+ cut_off_selectable_chosen = 1
1501
+ continue
1502
+
1503
+ return None
1504
+
1505
+ # anything selectable is better than what follows:
1506
+ if cut_off_selectable_chosen:
1507
+ return None
1508
+
1509
+ if fill_above and focus_widget.selectable() and self._body.get_prev(fill_above[-1][1]) == (None, None):
1510
+ # if we're at the top and have a selectable, return
1511
+ pass # return
1512
+
1513
+ # if still none found choose the topmost widget
1514
+ good_choices = [j for j in search_order if j not in bad_choices]
1515
+ for i in good_choices + search_order:
1516
+ row_offset, widget, pos, rows = t[i]
1517
+ if pos == focus_pos:
1518
+ continue
1519
+
1520
+ if not rows: # never focus a 0-height widget
1521
+ continue
1522
+
1523
+ # if completely within snap region, adjust row_offset
1524
+ if rows + row_offset <= 0:
1525
+ snap_rows -= (-row_offset) - (rows - 1)
1526
+ row_offset = -(rows - 1)
1527
+
1528
+ self.change_focus((maxcol, maxrow), pos, row_offset, "below", None, snap_rows)
1529
+ return None
1530
+
1531
+ # no choices available, just shift current one
1532
+ self.shift_focus((maxcol, maxrow), min(maxrow - 1, row_offset))
1533
+
1534
+ # final check for pathological case where we may fall short
1535
+ middle, top, _bottom = self.calculate_visible((maxcol, maxrow), True)
1536
+ act_row_offset, _ign1, pos, _ign2, _ign3 = middle
1537
+ if act_row_offset >= row_offset:
1538
+ # no problem
1539
+ return None
1540
+
1541
+ # fell short, try to select anything else above
1542
+ if not t:
1543
+ return None
1544
+ _ign1, _ign2, pos, _ign3 = t[-1]
1545
+ widget, pos = self._body.get_prev(pos)
1546
+ if widget is None:
1547
+ # no dice, we're stuck here
1548
+ return None
1549
+ # bring in only one row if possible
1550
+ rows = widget.rows((maxcol,), True)
1551
+ self.change_focus(
1552
+ (maxcol, maxrow),
1553
+ pos,
1554
+ -(rows - 1),
1555
+ "below",
1556
+ (self.pref_col, rows - 1),
1557
+ 0,
1558
+ )
1559
+ return None
1560
+
1561
+ def _keypress_page_down(self, size: tuple[int, int]) -> bool | None:
1562
+ (maxcol, maxrow) = size
1563
+
1564
+ middle, _top, bottom = self.calculate_visible((maxcol, maxrow), True)
1565
+ if middle is None:
1566
+ return True
1567
+
1568
+ row_offset, focus_widget, focus_pos, focus_rows, cursor = middle
1569
+ _trim_bottom, fill_below = bottom
1570
+
1571
+ # bottom_edge is maxrow-focus_pos rows below top row of focus
1572
+ bottom_edge = maxrow - row_offset
1573
+
1574
+ # scroll_from_row is (first match)
1575
+ # 1. bottom edge if focus is not selectable
1576
+ # 2. row containing cursor + 1 if focus has a cursor
1577
+ # 3. bottom edge of focus widget if it is visible
1578
+ # 4. bottom edge otherwise
1579
+ if not focus_widget.selectable():
1580
+ scroll_from_row = bottom_edge
1581
+ elif cursor is not None:
1582
+ _x, y = cursor
1583
+ scroll_from_row = y + 1
1584
+ elif bottom_edge >= focus_rows:
1585
+ scroll_from_row = focus_rows
1586
+ else:
1587
+ scroll_from_row = bottom_edge
1588
+
1589
+ # snap_rows is maximum extra rows to scroll when
1590
+ # snapping to new a focus
1591
+ snap_rows = bottom_edge - scroll_from_row
1592
+
1593
+ # move row_offset to the new desired value (1 "page" down)
1594
+ row_offset = -scroll_from_row
1595
+
1596
+ # not used below:
1597
+ scroll_from_row = bottom_edge = None
1598
+
1599
+ # gather potential target widgets and add current focus
1600
+ t = [(row_offset, focus_widget, focus_pos, focus_rows)]
1601
+ pos = focus_pos
1602
+ row_offset += focus_rows
1603
+ # include widgets from calculate_visible(..)
1604
+ for widget, pos, rows in fill_below:
1605
+ t.append((row_offset, widget, pos, rows))
1606
+ row_offset += rows
1607
+ # add newly visible ones, including within snap_rows
1608
+ snap_region_start = len(t)
1609
+ while row_offset < maxrow + snap_rows:
1610
+ widget, pos = self._body.get_next(pos)
1611
+ if widget is None:
1612
+ break
1613
+ rows = widget.rows((maxcol,))
1614
+ t.append((row_offset, widget, pos, rows))
1615
+ row_offset += rows
1616
+ # determine if one above puts current one into snap rgn
1617
+ if row_offset < maxrow:
1618
+ snap_region_start += 1
1619
+
1620
+ # if we can't fill the bottom we need to adjust the row offsets
1621
+ row_offset, _w, _p, rows = t[-1]
1622
+ if row_offset + rows < maxrow:
1623
+ adjust = maxrow - (row_offset + rows)
1624
+ t = [(ro + adjust, w, p, r) for (ro, w, p, r) in t]
1625
+
1626
+ # if focus_widget (first in t) is off edge, remove it
1627
+ row_offset, _w, _p, rows = t[0]
1628
+ if row_offset + rows <= 0:
1629
+ del t[0]
1630
+ snap_region_start -= 1
1631
+
1632
+ # we'll need this soon
1633
+ self.update_pref_col_from_focus((maxcol, maxrow))
1634
+
1635
+ # choose the bottommost selectable and (newly) visible widget
1636
+ # search within snap_rows then visible region
1637
+ search_order = list(range(snap_region_start, len(t))) + list(range(snap_region_start - 1, -1, -1))
1638
+ # assert 0, repr((t, search_order))
1639
+ bad_choices = []
1640
+ cut_off_selectable_chosen = 0
1641
+ for i in search_order:
1642
+ row_offset, widget, pos, rows = t[i]
1643
+ if not widget.selectable():
1644
+ continue
1645
+
1646
+ if not rows:
1647
+ continue
1648
+
1649
+ # try selecting this widget
1650
+ pref_row = min(maxrow - row_offset - 1, rows - 1)
1651
+
1652
+ # if completely within snap region, adjust row_offset
1653
+ if row_offset >= maxrow:
1654
+ self.change_focus(
1655
+ (maxcol, maxrow),
1656
+ pos,
1657
+ maxrow - 1,
1658
+ "above",
1659
+ (self.pref_col, 0),
1660
+ snap_rows + maxrow - row_offset - 1,
1661
+ )
1662
+ else:
1663
+ self.change_focus(
1664
+ (maxcol, maxrow),
1665
+ pos,
1666
+ row_offset,
1667
+ "above",
1668
+ (self.pref_col, pref_row),
1669
+ snap_rows,
1670
+ )
1671
+
1672
+ # find out where that actually puts us
1673
+ middle, _top, bottom = self.calculate_visible((maxcol, maxrow), True)
1674
+ act_row_offset, _ign1, _ign2, _ign3, _ign4 = middle
1675
+
1676
+ # discard chosen widget if it will reduce scroll amount
1677
+ # because of a fixed cursor (absolute last resort)
1678
+ if act_row_offset < row_offset - snap_rows:
1679
+ bad_choices.append(i)
1680
+ continue
1681
+ if act_row_offset > row_offset:
1682
+ bad_choices.append(i)
1683
+ continue
1684
+
1685
+ # also discard if off top edge (second last resort)
1686
+ if act_row_offset + rows > maxrow:
1687
+ bad_choices.append(i)
1688
+ cut_off_selectable_chosen = 1
1689
+ continue
1690
+
1691
+ return None
1692
+
1693
+ # anything selectable is better than what follows:
1694
+ if cut_off_selectable_chosen:
1695
+ return None
1696
+
1697
+ # if still none found choose the bottommost widget
1698
+ good_choices = [j for j in search_order if j not in bad_choices]
1699
+ for i in good_choices + search_order:
1700
+ row_offset, widget, pos, rows = t[i]
1701
+ if pos == focus_pos:
1702
+ continue
1703
+
1704
+ if not rows: # never focus a 0-height widget
1705
+ continue
1706
+
1707
+ # if completely within snap region, adjust row_offset
1708
+ if row_offset >= maxrow:
1709
+ snap_rows -= snap_rows + maxrow - row_offset - 1
1710
+ row_offset = maxrow - 1
1711
+
1712
+ self.change_focus((maxcol, maxrow), pos, row_offset, "above", None, snap_rows)
1713
+ return None
1714
+
1715
+ # no choices available, just shift current one
1716
+ self.shift_focus((maxcol, maxrow), max(1 - focus_rows, row_offset))
1717
+
1718
+ # final check for pathological case where we may fall short
1719
+ middle, _top, bottom = self.calculate_visible((maxcol, maxrow), True)
1720
+ act_row_offset, _ign1, pos, _ign2, _ign3 = middle
1721
+ if act_row_offset <= row_offset:
1722
+ # no problem
1723
+ return None
1724
+
1725
+ # fell short, try to select anything else below
1726
+ if not t:
1727
+ return None
1728
+ _ign1, _ign2, pos, _ign3 = t[-1]
1729
+ widget, pos = self._body.get_next(pos)
1730
+ if widget is None:
1731
+ # no dice, we're stuck here
1732
+ return None
1733
+ # bring in only one row if possible
1734
+ rows = widget.rows((maxcol,), True)
1735
+ self.change_focus(
1736
+ (maxcol, maxrow),
1737
+ pos,
1738
+ maxrow - 1,
1739
+ "above",
1740
+ (self.pref_col, 0),
1741
+ 0,
1742
+ )
1743
+ return None
1744
+
1745
+ def mouse_event(
1746
+ self,
1747
+ size: tuple[int, int],
1748
+ event,
1749
+ button: int,
1750
+ col: int,
1751
+ row: int,
1752
+ focus: bool,
1753
+ ) -> bool | None:
1754
+ """
1755
+ Pass the event to the contained widgets.
1756
+ May change focus on button 1 press.
1757
+ """
1758
+ (maxcol, maxrow) = size
1759
+ middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus=True)
1760
+ if middle is None:
1761
+ return False
1762
+
1763
+ _ignore, focus_widget, focus_pos, focus_rows, _cursor = middle
1764
+ trim_top, fill_above = top
1765
+ _ignore, fill_below = bottom
1766
+
1767
+ fill_above.reverse() # fill_above is in bottom-up order
1768
+ w_list = [*fill_above, (focus_widget, focus_pos, focus_rows), *fill_below]
1769
+
1770
+ wrow = -trim_top
1771
+ for w, w_pos, w_rows in w_list: # noqa: B007 # magic with scope
1772
+ if wrow + w_rows > row:
1773
+ break
1774
+ wrow += w_rows
1775
+ else:
1776
+ return False
1777
+
1778
+ focus = focus and w == focus_widget
1779
+ if is_mouse_press(event) and button == 1 and w.selectable():
1780
+ self.change_focus((maxcol, maxrow), w_pos, wrow)
1781
+
1782
+ if not hasattr(w, "mouse_event"):
1783
+ warnings.warn(
1784
+ f"{w.__class__.__module__}.{w.__class__.__name__} is not subclass of Widget",
1785
+ DeprecationWarning,
1786
+ stacklevel=2,
1787
+ )
1788
+ return False
1789
+
1790
+ handled = w.mouse_event((maxcol,), event, button, col, row - wrow, focus)
1791
+ if handled:
1792
+ return True
1793
+
1794
+ if is_mouse_press(event):
1795
+ if button == 4:
1796
+ return not self._keypress_up((maxcol, maxrow))
1797
+
1798
+ if button == 5:
1799
+ return not self._keypress_down((maxcol, maxrow))
1800
+
1801
+ return False
1802
+
1803
+ def ends_visible(self, size: tuple[int, int], focus: bool = False) -> list[Literal["top", "bottom"]]:
1804
+ """
1805
+ Return a list that may contain ``'top'`` and/or ``'bottom'``.
1806
+
1807
+ i.e. this function will return one of: [], [``'top'``],
1808
+ [``'bottom'``] or [``'top'``, ``'bottom'``].
1809
+
1810
+ convenience function for checking whether the top and bottom
1811
+ of the list are visible
1812
+ """
1813
+ (maxcol, maxrow) = size
1814
+ result = []
1815
+ middle, top, bottom = self.calculate_visible((maxcol, maxrow), focus=focus)
1816
+ if middle is None: # empty listbox
1817
+ return ["top", "bottom"]
1818
+ trim_top, above = top
1819
+ trim_bottom, below = bottom
1820
+
1821
+ if trim_bottom == 0:
1822
+ row_offset, _w, pos, rows, _c = middle
1823
+ row_offset += rows
1824
+ for _w, pos, rows in below: # noqa: B007 # magic with scope
1825
+ row_offset += rows
1826
+ if row_offset < maxrow or (self._body.get_next(pos) == (None, None)):
1827
+ result.append("bottom")
1828
+
1829
+ if trim_top == 0:
1830
+ row_offset, _w, pos, _rows, _c = middle
1831
+ for _w, pos, rows in above: # noqa: B007 # magic with scope
1832
+ row_offset -= rows
1833
+ if self._body.get_prev(pos) == (None, None):
1834
+ result.insert(0, "top")
1835
+
1836
+ return result
1837
+
1838
+ def __iter__(self):
1839
+ """
1840
+ Return an iterator over the positions in this ListBox.
1841
+
1842
+ If self._body does not implement positions() then iterate
1843
+ from the focus widget down to the bottom, then from above
1844
+ the focus up to the top. This is the best we can do with
1845
+ a minimal list walker implementation.
1846
+ """
1847
+ positions_fn = getattr(self._body, "positions", None)
1848
+ if positions_fn:
1849
+ for pos in positions_fn():
1850
+ yield pos
1851
+ return
1852
+
1853
+ focus_widget, focus_pos = self._body.get_focus()
1854
+ if focus_widget is None:
1855
+ return
1856
+ pos = focus_pos
1857
+ while True:
1858
+ yield pos
1859
+ w, pos = self._body.get_next(pos)
1860
+ if not w:
1861
+ break
1862
+ pos = focus_pos
1863
+ while True:
1864
+ w, pos = self._body.get_prev(pos)
1865
+ if not w:
1866
+ break
1867
+ yield pos
1868
+
1869
+ def __reversed__(self):
1870
+ """
1871
+ Return a reversed iterator over the positions in this ListBox.
1872
+
1873
+ If :attr:`body` does not implement :meth:`positions` then iterate
1874
+ from above the focus widget up to the top, then from the focus
1875
+ widget down to the bottom. Note that this is not actually the
1876
+ reverse of what `__iter__()` produces, but this is the best we can
1877
+ do with a minimal list walker implementation.
1878
+ """
1879
+ positions_fn = getattr(self._body, "positions", None)
1880
+ if positions_fn:
1881
+ for pos in positions_fn(reverse=True):
1882
+ yield pos
1883
+ return
1884
+
1885
+ focus_widget, focus_pos = self._body.get_focus()
1886
+ if focus_widget is None:
1887
+ return
1888
+ pos = focus_pos
1889
+ while True:
1890
+ w, pos = self._body.get_prev(pos)
1891
+ if not w:
1892
+ break
1893
+ yield pos
1894
+ pos = focus_pos
1895
+ while True:
1896
+ yield pos
1897
+ w, pos = self._body.get_next(pos)
1898
+ if not w:
1899
+ break