1from __future__ import annotations
2
3import math
4from itertools import zip_longest
5from typing import TYPE_CHECKING, Callable, Iterable, Sequence, TypeVar, cast
6from weakref import WeakKeyDictionary
7
8from prompt_toolkit.application.current import get_app
9from prompt_toolkit.buffer import CompletionState
10from prompt_toolkit.completion import Completion
11from prompt_toolkit.data_structures import Point
12from prompt_toolkit.filters import (
13 Condition,
14 FilterOrBool,
15 has_completions,
16 is_done,
17 to_filter,
18)
19from prompt_toolkit.formatted_text import (
20 StyleAndTextTuples,
21 fragment_list_width,
22 to_formatted_text,
23)
24from prompt_toolkit.key_binding.key_processor import KeyPressEvent
25from prompt_toolkit.layout.utils import explode_text_fragments
26from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
27from prompt_toolkit.utils import get_cwidth
28
29from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window
30from .controls import GetLinePrefixCallable, UIContent, UIControl
31from .dimension import Dimension
32from .margins import ScrollbarMargin
33
34if TYPE_CHECKING:
35 from prompt_toolkit.key_binding.key_bindings import (
36 KeyBindings,
37 NotImplementedOrNone,
38 )
39
40
41__all__ = [
42 "CompletionsMenu",
43 "MultiColumnCompletionsMenu",
44]
45
46E = KeyPressEvent
47
48
49class CompletionsMenuControl(UIControl):
50 """
51 Helper for drawing the complete menu to the screen.
52
53 :param scroll_offset: Number (integer) representing the preferred amount of
54 completions to be displayed before and after the current one. When this
55 is a very high number, the current completion will be shown in the
56 middle most of the time.
57 """
58
59 # Preferred minimum size of the menu control.
60 # The CompletionsMenu class defines a width of 8, and there is a scrollbar
61 # of 1.)
62 MIN_WIDTH = 7
63
64 def has_focus(self) -> bool:
65 return False
66
67 def preferred_width(self, max_available_width: int) -> int | None:
68 complete_state = get_app().current_buffer.complete_state
69 if complete_state:
70 menu_width = self._get_menu_width(500, complete_state)
71 menu_meta_width = self._get_menu_meta_width(500, complete_state)
72
73 return menu_width + menu_meta_width
74 else:
75 return 0
76
77 def preferred_height(
78 self,
79 width: int,
80 max_available_height: int,
81 wrap_lines: bool,
82 get_line_prefix: GetLinePrefixCallable | None,
83 ) -> int | None:
84 complete_state = get_app().current_buffer.complete_state
85 if complete_state:
86 return len(complete_state.completions)
87 else:
88 return 0
89
90 def create_content(self, width: int, height: int) -> UIContent:
91 """
92 Create a UIContent object for this control.
93 """
94 complete_state = get_app().current_buffer.complete_state
95 if complete_state:
96 completions = complete_state.completions
97 index = complete_state.complete_index # Can be None!
98
99 # Calculate width of completions menu.
100 menu_width = self._get_menu_width(width, complete_state)
101 menu_meta_width = self._get_menu_meta_width(
102 width - menu_width, complete_state
103 )
104 show_meta = self._show_meta(complete_state)
105
106 def get_line(i: int) -> StyleAndTextTuples:
107 c = completions[i]
108 is_current_completion = i == index
109 result = _get_menu_item_fragments(
110 c, is_current_completion, menu_width, space_after=True
111 )
112
113 if show_meta:
114 result += self._get_menu_item_meta_fragments(
115 c, is_current_completion, menu_meta_width
116 )
117 return result
118
119 return UIContent(
120 get_line=get_line,
121 cursor_position=Point(x=0, y=index or 0),
122 line_count=len(completions),
123 )
124
125 return UIContent()
126
127 def _show_meta(self, complete_state: CompletionState) -> bool:
128 """
129 Return ``True`` if we need to show a column with meta information.
130 """
131 return any(c.display_meta_text for c in complete_state.completions)
132
133 def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int:
134 """
135 Return the width of the main column.
136 """
137 return min(
138 max_width,
139 max(
140 self.MIN_WIDTH,
141 max(get_cwidth(c.display_text) for c in complete_state.completions) + 2,
142 ),
143 )
144
145 def _get_menu_meta_width(
146 self, max_width: int, complete_state: CompletionState
147 ) -> int:
148 """
149 Return the width of the meta column.
150 """
151
152 def meta_width(completion: Completion) -> int:
153 return get_cwidth(completion.display_meta_text)
154
155 if self._show_meta(complete_state):
156 # If the amount of completions is over 200, compute the width based
157 # on the first 200 completions, otherwise this can be very slow.
158 completions = complete_state.completions
159 if len(completions) > 200:
160 completions = completions[:200]
161
162 return min(max_width, max(meta_width(c) for c in completions) + 2)
163 else:
164 return 0
165
166 def _get_menu_item_meta_fragments(
167 self, completion: Completion, is_current_completion: bool, width: int
168 ) -> StyleAndTextTuples:
169 if is_current_completion:
170 style_str = "class:completion-menu.meta.completion.current"
171 else:
172 style_str = "class:completion-menu.meta.completion"
173
174 text, tw = _trim_formatted_text(completion.display_meta, width - 2)
175 padding = " " * (width - 1 - tw)
176
177 return to_formatted_text(
178 cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
179 style=style_str,
180 )
181
182 def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
183 """
184 Handle mouse events: clicking and scrolling.
185 """
186 b = get_app().current_buffer
187
188 if mouse_event.event_type == MouseEventType.MOUSE_UP:
189 # Select completion.
190 b.go_to_completion(mouse_event.position.y)
191 b.complete_state = None
192
193 elif mouse_event.event_type == MouseEventType.SCROLL_DOWN:
194 # Scroll up.
195 b.complete_next(count=3, disable_wrap_around=True)
196
197 elif mouse_event.event_type == MouseEventType.SCROLL_UP:
198 # Scroll down.
199 b.complete_previous(count=3, disable_wrap_around=True)
200
201 return None
202
203
204def _get_menu_item_fragments(
205 completion: Completion,
206 is_current_completion: bool,
207 width: int,
208 space_after: bool = False,
209) -> StyleAndTextTuples:
210 """
211 Get the style/text tuples for a menu item, styled and trimmed to the given
212 width.
213 """
214 if is_current_completion:
215 style_str = f"class:completion-menu.completion.current {completion.style} {completion.selected_style}"
216 else:
217 style_str = "class:completion-menu.completion " + completion.style
218
219 text, tw = _trim_formatted_text(
220 completion.display, (width - 2 if space_after else width - 1)
221 )
222
223 padding = " " * (width - 1 - tw)
224
225 return to_formatted_text(
226 cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
227 style=style_str,
228 )
229
230
231def _trim_formatted_text(
232 formatted_text: StyleAndTextTuples, max_width: int
233) -> tuple[StyleAndTextTuples, int]:
234 """
235 Trim the text to `max_width`, append dots when the text is too long.
236 Returns (text, width) tuple.
237 """
238 width = fragment_list_width(formatted_text)
239
240 # When the text is too wide, trim it.
241 if width > max_width:
242 result = [] # Text fragments.
243 remaining_width = max_width - 3
244
245 for style_and_ch in explode_text_fragments(formatted_text):
246 ch_width = get_cwidth(style_and_ch[1])
247
248 if ch_width <= remaining_width:
249 result.append(style_and_ch)
250 remaining_width -= ch_width
251 else:
252 break
253
254 result.append(("", "..."))
255
256 return result, max_width - remaining_width
257 else:
258 return formatted_text, width
259
260
261class CompletionsMenu(ConditionalContainer):
262 # NOTE: We use a pretty big z_index by default. Menus are supposed to be
263 # above anything else. We also want to make sure that the content is
264 # visible at the point where we draw this menu.
265 def __init__(
266 self,
267 max_height: int | None = None,
268 scroll_offset: int | Callable[[], int] = 0,
269 extra_filter: FilterOrBool = True,
270 display_arrows: FilterOrBool = False,
271 z_index: int = 10**8,
272 ) -> None:
273 extra_filter = to_filter(extra_filter)
274 display_arrows = to_filter(display_arrows)
275
276 super().__init__(
277 content=Window(
278 content=CompletionsMenuControl(),
279 width=Dimension(min=8),
280 height=Dimension(min=1, max=max_height),
281 scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset),
282 right_margins=[ScrollbarMargin(display_arrows=display_arrows)],
283 dont_extend_width=True,
284 style="class:completion-menu",
285 z_index=z_index,
286 ),
287 # Show when there are completions but not at the point we are
288 # returning the input.
289 filter=extra_filter & has_completions & ~is_done,
290 )
291
292
293class MultiColumnCompletionMenuControl(UIControl):
294 """
295 Completion menu that displays all the completions in several columns.
296 When there are more completions than space for them to be displayed, an
297 arrow is shown on the left or right side.
298
299 `min_rows` indicates how many rows will be available in any possible case.
300 When this is larger than one, it will try to use less columns and more
301 rows until this value is reached.
302 Be careful passing in a too big value, if less than the given amount of
303 rows are available, more columns would have been required, but
304 `preferred_width` doesn't know about that and reports a too small value.
305 This results in less completions displayed and additional scrolling.
306 (It's a limitation of how the layout engine currently works: first the
307 widths are calculated, then the heights.)
308
309 :param suggested_max_column_width: The suggested max width of a column.
310 The column can still be bigger than this, but if there is place for two
311 columns of this width, we will display two columns. This to avoid that
312 if there is one very wide completion, that it doesn't significantly
313 reduce the amount of columns.
314 """
315
316 _required_margin = 3 # One extra padding on the right + space for arrows.
317
318 def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None:
319 assert min_rows >= 1
320
321 self.min_rows = min_rows
322 self.suggested_max_column_width = suggested_max_column_width
323 self.scroll = 0
324
325 # Cache for column width computations. This computation is not cheap,
326 # so we don't want to do it over and over again while the user
327 # navigates through the completions.
328 # (map `completion_state` to `(completion_count, width)`. We remember
329 # the count, because a completer can add new completions to the
330 # `CompletionState` while loading.)
331 self._column_width_for_completion_state: WeakKeyDictionary[
332 CompletionState, tuple[int, int]
333 ] = WeakKeyDictionary()
334
335 # Info of last rendering.
336 self._rendered_rows = 0
337 self._rendered_columns = 0
338 self._total_columns = 0
339 self._render_pos_to_completion: dict[tuple[int, int], Completion] = {}
340 self._render_left_arrow = False
341 self._render_right_arrow = False
342 self._render_width = 0
343
344 def reset(self) -> None:
345 self.scroll = 0
346
347 def has_focus(self) -> bool:
348 return False
349
350 def preferred_width(self, max_available_width: int) -> int | None:
351 """
352 Preferred width: prefer to use at least min_rows, but otherwise as much
353 as possible horizontally.
354 """
355 complete_state = get_app().current_buffer.complete_state
356 if complete_state is None:
357 return 0
358
359 column_width = self._get_column_width(complete_state)
360 result = int(
361 column_width
362 * math.ceil(len(complete_state.completions) / float(self.min_rows))
363 )
364
365 # When the desired width is still more than the maximum available,
366 # reduce by removing columns until we are less than the available
367 # width.
368 while (
369 result > column_width
370 and result > max_available_width - self._required_margin
371 ):
372 result -= column_width
373 return result + self._required_margin
374
375 def preferred_height(
376 self,
377 width: int,
378 max_available_height: int,
379 wrap_lines: bool,
380 get_line_prefix: GetLinePrefixCallable | None,
381 ) -> int | None:
382 """
383 Preferred height: as much as needed in order to display all the completions.
384 """
385 complete_state = get_app().current_buffer.complete_state
386 if complete_state is None:
387 return 0
388
389 column_width = self._get_column_width(complete_state)
390 column_count = max(1, (width - self._required_margin) // column_width)
391
392 return int(math.ceil(len(complete_state.completions) / float(column_count)))
393
394 def create_content(self, width: int, height: int) -> UIContent:
395 """
396 Create a UIContent object for this menu.
397 """
398 complete_state = get_app().current_buffer.complete_state
399 if complete_state is None:
400 return UIContent()
401
402 column_width = self._get_column_width(complete_state)
403 self._render_pos_to_completion = {}
404
405 _T = TypeVar("_T")
406
407 def grouper(
408 n: int, iterable: Iterable[_T], fillvalue: _T | None = None
409 ) -> Iterable[Sequence[_T | None]]:
410 "grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx"
411 args = [iter(iterable)] * n
412 return zip_longest(fillvalue=fillvalue, *args)
413
414 def is_current_completion(completion: Completion) -> bool:
415 "Returns True when this completion is the currently selected one."
416 return (
417 complete_state is not None
418 and complete_state.complete_index is not None
419 and c == complete_state.current_completion
420 )
421
422 # Space required outside of the regular columns, for displaying the
423 # left and right arrow.
424 HORIZONTAL_MARGIN_REQUIRED = 3
425
426 # There should be at least one column, but it cannot be wider than
427 # the available width.
428 column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width)
429
430 # However, when the columns tend to be very wide, because there are
431 # some very wide entries, shrink it anyway.
432 if column_width > self.suggested_max_column_width:
433 # `column_width` can still be bigger that `suggested_max_column_width`,
434 # but if there is place for two columns, we divide by two.
435 column_width //= column_width // self.suggested_max_column_width
436
437 visible_columns = max(1, (width - self._required_margin) // column_width)
438
439 columns_ = list(grouper(height, complete_state.completions))
440 rows_ = list(zip(*columns_))
441
442 # Make sure the current completion is always visible: update scroll offset.
443 selected_column = (complete_state.complete_index or 0) // height
444 self.scroll = min(
445 selected_column, max(self.scroll, selected_column - visible_columns + 1)
446 )
447
448 render_left_arrow = self.scroll > 0
449 render_right_arrow = self.scroll < len(rows_[0]) - visible_columns
450
451 # Write completions to screen.
452 fragments_for_line = []
453
454 for row_index, row in enumerate(rows_):
455 fragments: StyleAndTextTuples = []
456 middle_row = row_index == len(rows_) // 2
457
458 # Draw left arrow if we have hidden completions on the left.
459 if render_left_arrow:
460 fragments.append(("class:scrollbar", "<" if middle_row else " "))
461 elif render_right_arrow:
462 # Reserve one column empty space. (If there is a right
463 # arrow right now, there can be a left arrow as well.)
464 fragments.append(("", " "))
465
466 # Draw row content.
467 for column_index, c in enumerate(row[self.scroll :][:visible_columns]):
468 if c is not None:
469 fragments += _get_menu_item_fragments(
470 c, is_current_completion(c), column_width, space_after=False
471 )
472
473 # Remember render position for mouse click handler.
474 for x in range(column_width):
475 self._render_pos_to_completion[
476 (column_index * column_width + x, row_index)
477 ] = c
478 else:
479 fragments.append(("class:completion", " " * column_width))
480
481 # Draw trailing padding for this row.
482 # (_get_menu_item_fragments only returns padding on the left.)
483 if render_left_arrow or render_right_arrow:
484 fragments.append(("class:completion", " "))
485
486 # Draw right arrow if we have hidden completions on the right.
487 if render_right_arrow:
488 fragments.append(("class:scrollbar", ">" if middle_row else " "))
489 elif render_left_arrow:
490 fragments.append(("class:completion", " "))
491
492 # Add line.
493 fragments_for_line.append(
494 to_formatted_text(fragments, style="class:completion-menu")
495 )
496
497 self._rendered_rows = height
498 self._rendered_columns = visible_columns
499 self._total_columns = len(columns_)
500 self._render_left_arrow = render_left_arrow
501 self._render_right_arrow = render_right_arrow
502 self._render_width = (
503 column_width * visible_columns + render_left_arrow + render_right_arrow + 1
504 )
505
506 def get_line(i: int) -> StyleAndTextTuples:
507 return fragments_for_line[i]
508
509 return UIContent(get_line=get_line, line_count=len(rows_))
510
511 def _get_column_width(self, completion_state: CompletionState) -> int:
512 """
513 Return the width of each column.
514 """
515 try:
516 count, width = self._column_width_for_completion_state[completion_state]
517 if count != len(completion_state.completions):
518 # Number of completions changed, recompute.
519 raise KeyError
520 return width
521 except KeyError:
522 result = (
523 max(get_cwidth(c.display_text) for c in completion_state.completions)
524 + 1
525 )
526 self._column_width_for_completion_state[completion_state] = (
527 len(completion_state.completions),
528 result,
529 )
530 return result
531
532 def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
533 """
534 Handle scroll and click events.
535 """
536 b = get_app().current_buffer
537
538 def scroll_left() -> None:
539 b.complete_previous(count=self._rendered_rows, disable_wrap_around=True)
540 self.scroll = max(0, self.scroll - 1)
541
542 def scroll_right() -> None:
543 b.complete_next(count=self._rendered_rows, disable_wrap_around=True)
544 self.scroll = min(
545 self._total_columns - self._rendered_columns, self.scroll + 1
546 )
547
548 if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
549 scroll_right()
550
551 elif mouse_event.event_type == MouseEventType.SCROLL_UP:
552 scroll_left()
553
554 elif mouse_event.event_type == MouseEventType.MOUSE_UP:
555 x = mouse_event.position.x
556 y = mouse_event.position.y
557
558 # Mouse click on left arrow.
559 if x == 0:
560 if self._render_left_arrow:
561 scroll_left()
562
563 # Mouse click on right arrow.
564 elif x == self._render_width - 1:
565 if self._render_right_arrow:
566 scroll_right()
567
568 # Mouse click on completion.
569 else:
570 completion = self._render_pos_to_completion.get((x, y))
571 if completion:
572 b.apply_completion(completion)
573
574 return None
575
576 def get_key_bindings(self) -> KeyBindings:
577 """
578 Expose key bindings that handle the left/right arrow keys when the menu
579 is displayed.
580 """
581 from prompt_toolkit.key_binding.key_bindings import KeyBindings
582
583 kb = KeyBindings()
584
585 @Condition
586 def filter() -> bool:
587 "Only handle key bindings if this menu is visible."
588 app = get_app()
589 complete_state = app.current_buffer.complete_state
590
591 # There need to be completions, and one needs to be selected.
592 if complete_state is None or complete_state.complete_index is None:
593 return False
594
595 # This menu needs to be visible.
596 return any(window.content == self for window in app.layout.visible_windows)
597
598 def move(right: bool = False) -> None:
599 buff = get_app().current_buffer
600 complete_state = buff.complete_state
601
602 if complete_state is not None and complete_state.complete_index is not None:
603 # Calculate new complete index.
604 new_index = complete_state.complete_index
605 if right:
606 new_index += self._rendered_rows
607 else:
608 new_index -= self._rendered_rows
609
610 if 0 <= new_index < len(complete_state.completions):
611 buff.go_to_completion(new_index)
612
613 # NOTE: the is_global is required because the completion menu will
614 # never be focussed.
615
616 @kb.add("left", is_global=True, filter=filter)
617 def _left(event: E) -> None:
618 move()
619
620 @kb.add("right", is_global=True, filter=filter)
621 def _right(event: E) -> None:
622 move(True)
623
624 return kb
625
626
627class MultiColumnCompletionsMenu(HSplit):
628 """
629 Container that displays the completions in several columns.
630 When `show_meta` (a :class:`~prompt_toolkit.filters.Filter`) evaluates
631 to True, it shows the meta information at the bottom.
632 """
633
634 def __init__(
635 self,
636 min_rows: int = 3,
637 suggested_max_column_width: int = 30,
638 show_meta: FilterOrBool = True,
639 extra_filter: FilterOrBool = True,
640 z_index: int = 10**8,
641 ) -> None:
642 show_meta = to_filter(show_meta)
643 extra_filter = to_filter(extra_filter)
644
645 # Display filter: show when there are completions but not at the point
646 # we are returning the input.
647 full_filter = extra_filter & has_completions & ~is_done
648
649 @Condition
650 def any_completion_has_meta() -> bool:
651 complete_state = get_app().current_buffer.complete_state
652 return complete_state is not None and any(
653 c.display_meta for c in complete_state.completions
654 )
655
656 # Create child windows.
657 # NOTE: We don't set style='class:completion-menu' to the
658 # `MultiColumnCompletionMenuControl`, because this is used in a
659 # Float that is made transparent, and the size of the control
660 # doesn't always correspond exactly with the size of the
661 # generated content.
662 completions_window = ConditionalContainer(
663 content=Window(
664 content=MultiColumnCompletionMenuControl(
665 min_rows=min_rows,
666 suggested_max_column_width=suggested_max_column_width,
667 ),
668 width=Dimension(min=8),
669 height=Dimension(min=1),
670 ),
671 filter=full_filter,
672 )
673
674 meta_window = ConditionalContainer(
675 content=Window(content=_SelectedCompletionMetaControl()),
676 filter=full_filter & show_meta & any_completion_has_meta,
677 )
678
679 # Initialize split.
680 super().__init__([completions_window, meta_window], z_index=z_index)
681
682
683class _SelectedCompletionMetaControl(UIControl):
684 """
685 Control that shows the meta information of the selected completion.
686 """
687
688 def preferred_width(self, max_available_width: int) -> int | None:
689 """
690 Report the width of the longest meta text as the preferred width of this control.
691
692 It could be that we use less width, but this way, we're sure that the
693 layout doesn't change when we select another completion (E.g. that
694 completions are suddenly shown in more or fewer columns.)
695 """
696 app = get_app()
697 if app.current_buffer.complete_state:
698 state = app.current_buffer.complete_state
699
700 if len(state.completions) >= 30:
701 # When there are many completions, calling `get_cwidth` for
702 # every `display_meta_text` is too expensive. In this case,
703 # just return the max available width. There will be enough
704 # columns anyway so that the whole screen is filled with
705 # completions and `create_content` will then take up as much
706 # space as needed.
707 return max_available_width
708
709 return 2 + max(
710 get_cwidth(c.display_meta_text) for c in state.completions[:100]
711 )
712 else:
713 return 0
714
715 def preferred_height(
716 self,
717 width: int,
718 max_available_height: int,
719 wrap_lines: bool,
720 get_line_prefix: GetLinePrefixCallable | None,
721 ) -> int | None:
722 return 1
723
724 def create_content(self, width: int, height: int) -> UIContent:
725 fragments = self._get_text_fragments()
726
727 def get_line(i: int) -> StyleAndTextTuples:
728 return fragments
729
730 return UIContent(get_line=get_line, line_count=1 if fragments else 0)
731
732 def _get_text_fragments(self) -> StyleAndTextTuples:
733 style = "class:completion-menu.multi-column-meta"
734 state = get_app().current_buffer.complete_state
735
736 if (
737 state
738 and state.current_completion
739 and state.current_completion.display_meta_text
740 ):
741 return to_formatted_text(
742 cast(StyleAndTextTuples, [("", " ")])
743 + state.current_completion.display_meta
744 + [("", " ")],
745 style=style,
746 )
747
748 return []