| from __future__ import annotations |
|
|
| import math |
| from itertools import zip_longest |
| from typing import TYPE_CHECKING, Callable, Iterable, Sequence, TypeVar, cast |
| from weakref import WeakKeyDictionary |
|
|
| from prompt_toolkit.application.current import get_app |
| from prompt_toolkit.buffer import CompletionState |
| from prompt_toolkit.completion import Completion |
| from prompt_toolkit.data_structures import Point |
| from prompt_toolkit.filters import ( |
| Condition, |
| FilterOrBool, |
| has_completions, |
| is_done, |
| to_filter, |
| ) |
| from prompt_toolkit.formatted_text import ( |
| StyleAndTextTuples, |
| fragment_list_width, |
| to_formatted_text, |
| ) |
| from prompt_toolkit.key_binding.key_processor import KeyPressEvent |
| from prompt_toolkit.layout.utils import explode_text_fragments |
| from prompt_toolkit.mouse_events import MouseEvent, MouseEventType |
| from prompt_toolkit.utils import get_cwidth |
|
|
| from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window |
| from .controls import GetLinePrefixCallable, UIContent, UIControl |
| from .dimension import Dimension |
| from .margins import ScrollbarMargin |
|
|
| if TYPE_CHECKING: |
| from prompt_toolkit.key_binding.key_bindings import ( |
| KeyBindings, |
| NotImplementedOrNone, |
| ) |
|
|
|
|
| __all__ = [ |
| "CompletionsMenu", |
| "MultiColumnCompletionsMenu", |
| ] |
|
|
| E = KeyPressEvent |
|
|
|
|
| class CompletionsMenuControl(UIControl): |
| """ |
| Helper for drawing the complete menu to the screen. |
| |
| :param scroll_offset: Number (integer) representing the preferred amount of |
| completions to be displayed before and after the current one. When this |
| is a very high number, the current completion will be shown in the |
| middle most of the time. |
| """ |
|
|
| |
| |
| |
| MIN_WIDTH = 7 |
|
|
| def has_focus(self) -> bool: |
| return False |
|
|
| def preferred_width(self, max_available_width: int) -> int | None: |
| complete_state = get_app().current_buffer.complete_state |
| if complete_state: |
| menu_width = self._get_menu_width(500, complete_state) |
| menu_meta_width = self._get_menu_meta_width(500, complete_state) |
|
|
| return menu_width + menu_meta_width |
| else: |
| return 0 |
|
|
| def preferred_height( |
| self, |
| width: int, |
| max_available_height: int, |
| wrap_lines: bool, |
| get_line_prefix: GetLinePrefixCallable | None, |
| ) -> int | None: |
| complete_state = get_app().current_buffer.complete_state |
| if complete_state: |
| return len(complete_state.completions) |
| else: |
| return 0 |
|
|
| def create_content(self, width: int, height: int) -> UIContent: |
| """ |
| Create a UIContent object for this control. |
| """ |
| complete_state = get_app().current_buffer.complete_state |
| if complete_state: |
| completions = complete_state.completions |
| index = complete_state.complete_index |
|
|
| |
| menu_width = self._get_menu_width(width, complete_state) |
| menu_meta_width = self._get_menu_meta_width( |
| width - menu_width, complete_state |
| ) |
| show_meta = self._show_meta(complete_state) |
|
|
| def get_line(i: int) -> StyleAndTextTuples: |
| c = completions[i] |
| is_current_completion = i == index |
| result = _get_menu_item_fragments( |
| c, is_current_completion, menu_width, space_after=True |
| ) |
|
|
| if show_meta: |
| result += self._get_menu_item_meta_fragments( |
| c, is_current_completion, menu_meta_width |
| ) |
| return result |
|
|
| return UIContent( |
| get_line=get_line, |
| cursor_position=Point(x=0, y=index or 0), |
| line_count=len(completions), |
| ) |
|
|
| return UIContent() |
|
|
| def _show_meta(self, complete_state: CompletionState) -> bool: |
| """ |
| Return ``True`` if we need to show a column with meta information. |
| """ |
| return any(c.display_meta_text for c in complete_state.completions) |
|
|
| def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int: |
| """ |
| Return the width of the main column. |
| """ |
| return min( |
| max_width, |
| max( |
| self.MIN_WIDTH, |
| max(get_cwidth(c.display_text) for c in complete_state.completions) + 2, |
| ), |
| ) |
|
|
| def _get_menu_meta_width( |
| self, max_width: int, complete_state: CompletionState |
| ) -> int: |
| """ |
| Return the width of the meta column. |
| """ |
|
|
| def meta_width(completion: Completion) -> int: |
| return get_cwidth(completion.display_meta_text) |
|
|
| if self._show_meta(complete_state): |
| |
| |
| completions = complete_state.completions |
| if len(completions) > 200: |
| completions = completions[:200] |
|
|
| return min(max_width, max(meta_width(c) for c in completions) + 2) |
| else: |
| return 0 |
|
|
| def _get_menu_item_meta_fragments( |
| self, completion: Completion, is_current_completion: bool, width: int |
| ) -> StyleAndTextTuples: |
| if is_current_completion: |
| style_str = "class:completion-menu.meta.completion.current" |
| else: |
| style_str = "class:completion-menu.meta.completion" |
|
|
| text, tw = _trim_formatted_text(completion.display_meta, width - 2) |
| padding = " " * (width - 1 - tw) |
|
|
| return to_formatted_text( |
| cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)], |
| style=style_str, |
| ) |
|
|
| def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: |
| """ |
| Handle mouse events: clicking and scrolling. |
| """ |
| b = get_app().current_buffer |
|
|
| if mouse_event.event_type == MouseEventType.MOUSE_UP: |
| |
| b.go_to_completion(mouse_event.position.y) |
| b.complete_state = None |
|
|
| elif mouse_event.event_type == MouseEventType.SCROLL_DOWN: |
| |
| b.complete_next(count=3, disable_wrap_around=True) |
|
|
| elif mouse_event.event_type == MouseEventType.SCROLL_UP: |
| |
| b.complete_previous(count=3, disable_wrap_around=True) |
|
|
| return None |
|
|
|
|
| def _get_menu_item_fragments( |
| completion: Completion, |
| is_current_completion: bool, |
| width: int, |
| space_after: bool = False, |
| ) -> StyleAndTextTuples: |
| """ |
| Get the style/text tuples for a menu item, styled and trimmed to the given |
| width. |
| """ |
| if is_current_completion: |
| style_str = "class:completion-menu.completion.current {} {}".format( |
| completion.style, |
| completion.selected_style, |
| ) |
| else: |
| style_str = "class:completion-menu.completion " + completion.style |
|
|
| text, tw = _trim_formatted_text( |
| completion.display, (width - 2 if space_after else width - 1) |
| ) |
|
|
| padding = " " * (width - 1 - tw) |
|
|
| return to_formatted_text( |
| cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)], |
| style=style_str, |
| ) |
|
|
|
|
| def _trim_formatted_text( |
| formatted_text: StyleAndTextTuples, max_width: int |
| ) -> tuple[StyleAndTextTuples, int]: |
| """ |
| Trim the text to `max_width`, append dots when the text is too long. |
| Returns (text, width) tuple. |
| """ |
| width = fragment_list_width(formatted_text) |
|
|
| |
| if width > max_width: |
| result = [] |
| remaining_width = max_width - 3 |
|
|
| for style_and_ch in explode_text_fragments(formatted_text): |
| ch_width = get_cwidth(style_and_ch[1]) |
|
|
| if ch_width <= remaining_width: |
| result.append(style_and_ch) |
| remaining_width -= ch_width |
| else: |
| break |
|
|
| result.append(("", "...")) |
|
|
| return result, max_width - remaining_width |
| else: |
| return formatted_text, width |
|
|
|
|
| class CompletionsMenu(ConditionalContainer): |
| |
| |
| |
| def __init__( |
| self, |
| max_height: int | None = None, |
| scroll_offset: int | Callable[[], int] = 0, |
| extra_filter: FilterOrBool = True, |
| display_arrows: FilterOrBool = False, |
| z_index: int = 10**8, |
| ) -> None: |
| extra_filter = to_filter(extra_filter) |
| display_arrows = to_filter(display_arrows) |
|
|
| super().__init__( |
| content=Window( |
| content=CompletionsMenuControl(), |
| width=Dimension(min=8), |
| height=Dimension(min=1, max=max_height), |
| scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset), |
| right_margins=[ScrollbarMargin(display_arrows=display_arrows)], |
| dont_extend_width=True, |
| style="class:completion-menu", |
| z_index=z_index, |
| ), |
| |
| |
| filter=extra_filter & has_completions & ~is_done, |
| ) |
|
|
|
|
| class MultiColumnCompletionMenuControl(UIControl): |
| """ |
| Completion menu that displays all the completions in several columns. |
| When there are more completions than space for them to be displayed, an |
| arrow is shown on the left or right side. |
| |
| `min_rows` indicates how many rows will be available in any possible case. |
| When this is larger than one, it will try to use less columns and more |
| rows until this value is reached. |
| Be careful passing in a too big value, if less than the given amount of |
| rows are available, more columns would have been required, but |
| `preferred_width` doesn't know about that and reports a too small value. |
| This results in less completions displayed and additional scrolling. |
| (It's a limitation of how the layout engine currently works: first the |
| widths are calculated, then the heights.) |
| |
| :param suggested_max_column_width: The suggested max width of a column. |
| The column can still be bigger than this, but if there is place for two |
| columns of this width, we will display two columns. This to avoid that |
| if there is one very wide completion, that it doesn't significantly |
| reduce the amount of columns. |
| """ |
|
|
| _required_margin = 3 |
|
|
| def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None: |
| assert min_rows >= 1 |
|
|
| self.min_rows = min_rows |
| self.suggested_max_column_width = suggested_max_column_width |
| self.scroll = 0 |
|
|
| |
| |
| |
| |
| |
| |
| self._column_width_for_completion_state: WeakKeyDictionary[ |
| CompletionState, tuple[int, int] |
| ] = WeakKeyDictionary() |
|
|
| |
| self._rendered_rows = 0 |
| self._rendered_columns = 0 |
| self._total_columns = 0 |
| self._render_pos_to_completion: dict[tuple[int, int], Completion] = {} |
| self._render_left_arrow = False |
| self._render_right_arrow = False |
| self._render_width = 0 |
|
|
| def reset(self) -> None: |
| self.scroll = 0 |
|
|
| def has_focus(self) -> bool: |
| return False |
|
|
| def preferred_width(self, max_available_width: int) -> int | None: |
| """ |
| Preferred width: prefer to use at least min_rows, but otherwise as much |
| as possible horizontally. |
| """ |
| complete_state = get_app().current_buffer.complete_state |
| if complete_state is None: |
| return 0 |
|
|
| column_width = self._get_column_width(complete_state) |
| result = int( |
| column_width |
| * math.ceil(len(complete_state.completions) / float(self.min_rows)) |
| ) |
|
|
| |
| |
| |
| while ( |
| result > column_width |
| and result > max_available_width - self._required_margin |
| ): |
| result -= column_width |
| return result + self._required_margin |
|
|
| def preferred_height( |
| self, |
| width: int, |
| max_available_height: int, |
| wrap_lines: bool, |
| get_line_prefix: GetLinePrefixCallable | None, |
| ) -> int | None: |
| """ |
| Preferred height: as much as needed in order to display all the completions. |
| """ |
| complete_state = get_app().current_buffer.complete_state |
| if complete_state is None: |
| return 0 |
|
|
| column_width = self._get_column_width(complete_state) |
| column_count = max(1, (width - self._required_margin) // column_width) |
|
|
| return int(math.ceil(len(complete_state.completions) / float(column_count))) |
|
|
| def create_content(self, width: int, height: int) -> UIContent: |
| """ |
| Create a UIContent object for this menu. |
| """ |
| complete_state = get_app().current_buffer.complete_state |
| if complete_state is None: |
| return UIContent() |
|
|
| column_width = self._get_column_width(complete_state) |
| self._render_pos_to_completion = {} |
|
|
| _T = TypeVar("_T") |
|
|
| def grouper( |
| n: int, iterable: Iterable[_T], fillvalue: _T | None = None |
| ) -> Iterable[Sequence[_T | None]]: |
| "grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx" |
| args = [iter(iterable)] * n |
| return zip_longest(fillvalue=fillvalue, *args) |
|
|
| def is_current_completion(completion: Completion) -> bool: |
| "Returns True when this completion is the currently selected one." |
| return ( |
| complete_state is not None |
| and complete_state.complete_index is not None |
| and c == complete_state.current_completion |
| ) |
|
|
| |
| |
| HORIZONTAL_MARGIN_REQUIRED = 3 |
|
|
| |
| |
| column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width) |
|
|
| |
| |
| if column_width > self.suggested_max_column_width: |
| |
| |
| column_width //= column_width // self.suggested_max_column_width |
|
|
| visible_columns = max(1, (width - self._required_margin) // column_width) |
|
|
| columns_ = list(grouper(height, complete_state.completions)) |
| rows_ = list(zip(*columns_)) |
|
|
| |
| selected_column = (complete_state.complete_index or 0) // height |
| self.scroll = min( |
| selected_column, max(self.scroll, selected_column - visible_columns + 1) |
| ) |
|
|
| render_left_arrow = self.scroll > 0 |
| render_right_arrow = self.scroll < len(rows_[0]) - visible_columns |
|
|
| |
| fragments_for_line = [] |
|
|
| for row_index, row in enumerate(rows_): |
| fragments: StyleAndTextTuples = [] |
| middle_row = row_index == len(rows_) // 2 |
|
|
| |
| if render_left_arrow: |
| fragments.append(("class:scrollbar", "<" if middle_row else " ")) |
| elif render_right_arrow: |
| |
| |
| fragments.append(("", " ")) |
|
|
| |
| for column_index, c in enumerate(row[self.scroll :][:visible_columns]): |
| if c is not None: |
| fragments += _get_menu_item_fragments( |
| c, is_current_completion(c), column_width, space_after=False |
| ) |
|
|
| |
| for x in range(column_width): |
| self._render_pos_to_completion[ |
| (column_index * column_width + x, row_index) |
| ] = c |
| else: |
| fragments.append(("class:completion", " " * column_width)) |
|
|
| |
| |
| if render_left_arrow or render_right_arrow: |
| fragments.append(("class:completion", " ")) |
|
|
| |
| if render_right_arrow: |
| fragments.append(("class:scrollbar", ">" if middle_row else " ")) |
| elif render_left_arrow: |
| fragments.append(("class:completion", " ")) |
|
|
| |
| fragments_for_line.append( |
| to_formatted_text(fragments, style="class:completion-menu") |
| ) |
|
|
| self._rendered_rows = height |
| self._rendered_columns = visible_columns |
| self._total_columns = len(columns_) |
| self._render_left_arrow = render_left_arrow |
| self._render_right_arrow = render_right_arrow |
| self._render_width = ( |
| column_width * visible_columns + render_left_arrow + render_right_arrow + 1 |
| ) |
|
|
| def get_line(i: int) -> StyleAndTextTuples: |
| return fragments_for_line[i] |
|
|
| return UIContent(get_line=get_line, line_count=len(rows_)) |
|
|
| def _get_column_width(self, completion_state: CompletionState) -> int: |
| """ |
| Return the width of each column. |
| """ |
| try: |
| count, width = self._column_width_for_completion_state[completion_state] |
| if count != len(completion_state.completions): |
| |
| raise KeyError |
| return width |
| except KeyError: |
| result = ( |
| max(get_cwidth(c.display_text) for c in completion_state.completions) |
| + 1 |
| ) |
| self._column_width_for_completion_state[completion_state] = ( |
| len(completion_state.completions), |
| result, |
| ) |
| return result |
|
|
| def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: |
| """ |
| Handle scroll and click events. |
| """ |
| b = get_app().current_buffer |
|
|
| def scroll_left() -> None: |
| b.complete_previous(count=self._rendered_rows, disable_wrap_around=True) |
| self.scroll = max(0, self.scroll - 1) |
|
|
| def scroll_right() -> None: |
| b.complete_next(count=self._rendered_rows, disable_wrap_around=True) |
| self.scroll = min( |
| self._total_columns - self._rendered_columns, self.scroll + 1 |
| ) |
|
|
| if mouse_event.event_type == MouseEventType.SCROLL_DOWN: |
| scroll_right() |
|
|
| elif mouse_event.event_type == MouseEventType.SCROLL_UP: |
| scroll_left() |
|
|
| elif mouse_event.event_type == MouseEventType.MOUSE_UP: |
| x = mouse_event.position.x |
| y = mouse_event.position.y |
|
|
| |
| if x == 0: |
| if self._render_left_arrow: |
| scroll_left() |
|
|
| |
| elif x == self._render_width - 1: |
| if self._render_right_arrow: |
| scroll_right() |
|
|
| |
| else: |
| completion = self._render_pos_to_completion.get((x, y)) |
| if completion: |
| b.apply_completion(completion) |
|
|
| return None |
|
|
| def get_key_bindings(self) -> KeyBindings: |
| """ |
| Expose key bindings that handle the left/right arrow keys when the menu |
| is displayed. |
| """ |
| from prompt_toolkit.key_binding.key_bindings import KeyBindings |
|
|
| kb = KeyBindings() |
|
|
| @Condition |
| def filter() -> bool: |
| "Only handle key bindings if this menu is visible." |
| app = get_app() |
| complete_state = app.current_buffer.complete_state |
|
|
| |
| if complete_state is None or complete_state.complete_index is None: |
| return False |
|
|
| |
| return any(window.content == self for window in app.layout.visible_windows) |
|
|
| def move(right: bool = False) -> None: |
| buff = get_app().current_buffer |
| complete_state = buff.complete_state |
|
|
| if complete_state is not None and complete_state.complete_index is not None: |
| |
| new_index = complete_state.complete_index |
| if right: |
| new_index += self._rendered_rows |
| else: |
| new_index -= self._rendered_rows |
|
|
| if 0 <= new_index < len(complete_state.completions): |
| buff.go_to_completion(new_index) |
|
|
| |
| |
|
|
| @kb.add("left", is_global=True, filter=filter) |
| def _left(event: E) -> None: |
| move() |
|
|
| @kb.add("right", is_global=True, filter=filter) |
| def _right(event: E) -> None: |
| move(True) |
|
|
| return kb |
|
|
|
|
| class MultiColumnCompletionsMenu(HSplit): |
| """ |
| Container that displays the completions in several columns. |
| When `show_meta` (a :class:`~prompt_toolkit.filters.Filter`) evaluates |
| to True, it shows the meta information at the bottom. |
| """ |
|
|
| def __init__( |
| self, |
| min_rows: int = 3, |
| suggested_max_column_width: int = 30, |
| show_meta: FilterOrBool = True, |
| extra_filter: FilterOrBool = True, |
| z_index: int = 10**8, |
| ) -> None: |
| show_meta = to_filter(show_meta) |
| extra_filter = to_filter(extra_filter) |
|
|
| |
| |
| full_filter = extra_filter & has_completions & ~is_done |
|
|
| @Condition |
| def any_completion_has_meta() -> bool: |
| complete_state = get_app().current_buffer.complete_state |
| return complete_state is not None and any( |
| c.display_meta for c in complete_state.completions |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| completions_window = ConditionalContainer( |
| content=Window( |
| content=MultiColumnCompletionMenuControl( |
| min_rows=min_rows, |
| suggested_max_column_width=suggested_max_column_width, |
| ), |
| width=Dimension(min=8), |
| height=Dimension(min=1), |
| ), |
| filter=full_filter, |
| ) |
|
|
| meta_window = ConditionalContainer( |
| content=Window(content=_SelectedCompletionMetaControl()), |
| filter=full_filter & show_meta & any_completion_has_meta, |
| ) |
|
|
| |
| super().__init__([completions_window, meta_window], z_index=z_index) |
|
|
|
|
| class _SelectedCompletionMetaControl(UIControl): |
| """ |
| Control that shows the meta information of the selected completion. |
| """ |
|
|
| def preferred_width(self, max_available_width: int) -> int | None: |
| """ |
| Report the width of the longest meta text as the preferred width of this control. |
| |
| It could be that we use less width, but this way, we're sure that the |
| layout doesn't change when we select another completion (E.g. that |
| completions are suddenly shown in more or fewer columns.) |
| """ |
| app = get_app() |
| if app.current_buffer.complete_state: |
| state = app.current_buffer.complete_state |
|
|
| if len(state.completions) >= 30: |
| |
| |
| |
| |
| |
| |
| return max_available_width |
|
|
| return 2 + max( |
| get_cwidth(c.display_meta_text) for c in state.completions[:100] |
| ) |
| else: |
| return 0 |
|
|
| def preferred_height( |
| self, |
| width: int, |
| max_available_height: int, |
| wrap_lines: bool, |
| get_line_prefix: GetLinePrefixCallable | None, |
| ) -> int | None: |
| return 1 |
|
|
| def create_content(self, width: int, height: int) -> UIContent: |
| fragments = self._get_text_fragments() |
|
|
| def get_line(i: int) -> StyleAndTextTuples: |
| return fragments |
|
|
| return UIContent(get_line=get_line, line_count=1 if fragments else 0) |
|
|
| def _get_text_fragments(self) -> StyleAndTextTuples: |
| style = "class:completion-menu.multi-column-meta" |
| state = get_app().current_buffer.complete_state |
|
|
| if ( |
| state |
| and state.current_completion |
| and state.current_completion.display_meta_text |
| ): |
| return to_formatted_text( |
| cast(StyleAndTextTuples, [("", " ")]) |
| + state.current_completion.display_meta |
| + [("", " ")], |
| style=style, |
| ) |
|
|
| return [] |
|
|