| | 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"] |
| |
|
| | |
| | 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) |
| |
|
| | |
| | |
| | content_width = self.content.preferred_width(max_available_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) |
| |
|
| | |
| | |
| | if self.show_scrollbar(): |
| | |
| | width -= 1 |
| |
|
| | dimension = self.content.preferred_height(width, self.max_available_height) |
| |
|
| | |
| | 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 |
| |
|
| | |
| | virtual_height = self.content.preferred_height( |
| | virtual_width, self.max_available_height |
| | ).preferred |
| |
|
| | |
| | virtual_height = max(virtual_height, write_position.height) |
| | virtual_height = min(virtual_height, self.max_available_height) |
| |
|
| | |
| | |
| | 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() |
| |
|
| | |
| | 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 |
| | else: |
| | |
| | self._make_window_visible( |
| | write_position.height, |
| | virtual_height, |
| | visible_win_write_pos, |
| | temp_screen.cursor_positions.get(focused_window), |
| | ) |
| |
|
| | |
| | self._copy_over_screen(screen, temp_screen, write_position, virtual_width) |
| |
|
| | |
| | self._copy_over_mouse_handlers( |
| | mouse_handlers, temp_mouse_handlers, write_position, virtual_width |
| | ) |
| |
|
| | |
| | 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) |
| |
|
| | |
| | self._copy_over_write_positions(screen, temp_screen, write_position) |
| |
|
| | if temp_screen.show_cursor: |
| | screen.show_cursor = True |
| |
|
| | |
| | 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 |
| | ) |
| |
|
| | |
| | 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, |
| | ) |
| |
|
| | |
| | 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 |
| |
|
| | |
| | |
| | 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] |
| |
|
| | |
| | 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, |
| | |
| | |
| | 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. |
| | """ |
| | |
| | |
| | min_scroll = 0 |
| | max_scroll = virtual_height - visible_height |
| |
|
| | if self.keep_cursor_visible(): |
| | |
| | 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(): |
| | |
| | |
| | |
| | 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_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 |
| |
|
| | |
| | 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 |
| |
|
| | |
| | if display_arrows: |
| | data_buffer[ypos][xpos] = Char( |
| | self.up_arrow_symbol, "class:scrollbar.arrow" |
| | ) |
| | ypos += 1 |
| |
|
| | |
| | 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): |
| | |
| | |
| | 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 |
| |
|
| | |
| | if display_arrows: |
| | data_buffer[ypos][xpos] = Char( |
| | self.down_arrow_symbol, "class:scrollbar.arrow" |
| | ) |
| |
|