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

1from __future__ import annotations 

2 

3from typing import Dict, List, Optional 

4 

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 

9 

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 

14 

15__all__ = ["ScrollablePane"] 

16 

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

18MAX_AVAILABLE_HEIGHT = 10_000 

19 

20 

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. 

25 

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. 

29 

30 .. note:: 

31 

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

38 

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

53 

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 

79 

80 self.vertical_scroll = 0 

81 

82 def __repr__(self) -> str: 

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

84 

85 def reset(self) -> None: 

86 self.content.reset() 

87 

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

89 if self.width is not None: 

90 return to_dimension(self.width) 

91 

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) 

95 

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

99 

100 return content_width 

101 

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) 

105 

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 

111 

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

113 

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

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

116 

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. 

128 

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

130 visible region. 

131 """ 

132 show_scrollbar = self.show_scrollbar() 

133 

134 if show_scrollbar: 

135 virtual_width = write_position.width - 1 

136 else: 

137 virtual_width = write_position.width 

138 

139 # Compute preferred height again. 

140 virtual_height = self.content.preferred_height( 

141 virtual_width, self.max_available_height 

142 ).preferred 

143 

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) 

147 

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 ) 

155 

156 temp_mouse_handlers = MouseHandlers() 

157 

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

167 

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

169 from prompt_toolkit.application import get_app 

170 

171 focused_window = get_app().layout.current_window 

172 

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 ) 

187 

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

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

190 

191 # Copy over mouse handlers. 

192 self._copy_over_mouse_handlers( 

193 mouse_handlers, temp_mouse_handlers, write_position, virtual_width 

194 ) 

195 

196 # Set screen.width/height. 

197 ypos = write_position.ypos 

198 xpos = write_position.xpos 

199 

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

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

202 

203 # Copy over window write positions. 

204 self._copy_over_write_positions(screen, temp_screen, write_position) 

205 

206 if temp_screen.show_cursor: 

207 screen.show_cursor = True 

208 

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 ) 

220 

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 ) 

227 

228 # Draw scrollbar. 

229 if show_scrollbar: 

230 self._draw_scrollbar( 

231 write_position, 

232 virtual_height, 

233 screen, 

234 ) 

235 

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) 

250 

251 return point 

252 

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 

265 

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] 

273 

274 for x in range(virtual_width): 

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

276 

277 if x in temp_zero_width_escapes: 

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

279 

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. 

289 

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 

295 

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

299 

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

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

302 if handler not in mouse_handler_wrappers: 

303 

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) 

315 

316 mouse_handler_wrappers[handler] = new_handler 

317 return mouse_handler_wrappers[handler] 

318 

319 # Copy handlers. 

320 mouse_handlers_dict = mouse_handlers.mouse_handlers 

321 temp_mouse_handlers_dict = temp_mouse_handlers.mouse_handlers 

322 

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

330 

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 

339 

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 ) 

349 

350 def is_modal(self) -> bool: 

351 return self.content.is_modal() 

352 

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

354 return self.content.get_key_bindings() 

355 

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

357 return [self.content] 

358 

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. 

368 

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 

380 

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

391 

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 ) 

412 

413 min_scroll = max(min_scroll, window_min_scroll) 

414 max_scroll = min(max_scroll, window_max_scroll) 

415 

416 if min_scroll > max_scroll: 

417 min_scroll = max_scroll # Should not happen. 

418 

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 

424 

425 def _draw_scrollbar( 

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

427 ) -> None: 

428 """ 

429 Draw the scrollbar on the screen. 

430 

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

432 implementation. 

433 """ 

434 

435 window_height = write_position.height 

436 display_arrows = self.display_arrows() 

437 

438 if display_arrows: 

439 window_height -= 2 

440 

441 try: 

442 fraction_visible = write_position.height / float(content_height) 

443 fraction_above = self.vertical_scroll / float(content_height) 

444 

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: 

452 

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 

456 

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

458 ypos = write_position.ypos 

459 data_buffer = screen.data_buffer 

460 

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 

467 

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" 

473 

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 

488 

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

490 ypos += 1 

491 

492 # Down arrow 

493 if display_arrows: 

494 data_buffer[ypos][xpos] = Char( 

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

496 )