Scrollable.py
1 # This program is free software: you can redistribute it and/or modify 2 # it under the terms of the GNU General Public License as published by 3 # the Free Software Foundation, either version 3 of the License, or 4 # (at your option) any later version. 5 # 6 # This program is distributed in the hope that it will be useful 7 # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 # GNU General Public License for more details 10 # http://www.gnu.org/licenses/gpl-3.0.txt 11 12 import urwid 13 from urwid.widget import BOX, FIXED, FLOW 14 15 # Scroll actions 16 SCROLL_LINE_UP = 'line up' 17 SCROLL_LINE_DOWN = 'line down' 18 SCROLL_PAGE_UP = 'page up' 19 SCROLL_PAGE_DOWN = 'page down' 20 SCROLL_TO_TOP = 'to top' 21 SCROLL_TO_END = 'to end' 22 23 # Scrollbar positions 24 SCROLLBAR_LEFT = 'left' 25 SCROLLBAR_RIGHT = 'right' 26 27 class Scrollable(urwid.WidgetDecoration): 28 def sizing(self): 29 return frozenset([BOX,]) 30 31 def selectable(self): 32 return True 33 34 def __init__(self, widget, force_forward_keypress = False): 35 """Box widget that makes a fixed or flow widget vertically scrollable 36 37 TODO: Focusable widgets are handled, including switching focus, but 38 possibly not intuitively, depending on the arrangement of widgets. When 39 switching focus to a widget that is ouside of the visible part of the 40 original widget, the canvas scrolls up/down to the focused widget. It 41 would be better to scroll until the next focusable widget is in sight 42 first. But for that to work we must somehow obtain a list of focusable 43 rows in the original canvas. 44 """ 45 if not any(s in widget.sizing() for s in (FIXED, FLOW)): 46 raise ValueError('Not a fixed or flow widget: %r' % widget) 47 self._trim_top = 0 48 self._scroll_action = None 49 self._forward_keypress = None 50 self._old_cursor_coords = None 51 self._rows_max_cached = 0 52 self.force_forward_keypress = force_forward_keypress 53 super().__init__(widget) 54 55 def render(self, size, focus=False): 56 maxcol, maxrow = size 57 58 # Render complete original widget 59 ow = self._original_widget 60 ow_size = self._get_original_widget_size(size) 61 canv_full = ow.render(ow_size, focus) 62 63 # Make full canvas editable 64 canv = urwid.CompositeCanvas(canv_full) 65 canv_cols, canv_rows = canv.cols(), canv.rows() 66 67 if canv_cols <= maxcol: 68 pad_width = maxcol - canv_cols 69 if pad_width > 0: 70 # Canvas is narrower than available horizontal space 71 canv.pad_trim_left_right(0, pad_width) 72 73 if canv_rows <= maxrow: 74 fill_height = maxrow - canv_rows 75 if fill_height > 0: 76 # Canvas is lower than available vertical space 77 canv.pad_trim_top_bottom(0, fill_height) 78 79 if canv_cols <= maxcol and canv_rows <= maxrow: 80 # Canvas is small enough to fit without trimming 81 return canv 82 83 self._adjust_trim_top(canv, size) 84 85 # Trim canvas if necessary 86 trim_top = self._trim_top 87 trim_end = canv_rows - maxrow - trim_top 88 trim_right = canv_cols - maxcol 89 if trim_top > 0: 90 canv.trim(trim_top) 91 if trim_end > 0: 92 canv.trim_end(trim_end) 93 if trim_right > 0: 94 canv.pad_trim_left_right(0, -trim_right) 95 96 # Disable cursor display if cursor is outside of visible canvas parts 97 if canv.cursor is not None: 98 curscol, cursrow = canv.cursor 99 if cursrow >= maxrow or cursrow < 0: 100 canv.cursor = None 101 102 # Figure out whether we should forward keypresses to original widget 103 if canv.cursor is not None: 104 # Trimmed canvas contains the cursor, e.g. in an Edit widget 105 self._forward_keypress = True 106 else: 107 if canv_full.cursor is not None: 108 # Full canvas contains the cursor, but scrolled out of view 109 self._forward_keypress = False 110 111 # Reset cursor position on page/up down scrolling 112 try: 113 if hasattr(ow, "automove_cursor_on_scroll") and ow.automove_cursor_on_scroll: 114 pwi = 0 115 ch = 0 116 last_hidden = False 117 first_visible = False 118 for w,o in ow.contents: 119 wcanv = w.render((maxcol,)) 120 wh = wcanv.rows() 121 if wh: 122 ch += wh 123 124 if not last_hidden and ch >= self._trim_top: 125 last_hidden = True 126 127 elif last_hidden: 128 if not first_visible: 129 first_visible = True 130 131 if w.selectable(): 132 ow.focus_item = pwi 133 134 st = None 135 nf = ow.get_focus() 136 if hasattr(nf, "key_timeout"): 137 st = nf 138 elif hasattr(nf, "original_widget"): 139 no = nf.original_widget 140 if hasattr(no, "original_widget"): 141 st = no.original_widget 142 else: 143 if hasattr(no, "key_timeout"): 144 st = no 145 146 if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress): 147 st.keypress(None, None) 148 149 break 150 151 pwi += 1 152 except Exception as e: 153 pass 154 155 else: 156 # Original widget does not have a cursor, but may be selectable 157 158 # FIXME: Using ow.selectable() is bad because the original 159 # widget may be selectable because it's a container widget with 160 # a key-grabbing widget that is scrolled out of view. 161 # ow.selectable() returns True anyway because it doesn't know 162 # how we trimmed our canvas. 163 # 164 # To fix this, we need to resolve ow.focus and somehow 165 # ask canv whether it contains bits of the focused widget. I 166 # can't see a way to do that. 167 if ow.selectable(): 168 self._forward_keypress = True 169 else: 170 self._forward_keypress = False 171 172 return canv 173 174 def keypress(self, size, key): 175 # Maybe offer key to original widget 176 if self._forward_keypress or self.force_forward_keypress: 177 ow = self._original_widget 178 ow_size = self._get_original_widget_size(size) 179 180 # Remember previous cursor position if possible 181 if hasattr(ow, 'get_cursor_coords'): 182 self._old_cursor_coords = ow.get_cursor_coords(ow_size) 183 184 key = ow.keypress(ow_size, key) 185 if key is None: 186 return None 187 188 # Handle up/down, page up/down, etc 189 command_map = self._command_map 190 if command_map[key] == urwid.CURSOR_UP: 191 self._scroll_action = SCROLL_LINE_UP 192 elif command_map[key] == urwid.CURSOR_DOWN: 193 self._scroll_action = SCROLL_LINE_DOWN 194 195 elif command_map[key] == urwid.CURSOR_PAGE_UP: 196 self._scroll_action = SCROLL_PAGE_UP 197 elif command_map[key] == urwid.CURSOR_PAGE_DOWN: 198 self._scroll_action = SCROLL_PAGE_DOWN 199 200 elif command_map[key] == urwid.CURSOR_MAX_LEFT: # 'home' 201 self._scroll_action = SCROLL_TO_TOP 202 elif command_map[key] == urwid.CURSOR_MAX_RIGHT: # 'end' 203 self._scroll_action = SCROLL_TO_END 204 205 else: 206 return key 207 208 self._invalidate() 209 210 def mouse_event(self, size, event, button, col, row, focus): 211 ow = self._original_widget 212 if hasattr(ow, 'mouse_event'): 213 ow_size = self._get_original_widget_size(size) 214 row += self._trim_top 215 return ow.mouse_event(ow_size, event, button, col, row, focus) 216 else: 217 return False 218 219 def _adjust_trim_top(self, canv, size): 220 """Adjust self._trim_top according to self._scroll_action""" 221 action = self._scroll_action 222 self._scroll_action = None 223 224 maxcol, maxrow = size 225 trim_top = self._trim_top 226 canv_rows = canv.rows() 227 228 if trim_top < 0: 229 # Negative trim_top values use bottom of canvas as reference 230 trim_top = canv_rows - maxrow + trim_top + 1 231 232 if canv_rows <= maxrow: 233 self._trim_top = 0 # Reset scroll position 234 return 235 236 def ensure_bounds(new_trim_top): 237 return max(0, min(canv_rows - maxrow, new_trim_top)) 238 239 if action == SCROLL_LINE_UP: 240 self._trim_top = ensure_bounds(trim_top - 1) 241 elif action == SCROLL_LINE_DOWN: 242 self._trim_top = ensure_bounds(trim_top + 1) 243 244 elif action == SCROLL_PAGE_UP: 245 self._trim_top = ensure_bounds(trim_top - maxrow + 1) 246 elif action == SCROLL_PAGE_DOWN: 247 self._trim_top = ensure_bounds(trim_top + maxrow - 1) 248 249 elif action == SCROLL_TO_TOP: 250 self._trim_top = 0 251 elif action == SCROLL_TO_END: 252 self._trim_top = canv_rows - maxrow 253 254 else: 255 self._trim_top = ensure_bounds(trim_top) 256 257 # If the cursor was moved by the most recent keypress, adjust trim_top 258 # so that the new cursor position is within the displayed canvas part. 259 # But don't do this if the cursor is at the top/bottom edge so we can still scroll out 260 if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor != None: 261 self._old_cursor_coords = None 262 curscol, cursrow = canv.cursor 263 if cursrow < self._trim_top: 264 self._trim_top = cursrow 265 elif cursrow >= self._trim_top + maxrow: 266 self._trim_top = max(0, cursrow - maxrow + 1) 267 268 def _get_original_widget_size(self, size): 269 ow = self._original_widget 270 sizing = ow.sizing() 271 if FLOW in sizing: 272 return (size[0],) 273 elif FIXED in sizing: 274 return () 275 276 def get_scrollpos(self, size=None, focus=False): 277 """Current scrolling position 278 279 Lower limit is 0, upper limit is the maximum number of rows with the 280 given maxcol minus maxrow. 281 282 NOTE: The returned value may be too low or too high if the position has 283 changed but the widget wasn't rendered yet. 284 """ 285 return self._trim_top 286 287 def set_scrollpos(self, position): 288 """Set scrolling position 289 290 If `position` is positive it is interpreted as lines from the top. 291 If `position` is negative it is interpreted as lines from the bottom. 292 293 Values that are too high or too low values are automatically adjusted 294 during rendering. 295 """ 296 self._trim_top = int(position) 297 self._invalidate() 298 299 def rows_max(self, size=None, focus=False): 300 """Return the number of rows for `size` 301 302 If `size` is not given, the currently rendered number of rows is returned. 303 """ 304 if size is not None: 305 ow = self._original_widget 306 ow_size = self._get_original_widget_size(size) 307 sizing = ow.sizing() 308 if FIXED in sizing: 309 self._rows_max_cached = ow.pack(ow_size, focus)[1] 310 elif FLOW in sizing: 311 self._rows_max_cached = ow.rows(ow_size, focus) 312 else: 313 raise RuntimeError('Not a flow/box widget: %r' % self._original_widget) 314 return self._rows_max_cached 315 316 317 class ScrollBar(urwid.WidgetDecoration): 318 def sizing(self): 319 return frozenset((BOX,)) 320 321 def selectable(self): 322 return True 323 324 def __init__(self, widget, thumb_char=u'\u2588', trough_char=' ', 325 side=SCROLLBAR_RIGHT, width=1): 326 """Box widget that adds a scrollbar to `widget` 327 328 `widget` must be a box widget with the following methods: 329 - `get_scrollpos` takes the arguments `size` and `focus` and returns 330 the index of the first visible row. 331 - `set_scrollpos` (optional; needed for mouse click support) takes the 332 index of the first visible row. 333 - `rows_max` takes `size` and `focus` and returns the total number of 334 rows `widget` can render. 335 336 `thumb_char` is the character used for the scrollbar handle. 337 `trough_char` is used for the space above and below the handle. 338 `side` must be 'left' or 'right'. 339 `width` specifies the number of columns the scrollbar uses. 340 """ 341 if BOX not in widget.sizing(): 342 raise ValueError('Not a box widget: %r' % widget) 343 super().__init__(widget) 344 self._thumb_char = thumb_char 345 self._trough_char = trough_char 346 self.scrollbar_side = side 347 self.scrollbar_width = max(1, width) 348 self._original_widget_size = (0, 0) 349 350 def render(self, size, focus=False): 351 maxcol, maxrow = size 352 353 sb_width = self._scrollbar_width 354 ow_size = (max(0, maxcol - sb_width), maxrow) 355 sb_width = maxcol - ow_size[0] 356 357 ow = self._original_widget 358 ow_base = self.scrolling_base_widget 359 ow_rows_max = ow_base.rows_max(size, focus) 360 if ow_rows_max <= maxrow: 361 # Canvas fits without scrolling - no scrollbar needed 362 self._original_widget_size = size 363 return ow.render(size, focus) 364 ow_rows_max = ow_base.rows_max(ow_size, focus) 365 366 ow_canv = ow.render(ow_size, focus) 367 self._original_widget_size = ow_size 368 369 pos = ow_base.get_scrollpos(ow_size, focus) 370 posmax = ow_rows_max - maxrow 371 372 # Thumb shrinks/grows according to the ratio of 373 # <number of visible lines> / <number of total lines> 374 thumb_weight = min(1, maxrow / max(1, ow_rows_max)) 375 thumb_height = max(1, round(thumb_weight * maxrow)) 376 377 # Thumb may only touch top/bottom if the first/last row is visible 378 top_weight = float(pos) / max(1, posmax) 379 top_height = int((maxrow - thumb_height) * top_weight) 380 if top_height == 0 and top_weight > 0: 381 top_height = 1 382 383 # Bottom part is remaining space 384 bottom_height = maxrow - thumb_height - top_height 385 assert thumb_height + top_height + bottom_height == maxrow 386 387 # Create scrollbar canvas 388 # Creating SolidCanvases of correct height may result in "cviews do not 389 # fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!" 390 # exceptions. Stacking the same SolidCanvas is a workaround. 391 # https://github.com/urwid/urwid/issues/226#issuecomment-437176837 392 top = urwid.SolidCanvas(self._trough_char, sb_width, 1) 393 thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1) 394 bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1) 395 sb_canv = urwid.CanvasCombine( 396 [(top, None, False)] * top_height + 397 [(thumb, None, False)] * thumb_height + 398 [(bottom, None, False)] * bottom_height, 399 ) 400 401 combinelist = [(ow_canv, None, True, ow_size[0]), 402 (sb_canv, None, False, sb_width)] 403 if self._scrollbar_side != SCROLLBAR_LEFT: 404 return urwid.CanvasJoin(combinelist) 405 else: 406 return urwid.CanvasJoin(reversed(combinelist)) 407 408 @property 409 def scrollbar_width(self): 410 """Columns the scrollbar uses""" 411 return max(1, self._scrollbar_width) 412 413 @scrollbar_width.setter 414 def scrollbar_width(self, width): 415 self._scrollbar_width = max(1, int(width)) 416 self._invalidate() 417 418 @property 419 def scrollbar_side(self): 420 """Where to display the scrollbar; must be 'left' or 'right'""" 421 return self._scrollbar_side 422 423 @scrollbar_side.setter 424 def scrollbar_side(self, side): 425 if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT): 426 raise ValueError('scrollbar_side must be "left" or "right", not %r' % side) 427 self._scrollbar_side = side 428 self._invalidate() 429 430 @property 431 def scrolling_base_widget(self): 432 """Nearest `original_widget` that is compatible with the scrolling API""" 433 def orig_iter(w): 434 while hasattr(w, 'original_widget'): 435 w = w.original_widget 436 yield w 437 yield w 438 439 def is_scrolling_widget(w): 440 return hasattr(w, 'get_scrollpos') and hasattr(w, 'rows_max') 441 442 for w in orig_iter(self): 443 if is_scrolling_widget(w): 444 return w 445 raise ValueError('Not compatible to be wrapped by ScrollBar: %r' % w) 446 447 def keypress(self, size, key): 448 return self._original_widget.keypress(self._original_widget_size, key) 449 450 def mouse_event(self, size, event, button, col, row, focus): 451 ow = self._original_widget 452 ow_size = self._original_widget_size 453 handled = False 454 if hasattr(ow, 'mouse_event'): 455 handled = ow.mouse_event(ow_size, event, button, col, row, focus) 456 457 if not handled and hasattr(ow, 'set_scrollpos'): 458 if button == 4: # scroll wheel up 459 pos = ow.get_scrollpos(ow_size) 460 newpos = pos - 1 461 if newpos < 0: 462 newpos = 0 463 ow.set_scrollpos(newpos) 464 return True 465 elif button == 5: # scroll wheel down 466 pos = ow.get_scrollpos(ow_size) 467 ow.set_scrollpos(pos + 1) 468 return True 469 470 return False