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

1from __future__ import annotations 

2 

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 

7 

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 

12 

13__all__ = ["ScrollablePane"] 

14 

15# Never go beyond this height, because performance will degrade. 

16MAX_AVAILABLE_HEIGHT = 10_000 

17 

18 

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. 

23 

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. 

27 

28 .. note:: 

29 

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

36 

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

51 

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 

77 

78 self.vertical_scroll = 0 

79 

80 def __repr__(self) -> str: 

81 return f"ScrollablePane({self.content!r})" 

82 

83 def reset(self) -> None: 

84 self.content.reset() 

85 

86 def preferred_width(self, max_available_width: int) -> Dimension: 

87 if self.width is not None: 

88 return to_dimension(self.width) 

89 

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) 

93 

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

97 

98 return content_width 

99 

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) 

103 

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 

109 

110 dimension = self.content.preferred_height(width, self.max_available_height) 

111 

112 # Only take 'preferred' into account. Min/max can be anything. 

113 return Dimension(min=0, preferred=dimension.preferred) 

114 

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. 

126 

127 This works by rendering on an off-screen canvas, and copying over the 

128 visible region. 

129 """ 

130 show_scrollbar = self.show_scrollbar() 

131 

132 if show_scrollbar: 

133 virtual_width = write_position.width - 1 

134 else: 

135 virtual_width = write_position.width 

136 

137 # Compute preferred height again. 

138 virtual_height = self.content.preferred_height( 

139 virtual_width, self.max_available_height 

140 ).preferred 

141 

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) 

145 

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 ) 

153 

154 temp_mouse_handlers = MouseHandlers() 

155 

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

165 

166 # If anything in the virtual screen is focused, move vertical scroll to 

167 from prompt_toolkit.application import get_app 

168 

169 focused_window = get_app().layout.current_window 

170 

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 ) 

185 

186 # Copy over virtual screen and zero width escapes to real screen. 

187 self._copy_over_screen(screen, temp_screen, write_position, virtual_width) 

188 

189 # Copy over mouse handlers. 

190 self._copy_over_mouse_handlers( 

191 mouse_handlers, temp_mouse_handlers, write_position, virtual_width 

192 ) 

193 

194 # Set screen.width/height. 

195 ypos = write_position.ypos 

196 xpos = write_position.xpos 

197 

198 screen.width = max(screen.width, xpos + virtual_width) 

199 screen.height = max(screen.height, ypos + write_position.height) 

200 

201 # Copy over window write positions. 

202 self._copy_over_write_positions(screen, temp_screen, write_position) 

203 

204 if temp_screen.show_cursor: 

205 screen.show_cursor = True 

206 

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 ) 

218 

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 ) 

225 

226 # Draw scrollbar. 

227 if show_scrollbar: 

228 self._draw_scrollbar( 

229 write_position, 

230 virtual_height, 

231 screen, 

232 ) 

233 

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) 

248 

249 return point 

250 

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 

263 

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] 

271 

272 for x in range(virtual_width): 

273 row[x + xpos] = temp_row[x] 

274 

275 if x in temp_zero_width_escapes: 

276 zero_width_escapes[x + xpos] = temp_zero_width_escapes[x] 

277 

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. 

287 

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 

293 

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] = {} 

297 

298 def wrap_mouse_handler(handler: MouseHandler) -> MouseHandler: 

299 "Wrap mouse handler. Translate coordinates in `MouseEvent`." 

300 if handler not in mouse_handler_wrappers: 

301 

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) 

313 

314 mouse_handler_wrappers[handler] = new_handler 

315 return mouse_handler_wrappers[handler] 

316 

317 # Copy handlers. 

318 mouse_handlers_dict = mouse_handlers.mouse_handlers 

319 temp_mouse_handlers_dict = temp_mouse_handlers.mouse_handlers 

320 

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

328 

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 

337 

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 ) 

347 

348 def is_modal(self) -> bool: 

349 return self.content.is_modal() 

350 

351 def get_key_bindings(self) -> KeyBindingsBase | None: 

352 return self.content.get_key_bindings() 

353 

354 def get_children(self) -> list[Container]: 

355 return [self.content] 

356 

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. 

366 

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 

378 

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

389 

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 ) 

410 

411 min_scroll = max(min_scroll, window_min_scroll) 

412 max_scroll = min(max_scroll, window_max_scroll) 

413 

414 if min_scroll > max_scroll: 

415 min_scroll = max_scroll # Should not happen. 

416 

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 

422 

423 def _draw_scrollbar( 

424 self, write_position: WritePosition, content_height: int, screen: Screen 

425 ) -> None: 

426 """ 

427 Draw the scrollbar on the screen. 

428 

429 Note: There is some code duplication with the `ScrollbarMargin` 

430 implementation. 

431 """ 

432 

433 window_height = write_position.height 

434 display_arrows = self.display_arrows() 

435 

436 if display_arrows: 

437 window_height -= 2 

438 

439 try: 

440 fraction_visible = write_position.height / float(content_height) 

441 fraction_above = self.vertical_scroll / float(content_height) 

442 

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: 

450 

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 

454 

455 xpos = write_position.xpos + write_position.width - 1 

456 ypos = write_position.ypos 

457 data_buffer = screen.data_buffer 

458 

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 

465 

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" 

471 

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 

486 

487 data_buffer[ypos][xpos] = Char(" ", style) 

488 ypos += 1 

489 

490 # Down arrow 

491 if display_arrows: 

492 data_buffer[ypos][xpos] = Char( 

493 self.down_arrow_symbol, "class:scrollbar.arrow" 

494 )