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