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.
- urwid/__init__.py +333 -0
- urwid/canvas.py +1413 -0
- urwid/command_map.py +137 -0
- urwid/container.py +59 -0
- urwid/decoration.py +65 -0
- urwid/display/__init__.py +97 -0
- urwid/display/_posix_raw_display.py +413 -0
- urwid/display/_raw_display_base.py +914 -0
- urwid/display/_web.css +12 -0
- urwid/display/_web.js +462 -0
- urwid/display/_win32.py +171 -0
- urwid/display/_win32_raw_display.py +269 -0
- urwid/display/common.py +1219 -0
- urwid/display/curses.py +690 -0
- urwid/display/escape.py +624 -0
- urwid/display/html_fragment.py +251 -0
- urwid/display/lcd.py +518 -0
- urwid/display/raw.py +37 -0
- urwid/display/web.py +636 -0
- urwid/event_loop/__init__.py +55 -0
- urwid/event_loop/abstract_loop.py +175 -0
- urwid/event_loop/asyncio_loop.py +231 -0
- urwid/event_loop/glib_loop.py +294 -0
- urwid/event_loop/main_loop.py +721 -0
- urwid/event_loop/select_loop.py +230 -0
- urwid/event_loop/tornado_loop.py +206 -0
- urwid/event_loop/trio_loop.py +302 -0
- urwid/event_loop/twisted_loop.py +269 -0
- urwid/event_loop/zmq_loop.py +275 -0
- urwid/font.py +695 -0
- urwid/graphics.py +96 -0
- urwid/highlight.css +19 -0
- urwid/listbox.py +1899 -0
- urwid/monitored_list.py +522 -0
- urwid/numedit.py +376 -0
- urwid/signals.py +330 -0
- urwid/split_repr.py +130 -0
- urwid/str_util.py +358 -0
- urwid/text_layout.py +632 -0
- urwid/treetools.py +515 -0
- urwid/util.py +557 -0
- urwid/version.py +16 -0
- urwid/vterm.py +1806 -0
- urwid/widget/__init__.py +181 -0
- urwid/widget/attr_map.py +161 -0
- urwid/widget/attr_wrap.py +140 -0
- urwid/widget/bar_graph.py +649 -0
- urwid/widget/big_text.py +77 -0
- urwid/widget/box_adapter.py +126 -0
- urwid/widget/columns.py +1145 -0
- urwid/widget/constants.py +574 -0
- urwid/widget/container.py +227 -0
- urwid/widget/divider.py +110 -0
- urwid/widget/edit.py +718 -0
- urwid/widget/filler.py +403 -0
- urwid/widget/frame.py +539 -0
- urwid/widget/grid_flow.py +539 -0
- urwid/widget/line_box.py +194 -0
- urwid/widget/overlay.py +829 -0
- urwid/widget/padding.py +597 -0
- urwid/widget/pile.py +971 -0
- urwid/widget/popup.py +170 -0
- urwid/widget/progress_bar.py +141 -0
- urwid/widget/scrollable.py +597 -0
- urwid/widget/solid_fill.py +44 -0
- urwid/widget/text.py +354 -0
- urwid/widget/widget.py +852 -0
- urwid/widget/widget_decoration.py +166 -0
- urwid/widget/wimp.py +792 -0
- urwid/wimp.py +23 -0
- urwid-2.6.0.post0.dist-info/COPYING +504 -0
- urwid-2.6.0.post0.dist-info/METADATA +332 -0
- urwid-2.6.0.post0.dist-info/RECORD +75 -0
- urwid-2.6.0.post0.dist-info/WHEEL +5 -0
- 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)
|