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