Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/prompt_toolkit/layout/menus.py: 19%
296 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-20 06:09 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-20 06:09 +0000
1from __future__ import annotations
3import math
4from itertools import zip_longest
5from typing import TYPE_CHECKING, Callable, Iterable, Sequence, TypeVar, cast
6from weakref import WeakKeyDictionary
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
29from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window
30from .controls import GetLinePrefixCallable, UIContent, UIControl
31from .dimension import Dimension
32from .margins import ScrollbarMargin
34if TYPE_CHECKING:
35 from prompt_toolkit.key_binding.key_bindings import (
36 KeyBindings,
37 NotImplementedOrNone,
38 )
41__all__ = [
42 "CompletionsMenu",
43 "MultiColumnCompletionsMenu",
44]
46E = KeyPressEvent
49class CompletionsMenuControl(UIControl):
50 """
51 Helper for drawing the complete menu to the screen.
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 """
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
64 def has_focus(self) -> bool:
65 return False
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)
73 return menu_width + menu_meta_width
74 else:
75 return 0
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
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!
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)
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 )
113 if show_meta:
114 result += self._get_menu_item_meta_fragments(
115 c, is_current_completion, menu_meta_width
116 )
117 return result
119 return UIContent(
120 get_line=get_line,
121 cursor_position=Point(x=0, y=index or 0),
122 line_count=len(completions),
123 )
125 return UIContent()
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)
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 )
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 """
152 def meta_width(completion: Completion) -> int:
153 return get_cwidth(completion.display_meta_text)
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]
162 return min(max_width, max(meta_width(c) for c in completions) + 2)
163 else:
164 return 0
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"
174 text, tw = _trim_formatted_text(completion.display_meta, width - 2)
175 padding = " " * (width - 1 - tw)
177 return to_formatted_text(
178 cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
179 style=style_str,
180 )
182 def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
183 """
184 Handle mouse events: clicking and scrolling.
185 """
186 b = get_app().current_buffer
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
193 elif mouse_event.event_type == MouseEventType.SCROLL_DOWN:
194 # Scroll up.
195 b.complete_next(count=3, disable_wrap_around=True)
197 elif mouse_event.event_type == MouseEventType.SCROLL_UP:
198 # Scroll down.
199 b.complete_previous(count=3, disable_wrap_around=True)
201 return None
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 = "class:completion-menu.completion.current {} {}".format(
216 completion.style,
217 completion.selected_style,
218 )
219 else:
220 style_str = "class:completion-menu.completion " + completion.style
222 text, tw = _trim_formatted_text(
223 completion.display, (width - 2 if space_after else width - 1)
224 )
226 padding = " " * (width - 1 - tw)
228 return to_formatted_text(
229 cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
230 style=style_str,
231 )
234def _trim_formatted_text(
235 formatted_text: StyleAndTextTuples, max_width: int
236) -> tuple[StyleAndTextTuples, int]:
237 """
238 Trim the text to `max_width`, append dots when the text is too long.
239 Returns (text, width) tuple.
240 """
241 width = fragment_list_width(formatted_text)
243 # When the text is too wide, trim it.
244 if width > max_width:
245 result = [] # Text fragments.
246 remaining_width = max_width - 3
248 for style_and_ch in explode_text_fragments(formatted_text):
249 ch_width = get_cwidth(style_and_ch[1])
251 if ch_width <= remaining_width:
252 result.append(style_and_ch)
253 remaining_width -= ch_width
254 else:
255 break
257 result.append(("", "..."))
259 return result, max_width - remaining_width
260 else:
261 return formatted_text, width
264class CompletionsMenu(ConditionalContainer):
265 # NOTE: We use a pretty big z_index by default. Menus are supposed to be
266 # above anything else. We also want to make sure that the content is
267 # visible at the point where we draw this menu.
268 def __init__(
269 self,
270 max_height: int | None = None,
271 scroll_offset: int | Callable[[], int] = 0,
272 extra_filter: FilterOrBool = True,
273 display_arrows: FilterOrBool = False,
274 z_index: int = 10**8,
275 ) -> None:
276 extra_filter = to_filter(extra_filter)
277 display_arrows = to_filter(display_arrows)
279 super().__init__(
280 content=Window(
281 content=CompletionsMenuControl(),
282 width=Dimension(min=8),
283 height=Dimension(min=1, max=max_height),
284 scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset),
285 right_margins=[ScrollbarMargin(display_arrows=display_arrows)],
286 dont_extend_width=True,
287 style="class:completion-menu",
288 z_index=z_index,
289 ),
290 # Show when there are completions but not at the point we are
291 # returning the input.
292 filter=extra_filter & has_completions & ~is_done,
293 )
296class MultiColumnCompletionMenuControl(UIControl):
297 """
298 Completion menu that displays all the completions in several columns.
299 When there are more completions than space for them to be displayed, an
300 arrow is shown on the left or right side.
302 `min_rows` indicates how many rows will be available in any possible case.
303 When this is larger than one, it will try to use less columns and more
304 rows until this value is reached.
305 Be careful passing in a too big value, if less than the given amount of
306 rows are available, more columns would have been required, but
307 `preferred_width` doesn't know about that and reports a too small value.
308 This results in less completions displayed and additional scrolling.
309 (It's a limitation of how the layout engine currently works: first the
310 widths are calculated, then the heights.)
312 :param suggested_max_column_width: The suggested max width of a column.
313 The column can still be bigger than this, but if there is place for two
314 columns of this width, we will display two columns. This to avoid that
315 if there is one very wide completion, that it doesn't significantly
316 reduce the amount of columns.
317 """
319 _required_margin = 3 # One extra padding on the right + space for arrows.
321 def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None:
322 assert min_rows >= 1
324 self.min_rows = min_rows
325 self.suggested_max_column_width = suggested_max_column_width
326 self.scroll = 0
328 # Cache for column width computations. This computation is not cheap,
329 # so we don't want to do it over and over again while the user
330 # navigates through the completions.
331 # (map `completion_state` to `(completion_count, width)`. We remember
332 # the count, because a completer can add new completions to the
333 # `CompletionState` while loading.)
334 self._column_width_for_completion_state: WeakKeyDictionary[
335 CompletionState, tuple[int, int]
336 ] = WeakKeyDictionary()
338 # Info of last rendering.
339 self._rendered_rows = 0
340 self._rendered_columns = 0
341 self._total_columns = 0
342 self._render_pos_to_completion: dict[tuple[int, int], Completion] = {}
343 self._render_left_arrow = False
344 self._render_right_arrow = False
345 self._render_width = 0
347 def reset(self) -> None:
348 self.scroll = 0
350 def has_focus(self) -> bool:
351 return False
353 def preferred_width(self, max_available_width: int) -> int | None:
354 """
355 Preferred width: prefer to use at least min_rows, but otherwise as much
356 as possible horizontally.
357 """
358 complete_state = get_app().current_buffer.complete_state
359 if complete_state is None:
360 return 0
362 column_width = self._get_column_width(complete_state)
363 result = int(
364 column_width
365 * math.ceil(len(complete_state.completions) / float(self.min_rows))
366 )
368 # When the desired width is still more than the maximum available,
369 # reduce by removing columns until we are less than the available
370 # width.
371 while (
372 result > column_width
373 and result > max_available_width - self._required_margin
374 ):
375 result -= column_width
376 return result + self._required_margin
378 def preferred_height(
379 self,
380 width: int,
381 max_available_height: int,
382 wrap_lines: bool,
383 get_line_prefix: GetLinePrefixCallable | None,
384 ) -> int | None:
385 """
386 Preferred height: as much as needed in order to display all the completions.
387 """
388 complete_state = get_app().current_buffer.complete_state
389 if complete_state is None:
390 return 0
392 column_width = self._get_column_width(complete_state)
393 column_count = max(1, (width - self._required_margin) // column_width)
395 return int(math.ceil(len(complete_state.completions) / float(column_count)))
397 def create_content(self, width: int, height: int) -> UIContent:
398 """
399 Create a UIContent object for this menu.
400 """
401 complete_state = get_app().current_buffer.complete_state
402 if complete_state is None:
403 return UIContent()
405 column_width = self._get_column_width(complete_state)
406 self._render_pos_to_completion = {}
408 _T = TypeVar("_T")
410 def grouper(
411 n: int, iterable: Iterable[_T], fillvalue: _T | None = None
412 ) -> Iterable[Sequence[_T | None]]:
413 "grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx"
414 args = [iter(iterable)] * n
415 return zip_longest(fillvalue=fillvalue, *args)
417 def is_current_completion(completion: Completion) -> bool:
418 "Returns True when this completion is the currently selected one."
419 return (
420 complete_state is not None
421 and complete_state.complete_index is not None
422 and c == complete_state.current_completion
423 )
425 # Space required outside of the regular columns, for displaying the
426 # left and right arrow.
427 HORIZONTAL_MARGIN_REQUIRED = 3
429 # There should be at least one column, but it cannot be wider than
430 # the available width.
431 column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width)
433 # However, when the columns tend to be very wide, because there are
434 # some very wide entries, shrink it anyway.
435 if column_width > self.suggested_max_column_width:
436 # `column_width` can still be bigger that `suggested_max_column_width`,
437 # but if there is place for two columns, we divide by two.
438 column_width //= column_width // self.suggested_max_column_width
440 visible_columns = max(1, (width - self._required_margin) // column_width)
442 columns_ = list(grouper(height, complete_state.completions))
443 rows_ = list(zip(*columns_))
445 # Make sure the current completion is always visible: update scroll offset.
446 selected_column = (complete_state.complete_index or 0) // height
447 self.scroll = min(
448 selected_column, max(self.scroll, selected_column - visible_columns + 1)
449 )
451 render_left_arrow = self.scroll > 0
452 render_right_arrow = self.scroll < len(rows_[0]) - visible_columns
454 # Write completions to screen.
455 fragments_for_line = []
457 for row_index, row in enumerate(rows_):
458 fragments: StyleAndTextTuples = []
459 middle_row = row_index == len(rows_) // 2
461 # Draw left arrow if we have hidden completions on the left.
462 if render_left_arrow:
463 fragments.append(("class:scrollbar", "<" if middle_row else " "))
464 elif render_right_arrow:
465 # Reserve one column empty space. (If there is a right
466 # arrow right now, there can be a left arrow as well.)
467 fragments.append(("", " "))
469 # Draw row content.
470 for column_index, c in enumerate(row[self.scroll :][:visible_columns]):
471 if c is not None:
472 fragments += _get_menu_item_fragments(
473 c, is_current_completion(c), column_width, space_after=False
474 )
476 # Remember render position for mouse click handler.
477 for x in range(column_width):
478 self._render_pos_to_completion[
479 (column_index * column_width + x, row_index)
480 ] = c
481 else:
482 fragments.append(("class:completion", " " * column_width))
484 # Draw trailing padding for this row.
485 # (_get_menu_item_fragments only returns padding on the left.)
486 if render_left_arrow or render_right_arrow:
487 fragments.append(("class:completion", " "))
489 # Draw right arrow if we have hidden completions on the right.
490 if render_right_arrow:
491 fragments.append(("class:scrollbar", ">" if middle_row else " "))
492 elif render_left_arrow:
493 fragments.append(("class:completion", " "))
495 # Add line.
496 fragments_for_line.append(
497 to_formatted_text(fragments, style="class:completion-menu")
498 )
500 self._rendered_rows = height
501 self._rendered_columns = visible_columns
502 self._total_columns = len(columns_)
503 self._render_left_arrow = render_left_arrow
504 self._render_right_arrow = render_right_arrow
505 self._render_width = (
506 column_width * visible_columns + render_left_arrow + render_right_arrow + 1
507 )
509 def get_line(i: int) -> StyleAndTextTuples:
510 return fragments_for_line[i]
512 return UIContent(get_line=get_line, line_count=len(rows_))
514 def _get_column_width(self, completion_state: CompletionState) -> int:
515 """
516 Return the width of each column.
517 """
518 try:
519 count, width = self._column_width_for_completion_state[completion_state]
520 if count != len(completion_state.completions):
521 # Number of completions changed, recompute.
522 raise KeyError
523 return width
524 except KeyError:
525 result = (
526 max(get_cwidth(c.display_text) for c in completion_state.completions)
527 + 1
528 )
529 self._column_width_for_completion_state[completion_state] = (
530 len(completion_state.completions),
531 result,
532 )
533 return result
535 def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
536 """
537 Handle scroll and click events.
538 """
539 b = get_app().current_buffer
541 def scroll_left() -> None:
542 b.complete_previous(count=self._rendered_rows, disable_wrap_around=True)
543 self.scroll = max(0, self.scroll - 1)
545 def scroll_right() -> None:
546 b.complete_next(count=self._rendered_rows, disable_wrap_around=True)
547 self.scroll = min(
548 self._total_columns - self._rendered_columns, self.scroll + 1
549 )
551 if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
552 scroll_right()
554 elif mouse_event.event_type == MouseEventType.SCROLL_UP:
555 scroll_left()
557 elif mouse_event.event_type == MouseEventType.MOUSE_UP:
558 x = mouse_event.position.x
559 y = mouse_event.position.y
561 # Mouse click on left arrow.
562 if x == 0:
563 if self._render_left_arrow:
564 scroll_left()
566 # Mouse click on right arrow.
567 elif x == self._render_width - 1:
568 if self._render_right_arrow:
569 scroll_right()
571 # Mouse click on completion.
572 else:
573 completion = self._render_pos_to_completion.get((x, y))
574 if completion:
575 b.apply_completion(completion)
577 return None
579 def get_key_bindings(self) -> KeyBindings:
580 """
581 Expose key bindings that handle the left/right arrow keys when the menu
582 is displayed.
583 """
584 from prompt_toolkit.key_binding.key_bindings import KeyBindings
586 kb = KeyBindings()
588 @Condition
589 def filter() -> bool:
590 "Only handle key bindings if this menu is visible."
591 app = get_app()
592 complete_state = app.current_buffer.complete_state
594 # There need to be completions, and one needs to be selected.
595 if complete_state is None or complete_state.complete_index is None:
596 return False
598 # This menu needs to be visible.
599 return any(window.content == self for window in app.layout.visible_windows)
601 def move(right: bool = False) -> None:
602 buff = get_app().current_buffer
603 complete_state = buff.complete_state
605 if complete_state is not None and complete_state.complete_index is not None:
606 # Calculate new complete index.
607 new_index = complete_state.complete_index
608 if right:
609 new_index += self._rendered_rows
610 else:
611 new_index -= self._rendered_rows
613 if 0 <= new_index < len(complete_state.completions):
614 buff.go_to_completion(new_index)
616 # NOTE: the is_global is required because the completion menu will
617 # never be focussed.
619 @kb.add("left", is_global=True, filter=filter)
620 def _left(event: E) -> None:
621 move()
623 @kb.add("right", is_global=True, filter=filter)
624 def _right(event: E) -> None:
625 move(True)
627 return kb
630class MultiColumnCompletionsMenu(HSplit):
631 """
632 Container that displays the completions in several columns.
633 When `show_meta` (a :class:`~prompt_toolkit.filters.Filter`) evaluates
634 to True, it shows the meta information at the bottom.
635 """
637 def __init__(
638 self,
639 min_rows: int = 3,
640 suggested_max_column_width: int = 30,
641 show_meta: FilterOrBool = True,
642 extra_filter: FilterOrBool = True,
643 z_index: int = 10**8,
644 ) -> None:
645 show_meta = to_filter(show_meta)
646 extra_filter = to_filter(extra_filter)
648 # Display filter: show when there are completions but not at the point
649 # we are returning the input.
650 full_filter = extra_filter & has_completions & ~is_done
652 @Condition
653 def any_completion_has_meta() -> bool:
654 complete_state = get_app().current_buffer.complete_state
655 return complete_state is not None and any(
656 c.display_meta for c in complete_state.completions
657 )
659 # Create child windows.
660 # NOTE: We don't set style='class:completion-menu' to the
661 # `MultiColumnCompletionMenuControl`, because this is used in a
662 # Float that is made transparent, and the size of the control
663 # doesn't always correspond exactly with the size of the
664 # generated content.
665 completions_window = ConditionalContainer(
666 content=Window(
667 content=MultiColumnCompletionMenuControl(
668 min_rows=min_rows,
669 suggested_max_column_width=suggested_max_column_width,
670 ),
671 width=Dimension(min=8),
672 height=Dimension(min=1),
673 ),
674 filter=full_filter,
675 )
677 meta_window = ConditionalContainer(
678 content=Window(content=_SelectedCompletionMetaControl()),
679 filter=full_filter & show_meta & any_completion_has_meta,
680 )
682 # Initialize split.
683 super().__init__([completions_window, meta_window], z_index=z_index)
686class _SelectedCompletionMetaControl(UIControl):
687 """
688 Control that shows the meta information of the selected completion.
689 """
691 def preferred_width(self, max_available_width: int) -> int | None:
692 """
693 Report the width of the longest meta text as the preferred width of this control.
695 It could be that we use less width, but this way, we're sure that the
696 layout doesn't change when we select another completion (E.g. that
697 completions are suddenly shown in more or fewer columns.)
698 """
699 app = get_app()
700 if app.current_buffer.complete_state:
701 state = app.current_buffer.complete_state
703 if len(state.completions) >= 30:
704 # When there are many completions, calling `get_cwidth` for
705 # every `display_meta_text` is too expensive. In this case,
706 # just return the max available width. There will be enough
707 # columns anyway so that the whole screen is filled with
708 # completions and `create_content` will then take up as much
709 # space as needed.
710 return max_available_width
712 return 2 + max(
713 get_cwidth(c.display_meta_text) for c in state.completions[:100]
714 )
715 else:
716 return 0
718 def preferred_height(
719 self,
720 width: int,
721 max_available_height: int,
722 wrap_lines: bool,
723 get_line_prefix: GetLinePrefixCallable | None,
724 ) -> int | None:
725 return 1
727 def create_content(self, width: int, height: int) -> UIContent:
728 fragments = self._get_text_fragments()
730 def get_line(i: int) -> StyleAndTextTuples:
731 return fragments
733 return UIContent(get_line=get_line, line_count=1 if fragments else 0)
735 def _get_text_fragments(self) -> StyleAndTextTuples:
736 style = "class:completion-menu.multi-column-meta"
737 state = get_app().current_buffer.complete_state
739 if (
740 state
741 and state.current_completion
742 and state.current_completion.display_meta_text
743 ):
744 return to_formatted_text(
745 cast(StyleAndTextTuples, [("", " ")])
746 + state.current_completion.display_meta
747 + [("", " ")],
748 style=style,
749 )
751 return []