123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- 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"
- )
|