You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			495 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			495 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Python
		
	
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"
 | 
						|
                )
 |