|
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"] |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
content_width = self.content.preferred_width(max_available_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) |
|
|
|
|
|
|
|
if self.show_scrollbar(): |
|
|
|
width -= 1 |
|
|
|
dimension = self.content.preferred_height(width, self.max_available_height) |
|
|
|
|
|
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 |
|
|
|
|
|
virtual_height = self.content.preferred_height( |
|
virtual_width, self.max_available_height |
|
).preferred |
|
|
|
|
|
virtual_height = max(virtual_height, write_position.height) |
|
virtual_height = min(virtual_height, self.max_available_height) |
|
|
|
|
|
|
|
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() |
|
|
|
|
|
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 |
|
else: |
|
|
|
self._make_window_visible( |
|
write_position.height, |
|
virtual_height, |
|
visible_win_write_pos, |
|
temp_screen.cursor_positions.get(focused_window), |
|
) |
|
|
|
|
|
self._copy_over_screen(screen, temp_screen, write_position, virtual_width) |
|
|
|
|
|
self._copy_over_mouse_handlers( |
|
mouse_handlers, temp_mouse_handlers, write_position, virtual_width |
|
) |
|
|
|
|
|
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) |
|
|
|
|
|
self._copy_over_write_positions(screen, temp_screen, write_position) |
|
|
|
if temp_screen.show_cursor: |
|
screen.show_cursor = True |
|
|
|
|
|
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 |
|
) |
|
|
|
|
|
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, |
|
) |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
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] |
|
|
|
|
|
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, |
|
|
|
|
|
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. |
|
""" |
|
|
|
|
|
min_scroll = 0 |
|
max_scroll = virtual_height - visible_height |
|
|
|
if self.keep_cursor_visible(): |
|
|
|
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(): |
|
|
|
|
|
|
|
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_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 |
|
|
|
|
|
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 |
|
|
|
|
|
if display_arrows: |
|
data_buffer[ypos][xpos] = Char( |
|
self.up_arrow_symbol, "class:scrollbar.arrow" |
|
) |
|
ypos += 1 |
|
|
|
|
|
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): |
|
|
|
|
|
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 |
|
|
|
|
|
if display_arrows: |
|
data_buffer[ypos][xpos] = Char( |
|
self.down_arrow_symbol, "class:scrollbar.arrow" |
|
) |
|
|