|
| 1 | +from typing import List, Optional |
| 2 | + |
| 3 | +from prompt_toolkit.data_structures import Point |
| 4 | +from prompt_toolkit.filters import FilterOrBool, to_filter |
| 5 | +from prompt_toolkit.key_binding import KeyBindingsBase |
| 6 | + |
| 7 | +from .containers import Container, ScrollOffsets |
| 8 | +from .dimension import AnyDimension, Dimension, to_dimension |
| 9 | +from .mouse_handlers import MouseHandlers |
| 10 | +from .screen import Char, Screen, WritePosition |
| 11 | + |
| 12 | +__all__ = ["ScrollablePane"] |
| 13 | + |
| 14 | +# Never go beyond this height, because performance will degrade. |
| 15 | +MAX_AVAILABLE_HEIGHT = 10_000 |
| 16 | + |
| 17 | + |
| 18 | +class ScrollablePane(Container): |
| 19 | + """ |
| 20 | + Container widget that exposes a larger virtual screen to its content and |
| 21 | + displays it in a vertical scrollbale region. |
| 22 | +
|
| 23 | + Typically this is wrapped in a large `HSplit` container. Make sure in that |
| 24 | + case to not specify a `height` dimension of the `HSplit`, so that it will |
| 25 | + scale according to the content. |
| 26 | +
|
| 27 | + .. note:: |
| 28 | +
|
| 29 | + If you want to display a completion menu for widgets in this |
| 30 | + `ScrollablePane`, then it's still a good practice to use a |
| 31 | + `FloatContainer` with a `CompletionMenu` in a `Float` at the top-level |
| 32 | + of the layout hierarchy, rather then nesting a `FloatContainer` in this |
| 33 | + `ScrollablePane`. (Otherwise, it's possible that the completion menu |
| 34 | + is clipped.) |
| 35 | +
|
| 36 | + :param content: The content container. |
| 37 | + :param scrolloffset: Try to keep the cursor within this distance from the |
| 38 | + top/bottom (left/right offset is not used). |
| 39 | + :param keep_cursor_visible: When `True`, automatically scroll the pane so |
| 40 | + that the cursor (of the focused window) is always visible. |
| 41 | + :param keep_focused_window_visible: When `True`, automatically scroll th e |
| 42 | + pane so that the focused window is visible, or as much visible as |
| 43 | + possible if it doen't completely fit the screen. |
| 44 | + :param max_available_height: Always constraint the height to this amount |
| 45 | + for performance reasons. |
| 46 | + :param width: When given, use this width instead of looking at the children. |
| 47 | + :param height: When given, use this height instead of looking at the children. |
| 48 | + """ |
| 49 | + |
| 50 | + def __init__( |
| 51 | + self, |
| 52 | + content: Container, |
| 53 | + scroll_offsets: Optional[ScrollOffsets] = None, |
| 54 | + keep_cursor_visible: FilterOrBool = True, |
| 55 | + keep_focused_window_visible: FilterOrBool = True, |
| 56 | + max_available_height: int = MAX_AVAILABLE_HEIGHT, |
| 57 | + width: AnyDimension = None, |
| 58 | + height: AnyDimension = None, |
| 59 | + ) -> None: |
| 60 | + self.content = content |
| 61 | + self.scroll_offsets = scroll_offsets or ScrollOffsets(top=1, bottom=1) |
| 62 | + self.keep_cursor_visible = to_filter(keep_cursor_visible) |
| 63 | + self.keep_focused_window_visible = to_filter(keep_focused_window_visible) |
| 64 | + self.max_available_height = max_available_height |
| 65 | + self.width = width |
| 66 | + self.height = height |
| 67 | + |
| 68 | + self.vertical_scroll = 0 |
| 69 | + |
| 70 | + def __repr__(self) -> str: |
| 71 | + return f"ScrollablePane({self.content!r})" |
| 72 | + |
| 73 | + def reset(self) -> None: |
| 74 | + self.content.reset() |
| 75 | + |
| 76 | + def preferred_width(self, max_available_width: int) -> Dimension: |
| 77 | + if self.width is not None: |
| 78 | + return to_dimension(self.width) |
| 79 | + |
| 80 | + # We're only scrolling vertical. So the preferred width is equal to |
| 81 | + # that of the content. |
| 82 | + return self.content.preferred_width(max_available_width) |
| 83 | + |
| 84 | + def preferred_height(self, width: int, max_available_height: int) -> Dimension: |
| 85 | + if self.height is not None: |
| 86 | + return to_dimension(self.height) |
| 87 | + |
| 88 | + # Prefer a height large enough so that it fits all the content. If not, |
| 89 | + # we'll make the pane scrollable. |
| 90 | + dimension = self.content.preferred_height(width, self.max_available_height) |
| 91 | + |
| 92 | + # Only take 'preferred' into account. Min/max can be anything. |
| 93 | + return Dimension(min=0, preferred=dimension.preferred) |
| 94 | + |
| 95 | + def write_to_screen( |
| 96 | + self, |
| 97 | + screen: Screen, |
| 98 | + mouse_handlers: MouseHandlers, |
| 99 | + write_position: WritePosition, |
| 100 | + parent_style: str, |
| 101 | + erase_bg: bool, |
| 102 | + z_index: Optional[int], |
| 103 | + ) -> None: |
| 104 | + # Compute preferred height again. |
| 105 | + virtual_height = self.content.preferred_height( |
| 106 | + write_position.width, self.max_available_height |
| 107 | + ).preferred |
| 108 | + virtual_width = write_position.width |
| 109 | + |
| 110 | + # Ensure virtual height is at least the available height. |
| 111 | + virtual_height = max(virtual_height, write_position.height) |
| 112 | + virtual_height = min(virtual_height, self.max_available_height) |
| 113 | + |
| 114 | + # First, write the content to a virtual screen, then copy over the |
| 115 | + # visible part to the real screen. |
| 116 | + temp_screen = Screen(default_char=Char(char=" ", style=parent_style)) |
| 117 | + temp_write_position = WritePosition( |
| 118 | + xpos=0, ypos=0, width=virtual_width, height=virtual_height |
| 119 | + ) |
| 120 | + |
| 121 | + temp_mouse_handlers = MouseHandlers() |
| 122 | + |
| 123 | + self.content.write_to_screen( |
| 124 | + temp_screen, |
| 125 | + temp_mouse_handlers, |
| 126 | + temp_write_position, |
| 127 | + parent_style, |
| 128 | + erase_bg, |
| 129 | + z_index, |
| 130 | + ) |
| 131 | + temp_screen.draw_all_floats() |
| 132 | + |
| 133 | + # TODO: draw scrollbar? |
| 134 | + |
| 135 | + # If anything in the virtual screen is focused, move vertical scroll to |
| 136 | + from prompt_toolkit.application import get_app |
| 137 | + |
| 138 | + focused_window = get_app().layout.current_window |
| 139 | + |
| 140 | + try: |
| 141 | + visible_win_write_pos = temp_screen.visible_windows_to_write_positions[ |
| 142 | + focused_window |
| 143 | + ] |
| 144 | + except KeyError: |
| 145 | + pass # No window focused here. Don't scroll. |
| 146 | + else: |
| 147 | + # Make sure this window is visible. |
| 148 | + self._make_window_visible( |
| 149 | + write_position.height, |
| 150 | + virtual_height, |
| 151 | + visible_win_write_pos, |
| 152 | + temp_screen.cursor_positions.get(focused_window), |
| 153 | + ) |
| 154 | + |
| 155 | + # Copy over virtual screen and zero width escapes to real screen. |
| 156 | + ypos = write_position.ypos |
| 157 | + xpos = write_position.xpos |
| 158 | + |
| 159 | + for y in range(write_position.height): |
| 160 | + temp_row = temp_screen.data_buffer[y + self.vertical_scroll] |
| 161 | + row = screen.data_buffer[y + ypos] |
| 162 | + temp_zero_width_escapes = temp_screen.zero_width_escapes[ |
| 163 | + y + self.vertical_scroll |
| 164 | + ] |
| 165 | + zero_width_escapes = screen.zero_width_escapes[y + ypos] |
| 166 | + |
| 167 | + for x in range(write_position.width): |
| 168 | + row[x + xpos] = temp_row[x] |
| 169 | + |
| 170 | + if x in temp_zero_width_escapes: |
| 171 | + zero_width_escapes[x + xpos] = temp_zero_width_escapes[x] |
| 172 | + |
| 173 | + # Set screen.width/height. |
| 174 | + screen.width = max(screen.width, xpos + write_position.width) |
| 175 | + screen.height = max(screen.height, ypos + write_position.height) |
| 176 | + |
| 177 | + for win, write_pos in temp_screen.visible_windows_to_write_positions.items(): |
| 178 | + screen.visible_windows_to_write_positions[win] = WritePosition( |
| 179 | + xpos=write_pos.xpos + xpos, |
| 180 | + ypos=write_pos.ypos + ypos - self.vertical_scroll, |
| 181 | + # TODO: if the window is only partly visible, then truncate width/height. |
| 182 | + # This could be important if we have nested ScrollablePanes. |
| 183 | + height=write_pos.height, |
| 184 | + width=write_pos.width, |
| 185 | + ) |
| 186 | + |
| 187 | + if temp_screen.show_cursor: |
| 188 | + screen.show_cursor = True |
| 189 | + |
| 190 | + # Copy over cursor positions. |
| 191 | + for window, point in temp_screen.cursor_positions.items(): |
| 192 | + screen.cursor_positions[window] = Point( |
| 193 | + x=point.x + xpos, y=point.y + ypos - self.vertical_scroll |
| 194 | + ) |
| 195 | + |
| 196 | + # Copy over menu positions. |
| 197 | + for window, point in temp_screen.menu_positions.items(): |
| 198 | + screen.menu_positions[window] = Point( |
| 199 | + x=point.x + xpos, y=point.y + ypos - self.vertical_scroll |
| 200 | + ) |
| 201 | + |
| 202 | + def is_modal(self) -> bool: |
| 203 | + return self.content.is_modal() |
| 204 | + |
| 205 | + def get_key_bindings(self) -> Optional[KeyBindingsBase]: |
| 206 | + return self.content.get_key_bindings() |
| 207 | + |
| 208 | + def get_children(self) -> List["Container"]: |
| 209 | + return [self.content] |
| 210 | + |
| 211 | + def _make_window_visible( |
| 212 | + self, |
| 213 | + visible_height: int, |
| 214 | + virtual_height: int, |
| 215 | + visible_win_write_pos: WritePosition, |
| 216 | + cursor_position: Optional[Point], |
| 217 | + ) -> None: |
| 218 | + """ |
| 219 | + Scroll the scrollable pane, so that this window becomes visible. |
| 220 | +
|
| 221 | + :param visible_height: Height of this `ScrollablePane` that is rendered. |
| 222 | + :param virtual_height: Height of the virtual, temp screen. |
| 223 | + :param visible_win_write_pos: `WritePosition` of the nested window on the |
| 224 | + temp screen. |
| 225 | + :param cursor_position: The location of the cursor position of this |
| 226 | + window on the temp screen. |
| 227 | + """ |
| 228 | + # Start with maximum allowed scroll range, and then reduce according to |
| 229 | + # the focused window and cursor position. |
| 230 | + min_scroll = 0 |
| 231 | + max_scroll = virtual_height - visible_height |
| 232 | + |
| 233 | + if self.keep_cursor_visible(): |
| 234 | + # Reduce min/max scroll according to the cursor in the focused window. |
| 235 | + if cursor_position is not None: |
| 236 | + offsets = self.scroll_offsets |
| 237 | + cpos_min_scroll = ( |
| 238 | + cursor_position.y - visible_height + 1 + offsets.bottom |
| 239 | + ) |
| 240 | + cpos_max_scroll = cursor_position.y - offsets.top |
| 241 | + min_scroll = max(min_scroll, cpos_min_scroll) |
| 242 | + max_scroll = max(0, min(max_scroll, cpos_max_scroll)) |
| 243 | + |
| 244 | + if self.keep_focused_window_visible(): |
| 245 | + # Reduce min/max scroll according to focused window position. |
| 246 | + # If the window is small enough, bot the top and bottom of the window |
| 247 | + # should be visible. |
| 248 | + if visible_win_write_pos.height <= visible_height: |
| 249 | + window_min_scroll = ( |
| 250 | + visible_win_write_pos.ypos |
| 251 | + + visible_win_write_pos.height |
| 252 | + - visible_height |
| 253 | + ) |
| 254 | + window_max_scroll = visible_win_write_pos.ypos |
| 255 | + else: |
| 256 | + # Window does not fit on the screen. Make sure at least the whole |
| 257 | + # screen is occupied with this window, and nothing else is shown. |
| 258 | + window_min_scroll = visible_win_write_pos.ypos |
| 259 | + window_max_scroll = ( |
| 260 | + visible_win_write_pos.ypos |
| 261 | + + visible_win_write_pos.height |
| 262 | + - visible_height |
| 263 | + ) |
| 264 | + |
| 265 | + min_scroll = max(min_scroll, window_min_scroll) |
| 266 | + max_scroll = min(max_scroll, window_max_scroll) |
| 267 | + |
| 268 | + if min_scroll > max_scroll: |
| 269 | + min_scroll = max_scroll # Should not happen. |
| 270 | + |
| 271 | + # Finally, properly clip the vertical scroll. |
| 272 | + if self.vertical_scroll > max_scroll: |
| 273 | + self.vertical_scroll = max_scroll |
| 274 | + if self.vertical_scroll < min_scroll: |
| 275 | + self.vertical_scroll = min_scroll |
0 commit comments