Skip to content

Commit a9dc22d

Browse files
Added scrollable pane.
1 parent 9d9ab1d commit a9dc22d

File tree

5 files changed

+328
-3
lines changed

5 files changed

+328
-3
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env python
2+
"""
3+
A simple example of a scrollable pane.
4+
"""
5+
from prompt_toolkit.application import Application
6+
from prompt_toolkit.application.current import get_app
7+
from prompt_toolkit.key_binding import KeyBindings
8+
from prompt_toolkit.layout import HSplit, Layout, ScrollablePane
9+
from prompt_toolkit.widgets import Frame, Label
10+
11+
12+
# Event handlers for all the buttons.
13+
14+
15+
def exit(event) -> None:
16+
get_app().exit()
17+
18+
19+
# Combine all the widgets in a UI.
20+
# The `Box` object ensures that padding will be inserted around the containing
21+
# widget. It adapts automatically, unless an explicit `padding` amount is given.
22+
root_container = Frame(
23+
ScrollablePane(HSplit([Frame(Label(text=f"label-{i}")) for i in range(20)]))
24+
)
25+
26+
layout = Layout(container=root_container)
27+
28+
29+
# Key bindings.
30+
kb = KeyBindings()
31+
kb.add("c-c")(exit)
32+
33+
34+
# Build a main application object.
35+
application = Application(layout=layout, key_bindings=kb, full_screen=True)
36+
37+
38+
def main():
39+
application.run()
40+
41+
42+
if __name__ == "__main__":
43+
main()

prompt_toolkit/layout/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
ScrollbarMargin,
9191
)
9292
from .menus import CompletionsMenu, MultiColumnCompletionsMenu
93+
from .scrollable_pane import ScrollablePane
9394

