summaryrefslogtreecommitdiffstats
path: root/src/prompt_toolkit/layout/scrollable_pane.py
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-04 17:35:20 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-04 17:35:20 +0000
commite106bf94eff07d9a59771d9ccc4406421e18ab64 (patch)
treeedb6545500e39df9c67aa918a6125bffc8ec1aee /src/prompt_toolkit/layout/scrollable_pane.py
parentInitial commit. (diff)
downloadprompt-toolkit-upstream.tar.xz
prompt-toolkit-upstream.zip
Adding upstream version 3.0.36.upstream/3.0.36upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/prompt_toolkit/layout/scrollable_pane.py')
-rw-r--r--src/prompt_toolkit/layout/scrollable_pane.py494
1 files changed, 494 insertions, 0 deletions
diff --git a/src/prompt_toolkit/layout/scrollable_pane.py b/src/prompt_toolkit/layout/scrollable_pane.py
new file mode 100644
index 0000000..810e405
--- /dev/null
+++ b/src/prompt_toolkit/layout/scrollable_pane.py
@@ -0,0 +1,494 @@
+from typing import Dict, List, Optional
+
+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 th e
+ pane so that the focused window is visible, or as much visible as
+ possible if it doen'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: Optional[ScrollOffsets] = 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: Optional[int],
+ ) -> 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) -> Optional[KeyBindingsBase]:
+ 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: Optional[Point],
+ ) -> 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"
+ )