1from __future__ import annotations 
    2 
    3from prompt_toolkit.data_structures import Point 
    4from prompt_toolkit.filters import FilterOrBool, to_filter 
    5from prompt_toolkit.key_binding import KeyBindingsBase 
    6from prompt_toolkit.mouse_events import MouseEvent 
    7 
    8from .containers import Container, ScrollOffsets 
    9from .dimension import AnyDimension, Dimension, sum_layout_dimensions, to_dimension 
    10from .mouse_handlers import MouseHandler, MouseHandlers 
    11from .screen import Char, Screen, WritePosition 
    12 
    13__all__ = ["ScrollablePane"] 
    14 
    15# Never go beyond this height, because performance will degrade. 
    16MAX_AVAILABLE_HEIGHT = 10_000 
    17 
    18 
    19class ScrollablePane(Container): 
    20    """ 
    21    Container widget that exposes a larger virtual screen to its content and 
    22    displays it in a vertical scrollbale region. 
    23 
    24    Typically this is wrapped in a large `HSplit` container. Make sure in that 
    25    case to not specify a `height` dimension of the `HSplit`, so that it will 
    26    scale according to the content. 
    27 
    28    .. note:: 
    29 
    30        If you want to display a completion menu for widgets in this 
    31        `ScrollablePane`, then it's still a good practice to use a 
    32        `FloatContainer` with a `CompletionsMenu` in a `Float` at the top-level 
    33        of the layout hierarchy, rather then nesting a `FloatContainer` in this 
    34        `ScrollablePane`. (Otherwise, it's possible that the completion menu 
    35        is clipped.) 
    36 
    37    :param content: The content container. 
    38    :param scrolloffset: Try to keep the cursor within this distance from the 
    39        top/bottom (left/right offset is not used). 
    40    :param keep_cursor_visible: When `True`, automatically scroll the pane so 
    41        that the cursor (of the focused window) is always visible. 
    42    :param keep_focused_window_visible: When `True`, automatically scroll the 
    43        pane so that the focused window is visible, or as much visible as 
    44        possible if it doesn't completely fit the screen. 
    45    :param max_available_height: Always constraint the height to this amount 
    46        for performance reasons. 
    47    :param width: When given, use this width instead of looking at the children. 
    48    :param height: When given, use this height instead of looking at the children. 
    49    :param show_scrollbar: When `True` display a scrollbar on the right. 
    50    """ 
    51 
    52    def __init__( 
    53        self, 
    54        content: Container, 
    55        scroll_offsets: ScrollOffsets | None = None, 
    56        keep_cursor_visible: FilterOrBool = True, 
    57        keep_focused_window_visible: FilterOrBool = True, 
    58        max_available_height: int = MAX_AVAILABLE_HEIGHT, 
    59        width: AnyDimension = None, 
    60        height: AnyDimension = None, 
    61        show_scrollbar: FilterOrBool = True, 
    62        display_arrows: FilterOrBool = True, 
    63        up_arrow_symbol: str = "^", 
    64        down_arrow_symbol: str = "v", 
    65    ) -> None: 
    66        self.content = content 
    67        self.scroll_offsets = scroll_offsets or ScrollOffsets(top=1, bottom=1) 
    68        self.keep_cursor_visible = to_filter(keep_cursor_visible) 
    69        self.keep_focused_window_visible = to_filter(keep_focused_window_visible) 
    70        self.max_available_height = max_available_height 
    71        self.width = width 
    72        self.height = height 
    73        self.show_scrollbar = to_filter(show_scrollbar) 
    74        self.display_arrows = to_filter(display_arrows) 
    75        self.up_arrow_symbol = up_arrow_symbol 
    76        self.down_arrow_symbol = down_arrow_symbol 
    77 
    78        self.vertical_scroll = 0 
    79 
    80    def __repr__(self) -> str: 
    81        return f"ScrollablePane({self.content!r})" 
    82 
    83    def reset(self) -> None: 
    84        self.content.reset() 
    85 
    86    def preferred_width(self, max_available_width: int) -> Dimension: 
    87        if self.width is not None: 
    88            return to_dimension(self.width) 
    89 
    90        # We're only scrolling vertical. So the preferred width is equal to 
    91        # that of the content. 
    92        content_width = self.content.preferred_width(max_available_width) 
    93 
    94        # If a scrollbar needs to be displayed, add +1 to the content width. 
    95        if self.show_scrollbar(): 
    96            return sum_layout_dimensions([Dimension.exact(1), content_width]) 
    97 
    98        return content_width 
    99 
    100    def preferred_height(self, width: int, max_available_height: int) -> Dimension: 
    101        if self.height is not None: 
    102            return to_dimension(self.height) 
    103 
    104        # Prefer a height large enough so that it fits all the content. If not, 
    105        # we'll make the pane scrollable. 
    106        if self.show_scrollbar(): 
    107            # If `show_scrollbar` is set. Always reserve space for the scrollbar. 
    108            width -= 1 
    109 
    110        dimension = self.content.preferred_height(width, self.max_available_height) 
    111 
    112        # Only take 'preferred' into account. Min/max can be anything. 
    113        return Dimension(min=0, preferred=dimension.preferred) 
    114 
    115    def write_to_screen( 
    116        self, 
    117        screen: Screen, 
    118        mouse_handlers: MouseHandlers, 
    119        write_position: WritePosition, 
    120        parent_style: str, 
    121        erase_bg: bool, 
    122        z_index: int | None, 
    123    ) -> None: 
    124        """ 
    125        Render scrollable pane content. 
    126 
    127        This works by rendering on an off-screen canvas, and copying over the 
    128        visible region. 
    129        """ 
    130        show_scrollbar = self.show_scrollbar() 
    131 
    132        if show_scrollbar: 
    133            virtual_width = write_position.width - 1 
    134        else: 
    135            virtual_width = write_position.width 
    136 
    137        # Compute preferred height again. 
    138        virtual_height = self.content.preferred_height( 
    139            virtual_width, self.max_available_height 
    140        ).preferred 
    141 
    142        # Ensure virtual height is at least the available height. 
    143        virtual_height = max(virtual_height, write_position.height) 
    144        virtual_height = min(virtual_height, self.max_available_height) 
    145 
    146        # First, write the content to a virtual screen, then copy over the 
    147        # visible part to the real screen. 
    148        temp_screen = Screen(default_char=Char(char=" ", style=parent_style)) 
    149        temp_screen.show_cursor = screen.show_cursor 
    150        temp_write_position = WritePosition( 
    151            xpos=0, ypos=0, width=virtual_width, height=virtual_height 
    152        ) 
    153 
    154        temp_mouse_handlers = MouseHandlers() 
    155 
    156        self.content.write_to_screen( 
    157            temp_screen, 
    158            temp_mouse_handlers, 
    159            temp_write_position, 
    160            parent_style, 
    161            erase_bg, 
    162            z_index, 
    163        ) 
    164        temp_screen.draw_all_floats() 
    165 
    166        # If anything in the virtual screen is focused, move vertical scroll to 
    167        from prompt_toolkit.application import get_app 
    168 
    169        focused_window = get_app().layout.current_window 
    170 
    171        try: 
    172            visible_win_write_pos = temp_screen.visible_windows_to_write_positions[ 
    173                focused_window 
    174            ] 
    175        except KeyError: 
    176            pass  # No window focused here. Don't scroll. 
    177        else: 
    178            # Make sure this window is visible. 
    179            self._make_window_visible( 
    180                write_position.height, 
    181                virtual_height, 
    182                visible_win_write_pos, 
    183                temp_screen.cursor_positions.get(focused_window), 
    184            ) 
    185 
    186        # Copy over virtual screen and zero width escapes to real screen. 
    187        self._copy_over_screen(screen, temp_screen, write_position, virtual_width) 
    188 
    189        # Copy over mouse handlers. 
    190        self._copy_over_mouse_handlers( 
    191            mouse_handlers, temp_mouse_handlers, write_position, virtual_width 
    192        ) 
    193 
    194        # Set screen.width/height. 
    195        ypos = write_position.ypos 
    196        xpos = write_position.xpos 
    197 
    198        screen.width = max(screen.width, xpos + virtual_width) 
    199        screen.height = max(screen.height, ypos + write_position.height) 
    200 
    201        # Copy over window write positions. 
    202        self._copy_over_write_positions(screen, temp_screen, write_position) 
    203 
    204        if temp_screen.show_cursor: 
    205            screen.show_cursor = True 
    206 
    207        # Copy over cursor positions, if they are visible. 
    208        for window, point in temp_screen.cursor_positions.items(): 
    209            if ( 
    210                0 <= point.x < write_position.width 
    211                and self.vertical_scroll 
    212                <= point.y 
    213                < write_position.height + self.vertical_scroll 
    214            ): 
    215                screen.cursor_positions[window] = Point( 
    216                    x=point.x + xpos, y=point.y + ypos - self.vertical_scroll 
    217                ) 
    218 
    219        # Copy over menu positions, but clip them to the visible area. 
    220        for window, point in temp_screen.menu_positions.items(): 
    221            screen.menu_positions[window] = self._clip_point_to_visible_area( 
    222                Point(x=point.x + xpos, y=point.y + ypos - self.vertical_scroll), 
    223                write_position, 
    224            ) 
    225 
    226        # Draw scrollbar. 
    227        if show_scrollbar: 
    228            self._draw_scrollbar( 
    229                write_position, 
    230                virtual_height, 
    231                screen, 
    232            ) 
    233 
    234    def _clip_point_to_visible_area( 
    235        self, point: Point, write_position: WritePosition 
    236    ) -> Point: 
    237        """ 
    238        Ensure that the cursor and menu positions always are always reported 
    239        """ 
    240        if point.x < write_position.xpos: 
    241            point = point._replace(x=write_position.xpos) 
    242        if point.y < write_position.ypos: 
    243            point = point._replace(y=write_position.ypos) 
    244        if point.x >= write_position.xpos + write_position.width: 
    245            point = point._replace(x=write_position.xpos + write_position.width - 1) 
    246        if point.y >= write_position.ypos + write_position.height: 
    247            point = point._replace(y=write_position.ypos + write_position.height - 1) 
    248 
    249        return point 
    250 
    251    def _copy_over_screen( 
    252        self, 
    253        screen: Screen, 
    254        temp_screen: Screen, 
    255        write_position: WritePosition, 
    256        virtual_width: int, 
    257    ) -> None: 
    258        """ 
    259        Copy over visible screen content and "zero width escape sequences". 
    260        """ 
    261        ypos = write_position.ypos 
    262        xpos = write_position.xpos 
    263 
    264        for y in range(write_position.height): 
    265            temp_row = temp_screen.data_buffer[y + self.vertical_scroll] 
    266            row = screen.data_buffer[y + ypos] 
    267            temp_zero_width_escapes = temp_screen.zero_width_escapes[ 
    268                y + self.vertical_scroll 
    269            ] 
    270            zero_width_escapes = screen.zero_width_escapes[y + ypos] 
    271 
    272            for x in range(virtual_width): 
    273                row[x + xpos] = temp_row[x] 
    274 
    275                if x in temp_zero_width_escapes: 
    276                    zero_width_escapes[x + xpos] = temp_zero_width_escapes[x] 
    277 
    278    def _copy_over_mouse_handlers( 
    279        self, 
    280        mouse_handlers: MouseHandlers, 
    281        temp_mouse_handlers: MouseHandlers, 
    282        write_position: WritePosition, 
    283        virtual_width: int, 
    284    ) -> None: 
    285        """ 
    286        Copy over mouse handlers from virtual screen to real screen. 
    287 
    288        Note: we take `virtual_width` because we don't want to copy over mouse 
    289              handlers that we possibly have behind the scrollbar. 
    290        """ 
    291        ypos = write_position.ypos 
    292        xpos = write_position.xpos 
    293 
    294        # Cache mouse handlers when wrapping them. Very often the same mouse 
    295        # handler is registered for many positions. 
    296        mouse_handler_wrappers: dict[MouseHandler, MouseHandler] = {} 
    297 
    298        def wrap_mouse_handler(handler: MouseHandler) -> MouseHandler: 
    299            "Wrap mouse handler. Translate coordinates in `MouseEvent`." 
    300            if handler not in mouse_handler_wrappers: 
    301 
    302                def new_handler(event: MouseEvent) -> None: 
    303                    new_event = MouseEvent( 
    304                        position=Point( 
    305                            x=event.position.x - xpos, 
    306                            y=event.position.y + self.vertical_scroll - ypos, 
    307                        ), 
    308                        event_type=event.event_type, 
    309                        button=event.button, 
    310                        modifiers=event.modifiers, 
    311                    ) 
    312                    handler(new_event) 
    313 
    314                mouse_handler_wrappers[handler] = new_handler 
    315            return mouse_handler_wrappers[handler] 
    316 
    317        # Copy handlers. 
    318        mouse_handlers_dict = mouse_handlers.mouse_handlers 
    319        temp_mouse_handlers_dict = temp_mouse_handlers.mouse_handlers 
    320 
    321        for y in range(write_position.height): 
    322            if y in temp_mouse_handlers_dict: 
    323                temp_mouse_row = temp_mouse_handlers_dict[y + self.vertical_scroll] 
    324                mouse_row = mouse_handlers_dict[y + ypos] 
    325                for x in range(virtual_width): 
    326                    if x in temp_mouse_row: 
    327                        mouse_row[x + xpos] = wrap_mouse_handler(temp_mouse_row[x]) 
    328 
    329    def _copy_over_write_positions( 
    330        self, screen: Screen, temp_screen: Screen, write_position: WritePosition 
    331    ) -> None: 
    332        """ 
    333        Copy over window write positions. 
    334        """ 
    335        ypos = write_position.ypos 
    336        xpos = write_position.xpos 
    337 
    338        for win, write_pos in temp_screen.visible_windows_to_write_positions.items(): 
    339            screen.visible_windows_to_write_positions[win] = WritePosition( 
    340                xpos=write_pos.xpos + xpos, 
    341                ypos=write_pos.ypos + ypos - self.vertical_scroll, 
    342                # TODO: if the window is only partly visible, then truncate width/height. 
    343                #       This could be important if we have nested ScrollablePanes. 
    344                height=write_pos.height, 
    345                width=write_pos.width, 
    346            ) 
    347 
    348    def is_modal(self) -> bool: 
    349        return self.content.is_modal() 
    350 
    351    def get_key_bindings(self) -> KeyBindingsBase | None: 
    352        return self.content.get_key_bindings() 
    353 
    354    def get_children(self) -> list[Container]: 
    355        return [self.content] 
    356 
    357    def _make_window_visible( 
    358        self, 
    359        visible_height: int, 
    360        virtual_height: int, 
    361        visible_win_write_pos: WritePosition, 
    362        cursor_position: Point | None, 
    363    ) -> None: 
    364        """ 
    365        Scroll the scrollable pane, so that this window becomes visible. 
    366 
    367        :param visible_height: Height of this `ScrollablePane` that is rendered. 
    368        :param virtual_height: Height of the virtual, temp screen. 
    369        :param visible_win_write_pos: `WritePosition` of the nested window on the 
    370            temp screen. 
    371        :param cursor_position: The location of the cursor position of this 
    372            window on the temp screen. 
    373        """ 
    374        # Start with maximum allowed scroll range, and then reduce according to 
    375        # the focused window and cursor position. 
    376        min_scroll = 0 
    377        max_scroll = virtual_height - visible_height 
    378 
    379        if self.keep_cursor_visible(): 
    380            # Reduce min/max scroll according to the cursor in the focused window. 
    381            if cursor_position is not None: 
    382                offsets = self.scroll_offsets 
    383                cpos_min_scroll = ( 
    384                    cursor_position.y - visible_height + 1 + offsets.bottom 
    385                ) 
    386                cpos_max_scroll = cursor_position.y - offsets.top 
    387                min_scroll = max(min_scroll, cpos_min_scroll) 
    388                max_scroll = max(0, min(max_scroll, cpos_max_scroll)) 
    389 
    390        if self.keep_focused_window_visible(): 
    391            # Reduce min/max scroll according to focused window position. 
    392            # If the window is small enough, bot the top and bottom of the window 
    393            # should be visible. 
    394            if visible_win_write_pos.height <= visible_height: 
    395                window_min_scroll = ( 
    396                    visible_win_write_pos.ypos 
    397                    + visible_win_write_pos.height 
    398                    - visible_height 
    399                ) 
    400                window_max_scroll = visible_win_write_pos.ypos 
    401            else: 
    402                # Window does not fit on the screen. Make sure at least the whole 
    403                # screen is occupied with this window, and nothing else is shown. 
    404                window_min_scroll = visible_win_write_pos.ypos 
    405                window_max_scroll = ( 
    406                    visible_win_write_pos.ypos 
    407                    + visible_win_write_pos.height 
    408                    - visible_height 
    409                ) 
    410 
    411            min_scroll = max(min_scroll, window_min_scroll) 
    412            max_scroll = min(max_scroll, window_max_scroll) 
    413 
    414        if min_scroll > max_scroll: 
    415            min_scroll = max_scroll  # Should not happen. 
    416 
    417        # Finally, properly clip the vertical scroll. 
    418        if self.vertical_scroll > max_scroll: 
    419            self.vertical_scroll = max_scroll 
    420        if self.vertical_scroll < min_scroll: 
    421            self.vertical_scroll = min_scroll 
    422 
    423    def _draw_scrollbar( 
    424        self, write_position: WritePosition, content_height: int, screen: Screen 
    425    ) -> None: 
    426        """ 
    427        Draw the scrollbar on the screen. 
    428 
    429        Note: There is some code duplication with the `ScrollbarMargin` 
    430              implementation. 
    431        """ 
    432 
    433        window_height = write_position.height 
    434        display_arrows = self.display_arrows() 
    435 
    436        if display_arrows: 
    437            window_height -= 2 
    438 
    439        try: 
    440            fraction_visible = write_position.height / float(content_height) 
    441            fraction_above = self.vertical_scroll / float(content_height) 
    442 
    443            scrollbar_height = int( 
    444                min(window_height, max(1, window_height * fraction_visible)) 
    445            ) 
    446            scrollbar_top = int(window_height * fraction_above) 
    447        except ZeroDivisionError: 
    448            return 
    449        else: 
    450 
    451            def is_scroll_button(row: int) -> bool: 
    452                "True if we should display a button on this row." 
    453                return scrollbar_top <= row <= scrollbar_top + scrollbar_height 
    454 
    455            xpos = write_position.xpos + write_position.width - 1 
    456            ypos = write_position.ypos 
    457            data_buffer = screen.data_buffer 
    458 
    459            # Up arrow. 
    460            if display_arrows: 
    461                data_buffer[ypos][xpos] = Char( 
    462                    self.up_arrow_symbol, "class:scrollbar.arrow" 
    463                ) 
    464                ypos += 1 
    465 
    466            # Scrollbar body. 
    467            scrollbar_background = "class:scrollbar.background" 
    468            scrollbar_background_start = "class:scrollbar.background,scrollbar.start" 
    469            scrollbar_button = "class:scrollbar.button" 
    470            scrollbar_button_end = "class:scrollbar.button,scrollbar.end" 
    471 
    472            for i in range(window_height): 
    473                style = "" 
    474                if is_scroll_button(i): 
    475                    if not is_scroll_button(i + 1): 
    476                        # Give the last cell a different style, because we want 
    477                        # to underline this. 
    478                        style = scrollbar_button_end 
    479                    else: 
    480                        style = scrollbar_button 
    481                else: 
    482                    if is_scroll_button(i + 1): 
    483                        style = scrollbar_background_start 
    484                    else: 
    485                        style = scrollbar_background 
    486 
    487                data_buffer[ypos][xpos] = Char(" ", style) 
    488                ypos += 1 
    489 
    490            # Down arrow 
    491            if display_arrows: 
    492                data_buffer[ypos][xpos] = Char( 
    493                    self.down_arrow_symbol, "class:scrollbar.arrow" 
    494                )