Spaces:
Sleeping
Sleeping
first-space / first-space-venv /lib /python3.12 /site-packages /prompt_toolkit /layout /scrollable_pane.py
| from __future__ import annotations | |
| from prompt_toolkit.data_structures import Point | |
| from prompt_toolkit.filters import FilterOrBool, to_filter | |
| from prompt_toolkit.key_binding import KeyBindingsBase | |
| from prompt_toolkit.mouse_events import MouseEvent | |
| from .containers import Container, ScrollOffsets | |
| from .dimension import AnyDimension, Dimension, sum_layout_dimensions, to_dimension | |
| from .mouse_handlers import MouseHandler, MouseHandlers | |
| from .screen import Char, Screen, WritePosition | |
| __all__ = ["ScrollablePane"] | |
| # Never go beyond this height, because performance will degrade. | |
| MAX_AVAILABLE_HEIGHT = 10_000 | |
| class ScrollablePane(Container): | |
| """ | |
| Container widget that exposes a larger virtual screen to its content and | |
| displays it in a vertical scrollbale region. | |
| Typically this is wrapped in a large `HSplit` container. Make sure in that | |
| case to not specify a `height` dimension of the `HSplit`, so that it will | |
| scale according to the content. | |
| .. note:: | |
| If you want to display a completion menu for widgets in this | |
| `ScrollablePane`, then it's still a good practice to use a | |
| `FloatContainer` with a `CompletionsMenu` in a `Float` at the top-level | |
| of the layout hierarchy, rather then nesting a `FloatContainer` in this | |
| `ScrollablePane`. (Otherwise, it's possible that the completion menu | |
| is clipped.) | |
| :param content: The content container. | |
| :param scrolloffset: Try to keep the cursor within this distance from the | |
| top/bottom (left/right offset is not used). | |
| :param keep_cursor_visible: When `True`, automatically scroll the pane so | |
| that the cursor (of the focused window) is always visible. | |
| :param keep_focused_window_visible: When `True`, automatically scroll the | |
| pane so that the focused window is visible, or as much visible as | |
| possible if it doesn't completely fit the screen. | |
| :param max_available_height: Always constraint the height to this amount | |
| for performance reasons. | |
| :param width: When given, use this width instead of looking at the children. | |
| :param height: When given, use this height instead of looking at the children. | |
| :param show_scrollbar: When `True` display a scrollbar on the right. | |
| """ | |
| def __init__( | |
| self, | |
| content: Container, | |
| scroll_offsets: ScrollOffsets | None = None, | |
| keep_cursor_visible: FilterOrBool = True, | |
| keep_focused_window_visible: FilterOrBool = True, | |
| max_available_height: int = MAX_AVAILABLE_HEIGHT, | |
| width: AnyDimension = None, | |
| height: AnyDimension = None, | |
| show_scrollbar: FilterOrBool = True, | |
| display_arrows: FilterOrBool = True, | |
| up_arrow_symbol: str = "^", | |
| down_arrow_symbol: str = "v", | |
| ) -> None: | |
| self.content = content | |
| self.scroll_offsets = scroll_offsets or ScrollOffsets(top=1, bottom=1) | |
| self.keep_cursor_visible = to_filter(keep_cursor_visible) | |
| self.keep_focused_window_visible = to_filter(keep_focused_window_visible) | |
| self.max_available_height = max_available_height | |
| self.width = width | |
| self.height = height | |
| self.show_scrollbar = to_filter(show_scrollbar) | |
| self.display_arrows = to_filter(display_arrows) | |
| self.up_arrow_symbol = up_arrow_symbol | |
| self.down_arrow_symbol = down_arrow_symbol | |
| self.vertical_scroll = 0 | |
| def __repr__(self) -> str: | |
| return f"ScrollablePane({self.content!r})" | |
| def reset(self) -> None: | |
| self.content.reset() | |
| def preferred_width(self, max_available_width: int) -> Dimension: | |
| if self.width is not None: | |
| return to_dimension(self.width) | |
| # We're only scrolling vertical. So the preferred width is equal to | |
| # that of the content. | |
| content_width = self.content.preferred_width(max_available_width) | |
| # If a scrollbar needs to be displayed, add +1 to the content width. | |
| if self.show_scrollbar(): | |
| return sum_layout_dimensions([Dimension.exact(1), content_width]) | |
| return content_width | |
| def preferred_height(self, width: int, max_available_height: int) -> Dimension: | |
| if self.height is not None: | |
| return to_dimension(self.height) | |
| # Prefer a height large enough so that it fits all the content. If not, | |
| # we'll make the pane scrollable. | |
| if self.show_scrollbar(): | |
| # If `show_scrollbar` is set. Always reserve space for the scrollbar. | |
| width -= 1 | |
| dimension = self.content.preferred_height(width, self.max_available_height) | |
| # Only take 'preferred' into account. Min/max can be anything. | |
| return Dimension(min=0, preferred=dimension.preferred) | |
| def write_to_screen( | |
| self, | |
| screen: Screen, | |
| mouse_handlers: MouseHandlers, | |
| write_position: WritePosition, | |
| parent_style: str, | |
| erase_bg: bool, | |
| z_index: int | None, | |
| ) -> None: | |
| """ | |
| Render scrollable pane content. | |
| This works by rendering on an off-screen canvas, and copying over the | |
| visible region. | |
| """ | |
| show_scrollbar = self.show_scrollbar() | |
| if show_scrollbar: | |
| virtual_width = write_position.width - 1 | |
| else: | |
| virtual_width = write_position.width | |
| # Compute preferred height again. | |
| virtual_height = self.content.preferred_height( | |
| virtual_width, self.max_available_height | |
| ).preferred | |
| # Ensure virtual height is at least the available height. | |
| virtual_height = max(virtual_height, write_position.height) | |
| virtual_height = min(virtual_height, self.max_available_height) | |
| # First, write the content to a virtual screen, then copy over the | |
| # visible part to the real screen. | |
| temp_screen = Screen(default_char=Char(char=" ", style=parent_style)) | |
| temp_screen.show_cursor = screen.show_cursor | |
| temp_write_position = WritePosition( | |
| xpos=0, ypos=0, width=virtual_width, height=virtual_height | |
| ) | |
| temp_mouse_handlers = MouseHandlers() | |
| self.content.write_to_screen( | |
| temp_screen, | |
| temp_mouse_handlers, | |
| temp_write_position, | |
| parent_style, | |
| erase_bg, | |
| z_index, | |
| ) | |
| temp_screen.draw_all_floats() | |
| # If anything in the virtual screen is focused, move vertical scroll to | |
| from prompt_toolkit.application import get_app | |
| focused_window = get_app().layout.current_window | |
| try: | |
| visible_win_write_pos = temp_screen.visible_windows_to_write_positions[ | |
| focused_window | |
| ] | |
| except KeyError: | |
| pass # No window focused here. Don't scroll. | |
| else: | |
| # Make sure this window is visible. | |
| self._make_window_visible( | |
| write_position.height, | |
| virtual_height, | |
| visible_win_write_pos, | |
| temp_screen.cursor_positions.get(focused_window), | |
| ) | |
| # Copy over virtual screen and zero width escapes to real screen. | |
| self._copy_over_screen(screen, temp_screen, write_position, virtual_width) | |
| # Copy over mouse handlers. | |
| self._copy_over_mouse_handlers( | |
| mouse_handlers, temp_mouse_handlers, write_position, virtual_width | |
| ) | |
| # Set screen.width/height. | |
| ypos = write_position.ypos | |
| xpos = write_position.xpos | |
| screen.width = max(screen.width, xpos + virtual_width) | |
| screen.height = max(screen.height, ypos + write_position.height) | |
| # Copy over window write positions. | |
| self._copy_over_write_positions(screen, temp_screen, write_position) | |
| if temp_screen.show_cursor: | |
| screen.show_cursor = True | |
| # Copy over cursor positions, if they are visible. | |
| for window, point in temp_screen.cursor_positions.items(): | |
| if ( | |
| 0 <= point.x < write_position.width | |
| and self.vertical_scroll | |
| <= point.y | |
| < write_position.height + self.vertical_scroll | |
| ): | |
| screen.cursor_positions[window] = Point( | |
| x=point.x + xpos, y=point.y + ypos - self.vertical_scroll | |
| ) | |
| # Copy over menu positions, but clip them to the visible area. | |
| for window, point in temp_screen.menu_positions.items(): | |
| screen.menu_positions[window] = self._clip_point_to_visible_area( | |
| Point(x=point.x + xpos, y=point.y + ypos - self.vertical_scroll), | |
| write_position, | |
| ) | |
| # Draw scrollbar. | |
| if show_scrollbar: | |
| self._draw_scrollbar( | |
| write_position, | |
| virtual_height, | |
| screen, | |
| ) | |
| def _clip_point_to_visible_area( | |
| self, point: Point, write_position: WritePosition | |
| ) -> Point: | |
| """ | |
| Ensure that the cursor and menu positions always are always reported | |
| """ | |
| if point.x < write_position.xpos: | |
| point = point._replace(x=write_position.xpos) | |
| if point.y < write_position.ypos: | |
| point = point._replace(y=write_position.ypos) | |
| if point.x >= write_position.xpos + write_position.width: | |
| point = point._replace(x=write_position.xpos + write_position.width - 1) | |
| if point.y >= write_position.ypos + write_position.height: | |
| point = point._replace(y=write_position.ypos + write_position.height - 1) | |
| return point | |
| def _copy_over_screen( | |
| self, | |
| screen: Screen, | |
| temp_screen: Screen, | |
| write_position: WritePosition, | |
| virtual_width: int, | |
| ) -> None: | |
| """ | |
| Copy over visible screen content and "zero width escape sequences". | |
| """ | |
| ypos = write_position.ypos | |
| xpos = write_position.xpos | |
| for y in range(write_position.height): | |
| temp_row = temp_screen.data_buffer[y + self.vertical_scroll] | |
| row = screen.data_buffer[y + ypos] | |
| temp_zero_width_escapes = temp_screen.zero_width_escapes[ | |
| y + self.vertical_scroll | |
| ] | |
| zero_width_escapes = screen.zero_width_escapes[y + ypos] | |
| for x in range(virtual_width): | |
| row[x + xpos] = temp_row[x] | |
| if x in temp_zero_width_escapes: | |
| zero_width_escapes[x + xpos] = temp_zero_width_escapes[x] | |
| def _copy_over_mouse_handlers( | |
| self, | |
| mouse_handlers: MouseHandlers, | |
| temp_mouse_handlers: MouseHandlers, | |
| write_position: WritePosition, | |
| virtual_width: int, | |
| ) -> None: | |
| """ | |
| Copy over mouse handlers from virtual screen to real screen. | |
| Note: we take `virtual_width` because we don't want to copy over mouse | |
| handlers that we possibly have behind the scrollbar. | |
| """ | |
| ypos = write_position.ypos | |
| xpos = write_position.xpos | |
| # Cache mouse handlers when wrapping them. Very often the same mouse | |
| # handler is registered for many positions. | |
| mouse_handler_wrappers: dict[MouseHandler, MouseHandler] = {} | |
| def wrap_mouse_handler(handler: MouseHandler) -> MouseHandler: | |
| "Wrap mouse handler. Translate coordinates in `MouseEvent`." | |
| if handler not in mouse_handler_wrappers: | |
| def new_handler(event: MouseEvent) -> None: | |
| new_event = MouseEvent( | |
| position=Point( | |
| x=event.position.x - xpos, | |
| y=event.position.y + self.vertical_scroll - ypos, | |
| ), | |
| event_type=event.event_type, | |
| button=event.button, | |
| modifiers=event.modifiers, | |
| ) | |
| handler(new_event) | |
| mouse_handler_wrappers[handler] = new_handler | |
| return mouse_handler_wrappers[handler] | |
| # Copy handlers. | |
| mouse_handlers_dict = mouse_handlers.mouse_handlers | |
| temp_mouse_handlers_dict = temp_mouse_handlers.mouse_handlers | |
| for y in range(write_position.height): | |
| if y in temp_mouse_handlers_dict: | |
| temp_mouse_row = temp_mouse_handlers_dict[y + self.vertical_scroll] | |
| mouse_row = mouse_handlers_dict[y + ypos] | |
| for x in range(virtual_width): | |
| if x in temp_mouse_row: | |
| mouse_row[x + xpos] = wrap_mouse_handler(temp_mouse_row[x]) | |
| def _copy_over_write_positions( | |
| self, screen: Screen, temp_screen: Screen, write_position: WritePosition | |
| ) -> None: | |
| """ | |
| Copy over window write positions. | |
| """ | |
| ypos = write_position.ypos | |
| xpos = write_position.xpos | |
| for win, write_pos in temp_screen.visible_windows_to_write_positions.items(): | |
| screen.visible_windows_to_write_positions[win] = WritePosition( | |
| xpos=write_pos.xpos + xpos, | |
| ypos=write_pos.ypos + ypos - self.vertical_scroll, | |
| # TODO: if the window is only partly visible, then truncate width/height. | |
| # This could be important if we have nested ScrollablePanes. | |
| height=write_pos.height, | |
| width=write_pos.width, | |
| ) | |
| def is_modal(self) -> bool: | |
| return self.content.is_modal() | |
| def get_key_bindings(self) -> KeyBindingsBase | None: | |
| return self.content.get_key_bindings() | |
| def get_children(self) -> list[Container]: | |
| return [self.content] | |
| def _make_window_visible( | |
| self, | |
| visible_height: int, | |
| virtual_height: int, | |
| visible_win_write_pos: WritePosition, | |
| cursor_position: Point | None, | |
| ) -> None: | |
| """ | |
| Scroll the scrollable pane, so that this window becomes visible. | |
| :param visible_height: Height of this `ScrollablePane` that is rendered. | |
| :param virtual_height: Height of the virtual, temp screen. | |
| :param visible_win_write_pos: `WritePosition` of the nested window on the | |
| temp screen. | |
| :param cursor_position: The location of the cursor position of this | |
| window on the temp screen. | |
| """ | |
| # Start with maximum allowed scroll range, and then reduce according to | |
| # the focused window and cursor position. | |
| min_scroll = 0 | |
| max_scroll = virtual_height - visible_height | |
| if self.keep_cursor_visible(): | |
| # Reduce min/max scroll according to the cursor in the focused window. | |
| if cursor_position is not None: | |
| offsets = self.scroll_offsets | |
| cpos_min_scroll = ( | |
| cursor_position.y - visible_height + 1 + offsets.bottom | |
| ) | |
| cpos_max_scroll = cursor_position.y - offsets.top | |
| min_scroll = max(min_scroll, cpos_min_scroll) | |
| max_scroll = max(0, min(max_scroll, cpos_max_scroll)) | |
| if self.keep_focused_window_visible(): | |
| # Reduce min/max scroll according to focused window position. | |
| # If the window is small enough, bot the top and bottom of the window | |
| # should be visible. | |
| if visible_win_write_pos.height <= visible_height: | |
| window_min_scroll = ( | |
| visible_win_write_pos.ypos | |
| + visible_win_write_pos.height | |
| - visible_height | |
| ) | |
| window_max_scroll = visible_win_write_pos.ypos | |
| else: | |
| # Window does not fit on the screen. Make sure at least the whole | |
| # screen is occupied with this window, and nothing else is shown. | |
| window_min_scroll = visible_win_write_pos.ypos | |
| window_max_scroll = ( | |
| visible_win_write_pos.ypos | |
| + visible_win_write_pos.height | |
| - visible_height | |
| ) | |
| min_scroll = max(min_scroll, window_min_scroll) | |
| max_scroll = min(max_scroll, window_max_scroll) | |
| if min_scroll > max_scroll: | |
| min_scroll = max_scroll # Should not happen. | |
| # Finally, properly clip the vertical scroll. | |
| if self.vertical_scroll > max_scroll: | |
| self.vertical_scroll = max_scroll | |
| if self.vertical_scroll < min_scroll: | |
| self.vertical_scroll = min_scroll | |
| def _draw_scrollbar( | |
| self, write_position: WritePosition, content_height: int, screen: Screen | |
| ) -> None: | |
| """ | |
| Draw the scrollbar on the screen. | |
| Note: There is some code duplication with the `ScrollbarMargin` | |
| implementation. | |
| """ | |
| window_height = write_position.height | |
| display_arrows = self.display_arrows() | |
| if display_arrows: | |
| window_height -= 2 | |
| try: | |
| fraction_visible = write_position.height / float(content_height) | |
| fraction_above = self.vertical_scroll / float(content_height) | |
| scrollbar_height = int( | |
| min(window_height, max(1, window_height * fraction_visible)) | |
| ) | |
| scrollbar_top = int(window_height * fraction_above) | |
| except ZeroDivisionError: | |
| return | |
| else: | |
| def is_scroll_button(row: int) -> bool: | |
| "True if we should display a button on this row." | |
| return scrollbar_top <= row <= scrollbar_top + scrollbar_height | |
| xpos = write_position.xpos + write_position.width - 1 | |
| ypos = write_position.ypos | |
| data_buffer = screen.data_buffer | |
| # Up arrow. | |
| if display_arrows: | |
| data_buffer[ypos][xpos] = Char( | |
| self.up_arrow_symbol, "class:scrollbar.arrow" | |
| ) | |
| ypos += 1 | |
| # Scrollbar body. | |
| scrollbar_background = "class:scrollbar.background" | |
| scrollbar_background_start = "class:scrollbar.background,scrollbar.start" | |
| scrollbar_button = "class:scrollbar.button" | |
| scrollbar_button_end = "class:scrollbar.button,scrollbar.end" | |
| for i in range(window_height): | |
| style = "" | |
| if is_scroll_button(i): | |
| if not is_scroll_button(i + 1): | |
| # Give the last cell a different style, because we want | |
| # to underline this. | |
| style = scrollbar_button_end | |
| else: | |
| style = scrollbar_button | |
| else: | |
| if is_scroll_button(i + 1): | |
| style = scrollbar_background_start | |
| else: | |
| style = scrollbar_background | |
| data_buffer[ypos][xpos] = Char(" ", style) | |
| ypos += 1 | |
| # Down arrow | |
| if display_arrows: | |
| data_buffer[ypos][xpos] = Char( | |
| self.down_arrow_symbol, "class:scrollbar.arrow" | |
| ) | |