|
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 [] |
|
|