Spaces:
Sleeping
Sleeping
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. | |
""" | |
# Preferred minimum size of the menu control. | |
# The CompletionsMenu class defines a width of 8, and there is a scrollbar | |
# of 1.) | |
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 # Can be None! | |
# Calculate width of completions menu. | |
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): | |
# If the amount of completions is over 200, compute the width based | |
# on the first 200 completions, otherwise this can be very slow. | |
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: | |
# Select completion. | |
b.go_to_completion(mouse_event.position.y) | |
b.complete_state = None | |
elif mouse_event.event_type == MouseEventType.SCROLL_DOWN: | |
# Scroll up. | |
b.complete_next(count=3, disable_wrap_around=True) | |
elif mouse_event.event_type == MouseEventType.SCROLL_UP: | |
# Scroll down. | |
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 = f"class:completion-menu.completion.current {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) | |
# When the text is too wide, trim it. | |
if width > max_width: | |
result = [] # Text fragments. | |
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): | |
# NOTE: We use a pretty big z_index by default. Menus are supposed to be | |
# above anything else. We also want to make sure that the content is | |
# visible at the point where we draw this menu. | |
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, | |
), | |
# Show when there are completions but not at the point we are | |
# returning the input. | |
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 # One extra padding on the right + space for arrows. | |
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 | |
# Cache for column width computations. This computation is not cheap, | |
# so we don't want to do it over and over again while the user | |
# navigates through the completions. | |
# (map `completion_state` to `(completion_count, width)`. We remember | |
# the count, because a completer can add new completions to the | |
# `CompletionState` while loading.) | |
self._column_width_for_completion_state: WeakKeyDictionary[ | |
CompletionState, tuple[int, int] | |
] = WeakKeyDictionary() | |
# Info of last rendering. | |
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)) | |
) | |
# When the desired width is still more than the maximum available, | |
# reduce by removing columns until we are less than the available | |
# width. | |
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 | |
) | |
# Space required outside of the regular columns, for displaying the | |
# left and right arrow. | |
HORIZONTAL_MARGIN_REQUIRED = 3 | |
# There should be at least one column, but it cannot be wider than | |
# the available width. | |
column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width) | |
# However, when the columns tend to be very wide, because there are | |
# some very wide entries, shrink it anyway. | |
if column_width > self.suggested_max_column_width: | |
# `column_width` can still be bigger that `suggested_max_column_width`, | |
# but if there is place for two columns, we divide by two. | |
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_)) | |
# Make sure the current completion is always visible: update scroll offset. | |
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 | |
# Write completions to screen. | |
fragments_for_line = [] | |
for row_index, row in enumerate(rows_): | |
fragments: StyleAndTextTuples = [] | |
middle_row = row_index == len(rows_) // 2 | |
# Draw left arrow if we have hidden completions on the left. | |
if render_left_arrow: | |
fragments.append(("class:scrollbar", "<" if middle_row else " ")) | |
elif render_right_arrow: | |
# Reserve one column empty space. (If there is a right | |
# arrow right now, there can be a left arrow as well.) | |
fragments.append(("", " ")) | |
# Draw row content. | |
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 | |
) | |
# Remember render position for mouse click handler. | |
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)) | |
# Draw trailing padding for this row. | |
# (_get_menu_item_fragments only returns padding on the left.) | |
if render_left_arrow or render_right_arrow: | |
fragments.append(("class:completion", " ")) | |
# Draw right arrow if we have hidden completions on the right. | |
if render_right_arrow: | |
fragments.append(("class:scrollbar", ">" if middle_row else " ")) | |
elif render_left_arrow: | |
fragments.append(("class:completion", " ")) | |
# Add line. | |
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): | |
# Number of completions changed, recompute. | |
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 | |
# Mouse click on left arrow. | |
if x == 0: | |
if self._render_left_arrow: | |
scroll_left() | |
# Mouse click on right arrow. | |
elif x == self._render_width - 1: | |
if self._render_right_arrow: | |
scroll_right() | |
# Mouse click on completion. | |
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() | |
def filter() -> bool: | |
"Only handle key bindings if this menu is visible." | |
app = get_app() | |
complete_state = app.current_buffer.complete_state | |
# There need to be completions, and one needs to be selected. | |
if complete_state is None or complete_state.complete_index is None: | |
return False | |
# This menu needs to be visible. | |
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: | |
# Calculate new complete index. | |
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) | |
# NOTE: the is_global is required because the completion menu will | |
# never be focussed. | |
def _left(event: E) -> None: | |
move() | |
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) | |
# Display filter: show when there are completions but not at the point | |
# we are returning the input. | |
full_filter = extra_filter & has_completions & ~is_done | |
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 | |
) | |
# Create child windows. | |
# NOTE: We don't set style='class:completion-menu' to the | |
# `MultiColumnCompletionMenuControl`, because this is used in a | |
# Float that is made transparent, and the size of the control | |
# doesn't always correspond exactly with the size of the | |
# generated content. | |
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, | |
) | |
# Initialize split. | |
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: | |
# When there are many completions, calling `get_cwidth` for | |
# every `display_meta_text` is too expensive. In this case, | |
# just return the max available width. There will be enough | |
# columns anyway so that the whole screen is filled with | |
# completions and `create_content` will then take up as much | |
# space as needed. | |
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 [] | |