AIM-PIbd-32-Kurbanova-A-A/aimenv/Lib/site-packages/prompt_toolkit/layout/scrollable_pane.py
2024-10-02 22:15:59 +04:00

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