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
@@ -0,0 +1,597 @@
1
+ # Copyright (C) 2024 Urwid developers
2
+ # This library is free software; you can redistribute it and/or
3
+ # modify it under the terms of the GNU Lesser General Public
4
+ # License as published by the Free Software Foundation; either
5
+ # version 2.1 of the License, or (at your option) any later version.
6
+ #
7
+ # This library is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
10
+ # Lesser General Public License for more details.
11
+ #
12
+ # You should have received a copy of the GNU Lesser General Public
13
+ # License along with this library; if not, write to the Free Software
14
+ # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
15
+ #
16
+ # Urwid web site: https://urwid.org/
17
+ #
18
+ # Copyright (C) 2017-2024 rndusr (https://github.com/rndusr)
19
+ # Re-licensed from gpl-3.0 with author permission.
20
+ # Permission comment link: https://github.com/markqvist/NomadNet/pull/46#issuecomment-1892712616
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextlib
25
+ import enum
26
+ import typing
27
+
28
+ from typing_extensions import Protocol, runtime_checkable
29
+
30
+ from .constants import BOX_SYMBOLS, SHADE_SYMBOLS, Sizing
31
+ from .widget_decoration import WidgetDecoration, WidgetError
32
+
33
+ if typing.TYPE_CHECKING:
34
+ from collections.abc import Iterator
35
+
36
+ from typing_extensions import Literal
37
+
38
+ from urwid import Canvas, CompositeCanvas
39
+
40
+ from .widget import Widget
41
+
42
+
43
+ __all__ = ("ScrollBar", "Scrollable", "ScrollableError", "ScrollbarSymbols")
44
+
45
+
46
+ WrappedWidget = typing.TypeVar("WrappedWidget")
47
+
48
+
49
+ class ScrollableError(WidgetError):
50
+ """Scrollable specific widget errors."""
51
+
52
+
53
+ # Scroll actions
54
+ SCROLL_LINE_UP = "line up"
55
+ SCROLL_LINE_DOWN = "line down"
56
+ SCROLL_PAGE_UP = "page up"
57
+ SCROLL_PAGE_DOWN = "page down"
58
+ SCROLL_TO_TOP = "to top"
59
+ SCROLL_TO_END = "to end"
60
+
61
+ # Scrollbar positions
62
+ SCROLLBAR_LEFT = "left"
63
+ SCROLLBAR_RIGHT = "right"
64
+
65
+
66
+ class ScrollbarSymbols(str, enum.Enum):
67
+ """Common symbols suitable for scrollbar."""
68
+
69
+ FULL_BLOCK = SHADE_SYMBOLS.FULL_BLOCK
70
+ DARK_SHADE = SHADE_SYMBOLS.DARK_SHADE
71
+ MEDIUM_SHADE = SHADE_SYMBOLS.MEDIUM_SHADE
72
+ LITE_SHADE = SHADE_SYMBOLS.LITE_SHADE
73
+
74
+ DRAWING_LIGHT = BOX_SYMBOLS.LIGHT.VERTICAL
75
+ DRAWING_LIGHT_2_DASH = BOX_SYMBOLS.LIGHT.VERTICAL_2_DASH
76
+ DRAWING_LIGHT_3_DASH = BOX_SYMBOLS.LIGHT.VERTICAL_3_DASH
77
+ DRAWING_LIGHT_4_DASH = BOX_SYMBOLS.LIGHT.VERTICAL_4_DASH
78
+
79
+ DRAWING_HEAVY = BOX_SYMBOLS.HEAVY.VERTICAL
80
+ DRAWING_HEAVY_2_DASH = BOX_SYMBOLS.HEAVY.VERTICAL_2_DASH
81
+ DRAWING_HEAVY_3_DASH = BOX_SYMBOLS.HEAVY.VERTICAL_3_DASH
82
+ DRAWING_HEAVY_4_DASH = BOX_SYMBOLS.HEAVY.VERTICAL_4_DASH
83
+
84
+ DRAWING_DOUBLE = BOX_SYMBOLS.DOUBLE.VERTICAL
85
+
86
+
87
+ @runtime_checkable
88
+ class SupportsScroll(Protocol):
89
+ """Protocol for scroll supporting widget.
90
+
91
+ Due to protocol can not inherit non-protocol bases, require also several obligatory Widget methods.
92
+ """
93
+
94
+ # Base widget methods (from Widget)
95
+ def sizing(self) -> frozenset[Sizing]: ...
96
+
97
+ def selectable(self) -> bool: ...
98
+
99
+ def pack(self, size: tuple[int, int], focus: bool = False) -> tuple[int, int]: ...
100
+
101
+ @property
102
+ def base_widget(self) -> Widget:
103
+ raise NotImplementedError
104
+
105
+ def keypress(self, size: tuple[int, int], key: str) -> str | None: ...
106
+
107
+ def mouse_event(
108
+ self,
109
+ size: tuple[int, int],
110
+ event: str,
111
+ button: int,
112
+ col: int,
113
+ row: int,
114
+ focus: bool,
115
+ ) -> bool | None: ...
116
+
117
+ def render(self, size: tuple[int, int], focus: bool = False) -> Canvas: ...
118
+
119
+ # Scroll specific methods
120
+ def get_scrollpos(self, size: tuple[int, int], focus: bool = False) -> int: ...
121
+
122
+ def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int: ...
123
+
124
+
125
+ class Scrollable(WidgetDecoration[WrappedWidget]):
126
+ def sizing(self) -> frozenset[Sizing]:
127
+ return frozenset((Sizing.BOX,))
128
+
129
+ def selectable(self) -> bool:
130
+ return True
131
+
132
+ def __init__(self, widget: WrappedWidget, force_forward_keypress: bool = False) -> None:
133
+ """Box widget that makes a fixed or flow widget vertically scrollable
134
+
135
+ .. note::
136
+ Focusable widgets are handled, including switching focus, but possibly not intuitively,
137
+ depending on the arrangement of widgets.
138
+
139
+ When switching focus to a widget that is ouside of the visible part of the original widget,
140
+ the canvas scrolls up/down to the focused widget.
141
+
142
+ It would be better to scroll until the next focusable widget is in sight first.
143
+ But for that to work we must somehow obtain a list of focusable rows in the original canvas.
144
+ """
145
+ if not widget.sizing() & frozenset((Sizing.FIXED, Sizing.FLOW)):
146
+ raise ValueError(f"Not a fixed or flow widget: {widget!r}")
147
+
148
+ self._trim_top = 0
149
+ self._scroll_action = None
150
+ self._forward_keypress = None
151
+ self._old_cursor_coords = None
152
+ self._rows_max_cached = 0
153
+ self.force_forward_keypress = force_forward_keypress
154
+ super().__init__(widget)
155
+
156
+ def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas:
157
+ from urwid import canvas
158
+
159
+ maxcol, maxrow = size
160
+
161
+ def automove_cursor() -> None:
162
+ ch = 0
163
+ last_hidden = False
164
+ first_visible = False
165
+ for pwi, (w, _o) in enumerate(ow.contents):
166
+ wcanv = w.render((maxcol,))
167
+ wh = wcanv.rows()
168
+ if wh:
169
+ ch += wh
170
+
171
+ if not last_hidden and ch >= self._trim_top:
172
+ last_hidden = True
173
+
174
+ elif last_hidden:
175
+ if not first_visible:
176
+ first_visible = True
177
+
178
+ if not w.selectable():
179
+ continue
180
+
181
+ ow.focus_item = pwi
182
+
183
+ st = None
184
+ nf = ow.get_focus()
185
+ if hasattr(nf, "key_timeout"):
186
+ st = nf
187
+ elif hasattr(nf, "original_widget"):
188
+ no = nf.original_widget
189
+ if hasattr(no, "original_widget"):
190
+ st = no.original_widget
191
+ elif hasattr(no, "key_timeout"):
192
+ st = no
193
+
194
+ if st and hasattr(st, "key_timeout") and callable(getattr(st, "keypress", None)):
195
+ st.keypress(None, None)
196
+
197
+ break
198
+
199
+ # Render complete original widget
200
+ ow = self._original_widget
201
+ ow_size = self._get_original_widget_size(size)
202
+ canv_full = ow.render(ow_size, focus)
203
+
204
+ # Make full canvas editable
205
+ canv = canvas.CompositeCanvas(canv_full)
206
+ canv_cols, canv_rows = canv.cols(), canv.rows()
207
+
208
+ if canv_cols <= maxcol:
209
+ pad_width = maxcol - canv_cols
210
+ if pad_width > 0:
211
+ # Canvas is narrower than available horizontal space
212
+ canv.pad_trim_left_right(0, pad_width)
213
+
214
+ if canv_rows <= maxrow:
215
+ fill_height = maxrow - canv_rows
216
+ if fill_height > 0:
217
+ # Canvas is lower than available vertical space
218
+ canv.pad_trim_top_bottom(0, fill_height)
219
+
220
+ if canv_cols <= maxcol and canv_rows <= maxrow:
221
+ # Canvas is small enough to fit without trimming
222
+ return canv
223
+
224
+ self._adjust_trim_top(canv, size)
225
+
226
+ # Trim canvas if necessary
227
+ trim_top = self._trim_top
228
+ trim_end = canv_rows - maxrow - trim_top
229
+ trim_right = canv_cols - maxcol
230
+ if trim_top > 0:
231
+ canv.trim(trim_top)
232
+ if trim_end > 0:
233
+ canv.trim_end(trim_end)
234
+ if trim_right > 0:
235
+ canv.pad_trim_left_right(0, -trim_right)
236
+
237
+ # Disable cursor display if cursor is outside of visible canvas parts
238
+ if canv.cursor is not None:
239
+ _curscol, cursrow = canv.cursor # pylint: disable=unpacking-non-sequence
240
+ if cursrow >= maxrow or cursrow < 0:
241
+ canv.cursor = None
242
+
243
+ # Figure out whether we should forward keypresses to original widget
244
+ if canv.cursor is not None:
245
+ # Trimmed canvas contains the cursor, e.g. in an Edit widget
246
+ self._forward_keypress = True
247
+ elif canv_full.cursor is not None:
248
+ # Full canvas contains the cursor, but scrolled out of view
249
+ self._forward_keypress = False
250
+
251
+ # Reset cursor position on page/up down scrolling
252
+ if getattr(ow, "automove_cursor_on_scroll", False):
253
+ with contextlib.suppress(Exception):
254
+ automove_cursor()
255
+
256
+ else:
257
+ # Original widget does not have a cursor, but may be selectable
258
+
259
+ # FIXME: Using ow.selectable() is bad because the original
260
+ # widget may be selectable because it's a container widget with
261
+ # a key-grabbing widget that is scrolled out of view.
262
+ # ow.selectable() returns True anyway because it doesn't know
263
+ # how we trimmed our canvas.
264
+ #
265
+ # To fix this, we need to resolve ow.focus and somehow
266
+ # ask canv whether it contains bits of the focused widget. I
267
+ # can't see a way to do that.
268
+ self._forward_keypress = ow.selectable()
269
+
270
+ return canv
271
+
272
+ def keypress(self, size: tuple[int, int], key: str) -> str | None:
273
+ from urwid.command_map import Command
274
+
275
+ # Maybe offer key to original widget
276
+ if self._forward_keypress or self.force_forward_keypress:
277
+ ow = self._original_widget
278
+ ow_size = self._get_original_widget_size(size)
279
+
280
+ # Remember previous cursor position if possible
281
+ if hasattr(ow, "get_cursor_coords"):
282
+ self._old_cursor_coords = ow.get_cursor_coords(ow_size)
283
+
284
+ key = ow.keypress(ow_size, key)
285
+ if key is None:
286
+ return None
287
+
288
+ # Handle up/down, page up/down, etc
289
+ command_map = self._command_map
290
+ if command_map[key] == Command.UP:
291
+ self._scroll_action = SCROLL_LINE_UP
292
+ elif command_map[key] == Command.DOWN:
293
+ self._scroll_action = SCROLL_LINE_DOWN
294
+
295
+ elif command_map[key] == Command.PAGE_UP:
296
+ self._scroll_action = SCROLL_PAGE_UP
297
+ elif command_map[key] == Command.PAGE_DOWN:
298
+ self._scroll_action = SCROLL_PAGE_DOWN
299
+
300
+ elif command_map[key] == Command.MAX_LEFT: # 'home'
301
+ self._scroll_action = SCROLL_TO_TOP
302
+ elif command_map[key] == Command.MAX_RIGHT: # 'end'
303
+ self._scroll_action = SCROLL_TO_END
304
+
305
+ else:
306
+ return key
307
+
308
+ self._invalidate()
309
+ return None
310
+
311
+ def mouse_event(
312
+ self,
313
+ size: tuple[int, int],
314
+ event: str,
315
+ button: int,
316
+ col: int,
317
+ row: int,
318
+ focus: bool,
319
+ ) -> bool | None:
320
+ ow = self._original_widget
321
+ if hasattr(ow, "mouse_event"):
322
+ ow_size = self._get_original_widget_size(size)
323
+ row += self._trim_top
324
+ return ow.mouse_event(ow_size, event, button, col, row, focus)
325
+
326
+ return False
327
+
328
+ def _adjust_trim_top(self, canv: Canvas, size: tuple[int, int]) -> None:
329
+ """Adjust self._trim_top according to self._scroll_action"""
330
+ action = self._scroll_action
331
+ self._scroll_action = None
332
+
333
+ _maxcol, maxrow = size
334
+ trim_top = self._trim_top
335
+ canv_rows = canv.rows()
336
+
337
+ if trim_top < 0:
338
+ # Negative trim_top values use bottom of canvas as reference
339
+ trim_top = canv_rows - maxrow + trim_top + 1
340
+
341
+ if canv_rows <= maxrow:
342
+ self._trim_top = 0 # Reset scroll position
343
+ return
344
+
345
+ def ensure_bounds(new_trim_top: int) -> int:
346
+ return max(0, min(canv_rows - maxrow, new_trim_top))
347
+
348
+ if action == SCROLL_LINE_UP:
349
+ self._trim_top = ensure_bounds(trim_top - 1)
350
+ elif action == SCROLL_LINE_DOWN:
351
+ self._trim_top = ensure_bounds(trim_top + 1)
352
+
353
+ elif action == SCROLL_PAGE_UP:
354
+ self._trim_top = ensure_bounds(trim_top - maxrow + 1)
355
+ elif action == SCROLL_PAGE_DOWN:
356
+ self._trim_top = ensure_bounds(trim_top + maxrow - 1)
357
+
358
+ elif action == SCROLL_TO_TOP:
359
+ self._trim_top = 0
360
+ elif action == SCROLL_TO_END:
361
+ self._trim_top = canv_rows - maxrow
362
+
363
+ else:
364
+ self._trim_top = ensure_bounds(trim_top)
365
+
366
+ # If the cursor was moved by the most recent keypress, adjust trim_top
367
+ # so that the new cursor position is within the displayed canvas part.
368
+ # But don't do this if the cursor is at the top/bottom edge so we can still scroll out
369
+ if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor is not None:
370
+ self._old_cursor_coords = None
371
+ _curscol, cursrow = canv.cursor
372
+ if cursrow < self._trim_top:
373
+ self._trim_top = cursrow
374
+ elif cursrow >= self._trim_top + maxrow:
375
+ self._trim_top = max(0, cursrow - maxrow + 1)
376
+
377
+ def _get_original_widget_size(self, size: tuple[int, int]) -> tuple[int] | tuple[()]:
378
+ ow = self._original_widget
379
+ sizing = ow.sizing()
380
+ if Sizing.FLOW in sizing:
381
+ return (size[0],)
382
+ if Sizing.FIXED in sizing:
383
+ return ()
384
+ raise ScrollableError(f"{ow!r} sizing is not supported")
385
+
386
+ def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
387
+ """Current scrolling position.
388
+
389
+ Lower limit is 0, upper limit is the maximum number of rows with the given maxcol minus maxrow.
390
+
391
+ ..note::
392
+ The returned value may be too low or too high if the position has
393
+ changed but the widget wasn't rendered yet.
394
+ """
395
+ return self._trim_top
396
+
397
+ def set_scrollpos(self, position: typing.SupportsInt) -> None:
398
+ """Set scrolling position
399
+
400
+ If `position` is positive it is interpreted as lines from the top.
401
+ If `position` is negative it is interpreted as lines from the bottom.
402
+
403
+ Values that are too high or too low values are automatically adjusted during rendering.
404
+ """
405
+ self._trim_top = int(position)
406
+ self._invalidate()
407
+
408
+ def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
409
+ """Return the number of rows for `size`
410
+
411
+ If `size` is not given, the currently rendered number of rows is returned.
412
+ """
413
+ if size is not None:
414
+ ow = self._original_widget
415
+ ow_size = self._get_original_widget_size(size)
416
+ sizing = ow.sizing()
417
+ if Sizing.FIXED in sizing:
418
+ self._rows_max_cached = ow.pack(ow_size, focus)[1]
419
+ elif Sizing.FLOW in sizing:
420
+ self._rows_max_cached = ow.rows(ow_size, focus)
421
+ else:
422
+ raise ScrollableError(f"Not a flow/box widget: {self._original_widget!r}")
423
+ return self._rows_max_cached
424
+
425
+
426
+ class ScrollBar(WidgetDecoration[WrappedWidget]):
427
+ Symbols = ScrollbarSymbols
428
+
429
+ def sizing(self):
430
+ return frozenset((Sizing.BOX,))
431
+
432
+ def selectable(self):
433
+ return True
434
+
435
+ def __init__(
436
+ self,
437
+ widget: WrappedWidget,
438
+ thumb_char: str = ScrollbarSymbols.FULL_BLOCK,
439
+ trough_char: str = " ",
440
+ side: Literal["left", "right"] = SCROLLBAR_RIGHT,
441
+ width: int = 1,
442
+ ) -> None:
443
+ """Box widget that adds a scrollbar to `widget`
444
+
445
+ `widget` must be a box widget with the following methods:
446
+ - `get_scrollpos` takes the arguments `size` and `focus` and returns the index of the first visible row.
447
+ - `set_scrollpos` (optional; needed for mouse click support) takes the index of the first visible row.
448
+ - `rows_max` takes `size` and `focus` and returns the total number of rows `widget` can render.
449
+
450
+ `thumb_char` is the character used for the scrollbar handle.
451
+ `trough_char` is used for the space above and below the handle.
452
+ `side` must be 'left' or 'right'.
453
+ `width` specifies the number of columns the scrollbar uses.
454
+ """
455
+ if Sizing.BOX not in widget.sizing():
456
+ raise ValueError(f"Not a box widget: {widget!r}")
457
+ if not isinstance(widget, SupportsScroll):
458
+ raise TypeError(f"Not a scrollable widget: {widget!r}")
459
+
460
+ super().__init__(widget)
461
+ self._thumb_char = thumb_char
462
+ self._trough_char = trough_char
463
+ self.scrollbar_side = side
464
+ self.scrollbar_width = max(1, width)
465
+ self._original_widget_size = (0, 0)
466
+
467
+ def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas:
468
+ from urwid import canvas
469
+
470
+ maxcol, maxrow = size
471
+
472
+ sb_width = self._scrollbar_width
473
+ ow_size = (max(0, maxcol - sb_width), maxrow)
474
+ sb_width = maxcol - ow_size[0]
475
+
476
+ ow = self._original_widget
477
+ ow_base = self.scrolling_base_widget
478
+ ow_rows_max = ow_base.rows_max(size, focus)
479
+ if ow_rows_max <= maxrow:
480
+ # Canvas fits without scrolling - no scrollbar needed
481
+ self._original_widget_size = size
482
+ return ow.render(size, focus)
483
+ ow_rows_max = ow_base.rows_max(ow_size, focus)
484
+
485
+ ow_canv = ow.render(ow_size, focus)
486
+ self._original_widget_size = ow_size
487
+
488
+ pos = ow_base.get_scrollpos(ow_size, focus)
489
+ posmax = ow_rows_max - maxrow
490
+
491
+ # Thumb shrinks/grows according to the ratio of
492
+ # <number of visible lines> / <number of total lines>
493
+ thumb_weight = min(1.0, maxrow / max(1, ow_rows_max))
494
+ thumb_height = max(1, round(thumb_weight * maxrow))
495
+
496
+ # Thumb may only touch top/bottom if the first/last row is visible
497
+ top_weight = float(pos) / max(1, posmax)
498
+ top_height = int((maxrow - thumb_height) * top_weight)
499
+ if top_height == 0 and top_weight > 0:
500
+ top_height = 1
501
+
502
+ # Bottom part is remaining space
503
+ bottom_height = maxrow - thumb_height - top_height
504
+
505
+ # Create scrollbar canvas
506
+ # Creating SolidCanvases of correct height may result in
507
+ # "cviews do not fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!" exceptions.
508
+ # Stacking the same SolidCanvas is a workaround.
509
+ # https://github.com/urwid/urwid/issues/226#issuecomment-437176837
510
+ top = canvas.SolidCanvas(self._trough_char, sb_width, 1)
511
+ thumb = canvas.SolidCanvas(self._thumb_char, sb_width, 1)
512
+ bottom = canvas.SolidCanvas(self._trough_char, sb_width, 1)
513
+ sb_canv = canvas.CanvasCombine(
514
+ (
515
+ *((top, None, False) for _ in range(top_height)),
516
+ *((thumb, None, False) for _ in range(thumb_height)),
517
+ *((bottom, None, False) for _ in range(bottom_height)),
518
+ ),
519
+ )
520
+
521
+ combinelist = [(ow_canv, None, True, ow_size[0]), (sb_canv, None, False, sb_width)]
522
+
523
+ if self._scrollbar_side != SCROLLBAR_LEFT:
524
+ return canvas.CanvasJoin(combinelist)
525
+
526
+ return canvas.CanvasJoin(reversed(combinelist))
527
+
528
+ @property
529
+ def scrollbar_width(self) -> int:
530
+ """Columns the scrollbar uses"""
531
+ return max(1, self._scrollbar_width)
532
+
533
+ @scrollbar_width.setter
534
+ def scrollbar_width(self, width: typing.SupportsInt) -> None:
535
+ self._scrollbar_width = max(1, int(width))
536
+ self._invalidate()
537
+
538
+ @property
539
+ def scrollbar_side(self) -> Literal["left", "right"]:
540
+ """Where to display the scrollbar; must be 'left' or 'right'"""
541
+ return self._scrollbar_side
542
+
543
+ @scrollbar_side.setter
544
+ def scrollbar_side(self, side: Literal["left", "right"]) -> None:
545
+ if side not in {SCROLLBAR_LEFT, SCROLLBAR_RIGHT}:
546
+ raise ValueError(f'scrollbar_side must be "left" or "right", not {side!r}')
547
+ self._scrollbar_side = side
548
+ self._invalidate()
549
+
550
+ @property
551
+ def scrolling_base_widget(self) -> SupportsScroll:
552
+ """Nearest `original_widget` that is compatible with the scrolling API"""
553
+
554
+ def orig_iter(w: Widget) -> Iterator[Widget]:
555
+ while hasattr(w, "original_widget"):
556
+ w = w.original_widget
557
+ yield w
558
+ yield w
559
+
560
+ w = self
561
+
562
+ for w in orig_iter(self):
563
+ if isinstance(w, SupportsScroll):
564
+ return w
565
+
566
+ raise ScrollableError(f"Not compatible to be wrapped by ScrollBar: {w!r}")
567
+
568
+ def keypress(self, size: tuple[int, int], key: str) -> str | None:
569
+ return self._original_widget.keypress(self._original_widget_size, key)
570
+
571
+ def mouse_event(
572
+ self,
573
+ size: tuple[int, int],
574
+ event: str,
575
+ button: int,
576
+ col: int,
577
+ row: int,
578
+ focus: bool,
579
+ ) -> bool | None:
580
+ ow = self._original_widget
581
+ ow_size = self._original_widget_size
582
+ handled = False
583
+ if hasattr(ow, "mouse_event"):
584
+ handled = ow.mouse_event(ow_size, event, button, col, row, focus)
585
+
586
+ if not handled and hasattr(ow, "set_scrollpos"):
587
+ if button == 4: # scroll wheel up
588
+ pos = ow.get_scrollpos(ow_size)
589
+ newpos = max(pos - 1, 0)
590
+ ow.set_scrollpos(newpos)
591
+ return True
592
+ if button == 5: # scroll wheel down
593
+ pos = ow.get_scrollpos(ow_size)
594
+ ow.set_scrollpos(pos + 1)
595
+ return True
596
+
597
+ return False
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from urwid.canvas import SolidCanvas
4
+
5
+ from .constants import SHADE_SYMBOLS, Sizing
6
+ from .widget import Widget
7
+
8
+
9
+ class SolidFill(Widget):
10
+ """
11
+ A box widget that fills an area with a single character
12
+ """
13
+
14
+ _selectable = False
15
+ ignore_focus = True
16
+ _sizing = frozenset([Sizing.BOX])
17
+
18
+ Symbols = SHADE_SYMBOLS
19
+
20
+ def __init__(self, fill_char: str = " ") -> None:
21
+ """
22
+ :param fill_char: character to fill area with
23
+ :type fill_char: bytes or unicode
24
+
25
+ >>> SolidFill(u'8')
26
+ <SolidFill box widget '8'>
27
+ """
28
+ super().__init__()
29
+ self.fill_char = fill_char
30
+
31
+ def _repr_words(self) -> list[str]:
32
+ return [*super()._repr_words(), repr(self.fill_char)]
33
+
34
+ def render(self, size: tuple[int, int], focus: bool = False) -> SolidCanvas:
35
+ """
36
+ Render the Fill as a canvas and return it.
37
+
38
+ >>> SolidFill().render((4,2)).text # ... = b in Python 3
39
+ [...' ', ...' ']
40
+ >>> SolidFill('#').render((5,3)).text
41
+ [...'#####', ...'#####', ...'#####']
42
+ """
43
+ maxcol, maxrow = size
44
+ return SolidCanvas(self.fill_char, maxcol, maxrow)