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