scrollable_pane.py 19 KB

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