diff options
author | Devtools Arcadia <arcadia-devtools@yandex-team.ru> | 2022-02-07 18:08:42 +0300 |
---|---|---|
committer | Devtools Arcadia <arcadia-devtools@mous.vla.yp-c.yandex.net> | 2022-02-07 18:08:42 +0300 |
commit | 1110808a9d39d4b808aef724c861a2e1a38d2a69 (patch) | |
tree | e26c9fed0de5d9873cce7e00bc214573dc2195b7 /contrib/python/prompt-toolkit/py2/prompt_toolkit/layout | |
download | ydb-1110808a9d39d4b808aef724c861a2e1a38d2a69.tar.gz |
intermediate changes
ref:cde9a383711a11544ce7e107a78147fb96cc4029
Diffstat (limited to 'contrib/python/prompt-toolkit/py2/prompt_toolkit/layout')
13 files changed, 4893 insertions, 0 deletions
diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/__init__.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/__init__.py new file mode 100644 index 00000000000..0dec5ecfaf3 --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/__init__.py @@ -0,0 +1,51 @@ +""" +Command line layout definitions +------------------------------- + +The layout of a command line interface is defined by a Container instance. +There are two main groups of classes here. Containers and controls: + +- A container can contain other containers or controls, it can have multiple + children and it decides about the dimensions. +- A control is responsible for rendering the actual content to a screen. + A control can propose some dimensions, but it's the container who decides + about the dimensions -- or when the control consumes more space -- which part + of the control will be visible. + + +Container classes:: + + - Container (Abstract base class) + |- HSplit (Horizontal split) + |- VSplit (Vertical split) + |- FloatContainer (Container which can also contain menus and other floats) + `- Window (Container which contains one actual control + +Control classes:: + + - UIControl (Abstract base class) + |- TokenListControl (Renders a simple list of tokens) + |- FillControl (Fills control with one token/character.) + `- BufferControl (Renders an input buffer.) + + +Usually, you end up wrapping every control inside a `Window` object, because +that's the only way to render it in a layout. + +There are some prepared toolbars which are ready to use:: + +- SystemToolbar (Shows the 'system' input buffer, for entering system commands.) +- ArgToolbar (Shows the input 'arg', for repetition of input commands.) +- SearchToolbar (Shows the 'search' input buffer, for incremental search.) +- CompletionsToolbar (Shows the completions of the current buffer.) +- ValidationToolbar (Shows validation errors of the current buffer.) + +And one prepared menu: + +- CompletionsMenu + +""" +from __future__ import unicode_literals + +from .containers import Float, FloatContainer, HSplit, VSplit, Window, ConditionalContainer +from .controls import TokenListControl, FillControl, BufferControl diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/containers.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/containers.py new file mode 100644 index 00000000000..0bdafe18e04 --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/containers.py @@ -0,0 +1,1665 @@ +""" +Container for the layout. +(Containers can contain other containers or user interface controls.) +""" +from __future__ import unicode_literals + +from abc import ABCMeta, abstractmethod +from six import with_metaclass +from six.moves import range + +from .controls import UIControl, TokenListControl, UIContent +from .dimension import LayoutDimension, sum_layout_dimensions, max_layout_dimensions +from .margins import Margin +from .screen import Point, WritePosition, _CHAR_CACHE +from .utils import token_list_to_text, explode_tokens +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.filters import to_cli_filter, ViInsertMode, EmacsInsertMode +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from prompt_toolkit.reactive import Integer +from prompt_toolkit.token import Token +from prompt_toolkit.utils import take_using_weights, get_cwidth + +__all__ = ( + 'Container', + 'HSplit', + 'VSplit', + 'FloatContainer', + 'Float', + 'Window', + 'WindowRenderInfo', + 'ConditionalContainer', + 'ScrollOffsets', + 'ColorColumn', +) + +Transparent = Token.Transparent + + +class Container(with_metaclass(ABCMeta, object)): + """ + Base class for user interface layout. + """ + @abstractmethod + def reset(self): + """ + Reset the state of this container and all the children. + (E.g. reset scroll offsets, etc...) + """ + + @abstractmethod + def preferred_width(self, cli, max_available_width): + """ + Return a :class:`~prompt_toolkit.layout.dimension.LayoutDimension` that + represents the desired width for this container. + + :param cli: :class:`~prompt_toolkit.interface.CommandLineInterface`. + """ + + @abstractmethod + def preferred_height(self, cli, width, max_available_height): + """ + Return a :class:`~prompt_toolkit.layout.dimension.LayoutDimension` that + represents the desired height for this container. + + :param cli: :class:`~prompt_toolkit.interface.CommandLineInterface`. + """ + + @abstractmethod + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + """ + Write the actual content to the screen. + + :param cli: :class:`~prompt_toolkit.interface.CommandLineInterface`. + :param screen: :class:`~prompt_toolkit.layout.screen.Screen` + :param mouse_handlers: :class:`~prompt_toolkit.layout.mouse_handlers.MouseHandlers`. + """ + + @abstractmethod + def walk(self, cli): + """ + Walk through all the layout nodes (and their children) and yield them. + """ + + +def _window_too_small(): + " Create a `Window` that displays the 'Window too small' text. " + return Window(TokenListControl.static( + [(Token.WindowTooSmall, ' Window too small... ')])) + + +class HSplit(Container): + """ + Several layouts, one stacked above/under the other. + + :param children: List of child :class:`.Container` objects. + :param window_too_small: A :class:`.Container` object that is displayed if + there is not enough space for all the children. By default, this is a + "Window too small" message. + :param get_dimensions: (`None` or a callable that takes a + `CommandLineInterface` and returns a list of `LayoutDimension` + instances.) By default the dimensions are taken from the children and + divided by the available space. However, when `get_dimensions` is specified, + this is taken instead. + :param report_dimensions_callback: When rendering, this function is called + with the `CommandLineInterface` and the list of used dimensions. (As a + list of integers.) + """ + def __init__(self, children, window_too_small=None, + get_dimensions=None, report_dimensions_callback=None): + assert all(isinstance(c, Container) for c in children) + assert window_too_small is None or isinstance(window_too_small, Container) + assert get_dimensions is None or callable(get_dimensions) + assert report_dimensions_callback is None or callable(report_dimensions_callback) + + self.children = children + self.window_too_small = window_too_small or _window_too_small() + self.get_dimensions = get_dimensions + self.report_dimensions_callback = report_dimensions_callback + + def preferred_width(self, cli, max_available_width): + if self.children: + dimensions = [c.preferred_width(cli, max_available_width) for c in self.children] + return max_layout_dimensions(dimensions) + else: + return LayoutDimension(0) + + def preferred_height(self, cli, width, max_available_height): + dimensions = [c.preferred_height(cli, width, max_available_height) for c in self.children] + return sum_layout_dimensions(dimensions) + + def reset(self): + for c in self.children: + c.reset() + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + """ + Render the prompt to a `Screen` instance. + + :param screen: The :class:`~prompt_toolkit.layout.screen.Screen` class + to which the output has to be written. + """ + sizes = self._divide_heigths(cli, write_position) + + if self.report_dimensions_callback: + self.report_dimensions_callback(cli, sizes) + + if sizes is None: + self.window_too_small.write_to_screen( + cli, screen, mouse_handlers, write_position) + else: + # Draw child panes. + ypos = write_position.ypos + xpos = write_position.xpos + width = write_position.width + + for s, c in zip(sizes, self.children): + c.write_to_screen(cli, screen, mouse_handlers, WritePosition(xpos, ypos, width, s)) + ypos += s + + def _divide_heigths(self, cli, write_position): + """ + Return the heights for all rows. + Or None when there is not enough space. + """ + if not self.children: + return [] + + # Calculate heights. + given_dimensions = self.get_dimensions(cli) if self.get_dimensions else None + + def get_dimension_for_child(c, index): + if given_dimensions and given_dimensions[index] is not None: + return given_dimensions[index] + else: + return c.preferred_height(cli, write_position.width, write_position.extended_height) + + dimensions = [get_dimension_for_child(c, index) for index, c in enumerate(self.children)] + + # Sum dimensions + sum_dimensions = sum_layout_dimensions(dimensions) + + # If there is not enough space for both. + # Don't do anything. + if sum_dimensions.min > write_position.extended_height: + return + + # Find optimal sizes. (Start with minimal size, increase until we cover + # the whole height.) + sizes = [d.min for d in dimensions] + + child_generator = take_using_weights( + items=list(range(len(dimensions))), + weights=[d.weight for d in dimensions]) + + i = next(child_generator) + + while sum(sizes) < min(write_position.extended_height, sum_dimensions.preferred): + # Increase until we meet at least the 'preferred' size. + if sizes[i] < dimensions[i].preferred: + sizes[i] += 1 + i = next(child_generator) + + if not any([cli.is_returning, cli.is_exiting, cli.is_aborting]): + while sum(sizes) < min(write_position.height, sum_dimensions.max): + # Increase until we use all the available space. (or until "max") + if sizes[i] < dimensions[i].max: + sizes[i] += 1 + i = next(child_generator) + + return sizes + + def walk(self, cli): + """ Walk through children. """ + yield self + for c in self.children: + for i in c.walk(cli): + yield i + + +class VSplit(Container): + """ + Several layouts, one stacked left/right of the other. + + :param children: List of child :class:`.Container` objects. + :param window_too_small: A :class:`.Container` object that is displayed if + there is not enough space for all the children. By default, this is a + "Window too small" message. + :param get_dimensions: (`None` or a callable that takes a + `CommandLineInterface` and returns a list of `LayoutDimension` + instances.) By default the dimensions are taken from the children and + divided by the available space. However, when `get_dimensions` is specified, + this is taken instead. + :param report_dimensions_callback: When rendering, this function is called + with the `CommandLineInterface` and the list of used dimensions. (As a + list of integers.) + """ + def __init__(self, children, window_too_small=None, + get_dimensions=None, report_dimensions_callback=None): + assert all(isinstance(c, Container) for c in children) + assert window_too_small is None or isinstance(window_too_small, Container) + assert get_dimensions is None or callable(get_dimensions) + assert report_dimensions_callback is None or callable(report_dimensions_callback) + + self.children = children + self.window_too_small = window_too_small or _window_too_small() + self.get_dimensions = get_dimensions + self.report_dimensions_callback = report_dimensions_callback + + def preferred_width(self, cli, max_available_width): + dimensions = [c.preferred_width(cli, max_available_width) for c in self.children] + return sum_layout_dimensions(dimensions) + + def preferred_height(self, cli, width, max_available_height): + sizes = self._divide_widths(cli, width) + if sizes is None: + return LayoutDimension() + else: + dimensions = [c.preferred_height(cli, s, max_available_height) + for s, c in zip(sizes, self.children)] + return max_layout_dimensions(dimensions) + + def reset(self): + for c in self.children: + c.reset() + + def _divide_widths(self, cli, width): + """ + Return the widths for all columns. + Or None when there is not enough space. + """ + if not self.children: + return [] + + # Calculate widths. + given_dimensions = self.get_dimensions(cli) if self.get_dimensions else None + + def get_dimension_for_child(c, index): + if given_dimensions and given_dimensions[index] is not None: + return given_dimensions[index] + else: + return c.preferred_width(cli, width) + + dimensions = [get_dimension_for_child(c, index) for index, c in enumerate(self.children)] + + # Sum dimensions + sum_dimensions = sum_layout_dimensions(dimensions) + + # If there is not enough space for both. + # Don't do anything. + if sum_dimensions.min > width: + return + + # Find optimal sizes. (Start with minimal size, increase until we cover + # the whole height.) + sizes = [d.min for d in dimensions] + + child_generator = take_using_weights( + items=list(range(len(dimensions))), + weights=[d.weight for d in dimensions]) + + i = next(child_generator) + + while sum(sizes) < min(width, sum_dimensions.preferred): + # Increase until we meet at least the 'preferred' size. + if sizes[i] < dimensions[i].preferred: + sizes[i] += 1 + i = next(child_generator) + + while sum(sizes) < min(width, sum_dimensions.max): + # Increase until we use all the available space. + if sizes[i] < dimensions[i].max: + sizes[i] += 1 + i = next(child_generator) + + return sizes + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + """ + Render the prompt to a `Screen` instance. + + :param screen: The :class:`~prompt_toolkit.layout.screen.Screen` class + to which the output has to be written. + """ + if not self.children: + return + + sizes = self._divide_widths(cli, write_position.width) + + if self.report_dimensions_callback: + self.report_dimensions_callback(cli, sizes) + + # If there is not enough space. + if sizes is None: + self.window_too_small.write_to_screen( + cli, screen, mouse_handlers, write_position) + return + + # Calculate heights, take the largest possible, but not larger than write_position.extended_height. + heights = [child.preferred_height(cli, width, write_position.extended_height).preferred + for width, child in zip(sizes, self.children)] + height = max(write_position.height, min(write_position.extended_height, max(heights))) + + # Draw child panes. + ypos = write_position.ypos + xpos = write_position.xpos + + for s, c in zip(sizes, self.children): + c.write_to_screen(cli, screen, mouse_handlers, WritePosition(xpos, ypos, s, height)) + xpos += s + + def walk(self, cli): + """ Walk through children. """ + yield self + for c in self.children: + for i in c.walk(cli): + yield i + + +class FloatContainer(Container): + """ + Container which can contain another container for the background, as well + as a list of floating containers on top of it. + + Example Usage:: + + FloatContainer(content=Window(...), + floats=[ + Float(xcursor=True, + ycursor=True, + layout=CompletionMenu(...)) + ]) + """ + def __init__(self, content, floats): + assert isinstance(content, Container) + assert all(isinstance(f, Float) for f in floats) + + self.content = content + self.floats = floats + + def reset(self): + self.content.reset() + + for f in self.floats: + f.content.reset() + + def preferred_width(self, cli, write_position): + return self.content.preferred_width(cli, write_position) + + def preferred_height(self, cli, width, max_available_height): + """ + Return the preferred height of the float container. + (We don't care about the height of the floats, they should always fit + into the dimensions provided by the container.) + """ + return self.content.preferred_height(cli, width, max_available_height) + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + self.content.write_to_screen(cli, screen, mouse_handlers, write_position) + + for fl in self.floats: + # When a menu_position was given, use this instead of the cursor + # position. (These cursor positions are absolute, translate again + # relative to the write_position.) + # Note: This should be inside the for-loop, because one float could + # set the cursor position to be used for the next one. + cursor_position = screen.menu_position or screen.cursor_position + cursor_position = Point(x=cursor_position.x - write_position.xpos, + y=cursor_position.y - write_position.ypos) + + fl_width = fl.get_width(cli) + fl_height = fl.get_height(cli) + + # Left & width given. + if fl.left is not None and fl_width is not None: + xpos = fl.left + width = fl_width + # Left & right given -> calculate width. + elif fl.left is not None and fl.right is not None: + xpos = fl.left + width = write_position.width - fl.left - fl.right + # Width & right given -> calculate left. + elif fl_width is not None and fl.right is not None: + xpos = write_position.width - fl.right - fl_width + width = fl_width + elif fl.xcursor: + width = fl_width + if width is None: + width = fl.content.preferred_width(cli, write_position.width).preferred + width = min(write_position.width, width) + + xpos = cursor_position.x + if xpos + width > write_position.width: + xpos = max(0, write_position.width - width) + # Only width given -> center horizontally. + elif fl_width: + xpos = int((write_position.width - fl_width) / 2) + width = fl_width + # Otherwise, take preferred width from float content. + else: + width = fl.content.preferred_width(cli, write_position.width).preferred + + if fl.left is not None: + xpos = fl.left + elif fl.right is not None: + xpos = max(0, write_position.width - width - fl.right) + else: # Center horizontally. + xpos = max(0, int((write_position.width - width) / 2)) + + # Trim. + width = min(width, write_position.width - xpos) + + # Top & height given. + if fl.top is not None and fl_height is not None: + ypos = fl.top + height = fl_height + # Top & bottom given -> calculate height. + elif fl.top is not None and fl.bottom is not None: + ypos = fl.top + height = write_position.height - fl.top - fl.bottom + # Height & bottom given -> calculate top. + elif fl_height is not None and fl.bottom is not None: + ypos = write_position.height - fl_height - fl.bottom + height = fl_height + # Near cursor + elif fl.ycursor: + ypos = cursor_position.y + 1 + + height = fl_height + if height is None: + height = fl.content.preferred_height( + cli, width, write_position.extended_height).preferred + + # Reduce height if not enough space. (We can use the + # extended_height when the content requires it.) + if height > write_position.extended_height - ypos: + if write_position.extended_height - ypos + 1 >= ypos: + # When the space below the cursor is more than + # the space above, just reduce the height. + height = write_position.extended_height - ypos + else: + # Otherwise, fit the float above the cursor. + height = min(height, cursor_position.y) + ypos = cursor_position.y - height + + # Only height given -> center vertically. + elif fl_width: + ypos = int((write_position.height - fl_height) / 2) + height = fl_height + # Otherwise, take preferred height from content. + else: + height = fl.content.preferred_height( + cli, width, write_position.extended_height).preferred + + if fl.top is not None: + ypos = fl.top + elif fl.bottom is not None: + ypos = max(0, write_position.height - height - fl.bottom) + else: # Center vertically. + ypos = max(0, int((write_position.height - height) / 2)) + + # Trim. + height = min(height, write_position.height - ypos) + + # Write float. + # (xpos and ypos can be negative: a float can be partially visible.) + if height > 0 and width > 0: + wp = WritePosition(xpos=xpos + write_position.xpos, + ypos=ypos + write_position.ypos, + width=width, height=height) + + if not fl.hide_when_covering_content or self._area_is_empty(screen, wp): + fl.content.write_to_screen(cli, screen, mouse_handlers, wp) + + def _area_is_empty(self, screen, write_position): + """ + Return True when the area below the write position is still empty. + (For floats that should not hide content underneath.) + """ + wp = write_position + Transparent = Token.Transparent + + for y in range(wp.ypos, wp.ypos + wp.height): + if y in screen.data_buffer: + row = screen.data_buffer[y] + + for x in range(wp.xpos, wp.xpos + wp.width): + c = row[x] + if c.char != ' ' or c.token != Transparent: + return False + + return True + + def walk(self, cli): + """ Walk through children. """ + yield self + + for i in self.content.walk(cli): + yield i + + for f in self.floats: + for i in f.content.walk(cli): + yield i + + +class Float(object): + """ + Float for use in a :class:`.FloatContainer`. + + :param content: :class:`.Container` instance. + :param hide_when_covering_content: Hide the float when it covers content underneath. + """ + def __init__(self, top=None, right=None, bottom=None, left=None, + width=None, height=None, get_width=None, get_height=None, + xcursor=False, ycursor=False, content=None, + hide_when_covering_content=False): + assert isinstance(content, Container) + assert width is None or get_width is None + assert height is None or get_height is None + + self.left = left + self.right = right + self.top = top + self.bottom = bottom + + self._width = width + self._height = height + + self._get_width = get_width + self._get_height = get_height + + self.xcursor = xcursor + self.ycursor = ycursor + + self.content = content + self.hide_when_covering_content = hide_when_covering_content + + def get_width(self, cli): + if self._width: + return self._width + if self._get_width: + return self._get_width(cli) + + def get_height(self, cli): + if self._height: + return self._height + if self._get_height: + return self._get_height(cli) + + def __repr__(self): + return 'Float(content=%r)' % self.content + + +class WindowRenderInfo(object): + """ + Render information, for the last render time of this control. + It stores mapping information between the input buffers (in case of a + :class:`~prompt_toolkit.layout.controls.BufferControl`) and the actual + render position on the output screen. + + (Could be used for implementation of the Vi 'H' and 'L' key bindings as + well as implementing mouse support.) + + :param ui_content: The original :class:`.UIContent` instance that contains + the whole input, without clipping. (ui_content) + :param horizontal_scroll: The horizontal scroll of the :class:`.Window` instance. + :param vertical_scroll: The vertical scroll of the :class:`.Window` instance. + :param window_width: The width of the window that displays the content, + without the margins. + :param window_height: The height of the window that displays the content. + :param configured_scroll_offsets: The scroll offsets as configured for the + :class:`Window` instance. + :param visible_line_to_row_col: Mapping that maps the row numbers on the + displayed screen (starting from zero for the first visible line) to + (row, col) tuples pointing to the row and column of the :class:`.UIContent`. + :param rowcol_to_yx: Mapping that maps (row, column) tuples representing + coordinates of the :class:`UIContent` to (y, x) absolute coordinates at + the rendered screen. + """ + def __init__(self, ui_content, horizontal_scroll, vertical_scroll, + window_width, window_height, + configured_scroll_offsets, + visible_line_to_row_col, rowcol_to_yx, + x_offset, y_offset, wrap_lines): + assert isinstance(ui_content, UIContent) + assert isinstance(horizontal_scroll, int) + assert isinstance(vertical_scroll, int) + assert isinstance(window_width, int) + assert isinstance(window_height, int) + assert isinstance(configured_scroll_offsets, ScrollOffsets) + assert isinstance(visible_line_to_row_col, dict) + assert isinstance(rowcol_to_yx, dict) + assert isinstance(x_offset, int) + assert isinstance(y_offset, int) + assert isinstance(wrap_lines, bool) + + self.ui_content = ui_content + self.vertical_scroll = vertical_scroll + self.window_width = window_width # Width without margins. + self.window_height = window_height + + self.configured_scroll_offsets = configured_scroll_offsets + self.visible_line_to_row_col = visible_line_to_row_col + self.wrap_lines = wrap_lines + + self._rowcol_to_yx = rowcol_to_yx # row/col from input to absolute y/x + # screen coordinates. + self._x_offset = x_offset + self._y_offset = y_offset + + @property + def visible_line_to_input_line(self): + return dict( + (visible_line, rowcol[0]) + for visible_line, rowcol in self.visible_line_to_row_col.items()) + + @property + def cursor_position(self): + """ + Return the cursor position coordinates, relative to the left/top corner + of the rendered screen. + """ + cpos = self.ui_content.cursor_position + y, x = self._rowcol_to_yx[cpos.y, cpos.x] + return Point(x=x - self._x_offset, y=y - self._y_offset) + + @property + def applied_scroll_offsets(self): + """ + Return a :class:`.ScrollOffsets` instance that indicates the actual + offset. This can be less than or equal to what's configured. E.g, when + the cursor is completely at the top, the top offset will be zero rather + than what's configured. + """ + if self.displayed_lines[0] == 0: + top = 0 + else: + # Get row where the cursor is displayed. + y = self.input_line_to_visible_line[self.ui_content.cursor_position.y] + top = min(y, self.configured_scroll_offsets.top) + + return ScrollOffsets( + top=top, + bottom=min(self.ui_content.line_count - self.displayed_lines[-1] - 1, + self.configured_scroll_offsets.bottom), + + # For left/right, it probably doesn't make sense to return something. + # (We would have to calculate the widths of all the lines and keep + # double width characters in mind.) + left=0, right=0) + + @property + def displayed_lines(self): + """ + List of all the visible rows. (Line numbers of the input buffer.) + The last line may not be entirely visible. + """ + return sorted(row for row, col in self.visible_line_to_row_col.values()) + + @property + def input_line_to_visible_line(self): + """ + Return the dictionary mapping the line numbers of the input buffer to + the lines of the screen. When a line spans several rows at the screen, + the first row appears in the dictionary. + """ + result = {} + for k, v in self.visible_line_to_input_line.items(): + if v in result: + result[v] = min(result[v], k) + else: + result[v] = k + return result + + def first_visible_line(self, after_scroll_offset=False): + """ + Return the line number (0 based) of the input document that corresponds + with the first visible line. + """ + if after_scroll_offset: + return self.displayed_lines[self.applied_scroll_offsets.top] + else: + return self.displayed_lines[0] + + def last_visible_line(self, before_scroll_offset=False): + """ + Like `first_visible_line`, but for the last visible line. + """ + if before_scroll_offset: + return self.displayed_lines[-1 - self.applied_scroll_offsets.bottom] + else: + return self.displayed_lines[-1] + + def center_visible_line(self, before_scroll_offset=False, + after_scroll_offset=False): + """ + Like `first_visible_line`, but for the center visible line. + """ + return (self.first_visible_line(after_scroll_offset) + + (self.last_visible_line(before_scroll_offset) - + self.first_visible_line(after_scroll_offset)) // 2 + ) + + @property + def content_height(self): + """ + The full height of the user control. + """ + return self.ui_content.line_count + + @property + def full_height_visible(self): + """ + True when the full height is visible (There is no vertical scroll.) + """ + return self.vertical_scroll == 0 and self.last_visible_line() == self.content_height + + @property + def top_visible(self): + """ + True when the top of the buffer is visible. + """ + return self.vertical_scroll == 0 + + @property + def bottom_visible(self): + """ + True when the bottom of the buffer is visible. + """ + return self.last_visible_line() == self.content_height - 1 + + @property + def vertical_scroll_percentage(self): + """ + Vertical scroll as a percentage. (0 means: the top is visible, + 100 means: the bottom is visible.) + """ + if self.bottom_visible: + return 100 + else: + return (100 * self.vertical_scroll // self.content_height) + + def get_height_for_line(self, lineno): + """ + Return the height of the given line. + (The height that it would take, if this line became visible.) + """ + if self.wrap_lines: + return self.ui_content.get_height_for_line(lineno, self.window_width) + else: + return 1 + + +class ScrollOffsets(object): + """ + Scroll offsets for the :class:`.Window` class. + + Note that left/right offsets only make sense if line wrapping is disabled. + """ + def __init__(self, top=0, bottom=0, left=0, right=0): + assert isinstance(top, Integer) + assert isinstance(bottom, Integer) + assert isinstance(left, Integer) + assert isinstance(right, Integer) + + self._top = top + self._bottom = bottom + self._left = left + self._right = right + + @property + def top(self): + return int(self._top) + + @property + def bottom(self): + return int(self._bottom) + + @property + def left(self): + return int(self._left) + + @property + def right(self): + return int(self._right) + + def __repr__(self): + return 'ScrollOffsets(top=%r, bottom=%r, left=%r, right=%r)' % ( + self.top, self.bottom, self.left, self.right) + + +class ColorColumn(object): + def __init__(self, position, token=Token.ColorColumn): + self.position = position + self.token = token + + +_in_insert_mode = ViInsertMode() | EmacsInsertMode() + + +class Window(Container): + """ + Container that holds a control. + + :param content: :class:`~prompt_toolkit.layout.controls.UIControl` instance. + :param width: :class:`~prompt_toolkit.layout.dimension.LayoutDimension` instance. + :param height: :class:`~prompt_toolkit.layout.dimension.LayoutDimension` instance. + :param get_width: callable which takes a `CommandLineInterface` and returns a `LayoutDimension`. + :param get_height: callable which takes a `CommandLineInterface` and returns a `LayoutDimension`. + :param dont_extend_width: When `True`, don't take up more width then the + preferred width reported by the control. + :param dont_extend_height: When `True`, don't take up more width then the + preferred height reported by the control. + :param left_margins: A list of :class:`~prompt_toolkit.layout.margins.Margin` + instance to be displayed on the left. For instance: + :class:`~prompt_toolkit.layout.margins.NumberredMargin` can be one of + them in order to show line numbers. + :param right_margins: Like `left_margins`, but on the other side. + :param scroll_offsets: :class:`.ScrollOffsets` instance, representing the + preferred amount of lines/columns to be always visible before/after the + cursor. When both top and bottom are a very high number, the cursor + will be centered vertically most of the time. + :param allow_scroll_beyond_bottom: A `bool` or + :class:`~prompt_toolkit.filters.CLIFilter` instance. When True, allow + scrolling so far, that the top part of the content is not visible + anymore, while there is still empty space available at the bottom of + the window. In the Vi editor for instance, this is possible. You will + see tildes while the top part of the body is hidden. + :param wrap_lines: A `bool` or :class:`~prompt_toolkit.filters.CLIFilter` + instance. When True, don't scroll horizontally, but wrap lines instead. + :param get_vertical_scroll: Callable that takes this window + instance as input and returns a preferred vertical scroll. + (When this is `None`, the scroll is only determined by the last and + current cursor position.) + :param get_horizontal_scroll: Callable that takes this window + instance as input and returns a preferred vertical scroll. + :param always_hide_cursor: A `bool` or + :class:`~prompt_toolkit.filters.CLIFilter` instance. When True, never + display the cursor, even when the user control specifies a cursor + position. + :param cursorline: A `bool` or :class:`~prompt_toolkit.filters.CLIFilter` + instance. When True, display a cursorline. + :param cursorcolumn: A `bool` or :class:`~prompt_toolkit.filters.CLIFilter` + instance. When True, display a cursorcolumn. + :param get_colorcolumns: A callable that takes a `CommandLineInterface` and + returns a a list of :class:`.ColorColumn` instances that describe the + columns to be highlighted. + :param cursorline_token: The token to be used for highlighting the current line, + if `cursorline` is True. + :param cursorcolumn_token: The token to be used for highlighting the current line, + if `cursorcolumn` is True. + """ + def __init__(self, content, width=None, height=None, get_width=None, + get_height=None, dont_extend_width=False, dont_extend_height=False, + left_margins=None, right_margins=None, scroll_offsets=None, + allow_scroll_beyond_bottom=False, wrap_lines=False, + get_vertical_scroll=None, get_horizontal_scroll=None, always_hide_cursor=False, + cursorline=False, cursorcolumn=False, get_colorcolumns=None, + cursorline_token=Token.CursorLine, cursorcolumn_token=Token.CursorColumn): + assert isinstance(content, UIControl) + assert width is None or isinstance(width, LayoutDimension) + assert height is None or isinstance(height, LayoutDimension) + assert get_width is None or callable(get_width) + assert get_height is None or callable(get_height) + assert width is None or get_width is None + assert height is None or get_height is None + assert scroll_offsets is None or isinstance(scroll_offsets, ScrollOffsets) + assert left_margins is None or all(isinstance(m, Margin) for m in left_margins) + assert right_margins is None or all(isinstance(m, Margin) for m in right_margins) + assert get_vertical_scroll is None or callable(get_vertical_scroll) + assert get_horizontal_scroll is None or callable(get_horizontal_scroll) + assert get_colorcolumns is None or callable(get_colorcolumns) + + self.allow_scroll_beyond_bottom = to_cli_filter(allow_scroll_beyond_bottom) + self.always_hide_cursor = to_cli_filter(always_hide_cursor) + self.wrap_lines = to_cli_filter(wrap_lines) + self.cursorline = to_cli_filter(cursorline) + self.cursorcolumn = to_cli_filter(cursorcolumn) + + self.content = content + self.dont_extend_width = dont_extend_width + self.dont_extend_height = dont_extend_height + self.left_margins = left_margins or [] + self.right_margins = right_margins or [] + self.scroll_offsets = scroll_offsets or ScrollOffsets() + self.get_vertical_scroll = get_vertical_scroll + self.get_horizontal_scroll = get_horizontal_scroll + self._width = get_width or (lambda cli: width) + self._height = get_height or (lambda cli: height) + self.get_colorcolumns = get_colorcolumns or (lambda cli: []) + self.cursorline_token = cursorline_token + self.cursorcolumn_token = cursorcolumn_token + + # Cache for the screens generated by the margin. + self._ui_content_cache = SimpleCache(maxsize=8) + self._margin_width_cache = SimpleCache(maxsize=1) + + self.reset() + + def __repr__(self): + return 'Window(content=%r)' % self.content + + def reset(self): + self.content.reset() + + #: Scrolling position of the main content. + self.vertical_scroll = 0 + self.horizontal_scroll = 0 + + # Vertical scroll 2: this is the vertical offset that a line is + # scrolled if a single line (the one that contains the cursor) consumes + # all of the vertical space. + self.vertical_scroll_2 = 0 + + #: Keep render information (mappings between buffer input and render + #: output.) + self.render_info = None + + def _get_margin_width(self, cli, margin): + """ + Return the width for this margin. + (Calculate only once per render time.) + """ + # Margin.get_width, needs to have a UIContent instance. + def get_ui_content(): + return self._get_ui_content(cli, width=0, height=0) + + def get_width(): + return margin.get_width(cli, get_ui_content) + + key = (margin, cli.render_counter) + return self._margin_width_cache.get(key, get_width) + + def preferred_width(self, cli, max_available_width): + # Calculate the width of the margin. + total_margin_width = sum(self._get_margin_width(cli, m) for m in + self.left_margins + self.right_margins) + + # Window of the content. (Can be `None`.) + preferred_width = self.content.preferred_width( + cli, max_available_width - total_margin_width) + + if preferred_width is not None: + # Include width of the margins. + preferred_width += total_margin_width + + # Merge. + return self._merge_dimensions( + dimension=self._width(cli), + preferred=preferred_width, + dont_extend=self.dont_extend_width) + + def preferred_height(self, cli, width, max_available_height): + total_margin_width = sum(self._get_margin_width(cli, m) for m in + self.left_margins + self.right_margins) + wrap_lines = self.wrap_lines(cli) + + return self._merge_dimensions( + dimension=self._height(cli), + preferred=self.content.preferred_height( + cli, width - total_margin_width, max_available_height, wrap_lines), + dont_extend=self.dont_extend_height) + + @staticmethod + def _merge_dimensions(dimension, preferred=None, dont_extend=False): + """ + Take the LayoutDimension from this `Window` class and the received + preferred size from the `UIControl` and return a `LayoutDimension` to + report to the parent container. + """ + dimension = dimension or LayoutDimension() + + # When a preferred dimension was explicitly given to the Window, + # ignore the UIControl. + if dimension.preferred_specified: + preferred = dimension.preferred + + # When a 'preferred' dimension is given by the UIControl, make sure + # that it stays within the bounds of the Window. + if preferred is not None: + if dimension.max: + preferred = min(preferred, dimension.max) + + if dimension.min: + preferred = max(preferred, dimension.min) + + # When a `dont_extend` flag has been given, use the preferred dimension + # also as the max dimension. + if dont_extend and preferred is not None: + max_ = min(dimension.max, preferred) + else: + max_ = dimension.max + + return LayoutDimension( + min=dimension.min, max=max_, + preferred=preferred, weight=dimension.weight) + + def _get_ui_content(self, cli, width, height): + """ + Create a `UIContent` instance. + """ + def get_content(): + return self.content.create_content(cli, width=width, height=height) + + key = (cli.render_counter, width, height) + return self._ui_content_cache.get(key, get_content) + + def _get_digraph_char(self, cli): + " Return `False`, or the Digraph symbol to be used. " + if cli.quoted_insert: + return '^' + if cli.vi_state.waiting_for_digraph: + if cli.vi_state.digraph_symbol1: + return cli.vi_state.digraph_symbol1 + return '?' + return False + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + """ + Write window to screen. This renders the user control, the margins and + copies everything over to the absolute position at the given screen. + """ + # Calculate margin sizes. + left_margin_widths = [self._get_margin_width(cli, m) for m in self.left_margins] + right_margin_widths = [self._get_margin_width(cli, m) for m in self.right_margins] + total_margin_width = sum(left_margin_widths + right_margin_widths) + + # Render UserControl. + ui_content = self.content.create_content( + cli, write_position.width - total_margin_width, write_position.height) + assert isinstance(ui_content, UIContent) + + # Scroll content. + wrap_lines = self.wrap_lines(cli) + scroll_func = self._scroll_when_linewrapping if wrap_lines else self._scroll_without_linewrapping + + scroll_func( + ui_content, write_position.width - total_margin_width, write_position.height, cli) + + # Write body + visible_line_to_row_col, rowcol_to_yx = self._copy_body( + cli, ui_content, screen, write_position, + sum(left_margin_widths), write_position.width - total_margin_width, + self.vertical_scroll, self.horizontal_scroll, + has_focus=self.content.has_focus(cli), + wrap_lines=wrap_lines, highlight_lines=True, + vertical_scroll_2=self.vertical_scroll_2, + always_hide_cursor=self.always_hide_cursor(cli)) + + # Remember render info. (Set before generating the margins. They need this.) + x_offset=write_position.xpos + sum(left_margin_widths) + y_offset=write_position.ypos + + self.render_info = WindowRenderInfo( + ui_content=ui_content, + horizontal_scroll=self.horizontal_scroll, + vertical_scroll=self.vertical_scroll, + window_width=write_position.width - total_margin_width, + window_height=write_position.height, + configured_scroll_offsets=self.scroll_offsets, + visible_line_to_row_col=visible_line_to_row_col, + rowcol_to_yx=rowcol_to_yx, + x_offset=x_offset, + y_offset=y_offset, + wrap_lines=wrap_lines) + + # Set mouse handlers. + def mouse_handler(cli, mouse_event): + """ Wrapper around the mouse_handler of the `UIControl` that turns + screen coordinates into line coordinates. """ + # Find row/col position first. + yx_to_rowcol = dict((v, k) for k, v in rowcol_to_yx.items()) + y = mouse_event.position.y + x = mouse_event.position.x + + # If clicked below the content area, look for a position in the + # last line instead. + max_y = write_position.ypos + len(visible_line_to_row_col) - 1 + y = min(max_y, y) + + while x >= 0: + try: + row, col = yx_to_rowcol[y, x] + except KeyError: + # Try again. (When clicking on the right side of double + # width characters, or on the right side of the input.) + x -= 1 + else: + # Found position, call handler of UIControl. + result = self.content.mouse_handler( + cli, MouseEvent(position=Point(x=col, y=row), + event_type=mouse_event.event_type)) + break + else: + # nobreak. + # (No x/y coordinate found for the content. This happens in + # case of a FillControl, that only specifies a background, but + # doesn't have a content. Report (0,0) instead.) + result = self.content.mouse_handler( + cli, MouseEvent(position=Point(x=0, y=0), + event_type=mouse_event.event_type)) + + # If it returns NotImplemented, handle it here. + if result == NotImplemented: + return self._mouse_handler(cli, mouse_event) + + return result + + mouse_handlers.set_mouse_handler_for_range( + x_min=write_position.xpos + sum(left_margin_widths), + x_max=write_position.xpos + write_position.width - total_margin_width, + y_min=write_position.ypos, + y_max=write_position.ypos + write_position.height, + handler=mouse_handler) + + # Render and copy margins. + move_x = 0 + + def render_margin(m, width): + " Render margin. Return `Screen`. " + # Retrieve margin tokens. + tokens = m.create_margin(cli, self.render_info, width, write_position.height) + + # Turn it into a UIContent object. + # already rendered those tokens using this size.) + return TokenListControl.static(tokens).create_content( + cli, width + 1, write_position.height) + + for m, width in zip(self.left_margins, left_margin_widths): + # Create screen for margin. + margin_screen = render_margin(m, width) + + # Copy and shift X. + self._copy_margin(cli, margin_screen, screen, write_position, move_x, width) + move_x += width + + move_x = write_position.width - sum(right_margin_widths) + + for m, width in zip(self.right_margins, right_margin_widths): + # Create screen for margin. + margin_screen = render_margin(m, width) + + # Copy and shift X. + self._copy_margin(cli, margin_screen, screen, write_position, move_x, width) + move_x += width + + def _copy_body(self, cli, ui_content, new_screen, write_position, move_x, + width, vertical_scroll=0, horizontal_scroll=0, + has_focus=False, wrap_lines=False, highlight_lines=False, + vertical_scroll_2=0, always_hide_cursor=False): + """ + Copy the UIContent into the output screen. + """ + xpos = write_position.xpos + move_x + ypos = write_position.ypos + line_count = ui_content.line_count + new_buffer = new_screen.data_buffer + empty_char = _CHAR_CACHE['', Token] + ZeroWidthEscape = Token.ZeroWidthEscape + + # Map visible line number to (row, col) of input. + # 'col' will always be zero if line wrapping is off. + visible_line_to_row_col = {} + rowcol_to_yx = {} # Maps (row, col) from the input to (y, x) screen coordinates. + + # Fill background with default_char first. + default_char = ui_content.default_char + + if default_char: + for y in range(ypos, ypos + write_position.height): + new_buffer_row = new_buffer[y] + for x in range(xpos, xpos + width): + new_buffer_row[x] = default_char + + # Copy content. + def copy(): + y = - vertical_scroll_2 + lineno = vertical_scroll + + while y < write_position.height and lineno < line_count: + # Take the next line and copy it in the real screen. + line = ui_content.get_line(lineno) + + col = 0 + x = -horizontal_scroll + + visible_line_to_row_col[y] = (lineno, horizontal_scroll) + new_buffer_row = new_buffer[y + ypos] + + for token, text in line: + # Remember raw VT escape sequences. (E.g. FinalTerm's + # escape sequences.) + if token == ZeroWidthEscape: + new_screen.zero_width_escapes[y + ypos][x + xpos] += text + continue + + for c in text: + char = _CHAR_CACHE[c, token] + char_width = char.width + + # Wrap when the line width is exceeded. + if wrap_lines and x + char_width > width: + visible_line_to_row_col[y + 1] = ( + lineno, visible_line_to_row_col[y][1] + x) + y += 1 + x = -horizontal_scroll # This would be equal to zero. + # (horizontal_scroll=0 when wrap_lines.) + new_buffer_row = new_buffer[y + ypos] + + if y >= write_position.height: + return y # Break out of all for loops. + + # Set character in screen and shift 'x'. + if x >= 0 and y >= 0 and x < write_position.width: + new_buffer_row[x + xpos] = char + + # When we print a multi width character, make sure + # to erase the neighbous positions in the screen. + # (The empty string if different from everything, + # so next redraw this cell will repaint anyway.) + if char_width > 1: + for i in range(1, char_width): + new_buffer_row[x + xpos + i] = empty_char + + # If this is a zero width characters, then it's + # probably part of a decomposed unicode character. + # See: https://en.wikipedia.org/wiki/Unicode_equivalence + # Merge it in the previous cell. + elif char_width == 0 and x - 1 >= 0: + prev_char = new_buffer_row[x + xpos - 1] + char2 = _CHAR_CACHE[prev_char.char + c, prev_char.token] + new_buffer_row[x + xpos - 1] = char2 + + # Keep track of write position for each character. + rowcol_to_yx[lineno, col] = (y + ypos, x + xpos) + + col += 1 + x += char_width + + lineno += 1 + y += 1 + return y + + y = copy() + + def cursor_pos_to_screen_pos(row, col): + " Translate row/col from UIContent to real Screen coordinates. " + try: + y, x = rowcol_to_yx[row, col] + except KeyError: + # Normally this should never happen. (It is a bug, if it happens.) + # But to be sure, return (0, 0) + return Point(y=0, x=0) + + # raise ValueError( + # 'Invalid position. row=%r col=%r, vertical_scroll=%r, ' + # 'horizontal_scroll=%r, height=%r' % + # (row, col, vertical_scroll, horizontal_scroll, write_position.height)) + else: + return Point(y=y, x=x) + + # Set cursor and menu positions. + if ui_content.cursor_position: + screen_cursor_position = cursor_pos_to_screen_pos( + ui_content.cursor_position.y, ui_content.cursor_position.x) + + if has_focus: + new_screen.cursor_position = screen_cursor_position + + if always_hide_cursor: + new_screen.show_cursor = False + else: + new_screen.show_cursor = ui_content.show_cursor + + self._highlight_digraph(cli, new_screen) + + if highlight_lines: + self._highlight_cursorlines( + cli, new_screen, screen_cursor_position, xpos, ypos, width, + write_position.height) + + # Draw input characters from the input processor queue. + if has_focus and ui_content.cursor_position: + self._show_input_processor_key_buffer(cli, new_screen) + + # Set menu position. + if not new_screen.menu_position and ui_content.menu_position: + new_screen.menu_position = cursor_pos_to_screen_pos( + ui_content.menu_position.y, ui_content.menu_position.x) + + # Update output screne height. + new_screen.height = max(new_screen.height, ypos + write_position.height) + + return visible_line_to_row_col, rowcol_to_yx + + def _highlight_digraph(self, cli, new_screen): + """ + When we are in Vi digraph mode, put a question mark underneath the + cursor. + """ + digraph_char = self._get_digraph_char(cli) + if digraph_char: + cpos = new_screen.cursor_position + new_screen.data_buffer[cpos.y][cpos.x] = \ + _CHAR_CACHE[digraph_char, Token.Digraph] + + def _show_input_processor_key_buffer(self, cli, new_screen): + """ + When the user is typing a key binding that consists of several keys, + display the last pressed key if the user is in insert mode and the key + is meaningful to be displayed. + E.g. Some people want to bind 'jj' to escape in Vi insert mode. But the + first 'j' needs to be displayed in order to get some feedback. + """ + key_buffer = cli.input_processor.key_buffer + + if key_buffer and _in_insert_mode(cli) and not cli.is_done: + # The textual data for the given key. (Can be a VT100 escape + # sequence.) + data = key_buffer[-1].data + + # Display only if this is a 1 cell width character. + if get_cwidth(data) == 1: + cpos = new_screen.cursor_position + new_screen.data_buffer[cpos.y][cpos.x] = \ + _CHAR_CACHE[data, Token.PartialKeyBinding] + + def _highlight_cursorlines(self, cli, new_screen, cpos, x, y, width, height): + """ + Highlight cursor row/column. + """ + cursor_line_token = (':', ) + self.cursorline_token + cursor_column_token = (':', ) + self.cursorcolumn_token + + data_buffer = new_screen.data_buffer + + # Highlight cursor line. + if self.cursorline(cli): + row = data_buffer[cpos.y] + for x in range(x, x + width): + original_char = row[x] + row[x] = _CHAR_CACHE[ + original_char.char, original_char.token + cursor_line_token] + + # Highlight cursor column. + if self.cursorcolumn(cli): + for y2 in range(y, y + height): + row = data_buffer[y2] + original_char = row[cpos.x] + row[cpos.x] = _CHAR_CACHE[ + original_char.char, original_char.token + cursor_column_token] + + # Highlight color columns + for cc in self.get_colorcolumns(cli): + assert isinstance(cc, ColorColumn) + color_column_token = (':', ) + cc.token + column = cc.position + + for y2 in range(y, y + height): + row = data_buffer[y2] + original_char = row[column] + row[column] = _CHAR_CACHE[ + original_char.char, original_char.token + color_column_token] + + def _copy_margin(self, cli, lazy_screen, new_screen, write_position, move_x, width): + """ + Copy characters from the margin screen to the real screen. + """ + xpos = write_position.xpos + move_x + ypos = write_position.ypos + + margin_write_position = WritePosition(xpos, ypos, width, write_position.height) + self._copy_body(cli, lazy_screen, new_screen, margin_write_position, 0, width) + + def _scroll_when_linewrapping(self, ui_content, width, height, cli): + """ + Scroll to make sure the cursor position is visible and that we maintain + the requested scroll offset. + + Set `self.horizontal_scroll/vertical_scroll`. + """ + scroll_offsets_bottom = self.scroll_offsets.bottom + scroll_offsets_top = self.scroll_offsets.top + + # We don't have horizontal scrolling. + self.horizontal_scroll = 0 + + # If the current line consumes more than the whole window height, + # then we have to scroll vertically inside this line. (We don't take + # the scroll offsets into account for this.) + # Also, ignore the scroll offsets in this case. Just set the vertical + # scroll to this line. + if ui_content.get_height_for_line(ui_content.cursor_position.y, width) > height - scroll_offsets_top: + # Calculate the height of the text before the cursor, with the line + # containing the cursor included, and the character belowe the + # cursor included as well. + line = explode_tokens(ui_content.get_line(ui_content.cursor_position.y)) + text_before_cursor = token_list_to_text(line[:ui_content.cursor_position.x + 1]) + text_before_height = UIContent.get_height_for_text(text_before_cursor, width) + + # Adjust scroll offset. + self.vertical_scroll = ui_content.cursor_position.y + self.vertical_scroll_2 = min(text_before_height - 1, self.vertical_scroll_2) + self.vertical_scroll_2 = max(0, text_before_height - height, self.vertical_scroll_2) + return + else: + self.vertical_scroll_2 = 0 + + # Current line doesn't consume the whole height. Take scroll offsets into account. + def get_min_vertical_scroll(): + # Make sure that the cursor line is not below the bottom. + # (Calculate how many lines can be shown between the cursor and the .) + used_height = 0 + prev_lineno = ui_content.cursor_position.y + + for lineno in range(ui_content.cursor_position.y, -1, -1): + used_height += ui_content.get_height_for_line(lineno, width) + + if used_height > height - scroll_offsets_bottom: + return prev_lineno + else: + prev_lineno = lineno + return 0 + + def get_max_vertical_scroll(): + # Make sure that the cursor line is not above the top. + prev_lineno = ui_content.cursor_position.y + used_height = 0 + + for lineno in range(ui_content.cursor_position.y - 1, -1, -1): + used_height += ui_content.get_height_for_line(lineno, width) + + if used_height > scroll_offsets_top: + return prev_lineno + else: + prev_lineno = lineno + return prev_lineno + + def get_topmost_visible(): + """ + Calculate the upper most line that can be visible, while the bottom + is still visible. We should not allow scroll more than this if + `allow_scroll_beyond_bottom` is false. + """ + prev_lineno = ui_content.line_count - 1 + used_height = 0 + for lineno in range(ui_content.line_count - 1, -1, -1): + used_height += ui_content.get_height_for_line(lineno, width) + if used_height > height: + return prev_lineno + else: + prev_lineno = lineno + return prev_lineno + + # Scroll vertically. (Make sure that the whole line which contains the + # cursor is visible. + topmost_visible = get_topmost_visible() + + # Note: the `min(topmost_visible, ...)` is to make sure that we + # don't require scrolling up because of the bottom scroll offset, + # when we are at the end of the document. + self.vertical_scroll = max(self.vertical_scroll, min(topmost_visible, get_min_vertical_scroll())) + self.vertical_scroll = min(self.vertical_scroll, get_max_vertical_scroll()) + + # Disallow scrolling beyond bottom? + if not self.allow_scroll_beyond_bottom(cli): + self.vertical_scroll = min(self.vertical_scroll, topmost_visible) + + def _scroll_without_linewrapping(self, ui_content, width, height, cli): + """ + Scroll to make sure the cursor position is visible and that we maintain + the requested scroll offset. + + Set `self.horizontal_scroll/vertical_scroll`. + """ + cursor_position = ui_content.cursor_position or Point(0, 0) + + # Without line wrapping, we will never have to scroll vertically inside + # a single line. + self.vertical_scroll_2 = 0 + + if ui_content.line_count == 0: + self.vertical_scroll = 0 + self.horizontal_scroll = 0 + return + else: + current_line_text = token_list_to_text(ui_content.get_line(cursor_position.y)) + + def do_scroll(current_scroll, scroll_offset_start, scroll_offset_end, + cursor_pos, window_size, content_size): + " Scrolling algorithm. Used for both horizontal and vertical scrolling. " + # Calculate the scroll offset to apply. + # This can obviously never be more than have the screen size. Also, when the + # cursor appears at the top or bottom, we don't apply the offset. + scroll_offset_start = int(min(scroll_offset_start, window_size / 2, cursor_pos)) + scroll_offset_end = int(min(scroll_offset_end, window_size / 2, + content_size - 1 - cursor_pos)) + + # Prevent negative scroll offsets. + if current_scroll < 0: + current_scroll = 0 + + # Scroll back if we scrolled to much and there's still space to show more of the document. + if (not self.allow_scroll_beyond_bottom(cli) and + current_scroll > content_size - window_size): + current_scroll = max(0, content_size - window_size) + + # Scroll up if cursor is before visible part. + if current_scroll > cursor_pos - scroll_offset_start: + current_scroll = max(0, cursor_pos - scroll_offset_start) + + # Scroll down if cursor is after visible part. + if current_scroll < (cursor_pos + 1) - window_size + scroll_offset_end: + current_scroll = (cursor_pos + 1) - window_size + scroll_offset_end + + return current_scroll + + # When a preferred scroll is given, take that first into account. + if self.get_vertical_scroll: + self.vertical_scroll = self.get_vertical_scroll(self) + assert isinstance(self.vertical_scroll, int) + if self.get_horizontal_scroll: + self.horizontal_scroll = self.get_horizontal_scroll(self) + assert isinstance(self.horizontal_scroll, int) + + # Update horizontal/vertical scroll to make sure that the cursor + # remains visible. + offsets = self.scroll_offsets + + self.vertical_scroll = do_scroll( + current_scroll=self.vertical_scroll, + scroll_offset_start=offsets.top, + scroll_offset_end=offsets.bottom, + cursor_pos=ui_content.cursor_position.y, + window_size=height, + content_size=ui_content.line_count) + + self.horizontal_scroll = do_scroll( + current_scroll=self.horizontal_scroll, + scroll_offset_start=offsets.left, + scroll_offset_end=offsets.right, + cursor_pos=get_cwidth(current_line_text[:ui_content.cursor_position.x]), + window_size=width, + # We can only analyse the current line. Calculating the width off + # all the lines is too expensive. + content_size=max(get_cwidth(current_line_text), self.horizontal_scroll + width)) + + def _mouse_handler(self, cli, mouse_event): + """ + Mouse handler. Called when the UI control doesn't handle this + particular event. + """ + if mouse_event.event_type == MouseEventType.SCROLL_DOWN: + self._scroll_down(cli) + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + self._scroll_up(cli) + + def _scroll_down(self, cli): + " Scroll window down. " + info = self.render_info + + if self.vertical_scroll < info.content_height - info.window_height: + if info.cursor_position.y <= info.configured_scroll_offsets.top: + self.content.move_cursor_down(cli) + + self.vertical_scroll += 1 + + def _scroll_up(self, cli): + " Scroll window up. " + info = self.render_info + + if info.vertical_scroll > 0: + # TODO: not entirely correct yet in case of line wrapping and long lines. + if info.cursor_position.y >= info.window_height - 1 - info.configured_scroll_offsets.bottom: + self.content.move_cursor_up(cli) + + self.vertical_scroll -= 1 + + def walk(self, cli): + # Only yield self. A window doesn't have children. + yield self + + +class ConditionalContainer(Container): + """ + Wrapper around any other container that can change the visibility. The + received `filter` determines whether the given container should be + displayed or not. + + :param content: :class:`.Container` instance. + :param filter: :class:`~prompt_toolkit.filters.CLIFilter` instance. + """ + def __init__(self, content, filter): + assert isinstance(content, Container) + + self.content = content + self.filter = to_cli_filter(filter) + + def __repr__(self): + return 'ConditionalContainer(%r, filter=%r)' % (self.content, self.filter) + + def reset(self): + self.content.reset() + + def preferred_width(self, cli, max_available_width): + if self.filter(cli): + return self.content.preferred_width(cli, max_available_width) + else: + return LayoutDimension.exact(0) + + def preferred_height(self, cli, width, max_available_height): + if self.filter(cli): + return self.content.preferred_height(cli, width, max_available_height) + else: + return LayoutDimension.exact(0) + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + if self.filter(cli): + return self.content.write_to_screen(cli, screen, mouse_handlers, write_position) + + def walk(self, cli): + return self.content.walk(cli) + + +# Deprecated alias for 'Container'. +Layout = Container diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/controls.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/controls.py new file mode 100644 index 00000000000..ca74931dbc2 --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/controls.py @@ -0,0 +1,730 @@ +""" +User interface Controls for the layout. +""" +from __future__ import unicode_literals + +from abc import ABCMeta, abstractmethod +from collections import namedtuple +from six import with_metaclass +from six.moves import range + +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER +from prompt_toolkit.filters import to_cli_filter +from prompt_toolkit.mouse_events import MouseEventType +from prompt_toolkit.search_state import SearchState +from prompt_toolkit.selection import SelectionType +from prompt_toolkit.token import Token +from prompt_toolkit.utils import get_cwidth + +from .lexers import Lexer, SimpleLexer +from .processors import Processor +from .screen import Char, Point +from .utils import token_list_width, split_lines, token_list_to_text + +import six +import time + + +__all__ = ( + 'BufferControl', + 'FillControl', + 'TokenListControl', + 'UIControl', + 'UIContent', +) + + +class UIControl(with_metaclass(ABCMeta, object)): + """ + Base class for all user interface controls. + """ + def reset(self): + # Default reset. (Doesn't have to be implemented.) + pass + + def preferred_width(self, cli, max_available_width): + return None + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + return None + + def has_focus(self, cli): + """ + Return ``True`` when this user control has the focus. + + If so, the cursor will be displayed according to the cursor position + reported by :meth:`.UIControl.create_content`. If the created content + has the property ``show_cursor=False``, the cursor will be hidden from + the output. + """ + return False + + @abstractmethod + def create_content(self, cli, width, height): + """ + Generate the content for this user control. + + Returns a :class:`.UIContent` instance. + """ + + def mouse_handler(self, cli, mouse_event): + """ + Handle mouse events. + + When `NotImplemented` is returned, it means that the given event is not + handled by the `UIControl` itself. The `Window` or key bindings can + decide to handle this event as scrolling or changing focus. + + :param cli: `CommandLineInterface` instance. + :param mouse_event: `MouseEvent` instance. + """ + return NotImplemented + + def move_cursor_down(self, cli): + """ + Request to move the cursor down. + This happens when scrolling down and the cursor is completely at the + top. + """ + + def move_cursor_up(self, cli): + """ + Request to move the cursor up. + """ + + +class UIContent(object): + """ + Content generated by a user control. This content consists of a list of + lines. + + :param get_line: Callable that returns the current line. This is a list of + (Token, text) tuples. + :param line_count: The number of lines. + :param cursor_position: a :class:`.Point` for the cursor position. + :param menu_position: a :class:`.Point` for the menu position. + :param show_cursor: Make the cursor visible. + :param default_char: The default :class:`.Char` for filling the background. + """ + def __init__(self, get_line=None, line_count=0, + cursor_position=None, menu_position=None, show_cursor=True, + default_char=None): + assert callable(get_line) + assert isinstance(line_count, six.integer_types) + assert cursor_position is None or isinstance(cursor_position, Point) + assert menu_position is None or isinstance(menu_position, Point) + assert default_char is None or isinstance(default_char, Char) + + self.get_line = get_line + self.line_count = line_count + self.cursor_position = cursor_position or Point(0, 0) + self.menu_position = menu_position + self.show_cursor = show_cursor + self.default_char = default_char + + # Cache for line heights. Maps (lineno, width) -> height. + self._line_heights = {} + + def __getitem__(self, lineno): + " Make it iterable (iterate line by line). " + if lineno < self.line_count: + return self.get_line(lineno) + else: + raise IndexError + + def get_height_for_line(self, lineno, width): + """ + Return the height that a given line would need if it is rendered in a + space with the given width. + """ + try: + return self._line_heights[lineno, width] + except KeyError: + text = token_list_to_text(self.get_line(lineno)) + result = self.get_height_for_text(text, width) + + # Cache and return + self._line_heights[lineno, width] = result + return result + + @staticmethod + def get_height_for_text(text, width): + # Get text width for this line. + line_width = get_cwidth(text) + + # Calculate height. + try: + quotient, remainder = divmod(line_width, width) + except ZeroDivisionError: + # Return something very big. + # (This can happen, when the Window gets very small.) + return 10 ** 10 + else: + if remainder: + quotient += 1 # Like math.ceil. + return max(1, quotient) + + +class TokenListControl(UIControl): + """ + Control that displays a list of (Token, text) tuples. + (It's mostly optimized for rather small widgets, like toolbars, menus, etc...) + + Mouse support: + + The list of tokens can also contain tuples of three items, looking like: + (Token, text, handler). When mouse support is enabled and the user + clicks on this token, then the given handler is called. That handler + should accept two inputs: (CommandLineInterface, MouseEvent) and it + should either handle the event or return `NotImplemented` in case we + want the containing Window to handle this event. + + :param get_tokens: Callable that takes a `CommandLineInterface` instance + and returns the list of (Token, text) tuples to be displayed right now. + :param default_char: default :class:`.Char` (character and Token) to use + for the background when there is more space available than `get_tokens` + returns. + :param get_default_char: Like `default_char`, but this is a callable that + takes a :class:`prompt_toolkit.interface.CommandLineInterface` and + returns a :class:`.Char` instance. + :param has_focus: `bool` or `CLIFilter`, when this evaluates to `True`, + this UI control will take the focus. The cursor will be shown in the + upper left corner of this control, unless `get_token` returns a + ``Token.SetCursorPosition`` token somewhere in the token list, then the + cursor will be shown there. + """ + def __init__(self, get_tokens, default_char=None, get_default_char=None, + align_right=False, align_center=False, has_focus=False): + assert callable(get_tokens) + assert default_char is None or isinstance(default_char, Char) + assert get_default_char is None or callable(get_default_char) + assert not (default_char and get_default_char) + + self.align_right = to_cli_filter(align_right) + self.align_center = to_cli_filter(align_center) + self._has_focus_filter = to_cli_filter(has_focus) + + self.get_tokens = get_tokens + + # Construct `get_default_char` callable. + if default_char: + get_default_char = lambda _: default_char + elif not get_default_char: + get_default_char = lambda _: Char(' ', Token.Transparent) + + self.get_default_char = get_default_char + + #: Cache for the content. + self._content_cache = SimpleCache(maxsize=18) + self._token_cache = SimpleCache(maxsize=1) + # Only cache one token list. We don't need the previous item. + + # Render info for the mouse support. + self._tokens = None + + def reset(self): + self._tokens = None + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.get_tokens) + + def _get_tokens_cached(self, cli): + """ + Get tokens, but only retrieve tokens once during one render run. + (This function is called several times during one rendering, because + we also need those for calculating the dimensions.) + """ + return self._token_cache.get( + cli.render_counter, lambda: self.get_tokens(cli)) + + def has_focus(self, cli): + return self._has_focus_filter(cli) + + def preferred_width(self, cli, max_available_width): + """ + Return the preferred width for this control. + That is the width of the longest line. + """ + text = token_list_to_text(self._get_tokens_cached(cli)) + line_lengths = [get_cwidth(l) for l in text.split('\n')] + return max(line_lengths) + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + content = self.create_content(cli, width, None) + return content.line_count + + def create_content(self, cli, width, height): + # Get tokens + tokens_with_mouse_handlers = self._get_tokens_cached(cli) + + default_char = self.get_default_char(cli) + + # Wrap/align right/center parameters. + right = self.align_right(cli) + center = self.align_center(cli) + + def process_line(line): + " Center or right align a single line. " + used_width = token_list_width(line) + padding = width - used_width + if center: + padding = int(padding / 2) + return [(default_char.token, default_char.char * padding)] + line + + if right or center: + token_lines_with_mouse_handlers = [] + + for line in split_lines(tokens_with_mouse_handlers): + token_lines_with_mouse_handlers.append(process_line(line)) + else: + token_lines_with_mouse_handlers = list(split_lines(tokens_with_mouse_handlers)) + + # Strip mouse handlers from tokens. + token_lines = [ + [tuple(item[:2]) for item in line] + for line in token_lines_with_mouse_handlers + ] + + # Keep track of the tokens with mouse handler, for later use in + # `mouse_handler`. + self._tokens = tokens_with_mouse_handlers + + # If there is a `Token.SetCursorPosition` in the token list, set the + # cursor position here. + def get_cursor_position(): + SetCursorPosition = Token.SetCursorPosition + + for y, line in enumerate(token_lines): + x = 0 + for token, text in line: + if token == SetCursorPosition: + return Point(x=x, y=y) + x += len(text) + return None + + # Create content, or take it from the cache. + key = (default_char.char, default_char.token, + tuple(tokens_with_mouse_handlers), width, right, center) + + def get_content(): + return UIContent(get_line=lambda i: token_lines[i], + line_count=len(token_lines), + default_char=default_char, + cursor_position=get_cursor_position()) + + return self._content_cache.get(key, get_content) + + @classmethod + def static(cls, tokens): + def get_static_tokens(cli): + return tokens + return cls(get_static_tokens) + + def mouse_handler(self, cli, mouse_event): + """ + Handle mouse events. + + (When the token list contained mouse handlers and the user clicked on + on any of these, the matching handler is called. This handler can still + return `NotImplemented` in case we want the `Window` to handle this + particular event.) + """ + if self._tokens: + # Read the generator. + tokens_for_line = list(split_lines(self._tokens)) + + try: + tokens = tokens_for_line[mouse_event.position.y] + except IndexError: + return NotImplemented + else: + # Find position in the token list. + xpos = mouse_event.position.x + + # Find mouse handler for this character. + count = 0 + for item in tokens: + count += len(item[1]) + if count >= xpos: + if len(item) >= 3: + # Handler found. Call it. + # (Handler can return NotImplemented, so return + # that result.) + handler = item[2] + return handler(cli, mouse_event) + else: + break + + # Otherwise, don't handle here. + return NotImplemented + + +class FillControl(UIControl): + """ + Fill whole control with characters with this token. + (Also helpful for debugging.) + + :param char: :class:`.Char` instance to use for filling. + :param get_char: A callable that takes a CommandLineInterface and returns a + :class:`.Char` object. + """ + def __init__(self, character=None, token=Token, char=None, get_char=None): # 'character' and 'token' parameters are deprecated. + assert char is None or isinstance(char, Char) + assert get_char is None or callable(get_char) + assert not (char and get_char) + + self.char = char + + if character: + # Passing (character=' ', token=token) is deprecated. + self.character = character + self.token = token + + self.get_char = lambda cli: Char(character, token) + elif get_char: + # When 'get_char' is given. + self.get_char = get_char + else: + # When 'char' is given. + self.char = self.char or Char() + self.get_char = lambda cli: self.char + self.char = char + + def __repr__(self): + if self.char: + return '%s(char=%r)' % (self.__class__.__name__, self.char) + else: + return '%s(get_char=%r)' % (self.__class__.__name__, self.get_char) + + def reset(self): + pass + + def has_focus(self, cli): + return False + + def create_content(self, cli, width, height): + def get_line(i): + return [] + + return UIContent( + get_line=get_line, + line_count=100 ** 100, # Something very big. + default_char=self.get_char(cli)) + + +_ProcessedLine = namedtuple('_ProcessedLine', 'tokens source_to_display display_to_source') + + +class BufferControl(UIControl): + """ + Control for visualising the content of a `Buffer`. + + :param input_processors: list of :class:`~prompt_toolkit.layout.processors.Processor`. + :param lexer: :class:`~prompt_toolkit.layout.lexers.Lexer` instance for syntax highlighting. + :param preview_search: `bool` or `CLIFilter`: Show search while typing. + :param get_search_state: Callable that takes a CommandLineInterface and + returns the SearchState to be used. (If not CommandLineInterface.search_state.) + :param buffer_name: String representing the name of the buffer to display. + :param default_char: :class:`.Char` instance to use to fill the background. This is + transparent by default. + :param focus_on_click: Focus this buffer when it's click, but not yet focussed. + """ + def __init__(self, + buffer_name=DEFAULT_BUFFER, + input_processors=None, + lexer=None, + preview_search=False, + search_buffer_name=SEARCH_BUFFER, + get_search_state=None, + menu_position=None, + default_char=None, + focus_on_click=False): + assert input_processors is None or all(isinstance(i, Processor) for i in input_processors) + assert menu_position is None or callable(menu_position) + assert lexer is None or isinstance(lexer, Lexer) + assert get_search_state is None or callable(get_search_state) + assert default_char is None or isinstance(default_char, Char) + + self.preview_search = to_cli_filter(preview_search) + self.get_search_state = get_search_state + self.focus_on_click = to_cli_filter(focus_on_click) + + self.input_processors = input_processors or [] + self.buffer_name = buffer_name + self.menu_position = menu_position + self.lexer = lexer or SimpleLexer() + self.default_char = default_char or Char(token=Token.Transparent) + self.search_buffer_name = search_buffer_name + + #: Cache for the lexer. + #: Often, due to cursor movement, undo/redo and window resizing + #: operations, it happens that a short time, the same document has to be + #: lexed. This is a faily easy way to cache such an expensive operation. + self._token_cache = SimpleCache(maxsize=8) + + self._xy_to_cursor_position = None + self._last_click_timestamp = None + self._last_get_processed_line = None + + def _buffer(self, cli): + """ + The buffer object that contains the 'main' content. + """ + return cli.buffers[self.buffer_name] + + def has_focus(self, cli): + # This control gets the focussed if the actual `Buffer` instance has the + # focus or when any of the `InputProcessor` classes tells us that it + # wants the focus. (E.g. in case of a reverse-search, where the actual + # search buffer may not be displayed, but the "reverse-i-search" text + # should get the focus.) + return cli.current_buffer_name == self.buffer_name or \ + any(i.has_focus(cli) for i in self.input_processors) + + def preferred_width(self, cli, max_available_width): + """ + This should return the preferred width. + + Note: We don't specify a preferred width according to the content, + because it would be too expensive. Calculating the preferred + width can be done by calculating the longest line, but this would + require applying all the processors to each line. This is + unfeasible for a larger document, and doing it for small + documents only would result in inconsistent behaviour. + """ + return None + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + # Calculate the content height, if it was drawn on a screen with the + # given width. + height = 0 + content = self.create_content(cli, width, None) + + # When line wrapping is off, the height should be equal to the amount + # of lines. + if not wrap_lines: + return content.line_count + + # When the number of lines exceeds the max_available_height, just + # return max_available_height. No need to calculate anything. + if content.line_count >= max_available_height: + return max_available_height + + for i in range(content.line_count): + height += content.get_height_for_line(i, width) + + if height >= max_available_height: + return max_available_height + + return height + + def _get_tokens_for_line_func(self, cli, document): + """ + Create a function that returns the tokens for a given line. + """ + # Cache using `document.text`. + def get_tokens_for_line(): + return self.lexer.lex_document(cli, document) + + return self._token_cache.get(document.text, get_tokens_for_line) + + def _create_get_processed_line_func(self, cli, document): + """ + Create a function that takes a line number of the current document and + returns a _ProcessedLine(processed_tokens, source_to_display, display_to_source) + tuple. + """ + def transform(lineno, tokens): + " Transform the tokens for a given line number. " + source_to_display_functions = [] + display_to_source_functions = [] + + # Get cursor position at this line. + if document.cursor_position_row == lineno: + cursor_column = document.cursor_position_col + else: + cursor_column = None + + def source_to_display(i): + """ Translate x position from the buffer to the x position in the + processed token list. """ + for f in source_to_display_functions: + i = f(i) + return i + + # Apply each processor. + for p in self.input_processors: + transformation = p.apply_transformation( + cli, document, lineno, source_to_display, tokens) + tokens = transformation.tokens + + if cursor_column: + cursor_column = transformation.source_to_display(cursor_column) + + display_to_source_functions.append(transformation.display_to_source) + source_to_display_functions.append(transformation.source_to_display) + + def display_to_source(i): + for f in reversed(display_to_source_functions): + i = f(i) + return i + + return _ProcessedLine(tokens, source_to_display, display_to_source) + + def create_func(): + get_line = self._get_tokens_for_line_func(cli, document) + cache = {} + + def get_processed_line(i): + try: + return cache[i] + except KeyError: + processed_line = transform(i, get_line(i)) + cache[i] = processed_line + return processed_line + return get_processed_line + + return create_func() + + def create_content(self, cli, width, height): + """ + Create a UIContent. + """ + buffer = self._buffer(cli) + + # Get the document to be shown. If we are currently searching (the + # search buffer has focus, and the preview_search filter is enabled), + # then use the search document, which has possibly a different + # text/cursor position.) + def preview_now(): + """ True when we should preview a search. """ + return bool(self.preview_search(cli) and + cli.buffers[self.search_buffer_name].text) + + if preview_now(): + if self.get_search_state: + ss = self.get_search_state(cli) + else: + ss = cli.search_state + + document = buffer.document_for_search(SearchState( + text=cli.current_buffer.text, + direction=ss.direction, + ignore_case=ss.ignore_case)) + else: + document = buffer.document + + get_processed_line = self._create_get_processed_line_func(cli, document) + self._last_get_processed_line = get_processed_line + + def translate_rowcol(row, col): + " Return the content column for this coordinate. " + return Point(y=row, x=get_processed_line(row).source_to_display(col)) + + def get_line(i): + " Return the tokens for a given line number. " + tokens = get_processed_line(i).tokens + + # Add a space at the end, because that is a possible cursor + # position. (When inserting after the input.) We should do this on + # all the lines, not just the line containing the cursor. (Because + # otherwise, line wrapping/scrolling could change when moving the + # cursor around.) + tokens = tokens + [(self.default_char.token, ' ')] + return tokens + + content = UIContent( + get_line=get_line, + line_count=document.line_count, + cursor_position=translate_rowcol(document.cursor_position_row, + document.cursor_position_col), + default_char=self.default_char) + + # If there is an auto completion going on, use that start point for a + # pop-up menu position. (But only when this buffer has the focus -- + # there is only one place for a menu, determined by the focussed buffer.) + if cli.current_buffer_name == self.buffer_name: + menu_position = self.menu_position(cli) if self.menu_position else None + if menu_position is not None: + assert isinstance(menu_position, int) + menu_row, menu_col = buffer.document.translate_index_to_position(menu_position) + content.menu_position = translate_rowcol(menu_row, menu_col) + elif buffer.complete_state: + # Position for completion menu. + # Note: We use 'min', because the original cursor position could be + # behind the input string when the actual completion is for + # some reason shorter than the text we had before. (A completion + # can change and shorten the input.) + menu_row, menu_col = buffer.document.translate_index_to_position( + min(buffer.cursor_position, + buffer.complete_state.original_document.cursor_position)) + content.menu_position = translate_rowcol(menu_row, menu_col) + else: + content.menu_position = None + + return content + + def mouse_handler(self, cli, mouse_event): + """ + Mouse handler for this control. + """ + buffer = self._buffer(cli) + position = mouse_event.position + + # Focus buffer when clicked. + if self.has_focus(cli): + if self._last_get_processed_line: + processed_line = self._last_get_processed_line(position.y) + + # Translate coordinates back to the cursor position of the + # original input. + xpos = processed_line.display_to_source(position.x) + index = buffer.document.translate_row_col_to_index(position.y, xpos) + + # Set the cursor position. + if mouse_event.event_type == MouseEventType.MOUSE_DOWN: + buffer.exit_selection() + buffer.cursor_position = index + + elif mouse_event.event_type == MouseEventType.MOUSE_UP: + # When the cursor was moved to another place, select the text. + # (The >1 is actually a small but acceptable workaround for + # selecting text in Vi navigation mode. In navigation mode, + # the cursor can never be after the text, so the cursor + # will be repositioned automatically.) + if abs(buffer.cursor_position - index) > 1: + buffer.start_selection(selection_type=SelectionType.CHARACTERS) + buffer.cursor_position = index + + # Select word around cursor on double click. + # Two MOUSE_UP events in a short timespan are considered a double click. + double_click = self._last_click_timestamp and time.time() - self._last_click_timestamp < .3 + self._last_click_timestamp = time.time() + + if double_click: + start, end = buffer.document.find_boundaries_of_current_word() + buffer.cursor_position += start + buffer.start_selection(selection_type=SelectionType.CHARACTERS) + buffer.cursor_position += end - start + else: + # Don't handle scroll events here. + return NotImplemented + + # Not focussed, but focussing on click events. + else: + if self.focus_on_click(cli) and mouse_event.event_type == MouseEventType.MOUSE_UP: + # Focus happens on mouseup. (If we did this on mousedown, the + # up event will be received at the point where this widget is + # focussed and be handled anyway.) + cli.focus(self.buffer_name) + else: + return NotImplemented + + def move_cursor_down(self, cli): + b = self._buffer(cli) + b.cursor_position += b.document.get_cursor_down_position() + + def move_cursor_up(self, cli): + b = self._buffer(cli) + b.cursor_position += b.document.get_cursor_up_position() diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/dimension.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/dimension.py new file mode 100644 index 00000000000..717ad7a81fa --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/dimension.py @@ -0,0 +1,92 @@ +""" +Layout dimensions are used to give the minimum, maximum and preferred +dimensions for containers and controls. +""" +from __future__ import unicode_literals + +__all__ = ( + 'LayoutDimension', + 'sum_layout_dimensions', + 'max_layout_dimensions', +) + + +class LayoutDimension(object): + """ + Specified dimension (width/height) of a user control or window. + + The layout engine tries to honor the preferred size. If that is not + possible, because the terminal is larger or smaller, it tries to keep in + between min and max. + + :param min: Minimum size. + :param max: Maximum size. + :param weight: For a VSplit/HSplit, the actual size will be determined + by taking the proportion of weights from all the children. + E.g. When there are two children, one width a weight of 1, + and the other with a weight of 2. The second will always be + twice as big as the first, if the min/max values allow it. + :param preferred: Preferred size. + """ + def __init__(self, min=None, max=None, weight=1, preferred=None): + assert isinstance(weight, int) and weight > 0 # Cannot be a float. + + self.min_specified = min is not None + self.max_specified = max is not None + self.preferred_specified = preferred is not None + + if min is None: + min = 0 # Smallest possible value. + if max is None: # 0-values are allowed, so use "is None" + max = 1000 ** 10 # Something huge. + if preferred is None: + preferred = min + + self.min = min + self.max = max + self.preferred = preferred + self.weight = weight + + # Make sure that the 'preferred' size is always in the min..max range. + if self.preferred < self.min: + self.preferred = self.min + + if self.preferred > self.max: + self.preferred = self.max + + @classmethod + def exact(cls, amount): + """ + Return a :class:`.LayoutDimension` with an exact size. (min, max and + preferred set to ``amount``). + """ + return cls(min=amount, max=amount, preferred=amount) + + def __repr__(self): + return 'LayoutDimension(min=%r, max=%r, preferred=%r, weight=%r)' % ( + self.min, self.max, self.preferred, self.weight) + + def __add__(self, other): + return sum_layout_dimensions([self, other]) + + +def sum_layout_dimensions(dimensions): + """ + Sum a list of :class:`.LayoutDimension` instances. + """ + min = sum([d.min for d in dimensions if d.min is not None]) + max = sum([d.max for d in dimensions if d.max is not None]) + preferred = sum([d.preferred for d in dimensions]) + + return LayoutDimension(min=min, max=max, preferred=preferred) + + +def max_layout_dimensions(dimensions): + """ + Take the maximum of a list of :class:`.LayoutDimension` instances. + """ + min_ = max([d.min for d in dimensions if d.min is not None]) + max_ = max([d.max for d in dimensions if d.max is not None]) + preferred = max([d.preferred for d in dimensions]) + + return LayoutDimension(min=min_, max=max_, preferred=preferred) diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/lexers.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/lexers.py new file mode 100644 index 00000000000..a928fd82264 --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/lexers.py @@ -0,0 +1,320 @@ +""" +Lexer interface and implementation. +Used for syntax highlighting. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass +from six.moves import range + +from prompt_toolkit.token import Token +from prompt_toolkit.filters import to_cli_filter +from .utils import split_lines + +import re +import six + +__all__ = ( + 'Lexer', + 'SimpleLexer', + 'PygmentsLexer', + 'SyntaxSync', + 'SyncFromStart', + 'RegexSync', +) + + +class Lexer(with_metaclass(ABCMeta, object)): + """ + Base class for all lexers. + """ + @abstractmethod + def lex_document(self, cli, document): + """ + Takes a :class:`~prompt_toolkit.document.Document` and returns a + callable that takes a line number and returns the tokens for that line. + """ + + +class SimpleLexer(Lexer): + """ + Lexer that doesn't do any tokenizing and returns the whole input as one token. + + :param token: The `Token` for this lexer. + """ + # `default_token` parameter is deprecated! + def __init__(self, token=Token, default_token=None): + self.token = token + + if default_token is not None: + self.token = default_token + + def lex_document(self, cli, document): + lines = document.lines + + def get_line(lineno): + " Return the tokens for the given line. " + try: + return [(self.token, lines[lineno])] + except IndexError: + return [] + return get_line + + +class SyntaxSync(with_metaclass(ABCMeta, object)): + """ + Syntax synchroniser. This is a tool that finds a start position for the + lexer. This is especially important when editing big documents; we don't + want to start the highlighting by running the lexer from the beginning of + the file. That is very slow when editing. + """ + @abstractmethod + def get_sync_start_position(self, document, lineno): + """ + Return the position from where we can start lexing as a (row, column) + tuple. + + :param document: `Document` instance that contains all the lines. + :param lineno: The line that we want to highlight. (We need to return + this line, or an earlier position.) + """ + +class SyncFromStart(SyntaxSync): + """ + Always start the syntax highlighting from the beginning. + """ + def get_sync_start_position(self, document, lineno): + return 0, 0 + + +class RegexSync(SyntaxSync): + """ + Synchronize by starting at a line that matches the given regex pattern. + """ + # Never go more than this amount of lines backwards for synchronisation. + # That would be too CPU intensive. + MAX_BACKWARDS = 500 + + # Start lexing at the start, if we are in the first 'n' lines and no + # synchronisation position was found. + FROM_START_IF_NO_SYNC_POS_FOUND = 100 + + def __init__(self, pattern): + assert isinstance(pattern, six.text_type) + self._compiled_pattern = re.compile(pattern) + + def get_sync_start_position(self, document, lineno): + " Scan backwards, and find a possible position to start. " + pattern = self._compiled_pattern + lines = document.lines + + # Scan upwards, until we find a point where we can start the syntax + # synchronisation. + for i in range(lineno, max(-1, lineno - self.MAX_BACKWARDS), -1): + match = pattern.match(lines[i]) + if match: + return i, match.start() + + # No synchronisation point found. If we aren't that far from the + # beginning, start at the very beginning, otherwise, just try to start + # at the current line. + if lineno < self.FROM_START_IF_NO_SYNC_POS_FOUND: + return 0, 0 + else: + return lineno, 0 + + @classmethod + def from_pygments_lexer_cls(cls, lexer_cls): + """ + Create a :class:`.RegexSync` instance for this Pygments lexer class. + """ + patterns = { + # For Python, start highlighting at any class/def block. + 'Python': r'^\s*(class|def)\s+', + 'Python 3': r'^\s*(class|def)\s+', + + # For HTML, start at any open/close tag definition. + 'HTML': r'<[/a-zA-Z]', + + # For javascript, start at a function. + 'JavaScript': r'\bfunction\b' + + # TODO: Add definitions for other languages. + # By default, we start at every possible line. + } + p = patterns.get(lexer_cls.name, '^') + return cls(p) + + +class PygmentsLexer(Lexer): + """ + Lexer that calls a pygments lexer. + + Example:: + + from pygments.lexers import HtmlLexer + lexer = PygmentsLexer(HtmlLexer) + + Note: Don't forget to also load a Pygments compatible style. E.g.:: + + from prompt_toolkit.styles.from_pygments import style_from_pygments + from pygments.styles import get_style_by_name + style = style_from_pygments(get_style_by_name('monokai')) + + :param pygments_lexer_cls: A `Lexer` from Pygments. + :param sync_from_start: Start lexing at the start of the document. This + will always give the best results, but it will be slow for bigger + documents. (When the last part of the document is display, then the + whole document will be lexed by Pygments on every key stroke.) It is + recommended to disable this for inputs that are expected to be more + than 1,000 lines. + :param syntax_sync: `SyntaxSync` object. + """ + # Minimum amount of lines to go backwards when starting the parser. + # This is important when the lines are retrieved in reverse order, or when + # scrolling upwards. (Due to the complexity of calculating the vertical + # scroll offset in the `Window` class, lines are not always retrieved in + # order.) + MIN_LINES_BACKWARDS = 50 + + # When a parser was started this amount of lines back, read the parser + # until we get the current line. Otherwise, start a new parser. + # (This should probably be bigger than MIN_LINES_BACKWARDS.) + REUSE_GENERATOR_MAX_DISTANCE = 100 + + def __init__(self, pygments_lexer_cls, sync_from_start=True, syntax_sync=None): + assert syntax_sync is None or isinstance(syntax_sync, SyntaxSync) + + self.pygments_lexer_cls = pygments_lexer_cls + self.sync_from_start = to_cli_filter(sync_from_start) + + # Instantiate the Pygments lexer. + self.pygments_lexer = pygments_lexer_cls( + stripnl=False, + stripall=False, + ensurenl=False) + + # Create syntax sync instance. + self.syntax_sync = syntax_sync or RegexSync.from_pygments_lexer_cls(pygments_lexer_cls) + + @classmethod + def from_filename(cls, filename, sync_from_start=True): + """ + Create a `Lexer` from a filename. + """ + # Inline imports: the Pygments dependency is optional! + from pygments.util import ClassNotFound + from pygments.lexers import get_lexer_for_filename + + try: + pygments_lexer = get_lexer_for_filename(filename) + except ClassNotFound: + return SimpleLexer() + else: + return cls(pygments_lexer.__class__, sync_from_start=sync_from_start) + + def lex_document(self, cli, document): + """ + Create a lexer function that takes a line number and returns the list + of (Token, text) tuples as the Pygments lexer returns for that line. + """ + # Cache of already lexed lines. + cache = {} + + # Pygments generators that are currently lexing. + line_generators = {} # Map lexer generator to the line number. + + def get_syntax_sync(): + " The Syntax synchronisation objcet that we currently use. " + if self.sync_from_start(cli): + return SyncFromStart() + else: + return self.syntax_sync + + def find_closest_generator(i): + " Return a generator close to line 'i', or None if none was fonud. " + for generator, lineno in line_generators.items(): + if lineno < i and i - lineno < self.REUSE_GENERATOR_MAX_DISTANCE: + return generator + + def create_line_generator(start_lineno, column=0): + """ + Create a generator that yields the lexed lines. + Each iteration it yields a (line_number, [(token, text), ...]) tuple. + """ + def get_tokens(): + text = '\n'.join(document.lines[start_lineno:])[column:] + + # We call `get_tokens_unprocessed`, because `get_tokens` will + # still replace \r\n and \r by \n. (We don't want that, + # Pygments should return exactly the same amount of text, as we + # have given as input.) + for _, t, v in self.pygments_lexer.get_tokens_unprocessed(text): + yield t, v + + return enumerate(split_lines(get_tokens()), start_lineno) + + def get_generator(i): + """ + Find an already started generator that is close, or create a new one. + """ + # Find closest line generator. + generator = find_closest_generator(i) + if generator: + return generator + + # No generator found. Determine starting point for the syntax + # synchronisation first. + + # Go at least x lines back. (Make scrolling upwards more + # efficient.) + i = max(0, i - self.MIN_LINES_BACKWARDS) + + if i == 0: + row = 0 + column = 0 + else: + row, column = get_syntax_sync().get_sync_start_position(document, i) + + # Find generator close to this point, or otherwise create a new one. + generator = find_closest_generator(i) + if generator: + return generator + else: + generator = create_line_generator(row, column) + + # If the column is not 0, ignore the first line. (Which is + # incomplete. This happens when the synchronisation algorithm tells + # us to start parsing in the middle of a line.) + if column: + next(generator) + row += 1 + + line_generators[generator] = row + return generator + + def get_line(i): + " Return the tokens for a given line number. " + try: + return cache[i] + except KeyError: + generator = get_generator(i) + + # Exhaust the generator, until we find the requested line. + for num, line in generator: + cache[num] = line + if num == i: + line_generators[generator] = i + + # Remove the next item from the cache. + # (It could happen that it's already there, because of + # another generator that started filling these lines, + # but we want to synchronise these lines with the + # current lexer's state.) + if num + 1 in cache: + del cache[num + 1] + + return cache[num] + return [] + + return get_line diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/margins.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/margins.py new file mode 100644 index 00000000000..2934dfc9a75 --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/margins.py @@ -0,0 +1,253 @@ +""" +Margin implementations for a :class:`~prompt_toolkit.layout.containers.Window`. +""" +from __future__ import unicode_literals + +from abc import ABCMeta, abstractmethod +from six import with_metaclass +from six.moves import range + +from prompt_toolkit.filters import to_cli_filter +from prompt_toolkit.token import Token +from prompt_toolkit.utils import get_cwidth +from .utils import token_list_to_text + +__all__ = ( + 'Margin', + 'NumberredMargin', + 'ScrollbarMargin', + 'ConditionalMargin', + 'PromptMargin', +) + + +class Margin(with_metaclass(ABCMeta, object)): + """ + Base interface for a margin. + """ + @abstractmethod + def get_width(self, cli, get_ui_content): + """ + Return the width that this margin is going to consume. + + :param cli: :class:`.CommandLineInterface` instance. + :param get_ui_content: Callable that asks the user control to create + a :class:`.UIContent` instance. This can be used for instance to + obtain the number of lines. + """ + return 0 + + @abstractmethod + def create_margin(self, cli, window_render_info, width, height): + """ + Creates a margin. + This should return a list of (Token, text) tuples. + + :param cli: :class:`.CommandLineInterface` instance. + :param window_render_info: + :class:`~prompt_toolkit.layout.containers.WindowRenderInfo` + instance, generated after rendering and copying the visible part of + the :class:`~prompt_toolkit.layout.controls.UIControl` into the + :class:`~prompt_toolkit.layout.containers.Window`. + :param width: The width that's available for this margin. (As reported + by :meth:`.get_width`.) + :param height: The height that's available for this margin. (The height + of the :class:`~prompt_toolkit.layout.containers.Window`.) + """ + return [] + + +class NumberredMargin(Margin): + """ + Margin that displays the line numbers. + + :param relative: Number relative to the cursor position. Similar to the Vi + 'relativenumber' option. + :param display_tildes: Display tildes after the end of the document, just + like Vi does. + """ + def __init__(self, relative=False, display_tildes=False): + self.relative = to_cli_filter(relative) + self.display_tildes = to_cli_filter(display_tildes) + + def get_width(self, cli, get_ui_content): + line_count = get_ui_content().line_count + return max(3, len('%s' % line_count) + 1) + + def create_margin(self, cli, window_render_info, width, height): + relative = self.relative(cli) + + token = Token.LineNumber + token_current = Token.LineNumber.Current + + # Get current line number. + current_lineno = window_render_info.ui_content.cursor_position.y + + # Construct margin. + result = [] + last_lineno = None + + for y, lineno in enumerate(window_render_info.displayed_lines): + # Only display line number if this line is not a continuation of the previous line. + if lineno != last_lineno: + if lineno is None: + pass + elif lineno == current_lineno: + # Current line. + if relative: + # Left align current number in relative mode. + result.append((token_current, '%i' % (lineno + 1))) + else: + result.append((token_current, ('%i ' % (lineno + 1)).rjust(width))) + else: + # Other lines. + if relative: + lineno = abs(lineno - current_lineno) - 1 + + result.append((token, ('%i ' % (lineno + 1)).rjust(width))) + + last_lineno = lineno + result.append((Token, '\n')) + + # Fill with tildes. + if self.display_tildes(cli): + while y < window_render_info.window_height: + result.append((Token.Tilde, '~\n')) + y += 1 + + return result + + +class ConditionalMargin(Margin): + """ + Wrapper around other :class:`.Margin` classes to show/hide them. + """ + def __init__(self, margin, filter): + assert isinstance(margin, Margin) + + self.margin = margin + self.filter = to_cli_filter(filter) + + def get_width(self, cli, ui_content): + if self.filter(cli): + return self.margin.get_width(cli, ui_content) + else: + return 0 + + def create_margin(self, cli, window_render_info, width, height): + if width and self.filter(cli): + return self.margin.create_margin(cli, window_render_info, width, height) + else: + return [] + + +class ScrollbarMargin(Margin): + """ + Margin displaying a scrollbar. + + :param display_arrows: Display scroll up/down arrows. + """ + def __init__(self, display_arrows=False): + self.display_arrows = to_cli_filter(display_arrows) + + def get_width(self, cli, ui_content): + return 1 + + def create_margin(self, cli, window_render_info, width, height): + total_height = window_render_info.content_height + display_arrows = self.display_arrows(cli) + + window_height = window_render_info.window_height + if display_arrows: + window_height -= 2 + + try: + items_per_row = float(total_height) / min(total_height, window_height) + except ZeroDivisionError: + return [] + else: + def is_scroll_button(row): + " True if we should display a button on this row. " + current_row_middle = int((row + .5) * items_per_row) + return current_row_middle in window_render_info.displayed_lines + + # Up arrow. + result = [] + if display_arrows: + result.extend([ + (Token.Scrollbar.Arrow, '^'), + (Token.Scrollbar, '\n') + ]) + + # Scrollbar body. + for i in range(window_height): + if is_scroll_button(i): + result.append((Token.Scrollbar.Button, ' ')) + else: + result.append((Token.Scrollbar, ' ')) + result.append((Token, '\n')) + + # Down arrow + if display_arrows: + result.append((Token.Scrollbar.Arrow, 'v')) + + return result + + +class PromptMargin(Margin): + """ + Create margin that displays a prompt. + This can display one prompt at the first line, and a continuation prompt + (e.g, just dots) on all the following lines. + + :param get_prompt_tokens: Callable that takes a CommandLineInterface as + input and returns a list of (Token, type) tuples to be shown as the + prompt at the first line. + :param get_continuation_tokens: Callable that takes a CommandLineInterface + and a width as input and returns a list of (Token, type) tuples for the + next lines of the input. + :param show_numbers: (bool or :class:`~prompt_toolkit.filters.CLIFilter`) + Display line numbers instead of the continuation prompt. + """ + def __init__(self, get_prompt_tokens, get_continuation_tokens=None, + show_numbers=False): + assert callable(get_prompt_tokens) + assert get_continuation_tokens is None or callable(get_continuation_tokens) + show_numbers = to_cli_filter(show_numbers) + + self.get_prompt_tokens = get_prompt_tokens + self.get_continuation_tokens = get_continuation_tokens + self.show_numbers = show_numbers + + def get_width(self, cli, ui_content): + " Width to report to the `Window`. " + # Take the width from the first line. + text = token_list_to_text(self.get_prompt_tokens(cli)) + return get_cwidth(text) + + def create_margin(self, cli, window_render_info, width, height): + # First line. + tokens = self.get_prompt_tokens(cli)[:] + + # Next lines. (Show line numbering when numbering is enabled.) + if self.get_continuation_tokens: + # Note: we turn this into a list, to make sure that we fail early + # in case `get_continuation_tokens` returns something else, + # like `None`. + tokens2 = list(self.get_continuation_tokens(cli, width)) + else: + tokens2 = [] + + show_numbers = self.show_numbers(cli) + last_y = None + + for y in window_render_info.displayed_lines[1:]: + tokens.append((Token, '\n')) + if show_numbers: + if y != last_y: + tokens.append((Token.LineNumber, ('%i ' % (y + 1)).rjust(width))) + else: + tokens.extend(tokens2) + last_y = y + + return tokens diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/menus.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/menus.py new file mode 100644 index 00000000000..a916846e458 --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/menus.py @@ -0,0 +1,496 @@ +from __future__ import unicode_literals + +from six.moves import zip_longest, range +from prompt_toolkit.filters import HasCompletions, IsDone, Condition, to_cli_filter +from prompt_toolkit.mouse_events import MouseEventType +from prompt_toolkit.token import Token +from prompt_toolkit.utils import get_cwidth + +from .containers import Window, HSplit, ConditionalContainer, ScrollOffsets +from .controls import UIControl, UIContent +from .dimension import LayoutDimension +from .margins import ScrollbarMargin +from .screen import Point, Char + +import math + +__all__ = ( + 'CompletionsMenu', + 'MultiColumnCompletionsMenu', +) + + +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 __init__(self): + self.token = Token.Menu.Completions + + def has_focus(self, cli): + return False + + def preferred_width(self, cli, max_available_width): + complete_state = cli.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, cli, width, max_available_height, wrap_lines): + complete_state = cli.current_buffer.complete_state + if complete_state: + return len(complete_state.current_completions) + else: + return 0 + + def create_content(self, cli, width, height): + """ + Create a UIContent object for this control. + """ + complete_state = cli.current_buffer.complete_state + if complete_state: + completions = complete_state.current_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): + c = completions[i] + is_current_completion = (i == index) + result = self._get_menu_item_tokens(c, is_current_completion, menu_width) + + if show_meta: + result += self._get_menu_item_meta_tokens(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), + default_char=Char(' ', self.token)) + + return UIContent() + + def _show_meta(self, complete_state): + """ + Return ``True`` if we need to show a column with meta information. + """ + return any(c.display_meta for c in complete_state.current_completions) + + def _get_menu_width(self, max_width, complete_state): + """ + Return the width of the main column. + """ + return min(max_width, max(self.MIN_WIDTH, max(get_cwidth(c.display) + for c in complete_state.current_completions) + 2)) + + def _get_menu_meta_width(self, max_width, complete_state): + """ + Return the width of the meta column. + """ + if self._show_meta(complete_state): + return min(max_width, max(get_cwidth(c.display_meta) + for c in complete_state.current_completions) + 2) + else: + return 0 + + def _get_menu_item_tokens(self, completion, is_current_completion, width): + if is_current_completion: + token = self.token.Completion.Current + else: + token = self.token.Completion + + text, tw = _trim_text(completion.display, width - 2) + padding = ' ' * (width - 2 - tw) + return [(token, ' %s%s ' % (text, padding))] + + def _get_menu_item_meta_tokens(self, completion, is_current_completion, width): + if is_current_completion: + token = self.token.Meta.Current + else: + token = self.token.Meta + + text, tw = _trim_text(completion.display_meta, width - 2) + padding = ' ' * (width - 2 - tw) + return [(token, ' %s%s ' % (text, padding))] + + def mouse_handler(self, cli, mouse_event): + """ + Handle mouse events: clicking and scrolling. + """ + b = cli.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) + + +def _trim_text(text, max_width): + """ + Trim the text to `max_width`, append dots when the text is too long. + Returns (text, width) tuple. + """ + width = get_cwidth(text) + + # When the text is too wide, trim it. + if width > max_width: + # When there are no double width characters, just use slice operation. + if len(text) == width: + trimmed_text = (text[:max(1, max_width-3)] + '...')[:max_width] + return trimmed_text, len(trimmed_text) + + # Otherwise, loop until we have the desired width. (Rather + # inefficient, but ok for now.) + else: + trimmed_text = '' + for c in text: + if get_cwidth(trimmed_text + c) <= max_width - 3: + trimmed_text += c + trimmed_text += '...' + + return (trimmed_text, get_cwidth(trimmed_text)) + else: + return text, width + + +class CompletionsMenu(ConditionalContainer): + def __init__(self, max_height=None, scroll_offset=0, extra_filter=True, display_arrows=False): + extra_filter = to_cli_filter(extra_filter) + display_arrows = to_cli_filter(display_arrows) + + super(CompletionsMenu, self).__init__( + content=Window( + content=CompletionsMenuControl(), + width=LayoutDimension(min=8), + height=LayoutDimension(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, + ), + # Show when there are completions but not at the point we are + # returning the input. + filter=HasCompletions() & ~IsDone() & extra_filter) + + +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 langer than one, in 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=3, suggested_max_column_width=30): + assert isinstance(min_rows, int) and min_rows >= 1 + + self.min_rows = min_rows + self.suggested_max_column_width = suggested_max_column_width + self.token = Token.Menu.Completions + self.scroll = 0 + + # Info of last rendering. + self._rendered_rows = 0 + self._rendered_columns = 0 + self._total_columns = 0 + self._render_pos_to_completion = {} + self._render_left_arrow = False + self._render_right_arrow = False + self._render_width = 0 + + def reset(self): + self.scroll = 0 + + def has_focus(self, cli): + return False + + def preferred_width(self, cli, max_available_width): + """ + Preferred width: prefer to use at least min_rows, but otherwise as much + as possible horizontally. + """ + complete_state = cli.current_buffer.complete_state + column_width = self._get_column_width(complete_state) + result = int(column_width * math.ceil(len(complete_state.current_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, cli, width, max_available_height, wrap_lines): + """ + Preferred height: as much as needed in order to display all the completions. + """ + complete_state = cli.current_buffer.complete_state + 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.current_completions) / float(column_count))) + + def create_content(self, cli, width, height): + """ + Create a UIContent object for this menu. + """ + complete_state = cli.current_buffer.complete_state + column_width = self._get_column_width(complete_state) + self._render_pos_to_completion = {} + + def grouper(n, iterable, fillvalue=None): + " grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx " + args = [iter(iterable)] * n + return zip_longest(fillvalue=fillvalue, *args) + + def is_current_completion(completion): + " Returns True when this completion is the currently selected one. " + return 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 + + if complete_state: + # 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.current_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. + tokens_for_line = [] + + for row_index, row in enumerate(rows_): + tokens = [] + middle_row = row_index == len(rows_) // 2 + + # Draw left arrow if we have hidden completions on the left. + if render_left_arrow: + tokens += [(Token.Scrollbar, '<' if middle_row else ' ')] + + # Draw row content. + for column_index, c in enumerate(row[self.scroll:][:visible_columns]): + if c is not None: + tokens += self._get_menu_item_tokens(c, is_current_completion(c), column_width) + + # 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: + tokens += [(self.token.Completion, ' ' * column_width)] + + # Draw trailing padding. (_get_menu_item_tokens only returns padding on the left.) + tokens += [(self.token.Completion, ' ')] + + # Draw right arrow if we have hidden completions on the right. + if render_right_arrow: + tokens += [(Token.Scrollbar, '>' if middle_row else ' ')] + + # Newline. + tokens_for_line.append(tokens) + + else: + tokens = [] + + 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): + return tokens_for_line[i] + + return UIContent(get_line=get_line, line_count=len(rows_)) + + def _get_column_width(self, complete_state): + """ + Return the width of each column. + """ + return max(get_cwidth(c.display) for c in complete_state.current_completions) + 1 + + def _get_menu_item_tokens(self, completion, is_current_completion, width): + if is_current_completion: + token = self.token.Completion.Current + else: + token = self.token.Completion + + text, tw = _trim_text(completion.display, width) + padding = ' ' * (width - tw - 1) + + return [(token, ' %s%s' % (text, padding))] + + def mouse_handler(self, cli, mouse_event): + """ + Handle scoll and click events. + """ + b = cli.current_buffer + + def scroll_left(): + b.complete_previous(count=self._rendered_rows, disable_wrap_around=True) + self.scroll = max(0, self.scroll - 1) + + def scroll_right(): + 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) + + +class MultiColumnCompletionsMenu(HSplit): + """ + Container that displays the completions in several columns. + When `show_meta` (a :class:`~prompt_toolkit.filters.CLIFilter`) evaluates + to True, it shows the meta information at the bottom. + """ + def __init__(self, min_rows=3, suggested_max_column_width=30, show_meta=True, extra_filter=True): + show_meta = to_cli_filter(show_meta) + extra_filter = to_cli_filter(extra_filter) + + # Display filter: show when there are completions but not at the point + # we are returning the input. + full_filter = HasCompletions() & ~IsDone() & extra_filter + + any_completion_has_meta = Condition(lambda cli: + any(c.display_meta for c in cli.current_buffer.complete_state.current_completions)) + + # Create child windows. + completions_window = ConditionalContainer( + content=Window( + content=MultiColumnCompletionMenuControl( + min_rows=min_rows, suggested_max_column_width=suggested_max_column_width), + width=LayoutDimension(min=8), + height=LayoutDimension(min=1)), + filter=full_filter) + + meta_window = ConditionalContainer( + content=Window(content=_SelectedCompletionMetaControl()), + filter=show_meta & full_filter & any_completion_has_meta) + + # Initialise split. + super(MultiColumnCompletionsMenu, self).__init__([ + completions_window, + meta_window + ]) + + +class _SelectedCompletionMetaControl(UIControl): + """ + Control that shows the meta information of the selected token. + """ + def preferred_width(self, cli, max_available_width): + """ + 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.) + """ + if cli.current_buffer.complete_state: + state = cli.current_buffer.complete_state + return 2 + max(get_cwidth(c.display_meta) for c in state.current_completions) + else: + return 0 + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + return 1 + + def create_content(self, cli, width, height): + tokens = self._get_tokens(cli) + + def get_line(i): + return tokens + + return UIContent(get_line=get_line, line_count=1 if tokens else 0) + + def _get_tokens(self, cli): + token = Token.Menu.Completions.MultiColumnMeta + state = cli.current_buffer.complete_state + + if state and state.current_completion and state.current_completion.display_meta: + return [(token, ' %s ' % state.current_completion.display_meta)] + + return [] diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/mouse_handlers.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/mouse_handlers.py new file mode 100644 index 00000000000..d443bf8315e --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/mouse_handlers.py @@ -0,0 +1,29 @@ +from __future__ import unicode_literals + +from itertools import product +from collections import defaultdict + +__all__ = ( + 'MouseHandlers', +) + + +class MouseHandlers(object): + """ + Two dimentional raster of callbacks for mouse events. + """ + def __init__(self): + def dummy_callback(cli, mouse_event): + """ + :param mouse_event: `MouseEvent` instance. + """ + + # Map (x,y) tuples to handlers. + self.mouse_handlers = defaultdict(lambda: dummy_callback) + + def set_mouse_handler_for_range(self, x_min, x_max, y_min, y_max, handler=None): + """ + Set mouse handler for a region. + """ + for x, y in product(range(x_min, x_max), range(y_min, y_max)): + self.mouse_handlers[x,y] = handler diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/processors.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/processors.py new file mode 100644 index 00000000000..0b8bc9c223d --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/processors.py @@ -0,0 +1,605 @@ +""" +Processors are little transformation blocks that transform the token list from +a buffer before the BufferControl will render it to the screen. + +They can insert tokens before or after, or highlight fragments by replacing the +token types. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass +from six.moves import range + +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.document import Document +from prompt_toolkit.enums import SEARCH_BUFFER +from prompt_toolkit.filters import to_cli_filter, ViInsertMultipleMode +from prompt_toolkit.layout.utils import token_list_to_text +from prompt_toolkit.reactive import Integer +from prompt_toolkit.token import Token + +from .utils import token_list_len, explode_tokens + +import re + +__all__ = ( + 'Processor', + 'Transformation', + + 'HighlightSearchProcessor', + 'HighlightSelectionProcessor', + 'PasswordProcessor', + 'HighlightMatchingBracketProcessor', + 'DisplayMultipleCursors', + 'BeforeInput', + 'AfterInput', + 'AppendAutoSuggestion', + 'ConditionalProcessor', + 'ShowLeadingWhiteSpaceProcessor', + 'ShowTrailingWhiteSpaceProcessor', + 'TabsProcessor', +) + + +class Processor(with_metaclass(ABCMeta, object)): + """ + Manipulate the tokens for a given line in a + :class:`~prompt_toolkit.layout.controls.BufferControl`. + """ + @abstractmethod + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + """ + Apply transformation. Returns a :class:`.Transformation` instance. + + :param cli: :class:`.CommandLineInterface` instance. + :param lineno: The number of the line to which we apply the processor. + :param source_to_display: A function that returns the position in the + `tokens` for any position in the source string. (This takes + previous processors into account.) + :param tokens: List of tokens that we can transform. (Received from the + previous processor.) + """ + return Transformation(tokens) + + def has_focus(self, cli): + """ + Processors can override the focus. + (Used for the reverse-i-search prefix in DefaultPrompt.) + """ + return False + + +class Transformation(object): + """ + Transformation result, as returned by :meth:`.Processor.apply_transformation`. + + Important: Always make sure that the length of `document.text` is equal to + the length of all the text in `tokens`! + + :param tokens: The transformed tokens. To be displayed, or to pass to the + next processor. + :param source_to_display: Cursor position transformation from original string to + transformed string. + :param display_to_source: Cursor position transformed from source string to + original string. + """ + def __init__(self, tokens, source_to_display=None, display_to_source=None): + self.tokens = tokens + self.source_to_display = source_to_display or (lambda i: i) + self.display_to_source = display_to_source or (lambda i: i) + + +class HighlightSearchProcessor(Processor): + """ + Processor that highlights search matches in the document. + Note that this doesn't support multiline search matches yet. + + :param preview_search: A Filter; when active it indicates that we take + the search text in real time while the user is typing, instead of the + last active search state. + """ + def __init__(self, preview_search=False, search_buffer_name=SEARCH_BUFFER, + get_search_state=None): + self.preview_search = to_cli_filter(preview_search) + self.search_buffer_name = search_buffer_name + self.get_search_state = get_search_state or (lambda cli: cli.search_state) + + def _get_search_text(self, cli): + """ + The text we are searching for. + """ + # When the search buffer has focus, take that text. + if self.preview_search(cli) and cli.buffers[self.search_buffer_name].text: + return cli.buffers[self.search_buffer_name].text + # Otherwise, take the text of the last active search. + else: + return self.get_search_state(cli).text + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + search_text = self._get_search_text(cli) + searchmatch_current_token = (':', ) + Token.SearchMatch.Current + searchmatch_token = (':', ) + Token.SearchMatch + + if search_text and not cli.is_returning: + # For each search match, replace the Token. + line_text = token_list_to_text(tokens) + tokens = explode_tokens(tokens) + + flags = re.IGNORECASE if cli.is_ignoring_case else 0 + + # Get cursor column. + if document.cursor_position_row == lineno: + cursor_column = source_to_display(document.cursor_position_col) + else: + cursor_column = None + + for match in re.finditer(re.escape(search_text), line_text, flags=flags): + if cursor_column is not None: + on_cursor = match.start() <= cursor_column < match.end() + else: + on_cursor = False + + for i in range(match.start(), match.end()): + old_token, text = tokens[i] + if on_cursor: + tokens[i] = (old_token + searchmatch_current_token, tokens[i][1]) + else: + tokens[i] = (old_token + searchmatch_token, tokens[i][1]) + + return Transformation(tokens) + + +class HighlightSelectionProcessor(Processor): + """ + Processor that highlights the selection in the document. + """ + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + selected_token = (':', ) + Token.SelectedText + + # In case of selection, highlight all matches. + selection_at_line = document.selection_range_at_line(lineno) + + if selection_at_line: + from_, to = selection_at_line + from_ = source_to_display(from_) + to = source_to_display(to) + + tokens = explode_tokens(tokens) + + if from_ == 0 and to == 0 and len(tokens) == 0: + # When this is an empty line, insert a space in order to + # visualiase the selection. + return Transformation([(Token.SelectedText, ' ')]) + else: + for i in range(from_, to + 1): + if i < len(tokens): + old_token, old_text = tokens[i] + tokens[i] = (old_token + selected_token, old_text) + + return Transformation(tokens) + + +class PasswordProcessor(Processor): + """ + Processor that turns masks the input. (For passwords.) + + :param char: (string) Character to be used. "*" by default. + """ + def __init__(self, char='*'): + self.char = char + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + tokens = [(token, self.char * len(text)) for token, text in tokens] + return Transformation(tokens) + + +class HighlightMatchingBracketProcessor(Processor): + """ + When the cursor is on or right after a bracket, it highlights the matching + bracket. + + :param max_cursor_distance: Only highlight matching brackets when the + cursor is within this distance. (From inside a `Processor`, we can't + know which lines will be visible on the screen. But we also don't want + to scan the whole document for matching brackets on each key press, so + we limit to this value.) + """ + _closing_braces = '])}>' + + def __init__(self, chars='[](){}<>', max_cursor_distance=1000): + self.chars = chars + self.max_cursor_distance = max_cursor_distance + + self._positions_cache = SimpleCache(maxsize=8) + + def _get_positions_to_highlight(self, document): + """ + Return a list of (row, col) tuples that need to be highlighted. + """ + # Try for the character under the cursor. + if document.current_char and document.current_char in self.chars: + pos = document.find_matching_bracket_position( + start_pos=document.cursor_position - self.max_cursor_distance, + end_pos=document.cursor_position + self.max_cursor_distance) + + # Try for the character before the cursor. + elif (document.char_before_cursor and document.char_before_cursor in + self._closing_braces and document.char_before_cursor in self.chars): + document = Document(document.text, document.cursor_position - 1) + + pos = document.find_matching_bracket_position( + start_pos=document.cursor_position - self.max_cursor_distance, + end_pos=document.cursor_position + self.max_cursor_distance) + else: + pos = None + + # Return a list of (row, col) tuples that need to be highlighted. + if pos: + pos += document.cursor_position # pos is relative. + row, col = document.translate_index_to_position(pos) + return [(row, col), (document.cursor_position_row, document.cursor_position_col)] + else: + return [] + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Get the highlight positions. + key = (cli.render_counter, document.text, document.cursor_position) + positions = self._positions_cache.get( + key, lambda: self._get_positions_to_highlight(document)) + + # Apply if positions were found at this line. + if positions: + for row, col in positions: + if row == lineno: + col = source_to_display(col) + tokens = explode_tokens(tokens) + token, text = tokens[col] + + if col == document.cursor_position_col: + token += (':', ) + Token.MatchingBracket.Cursor + else: + token += (':', ) + Token.MatchingBracket.Other + + tokens[col] = (token, text) + + return Transformation(tokens) + + +class DisplayMultipleCursors(Processor): + """ + When we're in Vi block insert mode, display all the cursors. + """ + _insert_multiple = ViInsertMultipleMode() + + def __init__(self, buffer_name): + self.buffer_name = buffer_name + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + buff = cli.buffers[self.buffer_name] + + if self._insert_multiple(cli): + positions = buff.multiple_cursor_positions + tokens = explode_tokens(tokens) + + # If any cursor appears on the current line, highlight that. + start_pos = document.translate_row_col_to_index(lineno, 0) + end_pos = start_pos + len(document.lines[lineno]) + + token_suffix = (':', ) + Token.MultipleCursors.Cursor + + for p in positions: + if start_pos <= p < end_pos: + column = source_to_display(p - start_pos) + + # Replace token. + token, text = tokens[column] + token += token_suffix + tokens[column] = (token, text) + elif p == end_pos: + tokens.append((token_suffix, ' ')) + + return Transformation(tokens) + else: + return Transformation(tokens) + + +class BeforeInput(Processor): + """ + Insert tokens before the input. + + :param get_tokens: Callable that takes a + :class:`~prompt_toolkit.interface.CommandLineInterface` and returns the + list of tokens to be inserted. + """ + def __init__(self, get_tokens): + assert callable(get_tokens) + self.get_tokens = get_tokens + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + if lineno == 0: + tokens_before = self.get_tokens(cli) + tokens = tokens_before + tokens + + shift_position = token_list_len(tokens_before) + source_to_display = lambda i: i + shift_position + display_to_source = lambda i: i - shift_position + else: + source_to_display = None + display_to_source = None + + return Transformation(tokens, source_to_display=source_to_display, + display_to_source=display_to_source) + + @classmethod + def static(cls, text, token=Token): + """ + Create a :class:`.BeforeInput` instance that always inserts the same + text. + """ + def get_static_tokens(cli): + return [(token, text)] + return cls(get_static_tokens) + + def __repr__(self): + return '%s(get_tokens=%r)' % ( + self.__class__.__name__, self.get_tokens) + + +class AfterInput(Processor): + """ + Insert tokens after the input. + + :param get_tokens: Callable that takes a + :class:`~prompt_toolkit.interface.CommandLineInterface` and returns the + list of tokens to be appended. + """ + def __init__(self, get_tokens): + assert callable(get_tokens) + self.get_tokens = get_tokens + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Insert tokens after the last line. + if lineno == document.line_count - 1: + return Transformation(tokens=tokens + self.get_tokens(cli)) + else: + return Transformation(tokens=tokens) + + @classmethod + def static(cls, text, token=Token): + """ + Create a :class:`.AfterInput` instance that always inserts the same + text. + """ + def get_static_tokens(cli): + return [(token, text)] + return cls(get_static_tokens) + + def __repr__(self): + return '%s(get_tokens=%r)' % ( + self.__class__.__name__, self.get_tokens) + + +class AppendAutoSuggestion(Processor): + """ + Append the auto suggestion to the input. + (The user can then press the right arrow the insert the suggestion.) + + :param buffer_name: The name of the buffer from where we should take the + auto suggestion. If not given, we take the current buffer. + """ + def __init__(self, buffer_name=None, token=Token.AutoSuggestion): + self.buffer_name = buffer_name + self.token = token + + def _get_buffer(self, cli): + if self.buffer_name: + return cli.buffers[self.buffer_name] + else: + return cli.current_buffer + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Insert tokens after the last line. + if lineno == document.line_count - 1: + buffer = self._get_buffer(cli) + + if buffer.suggestion and buffer.document.is_cursor_at_the_end: + suggestion = buffer.suggestion.text + else: + suggestion = '' + + return Transformation(tokens=tokens + [(self.token, suggestion)]) + else: + return Transformation(tokens=tokens) + + +class ShowLeadingWhiteSpaceProcessor(Processor): + """ + Make leading whitespace visible. + + :param get_char: Callable that takes a :class:`CommandLineInterface` + instance and returns one character. + :param token: Token to be used. + """ + def __init__(self, get_char=None, token=Token.LeadingWhiteSpace): + assert get_char is None or callable(get_char) + + if get_char is None: + def get_char(cli): + if '\xb7'.encode(cli.output.encoding(), 'replace') == b'?': + return '.' + else: + return '\xb7' + + self.token = token + self.get_char = get_char + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Walk through all te tokens. + if tokens and token_list_to_text(tokens).startswith(' '): + t = (self.token, self.get_char(cli)) + tokens = explode_tokens(tokens) + + for i in range(len(tokens)): + if tokens[i][1] == ' ': + tokens[i] = t + else: + break + + return Transformation(tokens) + + +class ShowTrailingWhiteSpaceProcessor(Processor): + """ + Make trailing whitespace visible. + + :param get_char: Callable that takes a :class:`CommandLineInterface` + instance and returns one character. + :param token: Token to be used. + """ + def __init__(self, get_char=None, token=Token.TrailingWhiteSpace): + assert get_char is None or callable(get_char) + + if get_char is None: + def get_char(cli): + if '\xb7'.encode(cli.output.encoding(), 'replace') == b'?': + return '.' + else: + return '\xb7' + + self.token = token + self.get_char = get_char + + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + if tokens and tokens[-1][1].endswith(' '): + t = (self.token, self.get_char(cli)) + tokens = explode_tokens(tokens) + + # Walk backwards through all te tokens and replace whitespace. + for i in range(len(tokens) - 1, -1, -1): + char = tokens[i][1] + if char == ' ': + tokens[i] = t + else: + break + + return Transformation(tokens) + + +class TabsProcessor(Processor): + """ + Render tabs as spaces (instead of ^I) or make them visible (for instance, + by replacing them with dots.) + + :param tabstop: (Integer) Horizontal space taken by a tab. + :param get_char1: Callable that takes a `CommandLineInterface` and return a + character (text of length one). This one is used for the first space + taken by the tab. + :param get_char2: Like `get_char1`, but for the rest of the space. + """ + def __init__(self, tabstop=4, get_char1=None, get_char2=None, token=Token.Tab): + assert isinstance(tabstop, Integer) + assert get_char1 is None or callable(get_char1) + assert get_char2 is None or callable(get_char2) + + self.get_char1 = get_char1 or get_char2 or (lambda cli: '|') + self.get_char2 = get_char2 or get_char1 or (lambda cli: '\u2508') + self.tabstop = tabstop + self.token = token + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + tabstop = int(self.tabstop) + token = self.token + + # Create separator for tabs. + separator1 = self.get_char1(cli) + separator2 = self.get_char2(cli) + + # Transform tokens. + tokens = explode_tokens(tokens) + + position_mappings = {} + result_tokens = [] + pos = 0 + + for i, token_and_text in enumerate(tokens): + position_mappings[i] = pos + + if token_and_text[1] == '\t': + # Calculate how many characters we have to insert. + count = tabstop - (pos % tabstop) + if count == 0: + count = tabstop + + # Insert tab. + result_tokens.append((token, separator1)) + result_tokens.append((token, separator2 * (count - 1))) + pos += count + else: + result_tokens.append(token_and_text) + pos += 1 + + position_mappings[len(tokens)] = pos + + def source_to_display(from_position): + " Maps original cursor position to the new one. " + return position_mappings[from_position] + + def display_to_source(display_pos): + " Maps display cursor position to the original one. " + position_mappings_reversed = dict((v, k) for k, v in position_mappings.items()) + + while display_pos >= 0: + try: + return position_mappings_reversed[display_pos] + except KeyError: + display_pos -= 1 + return 0 + + return Transformation( + result_tokens, + source_to_display=source_to_display, + display_to_source=display_to_source) + + +class ConditionalProcessor(Processor): + """ + Processor that applies another processor, according to a certain condition. + Example:: + + # Create a function that returns whether or not the processor should + # currently be applied. + def highlight_enabled(cli): + return true_or_false + + # Wrapt it in a `ConditionalProcessor` for usage in a `BufferControl`. + BufferControl(input_processors=[ + ConditionalProcessor(HighlightSearchProcessor(), + Condition(highlight_enabled))]) + + :param processor: :class:`.Processor` instance. + :param filter: :class:`~prompt_toolkit.filters.CLIFilter` instance. + """ + def __init__(self, processor, filter): + assert isinstance(processor, Processor) + + self.processor = processor + self.filter = to_cli_filter(filter) + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Run processor when enabled. + if self.filter(cli): + return self.processor.apply_transformation( + cli, document, lineno, source_to_display, tokens) + else: + return Transformation(tokens) + + def has_focus(self, cli): + if self.filter(cli): + return self.processor.has_focus(cli) + else: + return False + + def __repr__(self): + return '%s(processor=%r, filter=%r)' % ( + self.__class__.__name__, self.processor, self.filter) diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/prompt.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/prompt.py new file mode 100644 index 00000000000..7d00ec513e8 --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/prompt.py @@ -0,0 +1,111 @@ +from __future__ import unicode_literals + +from six import text_type + +from prompt_toolkit.enums import IncrementalSearchDirection, SEARCH_BUFFER +from prompt_toolkit.token import Token + +from .utils import token_list_len +from .processors import Processor, Transformation + +__all__ = ( + 'DefaultPrompt', +) + + +class DefaultPrompt(Processor): + """ + Default prompt. This one shows the 'arg' and reverse search like + Bash/readline normally do. + + There are two ways to instantiate a ``DefaultPrompt``. For a prompt + with a static message, do for instance:: + + prompt = DefaultPrompt.from_message('prompt> ') + + For a dynamic prompt, generated from a token list function:: + + def get_tokens(cli): + return [(Token.A, 'text'), (Token.B, 'text2')] + + prompt = DefaultPrompt(get_tokens) + """ + def __init__(self, get_tokens): + assert callable(get_tokens) + self.get_tokens = get_tokens + + @classmethod + def from_message(cls, message='> '): + """ + Create a default prompt with a static message text. + """ + assert isinstance(message, text_type) + + def get_message_tokens(cli): + return [(Token.Prompt, message)] + return cls(get_message_tokens) + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Get text before cursor. + if cli.is_searching: + before = _get_isearch_tokens(cli) + + elif cli.input_processor.arg is not None: + before = _get_arg_tokens(cli) + + else: + before = self.get_tokens(cli) + + # Insert before buffer text. + shift_position = token_list_len(before) + + # Only show the prompt before the first line. For the following lines, + # only indent using spaces. + if lineno != 0: + before = [(Token.Prompt, ' ' * shift_position)] + + return Transformation( + tokens=before + tokens, + source_to_display=lambda i: i + shift_position, + display_to_source=lambda i: i - shift_position) + + def has_focus(self, cli): + # Obtain focus when the CLI is searching. + + # Usually, when using this `DefaultPrompt`, we don't have a + # `BufferControl` instance that displays the content of the search + # buffer. Instead the search text is displayed before the current text. + # So, we can still show the cursor here, while it's actually not this + # buffer that's focussed. + return cli.is_searching + + +def _get_isearch_tokens(cli): + def before(): + if cli.search_state.direction == IncrementalSearchDirection.BACKWARD: + text = 'reverse-i-search' + else: + text = 'i-search' + + return [(Token.Prompt.Search, '(%s)`' % text)] + + def text(): + return [(Token.Prompt.Search.Text, cli.buffers[SEARCH_BUFFER].text)] + + def after(): + return [(Token.Prompt.Search, '`: ')] + + return before() + text() + after() + + +def _get_arg_tokens(cli): + """ + Tokens for the arg-prompt. + """ + arg = cli.input_processor.arg + + return [ + (Token.Prompt.Arg, '(arg: '), + (Token.Prompt.Arg.Text, str(arg)), + (Token.Prompt.Arg, ') '), + ] diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/screen.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/screen.py new file mode 100644 index 00000000000..95561f5de76 --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/screen.py @@ -0,0 +1,151 @@ +from __future__ import unicode_literals + +from prompt_toolkit.cache import FastDictCache +from prompt_toolkit.token import Token +from prompt_toolkit.utils import get_cwidth + +from collections import defaultdict, namedtuple + +__all__ = ( + 'Point', + 'Size', + 'Screen', + 'Char', +) + + +Point = namedtuple('Point', 'y x') +Size = namedtuple('Size', 'rows columns') + + +class Char(object): + """ + Represent a single character in a :class:`.Screen`. + + This should be considered immutable. + """ + __slots__ = ('char', 'token', 'width') + + # If we end up having one of these special control sequences in the input string, + # we should display them as follows: + # Usually this happens after a "quoted insert". + display_mappings = { + '\x00': '^@', # Control space + '\x01': '^A', + '\x02': '^B', + '\x03': '^C', + '\x04': '^D', + '\x05': '^E', + '\x06': '^F', + '\x07': '^G', + '\x08': '^H', + '\x09': '^I', + '\x0a': '^J', + '\x0b': '^K', + '\x0c': '^L', + '\x0d': '^M', + '\x0e': '^N', + '\x0f': '^O', + '\x10': '^P', + '\x11': '^Q', + '\x12': '^R', + '\x13': '^S', + '\x14': '^T', + '\x15': '^U', + '\x16': '^V', + '\x17': '^W', + '\x18': '^X', + '\x19': '^Y', + '\x1a': '^Z', + '\x1b': '^[', # Escape + '\x1c': '^\\', + '\x1d': '^]', + '\x1f': '^_', + '\x7f': '^?', # Backspace + } + + def __init__(self, char=' ', token=Token): + # If this character has to be displayed otherwise, take that one. + char = self.display_mappings.get(char, char) + + self.char = char + self.token = token + + # Calculate width. (We always need this, so better to store it directly + # as a member for performance.) + self.width = get_cwidth(char) + + def __eq__(self, other): + return self.char == other.char and self.token == other.token + + def __ne__(self, other): + # Not equal: We don't do `not char.__eq__` here, because of the + # performance of calling yet another function. + return self.char != other.char or self.token != other.token + + def __repr__(self): + return '%s(%r, %r)' % (self.__class__.__name__, self.char, self.token) + + +_CHAR_CACHE = FastDictCache(Char, size=1000 * 1000) +Transparent = Token.Transparent + + +class Screen(object): + """ + Two dimentional buffer of :class:`.Char` instances. + """ + def __init__(self, default_char=None, initial_width=0, initial_height=0): + if default_char is None: + default_char = _CHAR_CACHE[' ', Transparent] + + self.data_buffer = defaultdict(lambda: defaultdict(lambda: default_char)) + + #: Escape sequences to be injected. + self.zero_width_escapes = defaultdict(lambda: defaultdict(lambda: '')) + + #: Position of the cursor. + self.cursor_position = Point(y=0, x=0) + + #: Visibility of the cursor. + self.show_cursor = True + + #: (Optional) Where to position the menu. E.g. at the start of a completion. + #: (We can't use the cursor position, because we don't want the + #: completion menu to change its position when we browse through all the + #: completions.) + self.menu_position = None + + #: Currently used width/height of the screen. This will increase when + #: data is written to the screen. + self.width = initial_width or 0 + self.height = initial_height or 0 + + def replace_all_tokens(self, token): + """ + For all the characters in the screen. Set the token to the given `token`. + """ + b = self.data_buffer + + for y, row in b.items(): + for x, char in row.items(): + b[y][x] = _CHAR_CACHE[char.char, token] + + +class WritePosition(object): + def __init__(self, xpos, ypos, width, height, extended_height=None): + assert height >= 0 + assert extended_height is None or extended_height >= 0 + assert width >= 0 + # xpos and ypos can be negative. (A float can be partially visible.) + + self.xpos = xpos + self.ypos = ypos + self.width = width + self.height = height + self.extended_height = extended_height or height + + def __repr__(self): + return '%s(%r, %r, %r, %r, %r)' % ( + self.__class__.__name__, + self.xpos, self.ypos, self.width, self.height, self.extended_height) diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/toolbars.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/toolbars.py new file mode 100644 index 00000000000..2e77c2fa160 --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/toolbars.py @@ -0,0 +1,209 @@ +from __future__ import unicode_literals + +from ..enums import IncrementalSearchDirection + +from .processors import BeforeInput + +from .lexers import SimpleLexer +from .dimension import LayoutDimension +from .controls import BufferControl, TokenListControl, UIControl, UIContent +from .containers import Window, ConditionalContainer +from .screen import Char +from .utils import token_list_len +from prompt_toolkit.enums import SEARCH_BUFFER, SYSTEM_BUFFER +from prompt_toolkit.filters import HasFocus, HasArg, HasCompletions, HasValidationError, HasSearch, Always, IsDone +from prompt_toolkit.token import Token + +__all__ = ( + 'TokenListToolbar', + 'ArgToolbar', + 'CompletionsToolbar', + 'SearchToolbar', + 'SystemToolbar', + 'ValidationToolbar', +) + + +class TokenListToolbar(ConditionalContainer): + def __init__(self, get_tokens, filter=Always(), **kw): + super(TokenListToolbar, self).__init__( + content=Window( + TokenListControl(get_tokens, **kw), + height=LayoutDimension.exact(1)), + filter=filter) + + +class SystemToolbarControl(BufferControl): + def __init__(self): + token = Token.Toolbar.System + + super(SystemToolbarControl, self).__init__( + buffer_name=SYSTEM_BUFFER, + default_char=Char(token=token), + lexer=SimpleLexer(token=token.Text), + input_processors=[BeforeInput.static('Shell command: ', token)],) + + +class SystemToolbar(ConditionalContainer): + def __init__(self): + super(SystemToolbar, self).__init__( + content=Window( + SystemToolbarControl(), + height=LayoutDimension.exact(1)), + filter=HasFocus(SYSTEM_BUFFER) & ~IsDone()) + + +class ArgToolbarControl(TokenListControl): + def __init__(self): + def get_tokens(cli): + arg = cli.input_processor.arg + if arg == '-': + arg = '-1' + + return [ + (Token.Toolbar.Arg, 'Repeat: '), + (Token.Toolbar.Arg.Text, arg), + ] + + super(ArgToolbarControl, self).__init__(get_tokens) + + +class ArgToolbar(ConditionalContainer): + def __init__(self): + super(ArgToolbar, self).__init__( + content=Window( + ArgToolbarControl(), + height=LayoutDimension.exact(1)), + filter=HasArg()) + + +class SearchToolbarControl(BufferControl): + """ + :param vi_mode: Display '/' and '?' instead of I-search. + """ + def __init__(self, vi_mode=False): + token = Token.Toolbar.Search + + def get_before_input(cli): + if not cli.is_searching: + text = '' + elif cli.search_state.direction == IncrementalSearchDirection.BACKWARD: + text = ('?' if vi_mode else 'I-search backward: ') + else: + text = ('/' if vi_mode else 'I-search: ') + + return [(token, text)] + + super(SearchToolbarControl, self).__init__( + buffer_name=SEARCH_BUFFER, + input_processors=[BeforeInput(get_before_input)], + default_char=Char(token=token), + lexer=SimpleLexer(token=token.Text)) + + +class SearchToolbar(ConditionalContainer): + def __init__(self, vi_mode=False): + super(SearchToolbar, self).__init__( + content=Window( + SearchToolbarControl(vi_mode=vi_mode), + height=LayoutDimension.exact(1)), + filter=HasSearch() & ~IsDone()) + + +class CompletionsToolbarControl(UIControl): + token = Token.Toolbar.Completions + + def create_content(self, cli, width, height): + complete_state = cli.current_buffer.complete_state + if complete_state: + completions = complete_state.current_completions + index = complete_state.complete_index # Can be None! + + # Width of the completions without the left/right arrows in the margins. + content_width = width - 6 + + # Booleans indicating whether we stripped from the left/right + cut_left = False + cut_right = False + + # Create Menu content. + tokens = [] + + for i, c in enumerate(completions): + # When there is no more place for the next completion + if token_list_len(tokens) + len(c.display) >= content_width: + # If the current one was not yet displayed, page to the next sequence. + if i <= (index or 0): + tokens = [] + cut_left = True + # If the current one is visible, stop here. + else: + cut_right = True + break + + tokens.append((self.token.Completion.Current if i == index else self.token.Completion, c.display)) + tokens.append((self.token, ' ')) + + # Extend/strip until the content width. + tokens.append((self.token, ' ' * (content_width - token_list_len(tokens)))) + tokens = tokens[:content_width] + + # Return tokens + all_tokens = [ + (self.token, ' '), + (self.token.Arrow, '<' if cut_left else ' '), + (self.token, ' '), + ] + tokens + [ + (self.token, ' '), + (self.token.Arrow, '>' if cut_right else ' '), + (self.token, ' '), + ] + else: + all_tokens = [] + + def get_line(i): + return all_tokens + + return UIContent(get_line=get_line, line_count=1) + + +class CompletionsToolbar(ConditionalContainer): + def __init__(self, extra_filter=Always()): + super(CompletionsToolbar, self).__init__( + content=Window( + CompletionsToolbarControl(), + height=LayoutDimension.exact(1)), + filter=HasCompletions() & ~IsDone() & extra_filter) + + +class ValidationToolbarControl(TokenListControl): + def __init__(self, show_position=False): + token = Token.Toolbar.Validation + + def get_tokens(cli): + buffer = cli.current_buffer + + if buffer.validation_error: + row, column = buffer.document.translate_index_to_position( + buffer.validation_error.cursor_position) + + if show_position: + text = '%s (line=%s column=%s)' % ( + buffer.validation_error.message, row + 1, column + 1) + else: + text = buffer.validation_error.message + + return [(token, text)] + else: + return [] + + super(ValidationToolbarControl, self).__init__(get_tokens) + + +class ValidationToolbar(ConditionalContainer): + def __init__(self, show_position=False): + super(ValidationToolbar, self).__init__( + content=Window( + ValidationToolbarControl(show_position=show_position), + height=LayoutDimension.exact(1)), + filter=HasValidationError() & ~IsDone()) diff --git a/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/utils.py b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/utils.py new file mode 100644 index 00000000000..a4fb7ed0f5b --- /dev/null +++ b/contrib/python/prompt-toolkit/py2/prompt_toolkit/layout/utils.py @@ -0,0 +1,181 @@ +from __future__ import unicode_literals + +from prompt_toolkit.utils import get_cwidth +from prompt_toolkit.token import Token + +__all__ = ( + 'token_list_len', + 'token_list_width', + 'token_list_to_text', + 'explode_tokens', + 'split_lines', + 'find_window_for_buffer_name', +) + + +def token_list_len(tokenlist): + """ + Return the amount of characters in this token list. + + :param tokenlist: List of (token, text) or (token, text, mouse_handler) + tuples. + """ + ZeroWidthEscape = Token.ZeroWidthEscape + return sum(len(item[1]) for item in tokenlist if item[0] != ZeroWidthEscape) + + +def token_list_width(tokenlist): + """ + Return the character width of this token list. + (Take double width characters into account.) + + :param tokenlist: List of (token, text) or (token, text, mouse_handler) + tuples. + """ + ZeroWidthEscape = Token.ZeroWidthEscape + return sum(get_cwidth(c) for item in tokenlist for c in item[1] if item[0] != ZeroWidthEscape) + + +def token_list_to_text(tokenlist): + """ + Concatenate all the text parts again. + """ + ZeroWidthEscape = Token.ZeroWidthEscape + return ''.join(item[1] for item in tokenlist if item[0] != ZeroWidthEscape) + + +def iter_token_lines(tokenlist): + """ + Iterator that yields tokenlists for each line. + """ + line = [] + for token, c in explode_tokens(tokenlist): + line.append((token, c)) + + if c == '\n': + yield line + line = [] + + yield line + + +def split_lines(tokenlist): + """ + Take a single list of (Token, text) tuples and yield one such list for each + line. Just like str.split, this will yield at least one item. + + :param tokenlist: List of (token, text) or (token, text, mouse_handler) + tuples. + """ + line = [] + + for item in tokenlist: + # For (token, text) tuples. + if len(item) == 2: + token, string = item + parts = string.split('\n') + + for part in parts[:-1]: + if part: + line.append((token, part)) + yield line + line = [] + + line.append((token, parts[-1])) + # Note that parts[-1] can be empty, and that's fine. It happens + # in the case of [(Token.SetCursorPosition, '')]. + + # For (token, text, mouse_handler) tuples. + # I know, partly copy/paste, but understandable and more efficient + # than many tests. + else: + token, string, mouse_handler = item + parts = string.split('\n') + + for part in parts[:-1]: + if part: + line.append((token, part, mouse_handler)) + yield line + line = [] + + line.append((token, parts[-1], mouse_handler)) + + # Always yield the last line, even when this is an empty line. This ensures + # that when `tokenlist` ends with a newline character, an additional empty + # line is yielded. (Otherwise, there's no way to differentiate between the + # cases where `tokenlist` does and doesn't end with a newline.) + yield line + + +class _ExplodedList(list): + """ + Wrapper around a list, that marks it as 'exploded'. + + As soon as items are added or the list is extended, the new items are + automatically exploded as well. + """ + def __init__(self, *a, **kw): + super(_ExplodedList, self).__init__(*a, **kw) + self.exploded = True + + def append(self, item): + self.extend([item]) + + def extend(self, lst): + super(_ExplodedList, self).extend(explode_tokens(lst)) + + def insert(self, index, item): + raise NotImplementedError # TODO + + # TODO: When creating a copy() or [:], return also an _ExplodedList. + + def __setitem__(self, index, value): + """ + Ensure that when `(Token, 'long string')` is set, the string will be + exploded. + """ + if not isinstance(index, slice): + index = slice(index, index + 1) + value = explode_tokens([value]) + super(_ExplodedList, self).__setitem__(index, value) + + +def explode_tokens(tokenlist): + """ + Turn a list of (token, text) tuples into another list where each string is + exactly one character. + + It should be fine to call this function several times. Calling this on a + list that is already exploded, is a null operation. + + :param tokenlist: List of (token, text) tuples. + """ + # When the tokenlist is already exploded, don't explode again. + if getattr(tokenlist, 'exploded', False): + return tokenlist + + result = [] + + for token, string in tokenlist: + for c in string: + result.append((token, c)) + + return _ExplodedList(result) + + +def find_window_for_buffer_name(cli, buffer_name): + """ + Look for a :class:`~prompt_toolkit.layout.containers.Window` in the Layout + that contains the :class:`~prompt_toolkit.layout.controls.BufferControl` + for the given buffer and return it. If no such Window is found, return None. + """ + from prompt_toolkit.interface import CommandLineInterface + assert isinstance(cli, CommandLineInterface) + + from .containers import Window + from .controls import BufferControl + + for l in cli.layout.walk(cli): + if isinstance(l, Window) and isinstance(l.content, BufferControl): + if l.content.buffer_name == buffer_name: + return l |