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