9495
__all__ = [
9596
# Layout.
@@ -123,6 +124,7 @@
123124
"to_window",
124125
"is_container",
125126
"DynamicContainer",
127+
"ScrollablePane",
126128
# Controls.
127129
"BufferControl",
128130
"SearchBufferControl",

prompt_toolkit/layout/containers.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1897,8 +1897,9 @@ def render_margin(m: Margin, width: int) -> UIContent:
18971897
# Apply 'self.style'
18981898
self._apply_style(screen, write_position, parent_style)
18991899

1900-
# Tell the screen that this user control has been painted.
1901-
screen.visible_windows.append(self)
1900+
# Tell the screen that this user control has been painted at this
1901+
# position.
1902+
screen.visible_windows_to_write_positions[self] = write_position
19021903

19031904
def _copy_body(
19041905
self,

prompt_toolkit/layout/screen.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,15 @@ def __init__(
186186

187187
# Windows that have been drawn. (Each `Window` class will add itself to
188188
# this list.)
189-
self.visible_windows: List["Window"] = []
189+
self.visible_windows_to_write_positions: Dict["Window", "WritePosition"] = {}
190190

191191
# List of (z_index, draw_func)
192192
self._draw_float_functions: List[Tuple[int, Callable[[], None]]] = []
193193

194+
@property
195+
def visible_windows(self) -> List["Window"]:
196+
return list(self.visible_windows_to_write_positions.keys())
197+
194198
def set_cursor_position(self, window: "Window", position: Point) -> None:
195199
"""
196200
Set the cursor position for a given window.
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
from typing import List, Optional
2+
3+
from prompt_toolkit.data_structures import Point
4+
from prompt_toolkit.filters import FilterOrBool, to_filter
5+
from prompt_toolkit.key_binding import KeyBindingsBase
6+
7+
from .containers import Container, ScrollOffsets
8+
from .dimension import AnyDimension, Dimension, to_dimension
9+
from .mouse_handlers import MouseHandlers
10+
from .screen import Char, Screen, WritePosition
11+
12+
__all__ = ["ScrollablePane"]
13+
14+
# Never go beyond this height, because performance will degrade.
15+
MAX_AVAILABLE_HEIGHT = 10_000
16+
17+
18+
class ScrollablePane(Container):
19+
"""
20+
Container widget that exposes a larger virtual screen to its content and
21+
displays it in a vertical scrollbale region.
22+
23+
Typically this is wrapped in a large `HSplit` container. Make sure in that
24+
case to not specify a `height` dimension of the `HSplit`, so that it will
25+
scale according to the content.
26+
27+
.. note::
28+
29+
If you want to display a completion menu for widgets in this
30+
`ScrollablePane`, then it's still a good practice to use a
31+
`FloatContainer` with a `CompletionMenu` in a `Float` at the top-level
32+
of the layout hierarchy, rather then nesting a `FloatContainer` in this
33+
`ScrollablePane`. (Otherwise, it's possible that the completion menu
34+
is clipped.)
35+
36+
:param content: The content container.
37+
:param scrolloffset: Try to keep the cursor within this distance from the
38+
top/bottom (left/right offset is not used).
39+
:param keep_cursor_visible: When `True`, automatically scroll the pane so
40+
that the cursor (of the focused window) is always visible.
41+
:param keep_focused_window_visible: When `True`, automatically scroll th e
42+
pane so that the focused window is visible, or as much visible as
43+
possible if it doen't completely fit the screen.
44+
:param max_available_height: Always constraint the height to this amount
45+
for performance reasons.
46+
:param width: When given, use this width instead of looking at the children.
47+
:param height: When given, use this height instead of looking at the children.
48+
"""
49+
50+
def __init__(
51+
self,
52+
content: Container,
53+
scroll_offsets: Optional[ScrollOffsets] = None,
54+
keep_cursor_visible: FilterOrBool = True,
55+
keep_focused_window_visible: FilterOrBool = True,
56+
max_available_height: int = MAX_AVAILABLE_HEIGHT,
57+
width: AnyDimension = None,
58+
height: AnyDimension = None,
59+
) -> None:
60+
self.content = content
61+
self.scroll_offsets = scroll_offsets or ScrollOffsets(top=1, bottom=1)
62+
self.keep_cursor_visible = to_filter(keep_cursor_visible)
63+
self.keep_focused_window_visible = to_filter(keep_focused_window_visible)
64+
self.max_available_height = max_available_height
65+
self.width = width
66+
self.height = height
67+
68+
self.vertical_scroll = 0
69+
70+
def __repr__(self) -> str:
71+
return f"ScrollablePane({self.content!r})"
72+
73+
def reset(self) -> None:
74+
self.content.reset()
75+
76+
def preferred_width(self, max_available_width: int) -> Dimension:
77+
if self.width is not None:
78+
return to_dimension(self.width)
79+
80+
# We're only scrolling vertical. So the preferred width is equal to
81+
# that of the content.
82+
return self.content.preferred_width(max_available_width)
83+
84+
def preferred_height(self, width: int, max_available_height: int) -> Dimension:
85+
if self.height is not None:
86+
return to_dimension(self.height)
87+
88+
# Prefer a height large enough so that it fits all the content. If not,
89+
# we'll make the pane scrollable.
90+
dimension = self.content.preferred_height(width, self.max_available_height)
91+
92+
# Only take 'preferred' into account. Min/max can be anything.
93+
return Dimension(min=0, preferred=dimension.preferred)
94+
95+
def write_to_screen(
96+
self,
97+
screen: Screen,
98+
mouse_handlers: MouseHandlers,
99+
write_position: WritePosition,
100+
parent_style: str,
101+
erase_bg: bool,
102+
z_index: Optional[int],
103+
) -> None:
104+
# Compute preferred height again.
105+
virtual_height = self.content.preferred_height(
106+
write_position.width, self.max_available_height
107+
).preferred
108+
virtual_width = write_position.width
109+
110+
# Ensure virtual height is at least the available height.
111+
virtual_height = max(virtual_height, write_position.height)
112+
virtual_height = min(virtual_height, self.max_available_height)
113+
114+
# First, write the content to a virtual screen, then copy over the
115+
# visible part to the real screen.
116+
temp_screen = Screen(default_char=Char(char=" ", style=parent_style))
117+
temp_write_position = WritePosition(
118+
xpos=0, ypos=0, width=virtual_width, height=virtual_height
119+
)
120+
121+
temp_mouse_handlers = MouseHandlers()
122+
123+
self.content.write_to_screen(
124+
temp_screen,
125+
temp_mouse_handlers,
126+
temp_write_position,
127+
parent_style,
128+
erase_bg,
129+
z_index,
130+
)
131+
temp_screen.draw_all_floats()
132+
133+
# TODO: draw scrollbar?
134+
135+
# If anything in the virtual screen is focused, move vertical scroll to
136+
from prompt_toolkit.application import get_app
137+
138+
focused_window = get_app().layout.current_window
139+
140+
try:
141+
visible_win_write_pos = temp_screen.visible_windows_to_write_positions[
142+
focused_window
143+
]
144+
except KeyError:
145+
pass # No window focused here. Don't scroll.
146+
else:
147+
# Make sure this window is visible.
148+
self._make_window_visible(
149+
write_position.height,
150+
virtual_height,
151+
visible_win_write_pos,
152+
temp_screen.cursor_positions.get(focused_window),
153+
)
154+
155+
# Copy over virtual screen and zero width escapes to real screen.
156+
ypos = write_position.ypos
157+
xpos = write_position.xpos
158+
159+
for y in range(write_position.height):
160+
temp_row = temp_screen.data_buffer[y + self.vertical_scroll]
161+
row = screen.data_buffer[y + ypos]
162+
temp_zero_width_escapes = temp_screen.zero_width_escapes[
163+
y + self.vertical_scroll
164+
]
165+
zero_width_escapes = screen.zero_width_escapes[y + ypos]
166+
167+
for x in range(write_position.width):
168+
row[x + xpos] = temp_row[x]
169+
170+
if x in temp_zero_width_escapes:
171+
zero_width_escapes[x + xpos] = temp_zero_width_escapes[x]
172+
173+
# Set screen.width/height.
174+
screen.width = max(screen.width, xpos + write_position.width)
175+
screen.height = max(screen.height, ypos + write_position.height)
176+
177+
for win, write_pos in temp_screen.visible_windows_to_write_positions.items():
178+
screen.visible_windows_to_write_positions[win] = WritePosition(
179+
xpos=write_pos.xpos + xpos,
180+
ypos=write_pos.ypos + ypos - self.vertical_scroll,
181+
# TODO: if the window is only partly visible, then truncate width/height.
182+
# This could be important if we have nested ScrollablePanes.
183+
height=write_pos.height,
184+
width=write_pos.width,
185+
)
186+
187+
if temp_screen.show_cursor:
188+
screen.show_cursor = True
189+
190+
# Copy over cursor positions.
191+
for window, point in temp_screen.cursor_positions.items():
192+
screen.cursor_positions[window] = Point(
193+
x=point.x + xpos, y=point.y + ypos - self.vertical_scroll
194+
)
195+
196+
# Copy over menu positions.
197+
for window, point in temp_screen.menu_positions.items():
198+
screen.menu_positions[window] = Point(
199+
x=point.x + xpos, y=point.y + ypos - self.vertical_scroll
200+
)
201+
202+
def is_modal(self) -> bool:
203+
return self.content.is_modal()
204+
205+
def get_key_bindings(self) -> Optional[KeyBindingsBase]:
206+
return self.content.get_key_bindings()
207+
208+
def get_children(self) -> List["Container"]:
209+
return [self.content]
210+
211+
def _make_window_visible(
212+
self,
213+
visible_height: int,
214+
virtual_height: int,
215+
visible_win_write_pos: WritePosition,
216+
cursor_position: Optional[Point],
217+
) -> None:
218+
"""
219+
Scroll the scrollable pane, so that this window becomes visible.
220+
221+
:param visible_height: Height of this `ScrollablePane` that is rendered.
222+
:param virtual_height: Height of the virtual, temp screen.
223+
:param visible_win_write_pos: `WritePosition` of the nested window on the
224+
temp screen.
225+
:param cursor_position: The location of the cursor position of this
226+
window on the temp screen.
227+
"""
228+
# Start with maximum allowed scroll range, and then reduce according to
229+
# the focused window and cursor position.
230+
min_scroll = 0
231+
max_scroll = virtual_height - visible_height
232+
233+
if self.keep_cursor_visible():
234+
# Reduce min/max scroll according to the cursor in the focused window.
235+
if cursor_position is not None:
236+
offsets = self.scroll_offsets
237+
cpos_min_scroll = (
238+
cursor_position.y - visible_height + 1 + offsets.bottom
239+
)
240+
cpos_max_scroll = cursor_position.y - offsets.top
241+
min_scroll = max(min_scroll, cpos_min_scroll)
242+
max_scroll = max(0, min(max_scroll, cpos_max_scroll))
243+
244+
if self.keep_focused_window_visible():
245+
# Reduce min/max scroll according to focused window position.
246+
# If the window is small enough, bot the top and bottom of the window
247+
# should be visible.
248+
if visible_win_write_pos.height <= visible_height:
249+
window_min_scroll = (
250+
visible_win_write_pos.ypos
251+
+ visible_win_write_pos.height
252+
- visible_height
253+
)
254+
window_max_scroll = visible_win_write_pos.ypos
255+
else:
256+
# Window does not fit on the screen. Make sure at least the whole
257+
# screen is occupied with this window, and nothing else is shown.
258+
window_min_scroll = visible_win_write_pos.ypos
259+
window_max_scroll = (
260+
visible_win_write_pos.ypos
261+
+ visible_win_write_pos.height
262+
- visible_height
263+
)
264+
265+
min_scroll = max(min_scroll, window_min_scroll)
266+
max_scroll = min(max_scroll, window_max_scroll)
267+
268+
if min_scroll > max_scroll:
269+
min_scroll = max_scroll # Should not happen.
270+
271+
# Finally, properly clip the vertical scroll.
272+
if self.vertical_scroll > max_scroll:
273+
self.vertical_scroll = max_scroll
274+
if self.vertical_scroll < min_scroll:
275+
self.vertical_scroll = min_scroll

0 commit comments

Comments
 (0)