diff options
author | shadchin <shadchin@yandex-team.ru> | 2022-02-10 16:44:30 +0300 |
---|---|---|
committer | Daniil Cherednik <dcherednik@yandex-team.ru> | 2022-02-10 16:44:30 +0300 |
commit | 2598ef1d0aee359b4b6d5fdd1758916d5907d04f (patch) | |
tree | 012bb94d777798f1f56ac1cec429509766d05181 /contrib/python/prompt-toolkit/py3/prompt_toolkit/layout | |
parent | 6751af0b0c1b952fede40b19b71da8025b5d8bcf (diff) | |
download | ydb-2598ef1d0aee359b4b6d5fdd1758916d5907d04f.tar.gz |
Restoring authorship annotation for <shadchin@yandex-team.ru>. Commit 1 of 2.
Diffstat (limited to 'contrib/python/prompt-toolkit/py3/prompt_toolkit/layout')
13 files changed, 7533 insertions, 7533 deletions
diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/__init__.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/__init__.py index 6669da5d7a..348ded009b 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/__init__.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/__init__.py @@ -1,144 +1,144 @@ -""" -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) - |- FormattedTextControl (Renders formatted text, or a simple list of text fragments) - `- 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 .containers import ( - AnyContainer, - ColorColumn, - ConditionalContainer, - Container, - DynamicContainer, - Float, - FloatContainer, - HorizontalAlign, - HSplit, - ScrollOffsets, - VerticalAlign, - VSplit, - Window, - WindowAlign, - WindowRenderInfo, - is_container, - to_container, - to_window, -) -from .controls import ( - BufferControl, - DummyControl, - FormattedTextControl, - SearchBufferControl, - UIContent, - UIControl, -) -from .dimension import ( - AnyDimension, - D, - Dimension, - is_dimension, - max_layout_dimensions, - sum_layout_dimensions, - to_dimension, -) -from .layout import InvalidLayoutError, Layout, walk -from .margins import ( - ConditionalMargin, - Margin, - NumberedMargin, - PromptMargin, - ScrollbarMargin, -) -from .menus import CompletionsMenu, MultiColumnCompletionsMenu -from .scrollable_pane import ScrollablePane - -__all__ = [ - # Layout. - "Layout", - "InvalidLayoutError", - "walk", - # Dimensions. - "AnyDimension", - "Dimension", - "D", - "sum_layout_dimensions", - "max_layout_dimensions", - "to_dimension", - "is_dimension", - # Containers. - "AnyContainer", - "Container", - "HorizontalAlign", - "VerticalAlign", - "HSplit", - "VSplit", - "FloatContainer", - "Float", - "WindowAlign", - "Window", - "WindowRenderInfo", - "ConditionalContainer", - "ScrollOffsets", - "ColorColumn", - "to_container", - "to_window", - "is_container", - "DynamicContainer", - "ScrollablePane", - # Controls. - "BufferControl", - "SearchBufferControl", - "DummyControl", - "FormattedTextControl", - "UIControl", - "UIContent", - # Margins. - "Margin", - "NumberedMargin", - "ScrollbarMargin", - "ConditionalMargin", - "PromptMargin", - # Menus. - "CompletionsMenu", - "MultiColumnCompletionsMenu", -] +""" +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) + |- FormattedTextControl (Renders formatted text, or a simple list of text fragments) + `- 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 .containers import ( + AnyContainer, + ColorColumn, + ConditionalContainer, + Container, + DynamicContainer, + Float, + FloatContainer, + HorizontalAlign, + HSplit, + ScrollOffsets, + VerticalAlign, + VSplit, + Window, + WindowAlign, + WindowRenderInfo, + is_container, + to_container, + to_window, +) +from .controls import ( + BufferControl, + DummyControl, + FormattedTextControl, + SearchBufferControl, + UIContent, + UIControl, +) +from .dimension import ( + AnyDimension, + D, + Dimension, + is_dimension, + max_layout_dimensions, + sum_layout_dimensions, + to_dimension, +) +from .layout import InvalidLayoutError, Layout, walk +from .margins import ( + ConditionalMargin, + Margin, + NumberedMargin, + PromptMargin, + ScrollbarMargin, +) +from .menus import CompletionsMenu, MultiColumnCompletionsMenu +from .scrollable_pane import ScrollablePane + +__all__ = [ + # Layout. + "Layout", + "InvalidLayoutError", + "walk", + # Dimensions. + "AnyDimension", + "Dimension", + "D", + "sum_layout_dimensions", + "max_layout_dimensions", + "to_dimension", + "is_dimension", + # Containers. + "AnyContainer", + "Container", + "HorizontalAlign", + "VerticalAlign", + "HSplit", + "VSplit", + "FloatContainer", + "Float", + "WindowAlign", + "Window", + "WindowRenderInfo", + "ConditionalContainer", + "ScrollOffsets", + "ColorColumn", + "to_container", + "to_window", + "is_container", + "DynamicContainer", + "ScrollablePane", + # Controls. + "BufferControl", + "SearchBufferControl", + "DummyControl", + "FormattedTextControl", + "UIControl", + "UIContent", + # Margins. + "Margin", + "NumberedMargin", + "ScrollbarMargin", + "ConditionalMargin", + "PromptMargin", + # Menus. + "CompletionsMenu", + "MultiColumnCompletionsMenu", +] diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/containers.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/containers.py index 2c845a76aa..7059653151 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/containers.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/containers.py @@ -1,2757 +1,2757 @@ -""" -Container for the layout. -(Containers can contain other containers or user interface controls.) -""" -from abc import ABCMeta, abstractmethod -from enum import Enum -from functools import partial -from typing import ( - TYPE_CHECKING, - Callable, - Dict, - List, - Optional, - Sequence, - Tuple, - Union, - cast, -) - -from prompt_toolkit.application.current import get_app -from prompt_toolkit.cache import SimpleCache -from prompt_toolkit.data_structures import Point -from prompt_toolkit.filters import ( - FilterOrBool, - emacs_insert_mode, - to_filter, - vi_insert_mode, -) -from prompt_toolkit.formatted_text import ( - AnyFormattedText, - StyleAndTextTuples, - to_formatted_text, -) -from prompt_toolkit.formatted_text.utils import ( - fragment_list_to_text, - fragment_list_width, -) -from prompt_toolkit.key_binding import KeyBindingsBase -from prompt_toolkit.mouse_events import MouseEvent, MouseEventType -from prompt_toolkit.utils import get_cwidth, take_using_weights, to_int, to_str - -from .controls import ( - DummyControl, - FormattedTextControl, - GetLinePrefixCallable, - UIContent, - UIControl, -) -from .dimension import ( - AnyDimension, - Dimension, - max_layout_dimensions, - sum_layout_dimensions, - to_dimension, -) -from .margins import Margin -from .mouse_handlers import MouseHandlers -from .screen import _CHAR_CACHE, Screen, WritePosition -from .utils import explode_text_fragments - -if TYPE_CHECKING: - from typing_extensions import Protocol, TypeGuard - - from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone - - -__all__ = [ - "AnyContainer", - "Container", - "HorizontalAlign", - "VerticalAlign", - "HSplit", - "VSplit", - "FloatContainer", - "Float", - "WindowAlign", - "Window", - "WindowRenderInfo", - "ConditionalContainer", - "ScrollOffsets", - "ColorColumn", - "to_container", - "to_window", - "is_container", - "DynamicContainer", -] - - -class Container(metaclass=ABCMeta): - """ - Base class for user interface layout. - """ - - @abstractmethod - def reset(self) -> None: - """ - Reset the state of this container and all the children. - (E.g. reset scroll offsets, etc...) - """ - - @abstractmethod - def preferred_width(self, max_available_width: int) -> Dimension: - """ - Return a :class:`~prompt_toolkit.layout.Dimension` that represents the - desired width for this container. - """ - - @abstractmethod - def preferred_height(self, width: int, max_available_height: int) -> Dimension: - """ - Return a :class:`~prompt_toolkit.layout.Dimension` that represents the - desired height for this container. - """ - - @abstractmethod - def write_to_screen( - self, - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - parent_style: str, - erase_bg: bool, - z_index: Optional[int], - ) -> None: - """ - Write the actual content to the screen. - - :param screen: :class:`~prompt_toolkit.layout.screen.Screen` - :param mouse_handlers: :class:`~prompt_toolkit.layout.mouse_handlers.MouseHandlers`. - :param parent_style: Style string to pass to the :class:`.Window` - object. This will be applied to all content of the windows. - :class:`.VSplit` and :class:`.HSplit` can use it to pass their - style down to the windows that they contain. - :param z_index: Used for propagating z_index from parent to child. - """ - - def is_modal(self) -> bool: - """ - When this container is modal, key bindings from parent containers are - not taken into account if a user control in this container is focused. - """ - return False - - def get_key_bindings(self) -> Optional[KeyBindingsBase]: - """ - Returns a :class:`.KeyBindings` object. These bindings become active when any - user control in this container has the focus, except if any containers - between this container and the focused user control is modal. - """ - return None - - @abstractmethod - def get_children(self) -> List["Container"]: - """ - Return the list of child :class:`.Container` objects. - """ - return [] - - -if TYPE_CHECKING: - - class MagicContainer(Protocol): - """ - Any object that implements ``__pt_container__`` represents a container. - """ - - def __pt_container__(self) -> "AnyContainer": - ... - - -AnyContainer = Union[Container, "MagicContainer"] - - -def _window_too_small() -> "Window": - "Create a `Window` that displays the 'Window too small' text." - return Window( - FormattedTextControl(text=[("class:window-too-small", " Window too small... ")]) - ) - - -class VerticalAlign(Enum): - "Alignment for `HSplit`." - TOP = "TOP" - CENTER = "CENTER" - BOTTOM = "BOTTOM" - JUSTIFY = "JUSTIFY" - - -class HorizontalAlign(Enum): - "Alignment for `VSplit`." - LEFT = "LEFT" - CENTER = "CENTER" - RIGHT = "RIGHT" - JUSTIFY = "JUSTIFY" - - -class _Split(Container): - """ - The common parts of `VSplit` and `HSplit`. - """ - - def __init__( - self, - children: Sequence[AnyContainer], - window_too_small: Optional[Container] = None, - padding: AnyDimension = Dimension.exact(0), - padding_char: Optional[str] = None, - padding_style: str = "", - width: AnyDimension = None, - height: AnyDimension = None, - z_index: Optional[int] = None, - modal: bool = False, - key_bindings: Optional[KeyBindingsBase] = None, - style: Union[str, Callable[[], str]] = "", - ) -> None: - - self.children = [to_container(c) for c in children] - self.window_too_small = window_too_small or _window_too_small() - self.padding = padding - self.padding_char = padding_char - self.padding_style = padding_style - - self.width = width - self.height = height - self.z_index = z_index - - self.modal = modal - self.key_bindings = key_bindings - self.style = style - - def is_modal(self) -> bool: - return self.modal - - def get_key_bindings(self) -> Optional[KeyBindingsBase]: - return self.key_bindings - - def get_children(self) -> List[Container]: - return self.children - - -class HSplit(_Split): - """ - Several layouts, one stacked above/under the other. :: - - +--------------------+ - | | - +--------------------+ - | | - +--------------------+ - - By default, this doesn't display a horizontal line between the children, - but if this is something you need, then create a HSplit as follows:: - - HSplit(children=[ ... ], padding_char='-', - padding=1, padding_style='#ffff00') - - :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 align: `VerticalAlign` value. - :param width: When given, use this width instead of looking at the children. - :param height: When given, use this height instead of looking at the children. - :param z_index: (int or None) When specified, this can be used to bring - element in front of floating elements. `None` means: inherit from parent. - :param style: A style string. - :param modal: ``True`` or ``False``. - :param key_bindings: ``None`` or a :class:`.KeyBindings` object. - - :param padding: (`Dimension` or int), size to be used for the padding. - :param padding_char: Character to be used for filling in the padding. - :param padding_style: Style to applied to the padding. - """ - - def __init__( - self, - children: Sequence[AnyContainer], - window_too_small: Optional[Container] = None, - align: VerticalAlign = VerticalAlign.JUSTIFY, - padding: AnyDimension = 0, - padding_char: Optional[str] = None, - padding_style: str = "", - width: AnyDimension = None, - height: AnyDimension = None, - z_index: Optional[int] = None, - modal: bool = False, - key_bindings: Optional[KeyBindingsBase] = None, - style: Union[str, Callable[[], str]] = "", - ) -> None: - - super().__init__( - children=children, - window_too_small=window_too_small, - padding=padding, - padding_char=padding_char, - padding_style=padding_style, - width=width, - height=height, - z_index=z_index, - modal=modal, - key_bindings=key_bindings, - style=style, - ) - - self.align = align - - self._children_cache: SimpleCache[ - Tuple[Container, ...], List[Container] - ] = SimpleCache(maxsize=1) - self._remaining_space_window = Window() # Dummy window. - - def preferred_width(self, max_available_width: int) -> Dimension: - if self.width is not None: - return to_dimension(self.width) - - if self.children: - dimensions = [c.preferred_width(max_available_width) for c in self.children] - return max_layout_dimensions(dimensions) - else: - return Dimension() - - def preferred_height(self, width: int, max_available_height: int) -> Dimension: - if self.height is not None: - return to_dimension(self.height) - - dimensions = [ - c.preferred_height(width, max_available_height) for c in self._all_children - ] - return sum_layout_dimensions(dimensions) - - def reset(self) -> None: - for c in self.children: - c.reset() - - @property - def _all_children(self) -> List[Container]: - """ - List of child objects, including padding. - """ - - def get() -> List[Container]: - result: List[Container] = [] - - # Padding Top. - if self.align in (VerticalAlign.CENTER, VerticalAlign.BOTTOM): - result.append(Window(width=Dimension(preferred=0))) - - # The children with padding. - for child in self.children: - result.append(child) - result.append( - Window( - height=self.padding, - char=self.padding_char, - style=self.padding_style, - ) - ) - if result: - result.pop() - - # Padding right. - if self.align in (VerticalAlign.CENTER, VerticalAlign.TOP): - result.append(Window(width=Dimension(preferred=0))) - - return result - - return self._children_cache.get(tuple(self.children), get) - - def write_to_screen( - self, - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - parent_style: str, - erase_bg: bool, - z_index: Optional[int], - ) -> None: - """ - 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_heights(write_position) - style = parent_style + " " + to_str(self.style) - z_index = z_index if self.z_index is None else self.z_index - - if sizes is None: - self.window_too_small.write_to_screen( - screen, mouse_handlers, write_position, style, erase_bg, z_index - ) - else: - # - ypos = write_position.ypos - xpos = write_position.xpos - width = write_position.width - - # Draw child panes. - for s, c in zip(sizes, self._all_children): - c.write_to_screen( - screen, - mouse_handlers, - WritePosition(xpos, ypos, width, s), - style, - erase_bg, - z_index, - ) - ypos += s - - # Fill in the remaining space. This happens when a child control - # refuses to take more space and we don't have any padding. Adding a - # dummy child control for this (in `self._all_children`) is not - # desired, because in some situations, it would take more space, even - # when it's not required. This is required to apply the styling. - remaining_height = write_position.ypos + write_position.height - ypos - if remaining_height > 0: - self._remaining_space_window.write_to_screen( - screen, - mouse_handlers, - WritePosition(xpos, ypos, width, remaining_height), - style, - erase_bg, - z_index, - ) - - def _divide_heights(self, write_position: WritePosition) -> Optional[List[int]]: - """ - Return the heights for all rows. - Or None when there is not enough space. - """ - if not self.children: - return [] - - width = write_position.width - height = write_position.height - - # Calculate heights. - dimensions = [c.preferred_height(width, height) for c in self._all_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 > height: - return None - - # 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) - - # Increase until we meet at least the 'preferred' size. - preferred_stop = min(height, sum_dimensions.preferred) - preferred_dimensions = [d.preferred for d in dimensions] - - while sum(sizes) < preferred_stop: - if sizes[i] < preferred_dimensions[i]: - sizes[i] += 1 - i = next(child_generator) - - # Increase until we use all the available space. (or until "max") - if not get_app().is_done: - max_stop = min(height, sum_dimensions.max) - max_dimensions = [d.max for d in dimensions] - - while sum(sizes) < max_stop: - if sizes[i] < max_dimensions[i]: - sizes[i] += 1 - i = next(child_generator) - - return sizes - - -class VSplit(_Split): - """ - Several layouts, one stacked left/right of the other. :: - - +---------+----------+ - | | | - | | | - +---------+----------+ - - By default, this doesn't display a vertical line between the children, but - if this is something you need, then create a HSplit as follows:: - - VSplit(children=[ ... ], padding_char='|', - padding=1, padding_style='#ffff00') - - :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 align: `HorizontalAlign` value. - :param width: When given, use this width instead of looking at the children. - :param height: When given, use this height instead of looking at the children. - :param z_index: (int or None) When specified, this can be used to bring - element in front of floating elements. `None` means: inherit from parent. - :param style: A style string. - :param modal: ``True`` or ``False``. - :param key_bindings: ``None`` or a :class:`.KeyBindings` object. - - :param padding: (`Dimension` or int), size to be used for the padding. - :param padding_char: Character to be used for filling in the padding. - :param padding_style: Style to applied to the padding. - """ - - def __init__( - self, - children: Sequence[AnyContainer], - window_too_small: Optional[Container] = None, - align: HorizontalAlign = HorizontalAlign.JUSTIFY, - padding: AnyDimension = 0, - padding_char: Optional[str] = None, - padding_style: str = "", - width: AnyDimension = None, - height: AnyDimension = None, - z_index: Optional[int] = None, - modal: bool = False, - key_bindings: Optional[KeyBindingsBase] = None, - style: Union[str, Callable[[], str]] = "", - ) -> None: - - super().__init__( - children=children, - window_too_small=window_too_small, - padding=padding, - padding_char=padding_char, - padding_style=padding_style, - width=width, - height=height, - z_index=z_index, - modal=modal, - key_bindings=key_bindings, - style=style, - ) - - self.align = align - - self._children_cache: SimpleCache[ - Tuple[Container, ...], List[Container] - ] = SimpleCache(maxsize=1) - self._remaining_space_window = Window() # Dummy window. - - def preferred_width(self, max_available_width: int) -> Dimension: - if self.width is not None: - return to_dimension(self.width) - - dimensions = [ - c.preferred_width(max_available_width) for c in self._all_children - ] - - return sum_layout_dimensions(dimensions) - - def preferred_height(self, width: int, max_available_height: int) -> Dimension: - if self.height is not None: - return to_dimension(self.height) - - # At the point where we want to calculate the heights, the widths have - # already been decided. So we can trust `width` to be the actual - # `width` that's going to be used for the rendering. So, - # `divide_widths` is supposed to use all of the available width. - # Using only the `preferred` width caused a bug where the reported - # height was more than required. (we had a `BufferControl` which did - # wrap lines because of the smaller width returned by `_divide_widths`. - - sizes = self._divide_widths(width) - children = self._all_children - - if sizes is None: - return Dimension() - else: - dimensions = [ - c.preferred_height(s, max_available_height) - for s, c in zip(sizes, children) - ] - return max_layout_dimensions(dimensions) - - def reset(self) -> None: - for c in self.children: - c.reset() - - @property - def _all_children(self) -> List[Container]: - """ - List of child objects, including padding. - """ - - def get() -> List[Container]: - result: List[Container] = [] - - # Padding left. - if self.align in (HorizontalAlign.CENTER, HorizontalAlign.RIGHT): - result.append(Window(width=Dimension(preferred=0))) - - # The children with padding. - for child in self.children: - result.append(child) - result.append( - Window( - width=self.padding, - char=self.padding_char, - style=self.padding_style, - ) - ) - if result: - result.pop() - - # Padding right. - if self.align in (HorizontalAlign.CENTER, HorizontalAlign.LEFT): - result.append(Window(width=Dimension(preferred=0))) - - return result - - return self._children_cache.get(tuple(self.children), get) - - def _divide_widths(self, width: int) -> Optional[List[int]]: - """ - Return the widths for all columns. - Or None when there is not enough space. - """ - children = self._all_children - - if not children: - return [] - - # Calculate widths. - dimensions = [c.preferred_width(width) for c in children] - preferred_dimensions = [d.preferred for d in dimensions] - - # 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 None - - # Find optimal sizes. (Start with minimal size, increase until we cover - # the whole width.) - 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) - - # Increase until we meet at least the 'preferred' size. - preferred_stop = min(width, sum_dimensions.preferred) - - while sum(sizes) < preferred_stop: - if sizes[i] < preferred_dimensions[i]: - sizes[i] += 1 - i = next(child_generator) - - # Increase until we use all the available space. - max_dimensions = [d.max for d in dimensions] - max_stop = min(width, sum_dimensions.max) - - while sum(sizes) < max_stop: - if sizes[i] < max_dimensions[i]: - sizes[i] += 1 - i = next(child_generator) - - return sizes - - def write_to_screen( - self, - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - parent_style: str, - erase_bg: bool, - z_index: Optional[int], - ) -> None: - """ - 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 - - children = self._all_children - sizes = self._divide_widths(write_position.width) - style = parent_style + " " + to_str(self.style) - z_index = z_index if self.z_index is None else self.z_index - - # If there is not enough space. - if sizes is None: - self.window_too_small.write_to_screen( - screen, mouse_handlers, write_position, style, erase_bg, z_index - ) - return - - # Calculate heights, take the largest possible, but not larger than - # write_position.height. - heights = [ - child.preferred_height(width, write_position.height).preferred - for width, child in zip(sizes, children) - ] - height = max(write_position.height, min(write_position.height, max(heights))) - - # - ypos = write_position.ypos - xpos = write_position.xpos - - # Draw all child panes. - for s, c in zip(sizes, children): - c.write_to_screen( - screen, - mouse_handlers, - WritePosition(xpos, ypos, s, height), - style, - erase_bg, - z_index, - ) - xpos += s - - # Fill in the remaining space. This happens when a child control - # refuses to take more space and we don't have any padding. Adding a - # dummy child control for this (in `self._all_children`) is not - # desired, because in some situations, it would take more space, even - # when it's not required. This is required to apply the styling. - remaining_width = write_position.xpos + write_position.width - xpos - if remaining_width > 0: - self._remaining_space_window.write_to_screen( - screen, - mouse_handlers, - WritePosition(xpos, ypos, remaining_width, height), - style, - erase_bg, - z_index, - ) - - -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, - content=CompletionsMenu(...)) - ]) - - :param z_index: (int or None) When specified, this can be used to bring - element in front of floating elements. `None` means: inherit from parent. - This is the z_index for the whole `Float` container as a whole. - """ - - def __init__( - self, - content: AnyContainer, - floats: List["Float"], - modal: bool = False, - key_bindings: Optional[KeyBindingsBase] = None, - style: Union[str, Callable[[], str]] = "", - z_index: Optional[int] = None, - ) -> None: - - self.content = to_container(content) - self.floats = floats - - self.modal = modal - self.key_bindings = key_bindings - self.style = style - self.z_index = z_index - - def reset(self) -> None: - self.content.reset() - - for f in self.floats: - f.content.reset() - - def preferred_width(self, max_available_width: int) -> Dimension: - return self.content.preferred_width(max_available_width) - - def preferred_height(self, width: int, max_available_height: int) -> Dimension: - """ - 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(width, max_available_height) - - def write_to_screen( - self, - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - parent_style: str, - erase_bg: bool, - z_index: Optional[int], - ) -> None: - style = parent_style + " " + to_str(self.style) - z_index = z_index if self.z_index is None else self.z_index - - self.content.write_to_screen( - screen, mouse_handlers, write_position, style, erase_bg, z_index - ) - - for number, fl in enumerate(self.floats): - # z_index of a Float is computed by summing the z_index of the - # container and the `Float`. - new_z_index = (z_index or 0) + fl.z_index - style = parent_style + " " + to_str(self.style) - - # If the float that we have here, is positioned relative to the - # cursor position, but the Window that specifies the cursor - # position is not drawn yet, because it's a Float itself, we have - # to postpone this calculation. (This is a work-around, but good - # enough for now.) - postpone = fl.xcursor is not None or fl.ycursor is not None - - if postpone: - new_z_index = ( +""" +Container for the layout. +(Containers can contain other containers or user interface controls.) +""" +from abc import ABCMeta, abstractmethod +from enum import Enum +from functools import partial +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + List, + Optional, + Sequence, + Tuple, + Union, + cast, +) + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.data_structures import Point +from prompt_toolkit.filters import ( + FilterOrBool, + emacs_insert_mode, + to_filter, + vi_insert_mode, +) +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import ( + fragment_list_to_text, + fragment_list_width, +) +from prompt_toolkit.key_binding import KeyBindingsBase +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from prompt_toolkit.utils import get_cwidth, take_using_weights, to_int, to_str + +from .controls import ( + DummyControl, + FormattedTextControl, + GetLinePrefixCallable, + UIContent, + UIControl, +) +from .dimension import ( + AnyDimension, + Dimension, + max_layout_dimensions, + sum_layout_dimensions, + to_dimension, +) +from .margins import Margin +from .mouse_handlers import MouseHandlers +from .screen import _CHAR_CACHE, Screen, WritePosition +from .utils import explode_text_fragments + +if TYPE_CHECKING: + from typing_extensions import Protocol, TypeGuard + + from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone + + +__all__ = [ + "AnyContainer", + "Container", + "HorizontalAlign", + "VerticalAlign", + "HSplit", + "VSplit", + "FloatContainer", + "Float", + "WindowAlign", + "Window", + "WindowRenderInfo", + "ConditionalContainer", + "ScrollOffsets", + "ColorColumn", + "to_container", + "to_window", + "is_container", + "DynamicContainer", +] + + +class Container(metaclass=ABCMeta): + """ + Base class for user interface layout. + """ + + @abstractmethod + def reset(self) -> None: + """ + Reset the state of this container and all the children. + (E.g. reset scroll offsets, etc...) + """ + + @abstractmethod + def preferred_width(self, max_available_width: int) -> Dimension: + """ + Return a :class:`~prompt_toolkit.layout.Dimension` that represents the + desired width for this container. + """ + + @abstractmethod + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + """ + Return a :class:`~prompt_toolkit.layout.Dimension` that represents the + desired height for this container. + """ + + @abstractmethod + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: Optional[int], + ) -> None: + """ + Write the actual content to the screen. + + :param screen: :class:`~prompt_toolkit.layout.screen.Screen` + :param mouse_handlers: :class:`~prompt_toolkit.layout.mouse_handlers.MouseHandlers`. + :param parent_style: Style string to pass to the :class:`.Window` + object. This will be applied to all content of the windows. + :class:`.VSplit` and :class:`.HSplit` can use it to pass their + style down to the windows that they contain. + :param z_index: Used for propagating z_index from parent to child. + """ + + def is_modal(self) -> bool: + """ + When this container is modal, key bindings from parent containers are + not taken into account if a user control in this container is focused. + """ + return False + + def get_key_bindings(self) -> Optional[KeyBindingsBase]: + """ + Returns a :class:`.KeyBindings` object. These bindings become active when any + user control in this container has the focus, except if any containers + between this container and the focused user control is modal. + """ + return None + + @abstractmethod + def get_children(self) -> List["Container"]: + """ + Return the list of child :class:`.Container` objects. + """ + return [] + + +if TYPE_CHECKING: + + class MagicContainer(Protocol): + """ + Any object that implements ``__pt_container__`` represents a container. + """ + + def __pt_container__(self) -> "AnyContainer": + ... + + +AnyContainer = Union[Container, "MagicContainer"] + + +def _window_too_small() -> "Window": + "Create a `Window` that displays the 'Window too small' text." + return Window( + FormattedTextControl(text=[("class:window-too-small", " Window too small... ")]) + ) + + +class VerticalAlign(Enum): + "Alignment for `HSplit`." + TOP = "TOP" + CENTER = "CENTER" + BOTTOM = "BOTTOM" + JUSTIFY = "JUSTIFY" + + +class HorizontalAlign(Enum): + "Alignment for `VSplit`." + LEFT = "LEFT" + CENTER = "CENTER" + RIGHT = "RIGHT" + JUSTIFY = "JUSTIFY" + + +class _Split(Container): + """ + The common parts of `VSplit` and `HSplit`. + """ + + def __init__( + self, + children: Sequence[AnyContainer], + window_too_small: Optional[Container] = None, + padding: AnyDimension = Dimension.exact(0), + padding_char: Optional[str] = None, + padding_style: str = "", + width: AnyDimension = None, + height: AnyDimension = None, + z_index: Optional[int] = None, + modal: bool = False, + key_bindings: Optional[KeyBindingsBase] = None, + style: Union[str, Callable[[], str]] = "", + ) -> None: + + self.children = [to_container(c) for c in children] + self.window_too_small = window_too_small or _window_too_small() + self.padding = padding + self.padding_char = padding_char + self.padding_style = padding_style + + self.width = width + self.height = height + self.z_index = z_index + + self.modal = modal + self.key_bindings = key_bindings + self.style = style + + def is_modal(self) -> bool: + return self.modal + + def get_key_bindings(self) -> Optional[KeyBindingsBase]: + return self.key_bindings + + def get_children(self) -> List[Container]: + return self.children + + +class HSplit(_Split): + """ + Several layouts, one stacked above/under the other. :: + + +--------------------+ + | | + +--------------------+ + | | + +--------------------+ + + By default, this doesn't display a horizontal line between the children, + but if this is something you need, then create a HSplit as follows:: + + HSplit(children=[ ... ], padding_char='-', + padding=1, padding_style='#ffff00') + + :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 align: `VerticalAlign` value. + :param width: When given, use this width instead of looking at the children. + :param height: When given, use this height instead of looking at the children. + :param z_index: (int or None) When specified, this can be used to bring + element in front of floating elements. `None` means: inherit from parent. + :param style: A style string. + :param modal: ``True`` or ``False``. + :param key_bindings: ``None`` or a :class:`.KeyBindings` object. + + :param padding: (`Dimension` or int), size to be used for the padding. + :param padding_char: Character to be used for filling in the padding. + :param padding_style: Style to applied to the padding. + """ + + def __init__( + self, + children: Sequence[AnyContainer], + window_too_small: Optional[Container] = None, + align: VerticalAlign = VerticalAlign.JUSTIFY, + padding: AnyDimension = 0, + padding_char: Optional[str] = None, + padding_style: str = "", + width: AnyDimension = None, + height: AnyDimension = None, + z_index: Optional[int] = None, + modal: bool = False, + key_bindings: Optional[KeyBindingsBase] = None, + style: Union[str, Callable[[], str]] = "", + ) -> None: + + super().__init__( + children=children, + window_too_small=window_too_small, + padding=padding, + padding_char=padding_char, + padding_style=padding_style, + width=width, + height=height, + z_index=z_index, + modal=modal, + key_bindings=key_bindings, + style=style, + ) + + self.align = align + + self._children_cache: SimpleCache[ + Tuple[Container, ...], List[Container] + ] = SimpleCache(maxsize=1) + self._remaining_space_window = Window() # Dummy window. + + def preferred_width(self, max_available_width: int) -> Dimension: + if self.width is not None: + return to_dimension(self.width) + + if self.children: + dimensions = [c.preferred_width(max_available_width) for c in self.children] + return max_layout_dimensions(dimensions) + else: + return Dimension() + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + if self.height is not None: + return to_dimension(self.height) + + dimensions = [ + c.preferred_height(width, max_available_height) for c in self._all_children + ] + return sum_layout_dimensions(dimensions) + + def reset(self) -> None: + for c in self.children: + c.reset() + + @property + def _all_children(self) -> List[Container]: + """ + List of child objects, including padding. + """ + + def get() -> List[Container]: + result: List[Container] = [] + + # Padding Top. + if self.align in (VerticalAlign.CENTER, VerticalAlign.BOTTOM): + result.append(Window(width=Dimension(preferred=0))) + + # The children with padding. + for child in self.children: + result.append(child) + result.append( + Window( + height=self.padding, + char=self.padding_char, + style=self.padding_style, + ) + ) + if result: + result.pop() + + # Padding right. + if self.align in (VerticalAlign.CENTER, VerticalAlign.TOP): + result.append(Window(width=Dimension(preferred=0))) + + return result + + return self._children_cache.get(tuple(self.children), get) + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: Optional[int], + ) -> None: + """ + 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_heights(write_position) + style = parent_style + " " + to_str(self.style) + z_index = z_index if self.z_index is None else self.z_index + + if sizes is None: + self.window_too_small.write_to_screen( + screen, mouse_handlers, write_position, style, erase_bg, z_index + ) + else: + # + ypos = write_position.ypos + xpos = write_position.xpos + width = write_position.width + + # Draw child panes. + for s, c in zip(sizes, self._all_children): + c.write_to_screen( + screen, + mouse_handlers, + WritePosition(xpos, ypos, width, s), + style, + erase_bg, + z_index, + ) + ypos += s + + # Fill in the remaining space. This happens when a child control + # refuses to take more space and we don't have any padding. Adding a + # dummy child control for this (in `self._all_children`) is not + # desired, because in some situations, it would take more space, even + # when it's not required. This is required to apply the styling. + remaining_height = write_position.ypos + write_position.height - ypos + if remaining_height > 0: + self._remaining_space_window.write_to_screen( + screen, + mouse_handlers, + WritePosition(xpos, ypos, width, remaining_height), + style, + erase_bg, + z_index, + ) + + def _divide_heights(self, write_position: WritePosition) -> Optional[List[int]]: + """ + Return the heights for all rows. + Or None when there is not enough space. + """ + if not self.children: + return [] + + width = write_position.width + height = write_position.height + + # Calculate heights. + dimensions = [c.preferred_height(width, height) for c in self._all_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 > height: + return None + + # 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) + + # Increase until we meet at least the 'preferred' size. + preferred_stop = min(height, sum_dimensions.preferred) + preferred_dimensions = [d.preferred for d in dimensions] + + while sum(sizes) < preferred_stop: + if sizes[i] < preferred_dimensions[i]: + sizes[i] += 1 + i = next(child_generator) + + # Increase until we use all the available space. (or until "max") + if not get_app().is_done: + max_stop = min(height, sum_dimensions.max) + max_dimensions = [d.max for d in dimensions] + + while sum(sizes) < max_stop: + if sizes[i] < max_dimensions[i]: + sizes[i] += 1 + i = next(child_generator) + + return sizes + + +class VSplit(_Split): + """ + Several layouts, one stacked left/right of the other. :: + + +---------+----------+ + | | | + | | | + +---------+----------+ + + By default, this doesn't display a vertical line between the children, but + if this is something you need, then create a HSplit as follows:: + + VSplit(children=[ ... ], padding_char='|', + padding=1, padding_style='#ffff00') + + :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 align: `HorizontalAlign` value. + :param width: When given, use this width instead of looking at the children. + :param height: When given, use this height instead of looking at the children. + :param z_index: (int or None) When specified, this can be used to bring + element in front of floating elements. `None` means: inherit from parent. + :param style: A style string. + :param modal: ``True`` or ``False``. + :param key_bindings: ``None`` or a :class:`.KeyBindings` object. + + :param padding: (`Dimension` or int), size to be used for the padding. + :param padding_char: Character to be used for filling in the padding. + :param padding_style: Style to applied to the padding. + """ + + def __init__( + self, + children: Sequence[AnyContainer], + window_too_small: Optional[Container] = None, + align: HorizontalAlign = HorizontalAlign.JUSTIFY, + padding: AnyDimension = 0, + padding_char: Optional[str] = None, + padding_style: str = "", + width: AnyDimension = None, + height: AnyDimension = None, + z_index: Optional[int] = None, + modal: bool = False, + key_bindings: Optional[KeyBindingsBase] = None, + style: Union[str, Callable[[], str]] = "", + ) -> None: + + super().__init__( + children=children, + window_too_small=window_too_small, + padding=padding, + padding_char=padding_char, + padding_style=padding_style, + width=width, + height=height, + z_index=z_index, + modal=modal, + key_bindings=key_bindings, + style=style, + ) + + self.align = align + + self._children_cache: SimpleCache[ + Tuple[Container, ...], List[Container] + ] = SimpleCache(maxsize=1) + self._remaining_space_window = Window() # Dummy window. + + def preferred_width(self, max_available_width: int) -> Dimension: + if self.width is not None: + return to_dimension(self.width) + + dimensions = [ + c.preferred_width(max_available_width) for c in self._all_children + ] + + return sum_layout_dimensions(dimensions) + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + if self.height is not None: + return to_dimension(self.height) + + # At the point where we want to calculate the heights, the widths have + # already been decided. So we can trust `width` to be the actual + # `width` that's going to be used for the rendering. So, + # `divide_widths` is supposed to use all of the available width. + # Using only the `preferred` width caused a bug where the reported + # height was more than required. (we had a `BufferControl` which did + # wrap lines because of the smaller width returned by `_divide_widths`. + + sizes = self._divide_widths(width) + children = self._all_children + + if sizes is None: + return Dimension() + else: + dimensions = [ + c.preferred_height(s, max_available_height) + for s, c in zip(sizes, children) + ] + return max_layout_dimensions(dimensions) + + def reset(self) -> None: + for c in self.children: + c.reset() + + @property + def _all_children(self) -> List[Container]: + """ + List of child objects, including padding. + """ + + def get() -> List[Container]: + result: List[Container] = [] + + # Padding left. + if self.align in (HorizontalAlign.CENTER, HorizontalAlign.RIGHT): + result.append(Window(width=Dimension(preferred=0))) + + # The children with padding. + for child in self.children: + result.append(child) + result.append( + Window( + width=self.padding, + char=self.padding_char, + style=self.padding_style, + ) + ) + if result: + result.pop() + + # Padding right. + if self.align in (HorizontalAlign.CENTER, HorizontalAlign.LEFT): + result.append(Window(width=Dimension(preferred=0))) + + return result + + return self._children_cache.get(tuple(self.children), get) + + def _divide_widths(self, width: int) -> Optional[List[int]]: + """ + Return the widths for all columns. + Or None when there is not enough space. + """ + children = self._all_children + + if not children: + return [] + + # Calculate widths. + dimensions = [c.preferred_width(width) for c in children] + preferred_dimensions = [d.preferred for d in dimensions] + + # 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 None + + # Find optimal sizes. (Start with minimal size, increase until we cover + # the whole width.) + 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) + + # Increase until we meet at least the 'preferred' size. + preferred_stop = min(width, sum_dimensions.preferred) + + while sum(sizes) < preferred_stop: + if sizes[i] < preferred_dimensions[i]: + sizes[i] += 1 + i = next(child_generator) + + # Increase until we use all the available space. + max_dimensions = [d.max for d in dimensions] + max_stop = min(width, sum_dimensions.max) + + while sum(sizes) < max_stop: + if sizes[i] < max_dimensions[i]: + sizes[i] += 1 + i = next(child_generator) + + return sizes + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: Optional[int], + ) -> None: + """ + 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 + + children = self._all_children + sizes = self._divide_widths(write_position.width) + style = parent_style + " " + to_str(self.style) + z_index = z_index if self.z_index is None else self.z_index + + # If there is not enough space. + if sizes is None: + self.window_too_small.write_to_screen( + screen, mouse_handlers, write_position, style, erase_bg, z_index + ) + return + + # Calculate heights, take the largest possible, but not larger than + # write_position.height. + heights = [ + child.preferred_height(width, write_position.height).preferred + for width, child in zip(sizes, children) + ] + height = max(write_position.height, min(write_position.height, max(heights))) + + # + ypos = write_position.ypos + xpos = write_position.xpos + + # Draw all child panes. + for s, c in zip(sizes, children): + c.write_to_screen( + screen, + mouse_handlers, + WritePosition(xpos, ypos, s, height), + style, + erase_bg, + z_index, + ) + xpos += s + + # Fill in the remaining space. This happens when a child control + # refuses to take more space and we don't have any padding. Adding a + # dummy child control for this (in `self._all_children`) is not + # desired, because in some situations, it would take more space, even + # when it's not required. This is required to apply the styling. + remaining_width = write_position.xpos + write_position.width - xpos + if remaining_width > 0: + self._remaining_space_window.write_to_screen( + screen, + mouse_handlers, + WritePosition(xpos, ypos, remaining_width, height), + style, + erase_bg, + z_index, + ) + + +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, + content=CompletionsMenu(...)) + ]) + + :param z_index: (int or None) When specified, this can be used to bring + element in front of floating elements. `None` means: inherit from parent. + This is the z_index for the whole `Float` container as a whole. + """ + + def __init__( + self, + content: AnyContainer, + floats: List["Float"], + modal: bool = False, + key_bindings: Optional[KeyBindingsBase] = None, + style: Union[str, Callable[[], str]] = "", + z_index: Optional[int] = None, + ) -> None: + + self.content = to_container(content) + self.floats = floats + + self.modal = modal + self.key_bindings = key_bindings + self.style = style + self.z_index = z_index + + def reset(self) -> None: + self.content.reset() + + for f in self.floats: + f.content.reset() + + def preferred_width(self, max_available_width: int) -> Dimension: + return self.content.preferred_width(max_available_width) + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + """ + 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(width, max_available_height) + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: Optional[int], + ) -> None: + style = parent_style + " " + to_str(self.style) + z_index = z_index if self.z_index is None else self.z_index + + self.content.write_to_screen( + screen, mouse_handlers, write_position, style, erase_bg, z_index + ) + + for number, fl in enumerate(self.floats): + # z_index of a Float is computed by summing the z_index of the + # container and the `Float`. + new_z_index = (z_index or 0) + fl.z_index + style = parent_style + " " + to_str(self.style) + + # If the float that we have here, is positioned relative to the + # cursor position, but the Window that specifies the cursor + # position is not drawn yet, because it's a Float itself, we have + # to postpone this calculation. (This is a work-around, but good + # enough for now.) + postpone = fl.xcursor is not None or fl.ycursor is not None + + if postpone: + new_z_index = ( number + 10**8 - ) # Draw as late as possible, but keep the order. - screen.draw_with_z_index( - z_index=new_z_index, - draw_func=partial( - self._draw_float, - fl, - screen, - mouse_handlers, - write_position, - style, - erase_bg, - new_z_index, - ), - ) - else: - self._draw_float( - fl, - screen, - mouse_handlers, - write_position, - style, - erase_bg, - new_z_index, - ) - - def _draw_float( - self, - fl: "Float", - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - style: str, - erase_bg: bool, - z_index: Optional[int], - ) -> None: - "Draw a single Float." - # 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. - cpos = screen.get_menu_position( - fl.attach_to_window or get_app().layout.current_window - ) - cursor_position = Point( - x=cpos.x - write_position.xpos, y=cpos.y - write_position.ypos - ) - - fl_width = fl.get_width() - fl_height = fl.get_height() - width: int - height: int - xpos: int - ypos: int - - # 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 - # Near x position of cursor. - elif fl.xcursor: - if fl_width is None: - width = fl.content.preferred_width(write_position.width).preferred - width = min(write_position.width, width) - else: - width = fl_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(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 + (0 if fl.allow_cover_cursor else 1) - - if fl_height is None: - height = fl.content.preferred_height( - width, write_position.height - ).preferred - else: - height = fl_height - - # Reduce height if not enough space. (We can use the height - # when the content requires it.) - if height > write_position.height - ypos: - if write_position.height - ypos + 1 >= ypos: - # When the space below the cursor is more than - # the space above, just reduce the height. - height = write_position.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_height: - ypos = int((write_position.height - fl_height) / 2) - height = fl_height - # Otherwise, take preferred height from content. - else: - height = fl.content.preferred_height(width, write_position.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( - screen, - mouse_handlers, - wp, - style, - erase_bg=not fl.transparent(), - z_index=z_index, - ) - - def _area_is_empty(self, screen: Screen, write_position: WritePosition) -> bool: - """ - Return True when the area below the write position is still empty. - (For floats that should not hide content underneath.) - """ - wp = write_position - - 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 != " ": - return False - - return True - - def is_modal(self) -> bool: - return self.modal - - def get_key_bindings(self) -> Optional[KeyBindingsBase]: - return self.key_bindings - - def get_children(self) -> List[Container]: - children = [self.content] - children.extend(f.content for f in self.floats) - return children - - -class Float: - """ - Float for use in a :class:`.FloatContainer`. - Except for the `content` parameter, all other options are optional. - - :param content: :class:`.Container` instance. - - :param width: :class:`.Dimension` or callable which returns a :class:`.Dimension`. - :param height: :class:`.Dimension` or callable which returns a :class:`.Dimension`. - - :param left: Distance to the left edge of the :class:`.FloatContainer`. - :param right: Distance to the right edge of the :class:`.FloatContainer`. - :param top: Distance to the top of the :class:`.FloatContainer`. - :param bottom: Distance to the bottom of the :class:`.FloatContainer`. - - :param attach_to_window: Attach to the cursor from this window, instead of - the current window. - :param hide_when_covering_content: Hide the float when it covers content underneath. - :param allow_cover_cursor: When `False`, make sure to display the float - below the cursor. Not on top of the indicated position. - :param z_index: Z-index position. For a Float, this needs to be at least - one. It is relative to the z_index of the parent container. - :param transparent: :class:`.Filter` indicating whether this float needs to be - drawn transparently. - """ - - def __init__( - self, - content: AnyContainer, - top: Optional[int] = None, - right: Optional[int] = None, - bottom: Optional[int] = None, - left: Optional[int] = None, - width: Optional[Union[int, Callable[[], int]]] = None, - height: Optional[Union[int, Callable[[], int]]] = None, - xcursor: bool = False, - ycursor: bool = False, - attach_to_window: Optional[AnyContainer] = None, - hide_when_covering_content: bool = False, - allow_cover_cursor: bool = False, - z_index: int = 1, - transparent: bool = False, - ) -> None: - - assert z_index >= 1 - - self.left = left - self.right = right - self.top = top - self.bottom = bottom - - self.width = width - self.height = height - - self.xcursor = xcursor - self.ycursor = ycursor - - self.attach_to_window = ( - to_window(attach_to_window) if attach_to_window else None - ) - - self.content = to_container(content) - self.hide_when_covering_content = hide_when_covering_content - self.allow_cover_cursor = allow_cover_cursor - self.z_index = z_index - self.transparent = to_filter(transparent) - - def get_width(self) -> Optional[int]: - if callable(self.width): - return self.width() - return self.width - - def get_height(self) -> Optional[int]: - if callable(self.height): - return self.height() - return self.height - - def __repr__(self) -> str: - return "Float(content=%r)" % self.content - - -class WindowRenderInfo: - """ - 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, - window: "Window", - ui_content: UIContent, - horizontal_scroll: int, - vertical_scroll: int, - window_width: int, - window_height: int, - configured_scroll_offsets: "ScrollOffsets", - visible_line_to_row_col: Dict[int, Tuple[int, int]], - rowcol_to_yx: Dict[Tuple[int, int], Tuple[int, int]], - x_offset: int, - y_offset: int, - wrap_lines: bool, - ) -> None: - - self.window = window - 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) -> Dict[int, int]: - return { - visible_line: rowcol[0] - for visible_line, rowcol in self.visible_line_to_row_col.items() - } - - @property - def cursor_position(self) -> Point: - """ - Return the cursor position coordinates, relative to the left/top corner - of the rendered screen. - """ - cpos = self.ui_content.cursor_position - try: - y, x = self._rowcol_to_yx[cpos.y, cpos.x] - except KeyError: - # For `DummyControl` for instance, the content can be empty, and so - # will `_rowcol_to_yx` be. Return 0/0 by default. - return Point(x=0, y=0) - else: - return Point(x=x - self._x_offset, y=y - self._y_offset) - - @property - def applied_scroll_offsets(self) -> "ScrollOffsets": - """ - 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[int]: - """ - 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) -> Dict[int, int]: - """ - 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: Dict[int, int] = {} - 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: bool = False) -> int: - """ - 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: bool = False) -> int: - """ - 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: bool = False, after_scroll_offset: bool = False - ) -> int: - """ - 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) -> int: - """ - The full height of the user control. - """ - return self.ui_content.line_count - - @property - def full_height_visible(self) -> bool: - """ - 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) -> bool: - """ - True when the top of the buffer is visible. - """ - return self.vertical_scroll == 0 - - @property - def bottom_visible(self) -> bool: - """ - True when the bottom of the buffer is visible. - """ - return self.last_visible_line() == self.content_height - 1 - - @property - def vertical_scroll_percentage(self) -> int: - """ - 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: int) -> int: - """ - 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, self.window.get_line_prefix - ) - else: - return 1 - - -class ScrollOffsets: - """ - Scroll offsets for the :class:`.Window` class. - - Note that left/right offsets only make sense if line wrapping is disabled. - """ - - def __init__( - self, - top: Union[int, Callable[[], int]] = 0, - bottom: Union[int, Callable[[], int]] = 0, - left: Union[int, Callable[[], int]] = 0, - right: Union[int, Callable[[], int]] = 0, - ) -> None: - - self._top = top - self._bottom = bottom - self._left = left - self._right = right - - @property - def top(self) -> int: - return to_int(self._top) - - @property - def bottom(self) -> int: - return to_int(self._bottom) - - @property - def left(self) -> int: - return to_int(self._left) - - @property - def right(self) -> int: - return to_int(self._right) - - def __repr__(self) -> str: - return "ScrollOffsets(top=%r, bottom=%r, left=%r, right=%r)" % ( - self._top, - self._bottom, - self._left, - self._right, - ) - - -class ColorColumn: - """ - Column for a :class:`.Window` to be colored. - """ - - def __init__(self, position: int, style: str = "class:color-column") -> None: - self.position = position - self.style = style - - -_in_insert_mode = vi_insert_mode | emacs_insert_mode - - -class WindowAlign(Enum): - """ - Alignment of the Window content. - - Note that this is different from `HorizontalAlign` and `VerticalAlign`, - which are used for the alignment of the child containers in respectively - `VSplit` and `HSplit`. - """ - - LEFT = "LEFT" - RIGHT = "RIGHT" - CENTER = "CENTER" - - -class Window(Container): - """ - Container that holds a control. - - :param content: :class:`.UIControl` instance. - :param width: :class:`.Dimension` instance or callable. - :param height: :class:`.Dimension` instance or callable. - :param z_index: When specified, this can be used to bring element in front - of floating elements. - :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 ignore_content_width: A `bool` or :class:`.Filter` instance. Ignore - the :class:`.UIContent` width when calculating the dimensions. - :param ignore_content_height: A `bool` or :class:`.Filter` instance. Ignore - the :class:`.UIContent` height when calculating the dimensions. - :param left_margins: A list of :class:`.Margin` instance to be displayed on - the left. For instance: :class:`~prompt_toolkit.layout.NumberedMargin` - 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:`.Filter` 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:`.Filter` 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:`.Filter` instance. When True, never display the cursor, even - when the user control specifies a cursor position. - :param cursorline: A `bool` or :class:`.Filter` instance. When True, - display a cursorline. - :param cursorcolumn: A `bool` or :class:`.Filter` instance. When True, - display a cursorcolumn. - :param colorcolumns: A list of :class:`.ColorColumn` instances that - describe the columns to be highlighted, or a callable that returns such - a list. - :param align: :class:`.WindowAlign` value or callable that returns an - :class:`.WindowAlign` value. alignment of content. - :param style: A style string. Style to be applied to all the cells in this - window. (This can be a callable that returns a string.) - :param char: (string) Character to be used for filling the background. This - can also be a callable that returns a character. - :param get_line_prefix: None or a callable that returns formatted text to - be inserted before a line. It takes a line number (int) and a - wrap_count and returns formatted text. This can be used for - implementation of line continuations, things like Vim "breakindent" and - so on. - """ - - def __init__( - self, - content: Optional[UIControl] = None, - width: AnyDimension = None, - height: AnyDimension = None, - z_index: Optional[int] = None, - dont_extend_width: FilterOrBool = False, - dont_extend_height: FilterOrBool = False, - ignore_content_width: FilterOrBool = False, - ignore_content_height: FilterOrBool = False, - left_margins: Optional[Sequence[Margin]] = None, - right_margins: Optional[Sequence[Margin]] = None, - scroll_offsets: Optional[ScrollOffsets] = None, - allow_scroll_beyond_bottom: FilterOrBool = False, - wrap_lines: FilterOrBool = False, - get_vertical_scroll: Optional[Callable[["Window"], int]] = None, - get_horizontal_scroll: Optional[Callable[["Window"], int]] = None, - always_hide_cursor: FilterOrBool = False, - cursorline: FilterOrBool = False, - cursorcolumn: FilterOrBool = False, - colorcolumns: Union[ - None, List[ColorColumn], Callable[[], List[ColorColumn]] - ] = None, - align: Union[WindowAlign, Callable[[], WindowAlign]] = WindowAlign.LEFT, - style: Union[str, Callable[[], str]] = "", - char: Union[None, str, Callable[[], str]] = None, - get_line_prefix: Optional[GetLinePrefixCallable] = None, - ) -> None: - - self.allow_scroll_beyond_bottom = to_filter(allow_scroll_beyond_bottom) - self.always_hide_cursor = to_filter(always_hide_cursor) - self.wrap_lines = to_filter(wrap_lines) - self.cursorline = to_filter(cursorline) - self.cursorcolumn = to_filter(cursorcolumn) - - self.content = content or DummyControl() - self.dont_extend_width = to_filter(dont_extend_width) - self.dont_extend_height = to_filter(dont_extend_height) - self.ignore_content_width = to_filter(ignore_content_width) - self.ignore_content_height = to_filter(ignore_content_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.colorcolumns = colorcolumns or [] - self.align = align - self.style = style - self.char = char - self.get_line_prefix = get_line_prefix - - self.width = width - self.height = height - self.z_index = z_index - - # Cache for the screens generated by the margin. - self._ui_content_cache: SimpleCache[ - Tuple[int, int, int], UIContent - ] = SimpleCache(maxsize=8) - self._margin_width_cache: SimpleCache[Tuple[Margin, int], int] = SimpleCache( - maxsize=1 - ) - - self.reset() - - def __repr__(self) -> str: - return "Window(content=%r)" % self.content - - def reset(self) -> None: - 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: Optional[WindowRenderInfo] = None - - def _get_margin_width(self, margin: Margin) -> int: - """ - 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() -> UIContent: - return self._get_ui_content(width=0, height=0) - - def get_width() -> int: - return margin.get_width(get_ui_content) - - key = (margin, get_app().render_counter) - return self._margin_width_cache.get(key, get_width) - - def _get_total_margin_width(self) -> int: - """ - Calculate and return the width of the margin (left + right). - """ - return sum(self._get_margin_width(m) for m in self.left_margins) + sum( - self._get_margin_width(m) for m in self.right_margins - ) - - def preferred_width(self, max_available_width: int) -> Dimension: - """ - Calculate the preferred width for this window. - """ - - def preferred_content_width() -> Optional[int]: - """Content width: is only calculated if no exact width for the - window was given.""" - if self.ignore_content_width(): - return None - - # Calculate the width of the margin. - total_margin_width = self._get_total_margin_width() - - # Window of the content. (Can be `None`.) - preferred_width = self.content.preferred_width( - max_available_width - total_margin_width - ) - - if preferred_width is not None: - # Include width of the margins. - preferred_width += total_margin_width - return preferred_width - - # Merge. - return self._merge_dimensions( - dimension=to_dimension(self.width), - get_preferred=preferred_content_width, - dont_extend=self.dont_extend_width(), - ) - - def preferred_height(self, width: int, max_available_height: int) -> Dimension: - """ - Calculate the preferred height for this window. - """ - - def preferred_content_height() -> Optional[int]: - """Content height: is only calculated if no exact height for the - window was given.""" - if self.ignore_content_height(): - return None - - total_margin_width = self._get_total_margin_width() - wrap_lines = self.wrap_lines() - - return self.content.preferred_height( - width - total_margin_width, - max_available_height, - wrap_lines, - self.get_line_prefix, - ) - - return self._merge_dimensions( - dimension=to_dimension(self.height), - get_preferred=preferred_content_height, - dont_extend=self.dont_extend_height(), - ) - - @staticmethod - def _merge_dimensions( - dimension: Optional[Dimension], - get_preferred: Callable[[], Optional[int]], - dont_extend: bool = False, - ) -> Dimension: - """ - Take the Dimension from this `Window` class and the received preferred - size from the `UIControl` and return a `Dimension` to report to the - parent container. - """ - dimension = dimension or Dimension() - - # When a preferred dimension was explicitly given to the Window, - # ignore the UIControl. - preferred: Optional[int] - - if dimension.preferred_specified: - preferred = dimension.preferred - else: - # Otherwise, calculate the preferred dimension from the UI control - # content. - preferred = get_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_specified: - preferred = min(preferred, dimension.max) - - if dimension.min_specified: - preferred = max(preferred, dimension.min) - - # When a `dont_extend` flag has been given, use the preferred dimension - # also as the max dimension. - max_: Optional[int] - min_: Optional[int] - - if dont_extend and preferred is not None: - max_ = min(dimension.max, preferred) - else: - max_ = dimension.max if dimension.max_specified else None - - min_ = dimension.min if dimension.min_specified else None - - return Dimension( - min=min_, max=max_, preferred=preferred, weight=dimension.weight - ) - - def _get_ui_content(self, width: int, height: int) -> UIContent: - """ - Create a `UIContent` instance. - """ - - def get_content() -> UIContent: - return self.content.create_content(width=width, height=height) - - key = (get_app().render_counter, width, height) - return self._ui_content_cache.get(key, get_content) - - def _get_digraph_char(self) -> Optional[str]: - "Return `False`, or the Digraph symbol to be used." - app = get_app() - if app.quoted_insert: - return "^" - if app.vi_state.waiting_for_digraph: - if app.vi_state.digraph_symbol1: - return app.vi_state.digraph_symbol1 - return "?" - return None - - def write_to_screen( - self, - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - parent_style: str, - erase_bg: bool, - z_index: Optional[int], - ) -> None: - """ - Write window to screen. This renders the user control, the margins and - copies everything over to the absolute position at the given screen. - """ - # If dont_extend_width/height was given. Then reduce width/height in - # WritePosition if the parent wanted us to paint in a bigger area. - # (This happens if this window is bundled with another window in a - # HSplit/VSplit, but with different size requirements.) - write_position = WritePosition( - xpos=write_position.xpos, - ypos=write_position.ypos, - width=write_position.width, - height=write_position.height, - ) - - if self.dont_extend_width(): - write_position.width = min( - write_position.width, - self.preferred_width(write_position.width).preferred, - ) - - if self.dont_extend_height(): - write_position.height = min( - write_position.height, - self.preferred_height( - write_position.width, write_position.height - ).preferred, - ) - - # Draw - z_index = z_index if self.z_index is None else self.z_index - - draw_func = partial( - self._write_to_screen_at_index, - screen, - mouse_handlers, - write_position, - parent_style, - erase_bg, - ) - - if z_index is None or z_index <= 0: - # When no z_index is given, draw right away. - draw_func() - else: - # Otherwise, postpone. - screen.draw_with_z_index(z_index=z_index, draw_func=draw_func) - - def _write_to_screen_at_index( - self, - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - parent_style: str, - erase_bg: bool, - ) -> None: - # Don't bother writing invisible windows. - # (We save some time, but also avoid applying last-line styling.) - if write_position.height <= 0 or write_position.width <= 0: - return - - # Calculate margin sizes. - left_margin_widths = [self._get_margin_width(m) for m in self.left_margins] - right_margin_widths = [self._get_margin_width(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( - write_position.width - total_margin_width, write_position.height - ) - assert isinstance(ui_content, UIContent) - - # Scroll content. - wrap_lines = self.wrap_lines() - self._scroll( - ui_content, write_position.width - total_margin_width, write_position.height - ) - - # Erase background and fill with `char`. - self._fill_bg(screen, write_position, erase_bg) - - # Resolve `align` attribute. - align = self.align() if callable(self.align) else self.align - - # Write body - visible_line_to_row_col, rowcol_to_yx = self._copy_body( - ui_content, - screen, - write_position, - sum(left_margin_widths), - write_position.width - total_margin_width, - self.vertical_scroll, - self.horizontal_scroll, - wrap_lines=wrap_lines, - highlight_lines=True, - vertical_scroll_2=self.vertical_scroll_2, - always_hide_cursor=self.always_hide_cursor(), - has_focus=get_app().layout.current_control == self.content, - align=align, - get_line_prefix=self.get_line_prefix, - ) - - # 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 - - render_info = WindowRenderInfo( - window=self, - 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, - ) - self.render_info = render_info - - # Set mouse handlers. - def mouse_handler(mouse_event: MouseEvent) -> "NotImplementedOrNone": - """ - Wrapper around the mouse_handler of the `UIControl` that turns - screen coordinates into line coordinates. - Returns `NotImplemented` if no UI invalidation should be done. - """ - # Don't handle mouse events outside of the current modal part of - # the UI. - if self not in get_app().layout.walk_through_modal_area(): - return NotImplemented - - # Find row/col position first. - yx_to_rowcol = {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) - result: NotImplementedOrNone - - 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( - MouseEvent( - position=Point(x=col, y=row), - event_type=mouse_event.event_type, - button=mouse_event.button, - modifiers=mouse_event.modifiers, - ) - ) - break - else: - # nobreak. - # (No x/y coordinate found for the content. This happens in - # case of a DummyControl, that does not have any content. - # Report (0,0) instead.) - result = self.content.mouse_handler( - MouseEvent( - position=Point(x=0, y=0), - event_type=mouse_event.event_type, - button=mouse_event.button, - modifiers=mouse_event.modifiers, - ) - ) - - # If it returns NotImplemented, handle it here. - if result == NotImplemented: - result = self._mouse_handler(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: Margin, width: int) -> UIContent: - "Render margin. Return `Screen`." - # Retrieve margin fragments. - fragments = m.create_margin(render_info, width, write_position.height) - - # Turn it into a UIContent object. - # already rendered those fragments using this size.) - return FormattedTextControl(fragments).create_content( - width + 1, write_position.height - ) - - for m, width in zip(self.left_margins, left_margin_widths): - if width > 0: # (ConditionalMargin returns a zero width. -- Don't render.) - # Create screen for margin. - margin_content = render_margin(m, width) - - # Copy and shift X. - self._copy_margin(margin_content, 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_content = render_margin(m, width) - - # Copy and shift X. - self._copy_margin(margin_content, screen, write_position, move_x, width) - move_x += width - - # Apply 'self.style' - self._apply_style(screen, write_position, parent_style) - - # Tell the screen that this user control has been painted at this - # position. - screen.visible_windows_to_write_positions[self] = write_position - - def _copy_body( - self, - ui_content: UIContent, - new_screen: Screen, - write_position: WritePosition, - move_x: int, - width: int, - vertical_scroll: int = 0, - horizontal_scroll: int = 0, - wrap_lines: bool = False, - highlight_lines: bool = False, - vertical_scroll_2: int = 0, - always_hide_cursor: bool = False, - has_focus: bool = False, - align: WindowAlign = WindowAlign.LEFT, - get_line_prefix: Optional[Callable[[int, int], AnyFormattedText]] = None, - ) -> Tuple[Dict[int, Tuple[int, int]], Dict[Tuple[int, int], Tuple[int, int]]]: - """ - Copy the UIContent into the output screen. - Return (visible_line_to_row_col, rowcol_to_yx) tuple. - - :param get_line_prefix: None or a callable that takes a line number - (int) and a wrap_count (int) and returns formatted text. - """ - 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["", ""] - - # Map visible line number to (row, col) of input. - # 'col' will always be zero if line wrapping is off. - visible_line_to_row_col: Dict[int, Tuple[int, int]] = {} - - # Maps (row, col) from the input to (y, x) screen coordinates. - rowcol_to_yx: Dict[Tuple[int, int], Tuple[int, int]] = {} - - def copy_line( - line: StyleAndTextTuples, - lineno: int, - x: int, - y: int, - is_input: bool = False, - ) -> Tuple[int, int]: - """ - Copy over a single line to the output screen. This can wrap over - multiple lines in the output. It will call the prefix (prompt) - function before every line. - """ - if is_input: - current_rowcol_to_yx = rowcol_to_yx - else: - current_rowcol_to_yx = {} # Throwaway dictionary. - - # Draw line prefix. - if is_input and get_line_prefix: - prompt = to_formatted_text(get_line_prefix(lineno, 0)) - x, y = copy_line(prompt, lineno, x, y, is_input=False) - - # Scroll horizontally. - skipped = 0 # Characters skipped because of horizontal scrolling. - if horizontal_scroll and is_input: - h_scroll = horizontal_scroll - line = explode_text_fragments(line) - while h_scroll > 0 and line: - h_scroll -= get_cwidth(line[0][1]) - skipped += 1 - del line[:1] # Remove first character. - - x -= h_scroll # When scrolling over double width character, - # this can end up being negative. - - # Align this line. (Note that this doesn't work well when we use - # get_line_prefix and that function returns variable width prefixes.) - if align == WindowAlign.CENTER: - line_width = fragment_list_width(line) - if line_width < width: - x += (width - line_width) // 2 - elif align == WindowAlign.RIGHT: - line_width = fragment_list_width(line) - if line_width < width: - x += width - line_width - - col = 0 - wrap_count = 0 - for style, text, *_ in line: - new_buffer_row = new_buffer[y + ypos] - - # Remember raw VT escape sequences. (E.g. FinalTerm's - # escape sequences.) - if "[ZeroWidthEscape]" in style: - new_screen.zero_width_escapes[y + ypos][x + xpos] += text - continue - - for c in text: - char = _CHAR_CACHE[c, style] - 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 - wrap_count += 1 - x = 0 - - # Insert line prefix (continuation prompt). - if is_input and get_line_prefix: - prompt = to_formatted_text( - get_line_prefix(lineno, wrap_count) - ) - x, y = copy_line(prompt, lineno, x, y, is_input=False) - - new_buffer_row = new_buffer[y + ypos] - - if y >= write_position.height: - return x, y # Break out of all for loops. - - # Set character in screen and shift 'x'. + ) # Draw as late as possible, but keep the order. + screen.draw_with_z_index( + z_index=new_z_index, + draw_func=partial( + self._draw_float, + fl, + screen, + mouse_handlers, + write_position, + style, + erase_bg, + new_z_index, + ), + ) + else: + self._draw_float( + fl, + screen, + mouse_handlers, + write_position, + style, + erase_bg, + new_z_index, + ) + + def _draw_float( + self, + fl: "Float", + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + style: str, + erase_bg: bool, + z_index: Optional[int], + ) -> None: + "Draw a single Float." + # 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. + cpos = screen.get_menu_position( + fl.attach_to_window or get_app().layout.current_window + ) + cursor_position = Point( + x=cpos.x - write_position.xpos, y=cpos.y - write_position.ypos + ) + + fl_width = fl.get_width() + fl_height = fl.get_height() + width: int + height: int + xpos: int + ypos: int + + # 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 + # Near x position of cursor. + elif fl.xcursor: + if fl_width is None: + width = fl.content.preferred_width(write_position.width).preferred + width = min(write_position.width, width) + else: + width = fl_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(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 + (0 if fl.allow_cover_cursor else 1) + + if fl_height is None: + height = fl.content.preferred_height( + width, write_position.height + ).preferred + else: + height = fl_height + + # Reduce height if not enough space. (We can use the height + # when the content requires it.) + if height > write_position.height - ypos: + if write_position.height - ypos + 1 >= ypos: + # When the space below the cursor is more than + # the space above, just reduce the height. + height = write_position.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_height: + ypos = int((write_position.height - fl_height) / 2) + height = fl_height + # Otherwise, take preferred height from content. + else: + height = fl.content.preferred_height(width, write_position.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( + screen, + mouse_handlers, + wp, + style, + erase_bg=not fl.transparent(), + z_index=z_index, + ) + + def _area_is_empty(self, screen: Screen, write_position: WritePosition) -> bool: + """ + Return True when the area below the write position is still empty. + (For floats that should not hide content underneath.) + """ + wp = write_position + + 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 != " ": + return False + + return True + + def is_modal(self) -> bool: + return self.modal + + def get_key_bindings(self) -> Optional[KeyBindingsBase]: + return self.key_bindings + + def get_children(self) -> List[Container]: + children = [self.content] + children.extend(f.content for f in self.floats) + return children + + +class Float: + """ + Float for use in a :class:`.FloatContainer`. + Except for the `content` parameter, all other options are optional. + + :param content: :class:`.Container` instance. + + :param width: :class:`.Dimension` or callable which returns a :class:`.Dimension`. + :param height: :class:`.Dimension` or callable which returns a :class:`.Dimension`. + + :param left: Distance to the left edge of the :class:`.FloatContainer`. + :param right: Distance to the right edge of the :class:`.FloatContainer`. + :param top: Distance to the top of the :class:`.FloatContainer`. + :param bottom: Distance to the bottom of the :class:`.FloatContainer`. + + :param attach_to_window: Attach to the cursor from this window, instead of + the current window. + :param hide_when_covering_content: Hide the float when it covers content underneath. + :param allow_cover_cursor: When `False`, make sure to display the float + below the cursor. Not on top of the indicated position. + :param z_index: Z-index position. For a Float, this needs to be at least + one. It is relative to the z_index of the parent container. + :param transparent: :class:`.Filter` indicating whether this float needs to be + drawn transparently. + """ + + def __init__( + self, + content: AnyContainer, + top: Optional[int] = None, + right: Optional[int] = None, + bottom: Optional[int] = None, + left: Optional[int] = None, + width: Optional[Union[int, Callable[[], int]]] = None, + height: Optional[Union[int, Callable[[], int]]] = None, + xcursor: bool = False, + ycursor: bool = False, + attach_to_window: Optional[AnyContainer] = None, + hide_when_covering_content: bool = False, + allow_cover_cursor: bool = False, + z_index: int = 1, + transparent: bool = False, + ) -> None: + + assert z_index >= 1 + + self.left = left + self.right = right + self.top = top + self.bottom = bottom + + self.width = width + self.height = height + + self.xcursor = xcursor + self.ycursor = ycursor + + self.attach_to_window = ( + to_window(attach_to_window) if attach_to_window else None + ) + + self.content = to_container(content) + self.hide_when_covering_content = hide_when_covering_content + self.allow_cover_cursor = allow_cover_cursor + self.z_index = z_index + self.transparent = to_filter(transparent) + + def get_width(self) -> Optional[int]: + if callable(self.width): + return self.width() + return self.width + + def get_height(self) -> Optional[int]: + if callable(self.height): + return self.height() + return self.height + + def __repr__(self) -> str: + return "Float(content=%r)" % self.content + + +class WindowRenderInfo: + """ + 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, + window: "Window", + ui_content: UIContent, + horizontal_scroll: int, + vertical_scroll: int, + window_width: int, + window_height: int, + configured_scroll_offsets: "ScrollOffsets", + visible_line_to_row_col: Dict[int, Tuple[int, int]], + rowcol_to_yx: Dict[Tuple[int, int], Tuple[int, int]], + x_offset: int, + y_offset: int, + wrap_lines: bool, + ) -> None: + + self.window = window + 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) -> Dict[int, int]: + return { + visible_line: rowcol[0] + for visible_line, rowcol in self.visible_line_to_row_col.items() + } + + @property + def cursor_position(self) -> Point: + """ + Return the cursor position coordinates, relative to the left/top corner + of the rendered screen. + """ + cpos = self.ui_content.cursor_position + try: + y, x = self._rowcol_to_yx[cpos.y, cpos.x] + except KeyError: + # For `DummyControl` for instance, the content can be empty, and so + # will `_rowcol_to_yx` be. Return 0/0 by default. + return Point(x=0, y=0) + else: + return Point(x=x - self._x_offset, y=y - self._y_offset) + + @property + def applied_scroll_offsets(self) -> "ScrollOffsets": + """ + 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[int]: + """ + 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) -> Dict[int, int]: + """ + 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: Dict[int, int] = {} + 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: bool = False) -> int: + """ + 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: bool = False) -> int: + """ + 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: bool = False, after_scroll_offset: bool = False + ) -> int: + """ + 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) -> int: + """ + The full height of the user control. + """ + return self.ui_content.line_count + + @property + def full_height_visible(self) -> bool: + """ + 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) -> bool: + """ + True when the top of the buffer is visible. + """ + return self.vertical_scroll == 0 + + @property + def bottom_visible(self) -> bool: + """ + True when the bottom of the buffer is visible. + """ + return self.last_visible_line() == self.content_height - 1 + + @property + def vertical_scroll_percentage(self) -> int: + """ + 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: int) -> int: + """ + 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, self.window.get_line_prefix + ) + else: + return 1 + + +class ScrollOffsets: + """ + Scroll offsets for the :class:`.Window` class. + + Note that left/right offsets only make sense if line wrapping is disabled. + """ + + def __init__( + self, + top: Union[int, Callable[[], int]] = 0, + bottom: Union[int, Callable[[], int]] = 0, + left: Union[int, Callable[[], int]] = 0, + right: Union[int, Callable[[], int]] = 0, + ) -> None: + + self._top = top + self._bottom = bottom + self._left = left + self._right = right + + @property + def top(self) -> int: + return to_int(self._top) + + @property + def bottom(self) -> int: + return to_int(self._bottom) + + @property + def left(self) -> int: + return to_int(self._left) + + @property + def right(self) -> int: + return to_int(self._right) + + def __repr__(self) -> str: + return "ScrollOffsets(top=%r, bottom=%r, left=%r, right=%r)" % ( + self._top, + self._bottom, + self._left, + self._right, + ) + + +class ColorColumn: + """ + Column for a :class:`.Window` to be colored. + """ + + def __init__(self, position: int, style: str = "class:color-column") -> None: + self.position = position + self.style = style + + +_in_insert_mode = vi_insert_mode | emacs_insert_mode + + +class WindowAlign(Enum): + """ + Alignment of the Window content. + + Note that this is different from `HorizontalAlign` and `VerticalAlign`, + which are used for the alignment of the child containers in respectively + `VSplit` and `HSplit`. + """ + + LEFT = "LEFT" + RIGHT = "RIGHT" + CENTER = "CENTER" + + +class Window(Container): + """ + Container that holds a control. + + :param content: :class:`.UIControl` instance. + :param width: :class:`.Dimension` instance or callable. + :param height: :class:`.Dimension` instance or callable. + :param z_index: When specified, this can be used to bring element in front + of floating elements. + :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 ignore_content_width: A `bool` or :class:`.Filter` instance. Ignore + the :class:`.UIContent` width when calculating the dimensions. + :param ignore_content_height: A `bool` or :class:`.Filter` instance. Ignore + the :class:`.UIContent` height when calculating the dimensions. + :param left_margins: A list of :class:`.Margin` instance to be displayed on + the left. For instance: :class:`~prompt_toolkit.layout.NumberedMargin` + 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:`.Filter` 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:`.Filter` 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:`.Filter` instance. When True, never display the cursor, even + when the user control specifies a cursor position. + :param cursorline: A `bool` or :class:`.Filter` instance. When True, + display a cursorline. + :param cursorcolumn: A `bool` or :class:`.Filter` instance. When True, + display a cursorcolumn. + :param colorcolumns: A list of :class:`.ColorColumn` instances that + describe the columns to be highlighted, or a callable that returns such + a list. + :param align: :class:`.WindowAlign` value or callable that returns an + :class:`.WindowAlign` value. alignment of content. + :param style: A style string. Style to be applied to all the cells in this + window. (This can be a callable that returns a string.) + :param char: (string) Character to be used for filling the background. This + can also be a callable that returns a character. + :param get_line_prefix: None or a callable that returns formatted text to + be inserted before a line. It takes a line number (int) and a + wrap_count and returns formatted text. This can be used for + implementation of line continuations, things like Vim "breakindent" and + so on. + """ + + def __init__( + self, + content: Optional[UIControl] = None, + width: AnyDimension = None, + height: AnyDimension = None, + z_index: Optional[int] = None, + dont_extend_width: FilterOrBool = False, + dont_extend_height: FilterOrBool = False, + ignore_content_width: FilterOrBool = False, + ignore_content_height: FilterOrBool = False, + left_margins: Optional[Sequence[Margin]] = None, + right_margins: Optional[Sequence[Margin]] = None, + scroll_offsets: Optional[ScrollOffsets] = None, + allow_scroll_beyond_bottom: FilterOrBool = False, + wrap_lines: FilterOrBool = False, + get_vertical_scroll: Optional[Callable[["Window"], int]] = None, + get_horizontal_scroll: Optional[Callable[["Window"], int]] = None, + always_hide_cursor: FilterOrBool = False, + cursorline: FilterOrBool = False, + cursorcolumn: FilterOrBool = False, + colorcolumns: Union[ + None, List[ColorColumn], Callable[[], List[ColorColumn]] + ] = None, + align: Union[WindowAlign, Callable[[], WindowAlign]] = WindowAlign.LEFT, + style: Union[str, Callable[[], str]] = "", + char: Union[None, str, Callable[[], str]] = None, + get_line_prefix: Optional[GetLinePrefixCallable] = None, + ) -> None: + + self.allow_scroll_beyond_bottom = to_filter(allow_scroll_beyond_bottom) + self.always_hide_cursor = to_filter(always_hide_cursor) + self.wrap_lines = to_filter(wrap_lines) + self.cursorline = to_filter(cursorline) + self.cursorcolumn = to_filter(cursorcolumn) + + self.content = content or DummyControl() + self.dont_extend_width = to_filter(dont_extend_width) + self.dont_extend_height = to_filter(dont_extend_height) + self.ignore_content_width = to_filter(ignore_content_width) + self.ignore_content_height = to_filter(ignore_content_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.colorcolumns = colorcolumns or [] + self.align = align + self.style = style + self.char = char + self.get_line_prefix = get_line_prefix + + self.width = width + self.height = height + self.z_index = z_index + + # Cache for the screens generated by the margin. + self._ui_content_cache: SimpleCache[ + Tuple[int, int, int], UIContent + ] = SimpleCache(maxsize=8) + self._margin_width_cache: SimpleCache[Tuple[Margin, int], int] = SimpleCache( + maxsize=1 + ) + + self.reset() + + def __repr__(self) -> str: + return "Window(content=%r)" % self.content + + def reset(self) -> None: + 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: Optional[WindowRenderInfo] = None + + def _get_margin_width(self, margin: Margin) -> int: + """ + 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() -> UIContent: + return self._get_ui_content(width=0, height=0) + + def get_width() -> int: + return margin.get_width(get_ui_content) + + key = (margin, get_app().render_counter) + return self._margin_width_cache.get(key, get_width) + + def _get_total_margin_width(self) -> int: + """ + Calculate and return the width of the margin (left + right). + """ + return sum(self._get_margin_width(m) for m in self.left_margins) + sum( + self._get_margin_width(m) for m in self.right_margins + ) + + def preferred_width(self, max_available_width: int) -> Dimension: + """ + Calculate the preferred width for this window. + """ + + def preferred_content_width() -> Optional[int]: + """Content width: is only calculated if no exact width for the + window was given.""" + if self.ignore_content_width(): + return None + + # Calculate the width of the margin. + total_margin_width = self._get_total_margin_width() + + # Window of the content. (Can be `None`.) + preferred_width = self.content.preferred_width( + max_available_width - total_margin_width + ) + + if preferred_width is not None: + # Include width of the margins. + preferred_width += total_margin_width + return preferred_width + + # Merge. + return self._merge_dimensions( + dimension=to_dimension(self.width), + get_preferred=preferred_content_width, + dont_extend=self.dont_extend_width(), + ) + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + """ + Calculate the preferred height for this window. + """ + + def preferred_content_height() -> Optional[int]: + """Content height: is only calculated if no exact height for the + window was given.""" + if self.ignore_content_height(): + return None + + total_margin_width = self._get_total_margin_width() + wrap_lines = self.wrap_lines() + + return self.content.preferred_height( + width - total_margin_width, + max_available_height, + wrap_lines, + self.get_line_prefix, + ) + + return self._merge_dimensions( + dimension=to_dimension(self.height), + get_preferred=preferred_content_height, + dont_extend=self.dont_extend_height(), + ) + + @staticmethod + def _merge_dimensions( + dimension: Optional[Dimension], + get_preferred: Callable[[], Optional[int]], + dont_extend: bool = False, + ) -> Dimension: + """ + Take the Dimension from this `Window` class and the received preferred + size from the `UIControl` and return a `Dimension` to report to the + parent container. + """ + dimension = dimension or Dimension() + + # When a preferred dimension was explicitly given to the Window, + # ignore the UIControl. + preferred: Optional[int] + + if dimension.preferred_specified: + preferred = dimension.preferred + else: + # Otherwise, calculate the preferred dimension from the UI control + # content. + preferred = get_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_specified: + preferred = min(preferred, dimension.max) + + if dimension.min_specified: + preferred = max(preferred, dimension.min) + + # When a `dont_extend` flag has been given, use the preferred dimension + # also as the max dimension. + max_: Optional[int] + min_: Optional[int] + + if dont_extend and preferred is not None: + max_ = min(dimension.max, preferred) + else: + max_ = dimension.max if dimension.max_specified else None + + min_ = dimension.min if dimension.min_specified else None + + return Dimension( + min=min_, max=max_, preferred=preferred, weight=dimension.weight + ) + + def _get_ui_content(self, width: int, height: int) -> UIContent: + """ + Create a `UIContent` instance. + """ + + def get_content() -> UIContent: + return self.content.create_content(width=width, height=height) + + key = (get_app().render_counter, width, height) + return self._ui_content_cache.get(key, get_content) + + def _get_digraph_char(self) -> Optional[str]: + "Return `False`, or the Digraph symbol to be used." + app = get_app() + if app.quoted_insert: + return "^" + if app.vi_state.waiting_for_digraph: + if app.vi_state.digraph_symbol1: + return app.vi_state.digraph_symbol1 + return "?" + return None + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: Optional[int], + ) -> None: + """ + Write window to screen. This renders the user control, the margins and + copies everything over to the absolute position at the given screen. + """ + # If dont_extend_width/height was given. Then reduce width/height in + # WritePosition if the parent wanted us to paint in a bigger area. + # (This happens if this window is bundled with another window in a + # HSplit/VSplit, but with different size requirements.) + write_position = WritePosition( + xpos=write_position.xpos, + ypos=write_position.ypos, + width=write_position.width, + height=write_position.height, + ) + + if self.dont_extend_width(): + write_position.width = min( + write_position.width, + self.preferred_width(write_position.width).preferred, + ) + + if self.dont_extend_height(): + write_position.height = min( + write_position.height, + self.preferred_height( + write_position.width, write_position.height + ).preferred, + ) + + # Draw + z_index = z_index if self.z_index is None else self.z_index + + draw_func = partial( + self._write_to_screen_at_index, + screen, + mouse_handlers, + write_position, + parent_style, + erase_bg, + ) + + if z_index is None or z_index <= 0: + # When no z_index is given, draw right away. + draw_func() + else: + # Otherwise, postpone. + screen.draw_with_z_index(z_index=z_index, draw_func=draw_func) + + def _write_to_screen_at_index( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + ) -> None: + # Don't bother writing invisible windows. + # (We save some time, but also avoid applying last-line styling.) + if write_position.height <= 0 or write_position.width <= 0: + return + + # Calculate margin sizes. + left_margin_widths = [self._get_margin_width(m) for m in self.left_margins] + right_margin_widths = [self._get_margin_width(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( + write_position.width - total_margin_width, write_position.height + ) + assert isinstance(ui_content, UIContent) + + # Scroll content. + wrap_lines = self.wrap_lines() + self._scroll( + ui_content, write_position.width - total_margin_width, write_position.height + ) + + # Erase background and fill with `char`. + self._fill_bg(screen, write_position, erase_bg) + + # Resolve `align` attribute. + align = self.align() if callable(self.align) else self.align + + # Write body + visible_line_to_row_col, rowcol_to_yx = self._copy_body( + ui_content, + screen, + write_position, + sum(left_margin_widths), + write_position.width - total_margin_width, + self.vertical_scroll, + self.horizontal_scroll, + wrap_lines=wrap_lines, + highlight_lines=True, + vertical_scroll_2=self.vertical_scroll_2, + always_hide_cursor=self.always_hide_cursor(), + has_focus=get_app().layout.current_control == self.content, + align=align, + get_line_prefix=self.get_line_prefix, + ) + + # 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 + + render_info = WindowRenderInfo( + window=self, + 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, + ) + self.render_info = render_info + + # Set mouse handlers. + def mouse_handler(mouse_event: MouseEvent) -> "NotImplementedOrNone": + """ + Wrapper around the mouse_handler of the `UIControl` that turns + screen coordinates into line coordinates. + Returns `NotImplemented` if no UI invalidation should be done. + """ + # Don't handle mouse events outside of the current modal part of + # the UI. + if self not in get_app().layout.walk_through_modal_area(): + return NotImplemented + + # Find row/col position first. + yx_to_rowcol = {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) + result: NotImplementedOrNone + + 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( + MouseEvent( + position=Point(x=col, y=row), + event_type=mouse_event.event_type, + button=mouse_event.button, + modifiers=mouse_event.modifiers, + ) + ) + break + else: + # nobreak. + # (No x/y coordinate found for the content. This happens in + # case of a DummyControl, that does not have any content. + # Report (0,0) instead.) + result = self.content.mouse_handler( + MouseEvent( + position=Point(x=0, y=0), + event_type=mouse_event.event_type, + button=mouse_event.button, + modifiers=mouse_event.modifiers, + ) + ) + + # If it returns NotImplemented, handle it here. + if result == NotImplemented: + result = self._mouse_handler(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: Margin, width: int) -> UIContent: + "Render margin. Return `Screen`." + # Retrieve margin fragments. + fragments = m.create_margin(render_info, width, write_position.height) + + # Turn it into a UIContent object. + # already rendered those fragments using this size.) + return FormattedTextControl(fragments).create_content( + width + 1, write_position.height + ) + + for m, width in zip(self.left_margins, left_margin_widths): + if width > 0: # (ConditionalMargin returns a zero width. -- Don't render.) + # Create screen for margin. + margin_content = render_margin(m, width) + + # Copy and shift X. + self._copy_margin(margin_content, 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_content = render_margin(m, width) + + # Copy and shift X. + self._copy_margin(margin_content, screen, write_position, move_x, width) + move_x += width + + # Apply 'self.style' + self._apply_style(screen, write_position, parent_style) + + # Tell the screen that this user control has been painted at this + # position. + screen.visible_windows_to_write_positions[self] = write_position + + def _copy_body( + self, + ui_content: UIContent, + new_screen: Screen, + write_position: WritePosition, + move_x: int, + width: int, + vertical_scroll: int = 0, + horizontal_scroll: int = 0, + wrap_lines: bool = False, + highlight_lines: bool = False, + vertical_scroll_2: int = 0, + always_hide_cursor: bool = False, + has_focus: bool = False, + align: WindowAlign = WindowAlign.LEFT, + get_line_prefix: Optional[Callable[[int, int], AnyFormattedText]] = None, + ) -> Tuple[Dict[int, Tuple[int, int]], Dict[Tuple[int, int], Tuple[int, int]]]: + """ + Copy the UIContent into the output screen. + Return (visible_line_to_row_col, rowcol_to_yx) tuple. + + :param get_line_prefix: None or a callable that takes a line number + (int) and a wrap_count (int) and returns formatted text. + """ + 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["", ""] + + # Map visible line number to (row, col) of input. + # 'col' will always be zero if line wrapping is off. + visible_line_to_row_col: Dict[int, Tuple[int, int]] = {} + + # Maps (row, col) from the input to (y, x) screen coordinates. + rowcol_to_yx: Dict[Tuple[int, int], Tuple[int, int]] = {} + + def copy_line( + line: StyleAndTextTuples, + lineno: int, + x: int, + y: int, + is_input: bool = False, + ) -> Tuple[int, int]: + """ + Copy over a single line to the output screen. This can wrap over + multiple lines in the output. It will call the prefix (prompt) + function before every line. + """ + if is_input: + current_rowcol_to_yx = rowcol_to_yx + else: + current_rowcol_to_yx = {} # Throwaway dictionary. + + # Draw line prefix. + if is_input and get_line_prefix: + prompt = to_formatted_text(get_line_prefix(lineno, 0)) + x, y = copy_line(prompt, lineno, x, y, is_input=False) + + # Scroll horizontally. + skipped = 0 # Characters skipped because of horizontal scrolling. + if horizontal_scroll and is_input: + h_scroll = horizontal_scroll + line = explode_text_fragments(line) + while h_scroll > 0 and line: + h_scroll -= get_cwidth(line[0][1]) + skipped += 1 + del line[:1] # Remove first character. + + x -= h_scroll # When scrolling over double width character, + # this can end up being negative. + + # Align this line. (Note that this doesn't work well when we use + # get_line_prefix and that function returns variable width prefixes.) + if align == WindowAlign.CENTER: + line_width = fragment_list_width(line) + if line_width < width: + x += (width - line_width) // 2 + elif align == WindowAlign.RIGHT: + line_width = fragment_list_width(line) + if line_width < width: + x += width - line_width + + col = 0 + wrap_count = 0 + for style, text, *_ in line: + new_buffer_row = new_buffer[y + ypos] + + # Remember raw VT escape sequences. (E.g. FinalTerm's + # escape sequences.) + if "[ZeroWidthEscape]" in style: + new_screen.zero_width_escapes[y + ypos][x + xpos] += text + continue + + for c in text: + char = _CHAR_CACHE[c, style] + 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 + wrap_count += 1 + x = 0 + + # Insert line prefix (continuation prompt). + if is_input and get_line_prefix: + prompt = to_formatted_text( + get_line_prefix(lineno, wrap_count) + ) + x, y = copy_line(prompt, lineno, x, y, is_input=False) + + new_buffer_row = new_buffer[y + ypos] + + if y >= write_position.height: + return x, y # Break out of all for loops. + + # Set character in screen and shift 'x'. if x >= 0 and y >= 0 and x < width: - new_buffer_row[x + xpos] = char - - # When we print a multi width character, make sure - # to erase the neighbours 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: - # Handle all character widths. If the previous - # character is a multiwidth character, then - # merge it two positions back. - for pw in [2, 1]: # Previous character width. - if ( - x - pw >= 0 - and new_buffer_row[x + xpos - pw].width == pw - ): - prev_char = new_buffer_row[x + xpos - pw] - char2 = _CHAR_CACHE[ - prev_char.char + c, prev_char.style - ] - new_buffer_row[x + xpos - pw] = char2 - - # Keep track of write position for each character. - current_rowcol_to_yx[lineno, col + skipped] = ( - y + ypos, - x + xpos, - ) - - col += 1 - x += char_width - return x, y - - # Copy content. - def copy() -> int: - 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) - - visible_line_to_row_col[y] = (lineno, horizontal_scroll) - - # Copy margin and actual line. - x = 0 - x, y = copy_line(line, lineno, x, y, is_input=True) - - lineno += 1 - y += 1 - return y - - copy() - - def cursor_pos_to_screen_pos(row: int, col: int) -> Point: - "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(x=0, y=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(x=x, y=y) - - # 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.set_cursor_position(self, screen_cursor_position) - - if always_hide_cursor: - new_screen.show_cursor = False - else: - new_screen.show_cursor = ui_content.show_cursor - - self._highlight_digraph(new_screen) - - if highlight_lines: - self._highlight_cursorlines( - 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_key_processor_key_buffer(new_screen) - - # Set menu position. - if ui_content.menu_position: - new_screen.set_menu_position( - self, - cursor_pos_to_screen_pos( - ui_content.menu_position.y, ui_content.menu_position.x - ), - ) - - # Update output screen height. - new_screen.height = max(new_screen.height, ypos + write_position.height) - - return visible_line_to_row_col, rowcol_to_yx - - def _fill_bg( - self, screen: Screen, write_position: WritePosition, erase_bg: bool - ) -> None: - """ - Erase/fill the background. - (Useful for floats and when a `char` has been given.) - """ - char: Optional[str] - if callable(self.char): - char = self.char() - else: - char = self.char - - if erase_bg or char: - wp = write_position - char_obj = _CHAR_CACHE[char or " ", ""] - - for y in range(wp.ypos, wp.ypos + wp.height): - row = screen.data_buffer[y] - for x in range(wp.xpos, wp.xpos + wp.width): - row[x] = char_obj - - def _apply_style( - self, new_screen: Screen, write_position: WritePosition, parent_style: str - ) -> None: - - # Apply `self.style`. - style = parent_style + " " + to_str(self.style) - - new_screen.fill_area(write_position, style=style, after=False) - - # Apply the 'last-line' class to the last line of each Window. This can - # be used to apply an 'underline' to the user control. - wp = WritePosition( - write_position.xpos, - write_position.ypos + write_position.height - 1, - write_position.width, - 1, - ) - new_screen.fill_area(wp, "class:last-line", after=True) - - def _highlight_digraph(self, new_screen: Screen) -> None: - """ - When we are in Vi digraph mode, put a question mark underneath the - cursor. - """ - digraph_char = self._get_digraph_char() - if digraph_char: - cpos = new_screen.get_cursor_position(self) - new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[ - digraph_char, "class:digraph" - ] - - def _show_key_processor_key_buffer(self, new_screen: Screen) -> None: - """ - 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. - """ - app = get_app() - key_buffer = app.key_processor.key_buffer - - if key_buffer and _in_insert_mode() and not app.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.get_cursor_position(self) - new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[ - data, "class:partial-key-binding" - ] - - def _highlight_cursorlines( - self, new_screen: Screen, cpos: Point, x: int, y: int, width: int, height: int - ) -> None: - """ - Highlight cursor row/column. - """ - cursor_line_style = " class:cursor-line " - cursor_column_style = " class:cursor-column " - - data_buffer = new_screen.data_buffer - - # Highlight cursor line. - if self.cursorline(): - 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.style + cursor_line_style - ] - - # Highlight cursor column. - if self.cursorcolumn(): - 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.style + cursor_column_style - ] - - # Highlight color columns - colorcolumns = self.colorcolumns - if callable(colorcolumns): - colorcolumns = colorcolumns() - - for cc in colorcolumns: - assert isinstance(cc, ColorColumn) - column = cc.position - - if column < x + width: # Only draw when visible. - color_column_style = " " + cc.style - - for y2 in range(y, y + height): - row = data_buffer[y2] - original_char = row[column + x] - row[column + x] = _CHAR_CACHE[ - original_char.char, original_char.style + color_column_style - ] - - def _copy_margin( - self, - margin_content: UIContent, - new_screen: Screen, - write_position: WritePosition, - move_x: int, - width: int, - ) -> None: - """ - 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(margin_content, new_screen, margin_write_position, 0, width) - - def _scroll(self, ui_content: UIContent, width: int, height: int) -> None: - """ - Scroll body. Ensure that the cursor is visible. - """ - if self.wrap_lines(): - func = self._scroll_when_linewrapping - else: - func = self._scroll_without_linewrapping - - func(ui_content, width, height) - - def _scroll_when_linewrapping( - self, ui_content: UIContent, width: int, height: int - ) -> None: - """ - 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 - - def get_line_height(lineno: int) -> int: - return ui_content.get_height_for_line(lineno, width, self.get_line_prefix) - - # When there is no space, reset `vertical_scroll_2` to zero and abort. - # This can happen if the margin is bigger than the window width. - # Otherwise the text height will become "infinite" (a big number) and - # the copy_line will spend a huge amount of iterations trying to render - # nothing. - if width <= 0: - self.vertical_scroll = ui_content.cursor_position.y - self.vertical_scroll_2 = 0 - return - - # 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. - line_height = get_line_height(ui_content.cursor_position.y) - if line_height > height - scroll_offsets_top: - # Calculate the height of the text before the cursor (including - # line prefixes). - text_before_height = ui_content.get_height_for_line( - ui_content.cursor_position.y, - width, - self.get_line_prefix, - slice_stop=ui_content.cursor_position.x, - ) - - # Adjust scroll offset. - self.vertical_scroll = ui_content.cursor_position.y - self.vertical_scroll_2 = min( - text_before_height - 1, # Keep the cursor visible. - line_height - - height, # Avoid blank lines at the bottom when scolling up again. - 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() -> int: - # 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 += get_line_height(lineno) - - if used_height > height - scroll_offsets_bottom: - return prev_lineno - else: - prev_lineno = lineno - return 0 - - def get_max_vertical_scroll() -> int: - # 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 += get_line_height(lineno) - - if used_height > scroll_offsets_top: - return prev_lineno - else: - prev_lineno = lineno - return prev_lineno - - def get_topmost_visible() -> int: - """ - 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 += get_line_height(lineno) - 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(): - self.vertical_scroll = min(self.vertical_scroll, topmost_visible) - - def _scroll_without_linewrapping( - self, ui_content: UIContent, width: int, height: int - ) -> None: - """ - 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(x=0, y=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 = fragment_list_to_text( - ui_content.get_line(cursor_position.y) - ) - - def do_scroll( - current_scroll: int, - scroll_offset_start: int, - scroll_offset_end: int, - cursor_pos: int, - window_size: int, - content_size: int, - ) -> int: - "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() - 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, - ) - - if self.get_line_prefix: - current_line_prefix_width = fragment_list_width( - to_formatted_text(self.get_line_prefix(ui_content.cursor_position.y, 0)) - ) - else: - current_line_prefix_width = 0 - - 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 - current_line_prefix_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, mouse_event: MouseEvent) -> "NotImplementedOrNone": - """ - Mouse handler. Called when the UI control doesn't handle this - particular event. - - Return `NotImplemented` if nothing was done as a consequence of this - key binding (no UI invalidate required in that case). - """ - if mouse_event.event_type == MouseEventType.SCROLL_DOWN: - self._scroll_down() - return None - elif mouse_event.event_type == MouseEventType.SCROLL_UP: - self._scroll_up() - return None - - return NotImplemented - - def _scroll_down(self) -> None: - "Scroll window down." - info = self.render_info - - if info is None: - return - - 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() - - self.vertical_scroll += 1 - - def _scroll_up(self) -> None: - "Scroll window up." - info = self.render_info - - if info is None: - return - - 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() - - self.vertical_scroll -= 1 - - def get_key_bindings(self) -> Optional[KeyBindingsBase]: - return self.content.get_key_bindings() - - def get_children(self) -> List[Container]: - return [] - - -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:`.Filter` instance. - """ - - def __init__(self, content: AnyContainer, filter: FilterOrBool) -> None: - self.content = to_container(content) - self.filter = to_filter(filter) - - def __repr__(self) -> str: - return "ConditionalContainer(%r, filter=%r)" % (self.content, self.filter) - - def reset(self) -> None: - self.content.reset() - - def preferred_width(self, max_available_width: int) -> Dimension: - if self.filter(): - return self.content.preferred_width(max_available_width) - else: - return Dimension.zero() - - def preferred_height(self, width: int, max_available_height: int) -> Dimension: - if self.filter(): - return self.content.preferred_height(width, max_available_height) - else: - return Dimension.zero() - - def write_to_screen( - self, - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - parent_style: str, - erase_bg: bool, - z_index: Optional[int], - ) -> None: - if self.filter(): - return self.content.write_to_screen( - screen, mouse_handlers, write_position, parent_style, erase_bg, z_index - ) - - def get_children(self) -> List[Container]: - return [self.content] - - -class DynamicContainer(Container): - """ - Container class that dynamically returns any Container. - - :param get_container: Callable that returns a :class:`.Container` instance - or any widget with a ``__pt_container__`` method. - """ - - def __init__(self, get_container: Callable[[], AnyContainer]) -> None: - self.get_container = get_container - - def _get_container(self) -> Container: - """ - Return the current container object. - - We call `to_container`, because `get_container` can also return a - widget with a ``__pt_container__`` method. - """ - obj = self.get_container() - return to_container(obj) - - def reset(self) -> None: - self._get_container().reset() - - def preferred_width(self, max_available_width: int) -> Dimension: - return self._get_container().preferred_width(max_available_width) - - def preferred_height(self, width: int, max_available_height: int) -> Dimension: - return self._get_container().preferred_height(width, max_available_height) - - def write_to_screen( - self, - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - parent_style: str, - erase_bg: bool, - z_index: Optional[int], - ) -> None: - self._get_container().write_to_screen( - screen, mouse_handlers, write_position, parent_style, erase_bg, z_index - ) - - def is_modal(self) -> bool: - return False - - def get_key_bindings(self) -> Optional[KeyBindingsBase]: - # Key bindings will be collected when `layout.walk()` finds the child - # container. - return None - - def get_children(self) -> List[Container]: - # Here we have to return the current active container itself, not its - # children. Otherwise, we run into issues where `layout.walk()` will - # never see an object of type `Window` if this contains a window. We - # can't/shouldn't proxy the "isinstance" check. - return [self._get_container()] - - -def to_container(container: AnyContainer) -> Container: - """ - Make sure that the given object is a :class:`.Container`. - """ - if isinstance(container, Container): - return container - elif hasattr(container, "__pt_container__"): - return to_container(container.__pt_container__()) - else: - raise ValueError("Not a container object: %r" % (container,)) - - -def to_window(container: AnyContainer) -> Window: - """ - Make sure that the given argument is a :class:`.Window`. - """ - if isinstance(container, Window): - return container - elif hasattr(container, "__pt_container__"): - return to_window(cast("MagicContainer", container).__pt_container__()) - else: - raise ValueError("Not a Window object: %r." % (container,)) - - -def is_container(value: object) -> "TypeGuard[AnyContainer]": - """ - Checks whether the given value is a container object - (for use in assert statements). - """ - if isinstance(value, Container): - return True - if hasattr(value, "__pt_container__"): - return is_container(cast("MagicContainer", value).__pt_container__()) - return False + new_buffer_row[x + xpos] = char + + # When we print a multi width character, make sure + # to erase the neighbours 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: + # Handle all character widths. If the previous + # character is a multiwidth character, then + # merge it two positions back. + for pw in [2, 1]: # Previous character width. + if ( + x - pw >= 0 + and new_buffer_row[x + xpos - pw].width == pw + ): + prev_char = new_buffer_row[x + xpos - pw] + char2 = _CHAR_CACHE[ + prev_char.char + c, prev_char.style + ] + new_buffer_row[x + xpos - pw] = char2 + + # Keep track of write position for each character. + current_rowcol_to_yx[lineno, col + skipped] = ( + y + ypos, + x + xpos, + ) + + col += 1 + x += char_width + return x, y + + # Copy content. + def copy() -> int: + 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) + + visible_line_to_row_col[y] = (lineno, horizontal_scroll) + + # Copy margin and actual line. + x = 0 + x, y = copy_line(line, lineno, x, y, is_input=True) + + lineno += 1 + y += 1 + return y + + copy() + + def cursor_pos_to_screen_pos(row: int, col: int) -> Point: + "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(x=0, y=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(x=x, y=y) + + # 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.set_cursor_position(self, screen_cursor_position) + + if always_hide_cursor: + new_screen.show_cursor = False + else: + new_screen.show_cursor = ui_content.show_cursor + + self._highlight_digraph(new_screen) + + if highlight_lines: + self._highlight_cursorlines( + 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_key_processor_key_buffer(new_screen) + + # Set menu position. + if ui_content.menu_position: + new_screen.set_menu_position( + self, + cursor_pos_to_screen_pos( + ui_content.menu_position.y, ui_content.menu_position.x + ), + ) + + # Update output screen height. + new_screen.height = max(new_screen.height, ypos + write_position.height) + + return visible_line_to_row_col, rowcol_to_yx + + def _fill_bg( + self, screen: Screen, write_position: WritePosition, erase_bg: bool + ) -> None: + """ + Erase/fill the background. + (Useful for floats and when a `char` has been given.) + """ + char: Optional[str] + if callable(self.char): + char = self.char() + else: + char = self.char + + if erase_bg or char: + wp = write_position + char_obj = _CHAR_CACHE[char or " ", ""] + + for y in range(wp.ypos, wp.ypos + wp.height): + row = screen.data_buffer[y] + for x in range(wp.xpos, wp.xpos + wp.width): + row[x] = char_obj + + def _apply_style( + self, new_screen: Screen, write_position: WritePosition, parent_style: str + ) -> None: + + # Apply `self.style`. + style = parent_style + " " + to_str(self.style) + + new_screen.fill_area(write_position, style=style, after=False) + + # Apply the 'last-line' class to the last line of each Window. This can + # be used to apply an 'underline' to the user control. + wp = WritePosition( + write_position.xpos, + write_position.ypos + write_position.height - 1, + write_position.width, + 1, + ) + new_screen.fill_area(wp, "class:last-line", after=True) + + def _highlight_digraph(self, new_screen: Screen) -> None: + """ + When we are in Vi digraph mode, put a question mark underneath the + cursor. + """ + digraph_char = self._get_digraph_char() + if digraph_char: + cpos = new_screen.get_cursor_position(self) + new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[ + digraph_char, "class:digraph" + ] + + def _show_key_processor_key_buffer(self, new_screen: Screen) -> None: + """ + 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. + """ + app = get_app() + key_buffer = app.key_processor.key_buffer + + if key_buffer and _in_insert_mode() and not app.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.get_cursor_position(self) + new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[ + data, "class:partial-key-binding" + ] + + def _highlight_cursorlines( + self, new_screen: Screen, cpos: Point, x: int, y: int, width: int, height: int + ) -> None: + """ + Highlight cursor row/column. + """ + cursor_line_style = " class:cursor-line " + cursor_column_style = " class:cursor-column " + + data_buffer = new_screen.data_buffer + + # Highlight cursor line. + if self.cursorline(): + 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.style + cursor_line_style + ] + + # Highlight cursor column. + if self.cursorcolumn(): + 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.style + cursor_column_style + ] + + # Highlight color columns + colorcolumns = self.colorcolumns + if callable(colorcolumns): + colorcolumns = colorcolumns() + + for cc in colorcolumns: + assert isinstance(cc, ColorColumn) + column = cc.position + + if column < x + width: # Only draw when visible. + color_column_style = " " + cc.style + + for y2 in range(y, y + height): + row = data_buffer[y2] + original_char = row[column + x] + row[column + x] = _CHAR_CACHE[ + original_char.char, original_char.style + color_column_style + ] + + def _copy_margin( + self, + margin_content: UIContent, + new_screen: Screen, + write_position: WritePosition, + move_x: int, + width: int, + ) -> None: + """ + 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(margin_content, new_screen, margin_write_position, 0, width) + + def _scroll(self, ui_content: UIContent, width: int, height: int) -> None: + """ + Scroll body. Ensure that the cursor is visible. + """ + if self.wrap_lines(): + func = self._scroll_when_linewrapping + else: + func = self._scroll_without_linewrapping + + func(ui_content, width, height) + + def _scroll_when_linewrapping( + self, ui_content: UIContent, width: int, height: int + ) -> None: + """ + 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 + + def get_line_height(lineno: int) -> int: + return ui_content.get_height_for_line(lineno, width, self.get_line_prefix) + + # When there is no space, reset `vertical_scroll_2` to zero and abort. + # This can happen if the margin is bigger than the window width. + # Otherwise the text height will become "infinite" (a big number) and + # the copy_line will spend a huge amount of iterations trying to render + # nothing. + if width <= 0: + self.vertical_scroll = ui_content.cursor_position.y + self.vertical_scroll_2 = 0 + return + + # 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. + line_height = get_line_height(ui_content.cursor_position.y) + if line_height > height - scroll_offsets_top: + # Calculate the height of the text before the cursor (including + # line prefixes). + text_before_height = ui_content.get_height_for_line( + ui_content.cursor_position.y, + width, + self.get_line_prefix, + slice_stop=ui_content.cursor_position.x, + ) + + # Adjust scroll offset. + self.vertical_scroll = ui_content.cursor_position.y + self.vertical_scroll_2 = min( + text_before_height - 1, # Keep the cursor visible. + line_height + - height, # Avoid blank lines at the bottom when scolling up again. + 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() -> int: + # 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 += get_line_height(lineno) + + if used_height > height - scroll_offsets_bottom: + return prev_lineno + else: + prev_lineno = lineno + return 0 + + def get_max_vertical_scroll() -> int: + # 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 += get_line_height(lineno) + + if used_height > scroll_offsets_top: + return prev_lineno + else: + prev_lineno = lineno + return prev_lineno + + def get_topmost_visible() -> int: + """ + 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 += get_line_height(lineno) + 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(): + self.vertical_scroll = min(self.vertical_scroll, topmost_visible) + + def _scroll_without_linewrapping( + self, ui_content: UIContent, width: int, height: int + ) -> None: + """ + 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(x=0, y=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 = fragment_list_to_text( + ui_content.get_line(cursor_position.y) + ) + + def do_scroll( + current_scroll: int, + scroll_offset_start: int, + scroll_offset_end: int, + cursor_pos: int, + window_size: int, + content_size: int, + ) -> int: + "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() + 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, + ) + + if self.get_line_prefix: + current_line_prefix_width = fragment_list_width( + to_formatted_text(self.get_line_prefix(ui_content.cursor_position.y, 0)) + ) + else: + current_line_prefix_width = 0 + + 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 - current_line_prefix_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, mouse_event: MouseEvent) -> "NotImplementedOrNone": + """ + Mouse handler. Called when the UI control doesn't handle this + particular event. + + Return `NotImplemented` if nothing was done as a consequence of this + key binding (no UI invalidate required in that case). + """ + if mouse_event.event_type == MouseEventType.SCROLL_DOWN: + self._scroll_down() + return None + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + self._scroll_up() + return None + + return NotImplemented + + def _scroll_down(self) -> None: + "Scroll window down." + info = self.render_info + + if info is None: + return + + 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() + + self.vertical_scroll += 1 + + def _scroll_up(self) -> None: + "Scroll window up." + info = self.render_info + + if info is None: + return + + 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() + + self.vertical_scroll -= 1 + + def get_key_bindings(self) -> Optional[KeyBindingsBase]: + return self.content.get_key_bindings() + + def get_children(self) -> List[Container]: + return [] + + +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:`.Filter` instance. + """ + + def __init__(self, content: AnyContainer, filter: FilterOrBool) -> None: + self.content = to_container(content) + self.filter = to_filter(filter) + + def __repr__(self) -> str: + return "ConditionalContainer(%r, filter=%r)" % (self.content, self.filter) + + def reset(self) -> None: + self.content.reset() + + def preferred_width(self, max_available_width: int) -> Dimension: + if self.filter(): + return self.content.preferred_width(max_available_width) + else: + return Dimension.zero() + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + if self.filter(): + return self.content.preferred_height(width, max_available_height) + else: + return Dimension.zero() + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: Optional[int], + ) -> None: + if self.filter(): + return self.content.write_to_screen( + screen, mouse_handlers, write_position, parent_style, erase_bg, z_index + ) + + def get_children(self) -> List[Container]: + return [self.content] + + +class DynamicContainer(Container): + """ + Container class that dynamically returns any Container. + + :param get_container: Callable that returns a :class:`.Container` instance + or any widget with a ``__pt_container__`` method. + """ + + def __init__(self, get_container: Callable[[], AnyContainer]) -> None: + self.get_container = get_container + + def _get_container(self) -> Container: + """ + Return the current container object. + + We call `to_container`, because `get_container` can also return a + widget with a ``__pt_container__`` method. + """ + obj = self.get_container() + return to_container(obj) + + def reset(self) -> None: + self._get_container().reset() + + def preferred_width(self, max_available_width: int) -> Dimension: + return self._get_container().preferred_width(max_available_width) + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + return self._get_container().preferred_height(width, max_available_height) + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: Optional[int], + ) -> None: + self._get_container().write_to_screen( + screen, mouse_handlers, write_position, parent_style, erase_bg, z_index + ) + + def is_modal(self) -> bool: + return False + + def get_key_bindings(self) -> Optional[KeyBindingsBase]: + # Key bindings will be collected when `layout.walk()` finds the child + # container. + return None + + def get_children(self) -> List[Container]: + # Here we have to return the current active container itself, not its + # children. Otherwise, we run into issues where `layout.walk()` will + # never see an object of type `Window` if this contains a window. We + # can't/shouldn't proxy the "isinstance" check. + return [self._get_container()] + + +def to_container(container: AnyContainer) -> Container: + """ + Make sure that the given object is a :class:`.Container`. + """ + if isinstance(container, Container): + return container + elif hasattr(container, "__pt_container__"): + return to_container(container.__pt_container__()) + else: + raise ValueError("Not a container object: %r" % (container,)) + + +def to_window(container: AnyContainer) -> Window: + """ + Make sure that the given argument is a :class:`.Window`. + """ + if isinstance(container, Window): + return container + elif hasattr(container, "__pt_container__"): + return to_window(cast("MagicContainer", container).__pt_container__()) + else: + raise ValueError("Not a Window object: %r." % (container,)) + + +def is_container(value: object) -> "TypeGuard[AnyContainer]": + """ + Checks whether the given value is a container object + (for use in assert statements). + """ + if isinstance(value, Container): + return True + if hasattr(value, "__pt_container__"): + return is_container(cast("MagicContainer", value).__pt_container__()) + return False diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/controls.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/controls.py index 45b50e68f8..4810ed5dd4 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/controls.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/controls.py @@ -1,961 +1,961 @@ -""" -User interface Controls for the layout. -""" -import time -from abc import ABCMeta, abstractmethod -from typing import ( - TYPE_CHECKING, - Callable, - Dict, - Hashable, - Iterable, - List, - NamedTuple, - Optional, - Union, -) - -from prompt_toolkit.application.current import get_app -from prompt_toolkit.buffer import Buffer -from prompt_toolkit.cache import SimpleCache -from prompt_toolkit.data_structures import Point -from prompt_toolkit.document import Document -from prompt_toolkit.filters import FilterOrBool, to_filter -from prompt_toolkit.formatted_text import ( - AnyFormattedText, - StyleAndTextTuples, - to_formatted_text, -) -from prompt_toolkit.formatted_text.utils import ( - fragment_list_to_text, - fragment_list_width, - split_lines, -) -from prompt_toolkit.lexers import Lexer, SimpleLexer -from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType -from prompt_toolkit.search import SearchState -from prompt_toolkit.selection import SelectionType -from prompt_toolkit.utils import get_cwidth - -from .processors import ( - DisplayMultipleCursors, - HighlightIncrementalSearchProcessor, - HighlightSearchProcessor, - HighlightSelectionProcessor, - Processor, - TransformationInput, - merge_processors, -) - -if TYPE_CHECKING: - from prompt_toolkit.key_binding.key_bindings import ( - KeyBindingsBase, - NotImplementedOrNone, - ) - from prompt_toolkit.utils import Event - - -__all__ = [ - "BufferControl", - "SearchBufferControl", - "DummyControl", - "FormattedTextControl", - "UIControl", - "UIContent", -] - -GetLinePrefixCallable = Callable[[int, int], AnyFormattedText] - - -class UIControl(metaclass=ABCMeta): - """ - Base class for all user interface controls. - """ - - def reset(self) -> None: - # Default reset. (Doesn't have to be implemented.) - pass - - def preferred_width(self, max_available_width: int) -> Optional[int]: - return None - - def preferred_height( - self, - width: int, - max_available_height: int, - wrap_lines: bool, - get_line_prefix: Optional[GetLinePrefixCallable], - ) -> Optional[int]: - return None - - def is_focusable(self) -> bool: - """ - Tell whether this user control is focusable. - """ - return False - - @abstractmethod - def create_content(self, width: int, height: int) -> "UIContent": - """ - Generate the content for this user control. - - Returns a :class:`.UIContent` instance. - """ - - def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone": - """ - 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 mouse_event: `MouseEvent` instance. - """ - return NotImplemented - - def move_cursor_down(self) -> None: - """ - Request to move the cursor down. - This happens when scrolling down and the cursor is completely at the - top. - """ - - def move_cursor_up(self) -> None: - """ - Request to move the cursor up. - """ - - def get_key_bindings(self) -> Optional["KeyBindingsBase"]: - """ - The key bindings that are specific for this user control. - - Return a :class:`.KeyBindings` object if some key bindings are - specified, or `None` otherwise. - """ - - def get_invalidate_events(self) -> Iterable["Event[object]"]: - """ - Return a list of `Event` objects. This can be a generator. - (The application collects all these events, in order to bind redraw - handlers to these events.) - """ - return [] - - -class UIContent: - """ - Content generated by a user control. This content consists of a list of - lines. - - :param get_line: Callable that takes a line number and returns the current - line. This is a list of (style_str, 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. - """ - - def __init__( - self, - get_line: Callable[[int], StyleAndTextTuples] = (lambda i: []), - line_count: int = 0, - cursor_position: Optional[Point] = None, - menu_position: Optional[Point] = None, - show_cursor: bool = True, - ): - - self.get_line = get_line - self.line_count = line_count - self.cursor_position = cursor_position or Point(x=0, y=0) - self.menu_position = menu_position - self.show_cursor = show_cursor - - # Cache for line heights. Maps cache key -> height - self._line_heights_cache: Dict[Hashable, int] = {} - - def __getitem__(self, lineno: int) -> StyleAndTextTuples: - "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: int, - width: int, - get_line_prefix: Optional[GetLinePrefixCallable], - slice_stop: Optional[int] = None, - ) -> int: - """ - Return the height that a given line would need if it is rendered in a - space with the given width (using line wrapping). - - :param get_line_prefix: None or a `Window.get_line_prefix` callable - that returns the prefix to be inserted before this line. - :param slice_stop: Wrap only "line[:slice_stop]" and return that - partial result. This is needed for scrolling the window correctly - when line wrapping. - :returns: The computed height. - """ - # Instead of using `get_line_prefix` as key, we use render_counter - # instead. This is more reliable, because this function could still be - # the same, while the content would change over time. - key = get_app().render_counter, lineno, width, slice_stop - - try: - return self._line_heights_cache[key] - except KeyError: - if width == 0: +""" +User interface Controls for the layout. +""" +import time +from abc import ABCMeta, abstractmethod +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + Hashable, + Iterable, + List, + NamedTuple, + Optional, + Union, +) + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.data_structures import Point +from prompt_toolkit.document import Document +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import ( + fragment_list_to_text, + fragment_list_width, + split_lines, +) +from prompt_toolkit.lexers import Lexer, SimpleLexer +from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType +from prompt_toolkit.search import SearchState +from prompt_toolkit.selection import SelectionType +from prompt_toolkit.utils import get_cwidth + +from .processors import ( + DisplayMultipleCursors, + HighlightIncrementalSearchProcessor, + HighlightSearchProcessor, + HighlightSelectionProcessor, + Processor, + TransformationInput, + merge_processors, +) + +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_bindings import ( + KeyBindingsBase, + NotImplementedOrNone, + ) + from prompt_toolkit.utils import Event + + +__all__ = [ + "BufferControl", + "SearchBufferControl", + "DummyControl", + "FormattedTextControl", + "UIControl", + "UIContent", +] + +GetLinePrefixCallable = Callable[[int, int], AnyFormattedText] + + +class UIControl(metaclass=ABCMeta): + """ + Base class for all user interface controls. + """ + + def reset(self) -> None: + # Default reset. (Doesn't have to be implemented.) + pass + + def preferred_width(self, max_available_width: int) -> Optional[int]: + return None + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: Optional[GetLinePrefixCallable], + ) -> Optional[int]: + return None + + def is_focusable(self) -> bool: + """ + Tell whether this user control is focusable. + """ + return False + + @abstractmethod + def create_content(self, width: int, height: int) -> "UIContent": + """ + Generate the content for this user control. + + Returns a :class:`.UIContent` instance. + """ + + def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone": + """ + 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 mouse_event: `MouseEvent` instance. + """ + return NotImplemented + + def move_cursor_down(self) -> None: + """ + Request to move the cursor down. + This happens when scrolling down and the cursor is completely at the + top. + """ + + def move_cursor_up(self) -> None: + """ + Request to move the cursor up. + """ + + def get_key_bindings(self) -> Optional["KeyBindingsBase"]: + """ + The key bindings that are specific for this user control. + + Return a :class:`.KeyBindings` object if some key bindings are + specified, or `None` otherwise. + """ + + def get_invalidate_events(self) -> Iterable["Event[object]"]: + """ + Return a list of `Event` objects. This can be a generator. + (The application collects all these events, in order to bind redraw + handlers to these events.) + """ + return [] + + +class UIContent: + """ + Content generated by a user control. This content consists of a list of + lines. + + :param get_line: Callable that takes a line number and returns the current + line. This is a list of (style_str, 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. + """ + + def __init__( + self, + get_line: Callable[[int], StyleAndTextTuples] = (lambda i: []), + line_count: int = 0, + cursor_position: Optional[Point] = None, + menu_position: Optional[Point] = None, + show_cursor: bool = True, + ): + + self.get_line = get_line + self.line_count = line_count + self.cursor_position = cursor_position or Point(x=0, y=0) + self.menu_position = menu_position + self.show_cursor = show_cursor + + # Cache for line heights. Maps cache key -> height + self._line_heights_cache: Dict[Hashable, int] = {} + + def __getitem__(self, lineno: int) -> StyleAndTextTuples: + "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: int, + width: int, + get_line_prefix: Optional[GetLinePrefixCallable], + slice_stop: Optional[int] = None, + ) -> int: + """ + Return the height that a given line would need if it is rendered in a + space with the given width (using line wrapping). + + :param get_line_prefix: None or a `Window.get_line_prefix` callable + that returns the prefix to be inserted before this line. + :param slice_stop: Wrap only "line[:slice_stop]" and return that + partial result. This is needed for scrolling the window correctly + when line wrapping. + :returns: The computed height. + """ + # Instead of using `get_line_prefix` as key, we use render_counter + # instead. This is more reliable, because this function could still be + # the same, while the content would change over time. + key = get_app().render_counter, lineno, width, slice_stop + + try: + return self._line_heights_cache[key] + except KeyError: + if width == 0: height = 10**8 - else: - # Calculate line width first. - line = fragment_list_to_text(self.get_line(lineno))[:slice_stop] - text_width = get_cwidth(line) - - if get_line_prefix: - # Add prefix width. - text_width += fragment_list_width( - to_formatted_text(get_line_prefix(lineno, 0)) - ) - - # Slower path: compute path when there's a line prefix. - height = 1 - - # Keep wrapping as long as the line doesn't fit. - # Keep adding new prefixes for every wrapped line. - while text_width > width: - height += 1 - text_width -= width - - fragments2 = to_formatted_text( - get_line_prefix(lineno, height - 1) - ) - prefix_width = get_cwidth(fragment_list_to_text(fragments2)) - - if prefix_width >= width: # Prefix doesn't fit. + else: + # Calculate line width first. + line = fragment_list_to_text(self.get_line(lineno))[:slice_stop] + text_width = get_cwidth(line) + + if get_line_prefix: + # Add prefix width. + text_width += fragment_list_width( + to_formatted_text(get_line_prefix(lineno, 0)) + ) + + # Slower path: compute path when there's a line prefix. + height = 1 + + # Keep wrapping as long as the line doesn't fit. + # Keep adding new prefixes for every wrapped line. + while text_width > width: + height += 1 + text_width -= width + + fragments2 = to_formatted_text( + get_line_prefix(lineno, height - 1) + ) + prefix_width = get_cwidth(fragment_list_to_text(fragments2)) + + if prefix_width >= width: # Prefix doesn't fit. height = 10**8 - break - - text_width += prefix_width - else: - # Fast path: compute height when there's no line prefix. - try: - quotient, remainder = divmod(text_width, width) - except ZeroDivisionError: + break + + text_width += prefix_width + else: + # Fast path: compute height when there's no line prefix. + try: + quotient, remainder = divmod(text_width, width) + except ZeroDivisionError: height = 10**8 - else: - if remainder: - quotient += 1 # Like math.ceil. - height = max(1, quotient) - - # Cache and return - self._line_heights_cache[key] = height - return height - - -class FormattedTextControl(UIControl): - """ - Control that displays formatted text. This can be either plain text, an - :class:`~prompt_toolkit.formatted_text.HTML` object an - :class:`~prompt_toolkit.formatted_text.ANSI` object, a list of ``(style_str, - text)`` tuples or a callable that takes no argument and returns one of - those, depending on how you prefer to do the formatting. See - ``prompt_toolkit.layout.formatted_text`` for more information. - - (It's mostly optimized for rather small widgets, like toolbars, menus, etc...) - - When this UI control has the focus, the cursor will be shown in the upper - left corner of this control by default. There are two ways for specifying - the cursor position: - - - Pass a `get_cursor_position` function which returns a `Point` instance - with the current cursor position. - - - If the (formatted) text is passed as a list of ``(style, text)`` tuples - and there is one that looks like ``('[SetCursorPosition]', '')``, then - this will specify the cursor position. - - Mouse support: - - The list of fragments can also contain tuples of three items, looking like: - (style_str, text, handler). When mouse support is enabled and the user - clicks on this fragment, then the given handler is called. That handler - should accept two inputs: (Application, MouseEvent) and it should - either handle the event or return `NotImplemented` in case we want the - containing Window to handle this event. - - :param focusable: `bool` or :class:`.Filter`: Tell whether this control is - focusable. - - :param text: Text or formatted text to be displayed. - :param style: Style string applied to the content. (If you want to style - the whole :class:`~prompt_toolkit.layout.Window`, pass the style to the - :class:`~prompt_toolkit.layout.Window` instead.) - :param key_bindings: a :class:`.KeyBindings` object. - :param get_cursor_position: A callable that returns the cursor position as - a `Point` instance. - """ - - def __init__( - self, - text: AnyFormattedText = "", - style: str = "", - focusable: FilterOrBool = False, - key_bindings: Optional["KeyBindingsBase"] = None, - show_cursor: bool = True, - modal: bool = False, - get_cursor_position: Optional[Callable[[], Optional[Point]]] = None, - ) -> None: - - self.text = text # No type check on 'text'. This is done dynamically. - self.style = style - self.focusable = to_filter(focusable) - - # Key bindings. - self.key_bindings = key_bindings - self.show_cursor = show_cursor - self.modal = modal - self.get_cursor_position = get_cursor_position - - #: Cache for the content. - self._content_cache: SimpleCache[Hashable, UIContent] = SimpleCache(maxsize=18) - self._fragment_cache: SimpleCache[int, StyleAndTextTuples] = SimpleCache( - maxsize=1 - ) - # Only cache one fragment list. We don't need the previous item. - - # Render info for the mouse support. - self._fragments: Optional[StyleAndTextTuples] = None - - def reset(self) -> None: - self._fragments = None - - def is_focusable(self) -> bool: - return self.focusable() - - def __repr__(self) -> str: - return "%s(%r)" % (self.__class__.__name__, self.text) - - def _get_formatted_text_cached(self) -> StyleAndTextTuples: - """ - Get fragments, but only retrieve fragments 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._fragment_cache.get( - get_app().render_counter, lambda: to_formatted_text(self.text, self.style) - ) - - def preferred_width(self, max_available_width: int) -> int: - """ - Return the preferred width for this control. - That is the width of the longest line. - """ - text = fragment_list_to_text(self._get_formatted_text_cached()) - line_lengths = [get_cwidth(l) for l in text.split("\n")] - return max(line_lengths) - - def preferred_height( - self, - width: int, - max_available_height: int, - wrap_lines: bool, - get_line_prefix: Optional[GetLinePrefixCallable], - ) -> Optional[int]: - """ - Return the preferred height for this control. - """ - content = self.create_content(width, None) - if wrap_lines: - height = 0 - for i in range(content.line_count): - height += content.get_height_for_line(i, width, get_line_prefix) - if height >= max_available_height: - return max_available_height - return height - else: - return content.line_count - - def create_content(self, width: int, height: Optional[int]) -> UIContent: - # Get fragments - fragments_with_mouse_handlers = self._get_formatted_text_cached() - fragment_lines_with_mouse_handlers = list( - split_lines(fragments_with_mouse_handlers) - ) - - # Strip mouse handlers from fragments. - fragment_lines: List[StyleAndTextTuples] = [ - [(item[0], item[1]) for item in line] - for line in fragment_lines_with_mouse_handlers - ] - - # Keep track of the fragments with mouse handler, for later use in - # `mouse_handler`. - self._fragments = fragments_with_mouse_handlers - - # If there is a `[SetCursorPosition]` in the fragment list, set the - # cursor position here. - def get_cursor_position( - fragment: str = "[SetCursorPosition]", - ) -> Optional[Point]: - for y, line in enumerate(fragment_lines): - x = 0 - for style_str, text, *_ in line: - if fragment in style_str: - return Point(x=x, y=y) - x += len(text) - return None - - # If there is a `[SetMenuPosition]`, set the menu over here. - def get_menu_position() -> Optional[Point]: - return get_cursor_position("[SetMenuPosition]") - - cursor_position = (self.get_cursor_position or get_cursor_position)() - - # Create content, or take it from the cache. - key = (tuple(fragments_with_mouse_handlers), width, cursor_position) - - def get_content() -> UIContent: - return UIContent( - get_line=lambda i: fragment_lines[i], - line_count=len(fragment_lines), - show_cursor=self.show_cursor, - cursor_position=cursor_position, - menu_position=get_menu_position(), - ) - - return self._content_cache.get(key, get_content) - - def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone": - """ - Handle mouse events. - - (When the fragment 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 - :class:`~prompt_toolkit.layout.Window` to handle this particular - event.) - """ - if self._fragments: - # Read the generator. - fragments_for_line = list(split_lines(self._fragments)) - - try: - fragments = fragments_for_line[mouse_event.position.y] - except IndexError: - return NotImplemented - else: - # Find position in the fragment list. - xpos = mouse_event.position.x - - # Find mouse handler for this character. - count = 0 - for item in fragments: - 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] # type: ignore - return handler(mouse_event) - else: - break - - # Otherwise, don't handle here. - return NotImplemented - - def is_modal(self) -> bool: - return self.modal - - def get_key_bindings(self) -> Optional["KeyBindingsBase"]: - return self.key_bindings - - -class DummyControl(UIControl): - """ - A dummy control object that doesn't paint any content. - - Useful for filling a :class:`~prompt_toolkit.layout.Window`. (The - `fragment` and `char` attributes of the `Window` class can be used to - define the filling.) - """ - - def create_content(self, width: int, height: int) -> UIContent: - def get_line(i: int) -> StyleAndTextTuples: - return [] - - return UIContent( + else: + if remainder: + quotient += 1 # Like math.ceil. + height = max(1, quotient) + + # Cache and return + self._line_heights_cache[key] = height + return height + + +class FormattedTextControl(UIControl): + """ + Control that displays formatted text. This can be either plain text, an + :class:`~prompt_toolkit.formatted_text.HTML` object an + :class:`~prompt_toolkit.formatted_text.ANSI` object, a list of ``(style_str, + text)`` tuples or a callable that takes no argument and returns one of + those, depending on how you prefer to do the formatting. See + ``prompt_toolkit.layout.formatted_text`` for more information. + + (It's mostly optimized for rather small widgets, like toolbars, menus, etc...) + + When this UI control has the focus, the cursor will be shown in the upper + left corner of this control by default. There are two ways for specifying + the cursor position: + + - Pass a `get_cursor_position` function which returns a `Point` instance + with the current cursor position. + + - If the (formatted) text is passed as a list of ``(style, text)`` tuples + and there is one that looks like ``('[SetCursorPosition]', '')``, then + this will specify the cursor position. + + Mouse support: + + The list of fragments can also contain tuples of three items, looking like: + (style_str, text, handler). When mouse support is enabled and the user + clicks on this fragment, then the given handler is called. That handler + should accept two inputs: (Application, MouseEvent) and it should + either handle the event or return `NotImplemented` in case we want the + containing Window to handle this event. + + :param focusable: `bool` or :class:`.Filter`: Tell whether this control is + focusable. + + :param text: Text or formatted text to be displayed. + :param style: Style string applied to the content. (If you want to style + the whole :class:`~prompt_toolkit.layout.Window`, pass the style to the + :class:`~prompt_toolkit.layout.Window` instead.) + :param key_bindings: a :class:`.KeyBindings` object. + :param get_cursor_position: A callable that returns the cursor position as + a `Point` instance. + """ + + def __init__( + self, + text: AnyFormattedText = "", + style: str = "", + focusable: FilterOrBool = False, + key_bindings: Optional["KeyBindingsBase"] = None, + show_cursor: bool = True, + modal: bool = False, + get_cursor_position: Optional[Callable[[], Optional[Point]]] = None, + ) -> None: + + self.text = text # No type check on 'text'. This is done dynamically. + self.style = style + self.focusable = to_filter(focusable) + + # Key bindings. + self.key_bindings = key_bindings + self.show_cursor = show_cursor + self.modal = modal + self.get_cursor_position = get_cursor_position + + #: Cache for the content. + self._content_cache: SimpleCache[Hashable, UIContent] = SimpleCache(maxsize=18) + self._fragment_cache: SimpleCache[int, StyleAndTextTuples] = SimpleCache( + maxsize=1 + ) + # Only cache one fragment list. We don't need the previous item. + + # Render info for the mouse support. + self._fragments: Optional[StyleAndTextTuples] = None + + def reset(self) -> None: + self._fragments = None + + def is_focusable(self) -> bool: + return self.focusable() + + def __repr__(self) -> str: + return "%s(%r)" % (self.__class__.__name__, self.text) + + def _get_formatted_text_cached(self) -> StyleAndTextTuples: + """ + Get fragments, but only retrieve fragments 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._fragment_cache.get( + get_app().render_counter, lambda: to_formatted_text(self.text, self.style) + ) + + def preferred_width(self, max_available_width: int) -> int: + """ + Return the preferred width for this control. + That is the width of the longest line. + """ + text = fragment_list_to_text(self._get_formatted_text_cached()) + line_lengths = [get_cwidth(l) for l in text.split("\n")] + return max(line_lengths) + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: Optional[GetLinePrefixCallable], + ) -> Optional[int]: + """ + Return the preferred height for this control. + """ + content = self.create_content(width, None) + if wrap_lines: + height = 0 + for i in range(content.line_count): + height += content.get_height_for_line(i, width, get_line_prefix) + if height >= max_available_height: + return max_available_height + return height + else: + return content.line_count + + def create_content(self, width: int, height: Optional[int]) -> UIContent: + # Get fragments + fragments_with_mouse_handlers = self._get_formatted_text_cached() + fragment_lines_with_mouse_handlers = list( + split_lines(fragments_with_mouse_handlers) + ) + + # Strip mouse handlers from fragments. + fragment_lines: List[StyleAndTextTuples] = [ + [(item[0], item[1]) for item in line] + for line in fragment_lines_with_mouse_handlers + ] + + # Keep track of the fragments with mouse handler, for later use in + # `mouse_handler`. + self._fragments = fragments_with_mouse_handlers + + # If there is a `[SetCursorPosition]` in the fragment list, set the + # cursor position here. + def get_cursor_position( + fragment: str = "[SetCursorPosition]", + ) -> Optional[Point]: + for y, line in enumerate(fragment_lines): + x = 0 + for style_str, text, *_ in line: + if fragment in style_str: + return Point(x=x, y=y) + x += len(text) + return None + + # If there is a `[SetMenuPosition]`, set the menu over here. + def get_menu_position() -> Optional[Point]: + return get_cursor_position("[SetMenuPosition]") + + cursor_position = (self.get_cursor_position or get_cursor_position)() + + # Create content, or take it from the cache. + key = (tuple(fragments_with_mouse_handlers), width, cursor_position) + + def get_content() -> UIContent: + return UIContent( + get_line=lambda i: fragment_lines[i], + line_count=len(fragment_lines), + show_cursor=self.show_cursor, + cursor_position=cursor_position, + menu_position=get_menu_position(), + ) + + return self._content_cache.get(key, get_content) + + def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone": + """ + Handle mouse events. + + (When the fragment 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 + :class:`~prompt_toolkit.layout.Window` to handle this particular + event.) + """ + if self._fragments: + # Read the generator. + fragments_for_line = list(split_lines(self._fragments)) + + try: + fragments = fragments_for_line[mouse_event.position.y] + except IndexError: + return NotImplemented + else: + # Find position in the fragment list. + xpos = mouse_event.position.x + + # Find mouse handler for this character. + count = 0 + for item in fragments: + 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] # type: ignore + return handler(mouse_event) + else: + break + + # Otherwise, don't handle here. + return NotImplemented + + def is_modal(self) -> bool: + return self.modal + + def get_key_bindings(self) -> Optional["KeyBindingsBase"]: + return self.key_bindings + + +class DummyControl(UIControl): + """ + A dummy control object that doesn't paint any content. + + Useful for filling a :class:`~prompt_toolkit.layout.Window`. (The + `fragment` and `char` attributes of the `Window` class can be used to + define the filling.) + """ + + def create_content(self, width: int, height: int) -> UIContent: + def get_line(i: int) -> StyleAndTextTuples: + return [] + + return UIContent( get_line=get_line, line_count=100**100 - ) # Something very big. - - def is_focusable(self) -> bool: - return False - - -_ProcessedLine = NamedTuple( - "_ProcessedLine", - [ - ("fragments", StyleAndTextTuples), - ("source_to_display", Callable[[int], int]), - ("display_to_source", Callable[[int], int]), - ], -) - - -class BufferControl(UIControl): - """ - Control for visualising the content of a :class:`.Buffer`. - - :param buffer: The :class:`.Buffer` object to be displayed. - :param input_processors: A list of - :class:`~prompt_toolkit.layout.processors.Processor` objects. - :param include_default_input_processors: When True, include the default - processors for highlighting of selection, search and displaying of - multiple cursors. - :param lexer: :class:`.Lexer` instance for syntax highlighting. - :param preview_search: `bool` or :class:`.Filter`: Show search while - typing. When this is `True`, probably you want to add a - ``HighlightIncrementalSearchProcessor`` as well. Otherwise only the - cursor position will move, but the text won't be highlighted. - :param focusable: `bool` or :class:`.Filter`: Tell whether this control is focusable. - :param focus_on_click: Focus this buffer when it's click, but not yet focused. - :param key_bindings: a :class:`.KeyBindings` object. - """ - - def __init__( - self, - buffer: Optional[Buffer] = None, - input_processors: Optional[List[Processor]] = None, - include_default_input_processors: bool = True, - lexer: Optional[Lexer] = None, - preview_search: FilterOrBool = False, - focusable: FilterOrBool = True, - search_buffer_control: Union[ - None, "SearchBufferControl", Callable[[], "SearchBufferControl"] - ] = None, - menu_position: Optional[Callable[[], Optional[int]]] = None, - focus_on_click: FilterOrBool = False, - key_bindings: Optional["KeyBindingsBase"] = None, - ): - - self.input_processors = input_processors - self.include_default_input_processors = include_default_input_processors - - self.default_input_processors = [ - HighlightSearchProcessor(), - HighlightIncrementalSearchProcessor(), - HighlightSelectionProcessor(), - DisplayMultipleCursors(), - ] - - self.preview_search = to_filter(preview_search) - self.focusable = to_filter(focusable) - self.focus_on_click = to_filter(focus_on_click) - - self.buffer = buffer or Buffer() - self.menu_position = menu_position - self.lexer = lexer or SimpleLexer() - self.key_bindings = key_bindings - self._search_buffer_control = search_buffer_control - - #: 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 fairly easy way to cache such an expensive operation. - self._fragment_cache: SimpleCache[ - Hashable, Callable[[int], StyleAndTextTuples] - ] = SimpleCache(maxsize=8) - - self._last_click_timestamp: Optional[float] = None - self._last_get_processed_line: Optional[Callable[[int], _ProcessedLine]] = None - - def __repr__(self) -> str: - return "<%s buffer=%r at %r>" % (self.__class__.__name__, self.buffer, id(self)) - - @property - def search_buffer_control(self) -> Optional["SearchBufferControl"]: - result: Optional[SearchBufferControl] - - if callable(self._search_buffer_control): - result = self._search_buffer_control() - else: - result = self._search_buffer_control - - assert result is None or isinstance(result, SearchBufferControl) - return result - - @property - def search_buffer(self) -> Optional[Buffer]: - control = self.search_buffer_control - if control is not None: - return control.buffer - return None - - @property - def search_state(self) -> SearchState: - """ - Return the `SearchState` for searching this `BufferControl`. This is - always associated with the search control. If one search bar is used - for searching multiple `BufferControls`, then they share the same - `SearchState`. - """ - search_buffer_control = self.search_buffer_control - if search_buffer_control: - return search_buffer_control.searcher_search_state - else: - return SearchState() - - def is_focusable(self) -> bool: - return self.focusable() - - def preferred_width(self, max_available_width: int) -> Optional[int]: - """ - 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, - width: int, - max_available_height: int, - wrap_lines: bool, - get_line_prefix: Optional[GetLinePrefixCallable], - ) -> Optional[int]: - - # Calculate the content height, if it was drawn on a screen with the - # given width. - height = 0 - content = self.create_content(width, height=1) # Pass a dummy '1' as height. - - # 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, get_line_prefix) - - if height >= max_available_height: - return max_available_height - - return height - - def _get_formatted_text_for_line_func( - self, document: Document - ) -> Callable[[int], StyleAndTextTuples]: - """ - Create a function that returns the fragments for a given line. - """ - # Cache using `document.text`. - def get_formatted_text_for_line() -> Callable[[int], StyleAndTextTuples]: - return self.lexer.lex_document(document) - - key = (document.text, self.lexer.invalidation_hash()) - return self._fragment_cache.get(key, get_formatted_text_for_line) - - def _create_get_processed_line_func( - self, document: Document, width: int, height: int - ) -> Callable[[int], _ProcessedLine]: - """ - Create a function that takes a line number of the current document and - returns a _ProcessedLine(processed_fragments, source_to_display, display_to_source) - tuple. - """ - # Merge all input processors together. - input_processors = self.input_processors or [] - if self.include_default_input_processors: - input_processors = self.default_input_processors + input_processors - - merged_processor = merge_processors(input_processors) - - def transform(lineno: int, fragments: StyleAndTextTuples) -> _ProcessedLine: - "Transform the fragments for a given line number." - # Get cursor position at this line. - def source_to_display(i: int) -> int: - """X position from the buffer to the x position in the - processed fragment list. By default, we start from the 'identity' - operation.""" - return i - - transformation = merged_processor.apply_transformation( - TransformationInput( - self, document, lineno, source_to_display, fragments, width, height - ) - ) - - return _ProcessedLine( - transformation.fragments, - transformation.source_to_display, - transformation.display_to_source, - ) - - def create_func() -> Callable[[int], _ProcessedLine]: - get_line = self._get_formatted_text_for_line_func(document) - cache: Dict[int, _ProcessedLine] = {} - - def get_processed_line(i: int) -> _ProcessedLine: - 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, width: int, height: int, preview_search: bool = False - ) -> UIContent: - """ - Create a UIContent. - """ - buffer = self.buffer - - # Trigger history loading of the buffer. We do this during the - # rendering of the UI here, because it needs to happen when an - # `Application` with its event loop is running. During the rendering of - # the buffer control is the earliest place we can achieve this, where - # we're sure the right event loop is active, and don't require user - # interaction (like in a key binding). - buffer.load_history_if_not_yet_loaded() - - # 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.) - search_control = self.search_buffer_control - preview_now = preview_search or bool( - # Only if this feature is enabled. - self.preview_search() - and - # And something was typed in the associated search field. - search_control - and search_control.buffer.text - and - # And we are searching in this control. (Many controls can point to - # the same search field, like in Pyvim.) - get_app().layout.search_target_buffer_control == self - ) - - if preview_now and search_control is not None: - ss = self.search_state - - document = buffer.document_for_search( - SearchState( - text=search_control.buffer.text, - direction=ss.direction, - ignore_case=ss.ignore_case, - ) - ) - else: - document = buffer.document - - get_processed_line = self._create_get_processed_line_func( - document, width, height - ) - self._last_get_processed_line = get_processed_line - - def translate_rowcol(row: int, col: int) -> Point: - "Return the content column for this coordinate." - return Point(x=get_processed_line(row).source_to_display(col), y=row) - - def get_line(i: int) -> StyleAndTextTuples: - "Return the fragments for a given line number." - fragments = get_processed_line(i).fragments - - # 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.) - fragments = fragments + [("", " ")] - return fragments - - content = UIContent( - get_line=get_line, - line_count=document.line_count, - cursor_position=translate_rowcol( - document.cursor_position_row, document.cursor_position_col - ), - ) - - # 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 focused buffer.) - if get_app().layout.current_control == self: - menu_position = self.menu_position() 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, mouse_event: MouseEvent) -> "NotImplementedOrNone": - """ - Mouse handler for this control. - """ - buffer = self.buffer - position = mouse_event.position - - # Focus buffer when clicked. - if get_app().layout.current_control == self: - 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_MOVE - and mouse_event.button != MouseButton.NONE - ): - # Click and drag to highlight a selection - if ( - buffer.selection_state is None - and abs(buffer.cursor_position - index) > 0 - ): - buffer.start_selection(selection_type=SelectionType.CHARACTERS) - 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: - if buffer.selection_state is None: - 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 < 0.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 focused, but focusing on click events. - else: - if ( - self.focus_on_click() - 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 - # focused and be handled anyway.) - get_app().layout.current_control = self - else: - return NotImplemented - - return None - - def move_cursor_down(self) -> None: - b = self.buffer - b.cursor_position += b.document.get_cursor_down_position() - - def move_cursor_up(self) -> None: - b = self.buffer - b.cursor_position += b.document.get_cursor_up_position() - - def get_key_bindings(self) -> Optional["KeyBindingsBase"]: - """ - When additional key bindings are given. Return these. - """ - return self.key_bindings - - def get_invalidate_events(self) -> Iterable["Event[object]"]: - """ - Return the Window invalidate events. - """ - # Whenever the buffer changes, the UI has to be updated. - yield self.buffer.on_text_changed - yield self.buffer.on_cursor_position_changed - - yield self.buffer.on_completions_changed - yield self.buffer.on_suggestion_set - - -class SearchBufferControl(BufferControl): - """ - :class:`.BufferControl` which is used for searching another - :class:`.BufferControl`. - - :param ignore_case: Search case insensitive. - """ - - def __init__( - self, - buffer: Optional[Buffer] = None, - input_processors: Optional[List[Processor]] = None, - lexer: Optional[Lexer] = None, - focus_on_click: FilterOrBool = False, - key_bindings: Optional["KeyBindingsBase"] = None, - ignore_case: FilterOrBool = False, - ): - - super().__init__( - buffer=buffer, - input_processors=input_processors, - lexer=lexer, - focus_on_click=focus_on_click, - key_bindings=key_bindings, - ) - - # If this BufferControl is used as a search field for one or more other - # BufferControls, then represents the search state. - self.searcher_search_state = SearchState(ignore_case=ignore_case) + ) # Something very big. + + def is_focusable(self) -> bool: + return False + + +_ProcessedLine = NamedTuple( + "_ProcessedLine", + [ + ("fragments", StyleAndTextTuples), + ("source_to_display", Callable[[int], int]), + ("display_to_source", Callable[[int], int]), + ], +) + + +class BufferControl(UIControl): + """ + Control for visualising the content of a :class:`.Buffer`. + + :param buffer: The :class:`.Buffer` object to be displayed. + :param input_processors: A list of + :class:`~prompt_toolkit.layout.processors.Processor` objects. + :param include_default_input_processors: When True, include the default + processors for highlighting of selection, search and displaying of + multiple cursors. + :param lexer: :class:`.Lexer` instance for syntax highlighting. + :param preview_search: `bool` or :class:`.Filter`: Show search while + typing. When this is `True`, probably you want to add a + ``HighlightIncrementalSearchProcessor`` as well. Otherwise only the + cursor position will move, but the text won't be highlighted. + :param focusable: `bool` or :class:`.Filter`: Tell whether this control is focusable. + :param focus_on_click: Focus this buffer when it's click, but not yet focused. + :param key_bindings: a :class:`.KeyBindings` object. + """ + + def __init__( + self, + buffer: Optional[Buffer] = None, + input_processors: Optional[List[Processor]] = None, + include_default_input_processors: bool = True, + lexer: Optional[Lexer] = None, + preview_search: FilterOrBool = False, + focusable: FilterOrBool = True, + search_buffer_control: Union[ + None, "SearchBufferControl", Callable[[], "SearchBufferControl"] + ] = None, + menu_position: Optional[Callable[[], Optional[int]]] = None, + focus_on_click: FilterOrBool = False, + key_bindings: Optional["KeyBindingsBase"] = None, + ): + + self.input_processors = input_processors + self.include_default_input_processors = include_default_input_processors + + self.default_input_processors = [ + HighlightSearchProcessor(), + HighlightIncrementalSearchProcessor(), + HighlightSelectionProcessor(), + DisplayMultipleCursors(), + ] + + self.preview_search = to_filter(preview_search) + self.focusable = to_filter(focusable) + self.focus_on_click = to_filter(focus_on_click) + + self.buffer = buffer or Buffer() + self.menu_position = menu_position + self.lexer = lexer or SimpleLexer() + self.key_bindings = key_bindings + self._search_buffer_control = search_buffer_control + + #: 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 fairly easy way to cache such an expensive operation. + self._fragment_cache: SimpleCache[ + Hashable, Callable[[int], StyleAndTextTuples] + ] = SimpleCache(maxsize=8) + + self._last_click_timestamp: Optional[float] = None + self._last_get_processed_line: Optional[Callable[[int], _ProcessedLine]] = None + + def __repr__(self) -> str: + return "<%s buffer=%r at %r>" % (self.__class__.__name__, self.buffer, id(self)) + + @property + def search_buffer_control(self) -> Optional["SearchBufferControl"]: + result: Optional[SearchBufferControl] + + if callable(self._search_buffer_control): + result = self._search_buffer_control() + else: + result = self._search_buffer_control + + assert result is None or isinstance(result, SearchBufferControl) + return result + + @property + def search_buffer(self) -> Optional[Buffer]: + control = self.search_buffer_control + if control is not None: + return control.buffer + return None + + @property + def search_state(self) -> SearchState: + """ + Return the `SearchState` for searching this `BufferControl`. This is + always associated with the search control. If one search bar is used + for searching multiple `BufferControls`, then they share the same + `SearchState`. + """ + search_buffer_control = self.search_buffer_control + if search_buffer_control: + return search_buffer_control.searcher_search_state + else: + return SearchState() + + def is_focusable(self) -> bool: + return self.focusable() + + def preferred_width(self, max_available_width: int) -> Optional[int]: + """ + 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, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: Optional[GetLinePrefixCallable], + ) -> Optional[int]: + + # Calculate the content height, if it was drawn on a screen with the + # given width. + height = 0 + content = self.create_content(width, height=1) # Pass a dummy '1' as height. + + # 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, get_line_prefix) + + if height >= max_available_height: + return max_available_height + + return height + + def _get_formatted_text_for_line_func( + self, document: Document + ) -> Callable[[int], StyleAndTextTuples]: + """ + Create a function that returns the fragments for a given line. + """ + # Cache using `document.text`. + def get_formatted_text_for_line() -> Callable[[int], StyleAndTextTuples]: + return self.lexer.lex_document(document) + + key = (document.text, self.lexer.invalidation_hash()) + return self._fragment_cache.get(key, get_formatted_text_for_line) + + def _create_get_processed_line_func( + self, document: Document, width: int, height: int + ) -> Callable[[int], _ProcessedLine]: + """ + Create a function that takes a line number of the current document and + returns a _ProcessedLine(processed_fragments, source_to_display, display_to_source) + tuple. + """ + # Merge all input processors together. + input_processors = self.input_processors or [] + if self.include_default_input_processors: + input_processors = self.default_input_processors + input_processors + + merged_processor = merge_processors(input_processors) + + def transform(lineno: int, fragments: StyleAndTextTuples) -> _ProcessedLine: + "Transform the fragments for a given line number." + # Get cursor position at this line. + def source_to_display(i: int) -> int: + """X position from the buffer to the x position in the + processed fragment list. By default, we start from the 'identity' + operation.""" + return i + + transformation = merged_processor.apply_transformation( + TransformationInput( + self, document, lineno, source_to_display, fragments, width, height + ) + ) + + return _ProcessedLine( + transformation.fragments, + transformation.source_to_display, + transformation.display_to_source, + ) + + def create_func() -> Callable[[int], _ProcessedLine]: + get_line = self._get_formatted_text_for_line_func(document) + cache: Dict[int, _ProcessedLine] = {} + + def get_processed_line(i: int) -> _ProcessedLine: + 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, width: int, height: int, preview_search: bool = False + ) -> UIContent: + """ + Create a UIContent. + """ + buffer = self.buffer + + # Trigger history loading of the buffer. We do this during the + # rendering of the UI here, because it needs to happen when an + # `Application` with its event loop is running. During the rendering of + # the buffer control is the earliest place we can achieve this, where + # we're sure the right event loop is active, and don't require user + # interaction (like in a key binding). + buffer.load_history_if_not_yet_loaded() + + # 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.) + search_control = self.search_buffer_control + preview_now = preview_search or bool( + # Only if this feature is enabled. + self.preview_search() + and + # And something was typed in the associated search field. + search_control + and search_control.buffer.text + and + # And we are searching in this control. (Many controls can point to + # the same search field, like in Pyvim.) + get_app().layout.search_target_buffer_control == self + ) + + if preview_now and search_control is not None: + ss = self.search_state + + document = buffer.document_for_search( + SearchState( + text=search_control.buffer.text, + direction=ss.direction, + ignore_case=ss.ignore_case, + ) + ) + else: + document = buffer.document + + get_processed_line = self._create_get_processed_line_func( + document, width, height + ) + self._last_get_processed_line = get_processed_line + + def translate_rowcol(row: int, col: int) -> Point: + "Return the content column for this coordinate." + return Point(x=get_processed_line(row).source_to_display(col), y=row) + + def get_line(i: int) -> StyleAndTextTuples: + "Return the fragments for a given line number." + fragments = get_processed_line(i).fragments + + # 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.) + fragments = fragments + [("", " ")] + return fragments + + content = UIContent( + get_line=get_line, + line_count=document.line_count, + cursor_position=translate_rowcol( + document.cursor_position_row, document.cursor_position_col + ), + ) + + # 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 focused buffer.) + if get_app().layout.current_control == self: + menu_position = self.menu_position() 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, mouse_event: MouseEvent) -> "NotImplementedOrNone": + """ + Mouse handler for this control. + """ + buffer = self.buffer + position = mouse_event.position + + # Focus buffer when clicked. + if get_app().layout.current_control == self: + 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_MOVE + and mouse_event.button != MouseButton.NONE + ): + # Click and drag to highlight a selection + if ( + buffer.selection_state is None + and abs(buffer.cursor_position - index) > 0 + ): + buffer.start_selection(selection_type=SelectionType.CHARACTERS) + 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: + if buffer.selection_state is None: + 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 < 0.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 focused, but focusing on click events. + else: + if ( + self.focus_on_click() + 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 + # focused and be handled anyway.) + get_app().layout.current_control = self + else: + return NotImplemented + + return None + + def move_cursor_down(self) -> None: + b = self.buffer + b.cursor_position += b.document.get_cursor_down_position() + + def move_cursor_up(self) -> None: + b = self.buffer + b.cursor_position += b.document.get_cursor_up_position() + + def get_key_bindings(self) -> Optional["KeyBindingsBase"]: + """ + When additional key bindings are given. Return these. + """ + return self.key_bindings + + def get_invalidate_events(self) -> Iterable["Event[object]"]: + """ + Return the Window invalidate events. + """ + # Whenever the buffer changes, the UI has to be updated. + yield self.buffer.on_text_changed + yield self.buffer.on_cursor_position_changed + + yield self.buffer.on_completions_changed + yield self.buffer.on_suggestion_set + + +class SearchBufferControl(BufferControl): + """ + :class:`.BufferControl` which is used for searching another + :class:`.BufferControl`. + + :param ignore_case: Search case insensitive. + """ + + def __init__( + self, + buffer: Optional[Buffer] = None, + input_processors: Optional[List[Processor]] = None, + lexer: Optional[Lexer] = None, + focus_on_click: FilterOrBool = False, + key_bindings: Optional["KeyBindingsBase"] = None, + ignore_case: FilterOrBool = False, + ): + + super().__init__( + buffer=buffer, + input_processors=input_processors, + lexer=lexer, + focus_on_click=focus_on_click, + key_bindings=key_bindings, + ) + + # If this BufferControl is used as a search field for one or more other + # BufferControls, then represents the search state. + self.searcher_search_state = SearchState(ignore_case=ignore_case) diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/dimension.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/dimension.py index 04c21637cb..128e51388f 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/dimension.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/dimension.py @@ -1,217 +1,217 @@ -""" -Layout dimensions are used to give the minimum, maximum and preferred -dimensions for containers and controls. -""" -from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union - -__all__ = [ - "Dimension", - "D", - "sum_layout_dimensions", - "max_layout_dimensions", - "AnyDimension", - "to_dimension", - "is_dimension", -] - -if TYPE_CHECKING: - from typing_extensions import TypeGuard - - -class Dimension: - """ - 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 with 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: Optional[int] = None, - max: Optional[int] = None, - weight: Optional[int] = None, - preferred: Optional[int] = None, - ) -> None: - if weight is not None: - assert weight >= 0 # Also cannot be a float. - - assert min is None or min >= 0 - assert max is None or max >= 0 - assert preferred is None or preferred >= 0 - - self.min_specified = min is not None - self.max_specified = max is not None - self.preferred_specified = preferred is not None - self.weight_specified = weight is not None - - if min is None: - min = 0 # Smallest possible value. - if max is None: # 0-values are allowed, so use "is None" +""" +Layout dimensions are used to give the minimum, maximum and preferred +dimensions for containers and controls. +""" +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union + +__all__ = [ + "Dimension", + "D", + "sum_layout_dimensions", + "max_layout_dimensions", + "AnyDimension", + "to_dimension", + "is_dimension", +] + +if TYPE_CHECKING: + from typing_extensions import TypeGuard + + +class Dimension: + """ + 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 with 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: Optional[int] = None, + max: Optional[int] = None, + weight: Optional[int] = None, + preferred: Optional[int] = None, + ) -> None: + if weight is not None: + assert weight >= 0 # Also cannot be a float. + + assert min is None or min >= 0 + assert max is None or max >= 0 + assert preferred is None or preferred >= 0 + + self.min_specified = min is not None + self.max_specified = max is not None + self.preferred_specified = preferred is not None + self.weight_specified = weight 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 - if weight is None: - weight = 1 - - self.min = min - self.max = max - self.preferred = preferred - self.weight = weight - - # Don't allow situations where max < min. (This would be a bug.) - if max < min: - raise ValueError("Invalid Dimension: max < min.") - - # 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: int) -> "Dimension": - """ - Return a :class:`.Dimension` with an exact size. (min, max and - preferred set to ``amount``). - """ - return cls(min=amount, max=amount, preferred=amount) - - @classmethod - def zero(cls) -> "Dimension": - """ - Create a dimension that represents a zero size. (Used for 'invisible' - controls.) - """ - return cls.exact(amount=0) - - def is_zero(self) -> bool: - "True if this `Dimension` represents a zero size." - return self.preferred == 0 or self.max == 0 - - def __repr__(self) -> str: - fields = [] - if self.min_specified: - fields.append("min=%r" % self.min) - if self.max_specified: - fields.append("max=%r" % self.max) - if self.preferred_specified: - fields.append("preferred=%r" % self.preferred) - if self.weight_specified: - fields.append("weight=%r" % self.weight) - - return "Dimension(%s)" % ", ".join(fields) - - -def sum_layout_dimensions(dimensions: List[Dimension]) -> Dimension: - """ - Sum a list of :class:`.Dimension` instances. - """ - min = sum(d.min for d in dimensions) - max = sum(d.max for d in dimensions) - preferred = sum(d.preferred for d in dimensions) - - return Dimension(min=min, max=max, preferred=preferred) - - -def max_layout_dimensions(dimensions: List[Dimension]) -> Dimension: - """ - Take the maximum of a list of :class:`.Dimension` instances. - Used when we have a HSplit/VSplit, and we want to get the best width/height.) - """ - if not len(dimensions): - return Dimension.zero() - - # If all dimensions are size zero. Return zero. - # (This is important for HSplit/VSplit, to report the right values to their - # parent when all children are invisible.) - if all(d.is_zero() for d in dimensions): - return dimensions[0] - - # Ignore empty dimensions. (They should not reduce the size of others.) - dimensions = [d for d in dimensions if not d.is_zero()] - - if dimensions: - # Take the highest minimum dimension. - min_ = max(d.min for d in dimensions) - - # For the maximum, we would prefer not to go larger than then smallest - # 'max' value, unless other dimensions have a bigger preferred value. - # This seems to work best: - # - We don't want that a widget with a small height in a VSplit would - # shrink other widgets in the split. - # If it doesn't work well enough, then it's up to the UI designer to - # explicitly pass dimensions. - max_ = min(d.max for d in dimensions) - max_ = max(max_, max(d.preferred for d in dimensions)) - - # Make sure that min>=max. In some scenarios, when certain min..max - # ranges don't have any overlap, we can end up in such an impossible - # situation. In that case, give priority to the max value. - # E.g. taking (1..5) and (8..9) would return (8..5). Instead take (8..8). - if min_ > max_: - max_ = min_ - - preferred = max(d.preferred for d in dimensions) - - return Dimension(min=min_, max=max_, preferred=preferred) - else: - return Dimension() - - -# Anything that can be converted to a dimension. -AnyDimension = Union[ - None, # None is a valid dimension that will fit anything. - int, - Dimension, - # Callable[[], 'AnyDimension'] # Recursive definition not supported by mypy. - Callable[[], Any], -] - - -def to_dimension(value: AnyDimension) -> Dimension: - """ - Turn the given object into a `Dimension` object. - """ - if value is None: - return Dimension() - if isinstance(value, int): - return Dimension.exact(value) - if isinstance(value, Dimension): - return value - if callable(value): - return to_dimension(value()) - - raise ValueError("Not an integer or Dimension object.") - - -def is_dimension(value: object) -> "TypeGuard[AnyDimension]": - """ - Test whether the given value could be a valid dimension. - (For usage in an assertion. It's not guaranteed in case of a callable.) - """ - if value is None: - return True - if callable(value): - return True # Assume it's a callable that doesn't take arguments. - if isinstance(value, (int, Dimension)): - return True - return False - - -# Common alias. -D = Dimension - -# For backward-compatibility. -LayoutDimension = Dimension + if preferred is None: + preferred = min + if weight is None: + weight = 1 + + self.min = min + self.max = max + self.preferred = preferred + self.weight = weight + + # Don't allow situations where max < min. (This would be a bug.) + if max < min: + raise ValueError("Invalid Dimension: max < min.") + + # 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: int) -> "Dimension": + """ + Return a :class:`.Dimension` with an exact size. (min, max and + preferred set to ``amount``). + """ + return cls(min=amount, max=amount, preferred=amount) + + @classmethod + def zero(cls) -> "Dimension": + """ + Create a dimension that represents a zero size. (Used for 'invisible' + controls.) + """ + return cls.exact(amount=0) + + def is_zero(self) -> bool: + "True if this `Dimension` represents a zero size." + return self.preferred == 0 or self.max == 0 + + def __repr__(self) -> str: + fields = [] + if self.min_specified: + fields.append("min=%r" % self.min) + if self.max_specified: + fields.append("max=%r" % self.max) + if self.preferred_specified: + fields.append("preferred=%r" % self.preferred) + if self.weight_specified: + fields.append("weight=%r" % self.weight) + + return "Dimension(%s)" % ", ".join(fields) + + +def sum_layout_dimensions(dimensions: List[Dimension]) -> Dimension: + """ + Sum a list of :class:`.Dimension` instances. + """ + min = sum(d.min for d in dimensions) + max = sum(d.max for d in dimensions) + preferred = sum(d.preferred for d in dimensions) + + return Dimension(min=min, max=max, preferred=preferred) + + +def max_layout_dimensions(dimensions: List[Dimension]) -> Dimension: + """ + Take the maximum of a list of :class:`.Dimension` instances. + Used when we have a HSplit/VSplit, and we want to get the best width/height.) + """ + if not len(dimensions): + return Dimension.zero() + + # If all dimensions are size zero. Return zero. + # (This is important for HSplit/VSplit, to report the right values to their + # parent when all children are invisible.) + if all(d.is_zero() for d in dimensions): + return dimensions[0] + + # Ignore empty dimensions. (They should not reduce the size of others.) + dimensions = [d for d in dimensions if not d.is_zero()] + + if dimensions: + # Take the highest minimum dimension. + min_ = max(d.min for d in dimensions) + + # For the maximum, we would prefer not to go larger than then smallest + # 'max' value, unless other dimensions have a bigger preferred value. + # This seems to work best: + # - We don't want that a widget with a small height in a VSplit would + # shrink other widgets in the split. + # If it doesn't work well enough, then it's up to the UI designer to + # explicitly pass dimensions. + max_ = min(d.max for d in dimensions) + max_ = max(max_, max(d.preferred for d in dimensions)) + + # Make sure that min>=max. In some scenarios, when certain min..max + # ranges don't have any overlap, we can end up in such an impossible + # situation. In that case, give priority to the max value. + # E.g. taking (1..5) and (8..9) would return (8..5). Instead take (8..8). + if min_ > max_: + max_ = min_ + + preferred = max(d.preferred for d in dimensions) + + return Dimension(min=min_, max=max_, preferred=preferred) + else: + return Dimension() + + +# Anything that can be converted to a dimension. +AnyDimension = Union[ + None, # None is a valid dimension that will fit anything. + int, + Dimension, + # Callable[[], 'AnyDimension'] # Recursive definition not supported by mypy. + Callable[[], Any], +] + + +def to_dimension(value: AnyDimension) -> Dimension: + """ + Turn the given object into a `Dimension` object. + """ + if value is None: + return Dimension() + if isinstance(value, int): + return Dimension.exact(value) + if isinstance(value, Dimension): + return value + if callable(value): + return to_dimension(value()) + + raise ValueError("Not an integer or Dimension object.") + + +def is_dimension(value: object) -> "TypeGuard[AnyDimension]": + """ + Test whether the given value could be a valid dimension. + (For usage in an assertion. It's not guaranteed in case of a callable.) + """ + if value is None: + return True + if callable(value): + return True # Assume it's a callable that doesn't take arguments. + if isinstance(value, (int, Dimension)): + return True + return False + + +# Common alias. +D = Dimension + +# For backward-compatibility. +LayoutDimension = Dimension diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/dummy.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/dummy.py index dcd54e9fc9..0ff8195cff 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/dummy.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/dummy.py @@ -1,37 +1,37 @@ -""" -Dummy layout. Used when somebody creates an `Application` without specifying a -`Layout`. -""" -from prompt_toolkit.formatted_text import HTML -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.key_binding.key_processor import KeyPressEvent - -from .containers import Window -from .controls import FormattedTextControl -from .dimension import D -from .layout import Layout - -__all__ = [ - "create_dummy_layout", -] - -E = KeyPressEvent - - -def create_dummy_layout() -> Layout: - """ - Create a dummy layout for use in an 'Application' that doesn't have a - layout specified. When ENTER is pressed, the application quits. - """ - kb = KeyBindings() - - @kb.add("enter") - def enter(event: E) -> None: - event.app.exit() - - control = FormattedTextControl( - HTML("No layout specified. Press <reverse>ENTER</reverse> to quit."), - key_bindings=kb, - ) - window = Window(content=control, height=D(min=1)) - return Layout(container=window, focused_element=window) +""" +Dummy layout. Used when somebody creates an `Application` without specifying a +`Layout`. +""" +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent + +from .containers import Window +from .controls import FormattedTextControl +from .dimension import D +from .layout import Layout + +__all__ = [ + "create_dummy_layout", +] + +E = KeyPressEvent + + +def create_dummy_layout() -> Layout: + """ + Create a dummy layout for use in an 'Application' that doesn't have a + layout specified. When ENTER is pressed, the application quits. + """ + kb = KeyBindings() + + @kb.add("enter") + def enter(event: E) -> None: + event.app.exit() + + control = FormattedTextControl( + HTML("No layout specified. Press <reverse>ENTER</reverse> to quit."), + key_bindings=kb, + ) + window = Window(content=control, height=D(min=1)) + return Layout(container=window, focused_element=window) diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/layout.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/layout.py index 28bfcb3cac..6a0acd859e 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/layout.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/layout.py @@ -1,417 +1,417 @@ -""" -Wrapper for the layout. -""" -from typing import Dict, Generator, Iterable, List, Optional, Union - -from prompt_toolkit.buffer import Buffer - -from .containers import ( - AnyContainer, - ConditionalContainer, - Container, - Window, - to_container, -) -from .controls import BufferControl, SearchBufferControl, UIControl - -__all__ = [ - "Layout", - "InvalidLayoutError", - "walk", -] - -FocusableElement = Union[str, Buffer, UIControl, AnyContainer] - - -class Layout: - """ - The layout for a prompt_toolkit - :class:`~prompt_toolkit.application.Application`. - This also keeps track of which user control is focused. - - :param container: The "root" container for the layout. - :param focused_element: element to be focused initially. (Can be anything - the `focus` function accepts.) - """ - - def __init__( - self, - container: AnyContainer, - focused_element: Optional[FocusableElement] = None, - ) -> None: - - self.container = to_container(container) - self._stack: List[Window] = [] - - # Map search BufferControl back to the original BufferControl. - # This is used to keep track of when exactly we are searching, and for - # applying the search. - # When a link exists in this dictionary, that means the search is - # currently active. - # Map: search_buffer_control -> original buffer control. - self.search_links: Dict[SearchBufferControl, BufferControl] = {} - - # Mapping that maps the children in the layout to their parent. - # This relationship is calculated dynamically, each time when the UI - # is rendered. (UI elements have only references to their children.) - self._child_to_parent: Dict[Container, Container] = {} - - if focused_element is None: - try: - self._stack.append(next(self.find_all_windows())) - except StopIteration as e: - raise InvalidLayoutError( - "Invalid layout. The layout does not contain any Window object." - ) from e - else: - self.focus(focused_element) - - # List of visible windows. - self.visible_windows: List[Window] = [] # List of `Window` objects. - - def __repr__(self) -> str: - return "Layout(%r, current_window=%r)" % (self.container, self.current_window) - - def find_all_windows(self) -> Generator[Window, None, None]: - """ - Find all the :class:`.UIControl` objects in this layout. - """ - for item in self.walk(): - if isinstance(item, Window): - yield item - - def find_all_controls(self) -> Iterable[UIControl]: - for container in self.find_all_windows(): - yield container.content - - def focus(self, value: FocusableElement) -> None: - """ - Focus the given UI element. - - `value` can be either: - - - a :class:`.UIControl` - - a :class:`.Buffer` instance or the name of a :class:`.Buffer` - - a :class:`.Window` - - Any container object. In this case we will focus the :class:`.Window` - from this container that was focused most recent, or the very first - focusable :class:`.Window` of the container. - """ - # BufferControl by buffer name. - if isinstance(value, str): - for control in self.find_all_controls(): - if isinstance(control, BufferControl) and control.buffer.name == value: - self.focus(control) - return - raise ValueError( - "Couldn't find Buffer in the current layout: %r." % (value,) - ) - - # BufferControl by buffer object. - elif isinstance(value, Buffer): - for control in self.find_all_controls(): - if isinstance(control, BufferControl) and control.buffer == value: - self.focus(control) - return - raise ValueError( - "Couldn't find Buffer in the current layout: %r." % (value,) - ) - - # Focus UIControl. - elif isinstance(value, UIControl): - if value not in self.find_all_controls(): - raise ValueError( - "Invalid value. Container does not appear in the layout." - ) - if not value.is_focusable(): - raise ValueError("Invalid value. UIControl is not focusable.") - - self.current_control = value - - # Otherwise, expecting any Container object. - else: - value = to_container(value) - - if isinstance(value, Window): - # This is a `Window`: focus that. - if value not in self.find_all_windows(): - raise ValueError( - "Invalid value. Window does not appear in the layout: %r" - % (value,) - ) - - self.current_window = value - else: - # Focus a window in this container. - # If we have many windows as part of this container, and some - # of them have been focused before, take the last focused - # item. (This is very useful when the UI is composed of more - # complex sub components.) - windows = [] - for c in walk(value, skip_hidden=True): - if isinstance(c, Window) and c.content.is_focusable(): - windows.append(c) - - # Take the first one that was focused before. - for w in reversed(self._stack): - if w in windows: - self.current_window = w - return - - # None was focused before: take the very first focusable window. - if windows: - self.current_window = windows[0] - return - - raise ValueError( - "Invalid value. Container cannot be focused: %r" % (value,) - ) - - def has_focus(self, value: FocusableElement) -> bool: - """ - Check whether the given control has the focus. - :param value: :class:`.UIControl` or :class:`.Window` instance. - """ - if isinstance(value, str): - if self.current_buffer is None: - return False - return self.current_buffer.name == value - if isinstance(value, Buffer): - return self.current_buffer == value - if isinstance(value, UIControl): - return self.current_control == value - else: - value = to_container(value) - if isinstance(value, Window): - return self.current_window == value - else: - # Check whether this "container" is focused. This is true if - # one of the elements inside is focused. - for element in walk(value): - if element == self.current_window: - return True - return False - - @property - def current_control(self) -> UIControl: - """ - Get the :class:`.UIControl` to currently has the focus. - """ - return self._stack[-1].content - - @current_control.setter - def current_control(self, control: UIControl) -> None: - """ - Set the :class:`.UIControl` to receive the focus. - """ - for window in self.find_all_windows(): - if window.content == control: - self.current_window = window - return - - raise ValueError("Control not found in the user interface.") - - @property - def current_window(self) -> Window: - "Return the :class:`.Window` object that is currently focused." - return self._stack[-1] - - @current_window.setter - def current_window(self, value: Window) -> None: - "Set the :class:`.Window` object to be currently focused." - self._stack.append(value) - - @property - def is_searching(self) -> bool: - "True if we are searching right now." - return self.current_control in self.search_links - - @property - def search_target_buffer_control(self) -> Optional[BufferControl]: - """ - Return the :class:`.BufferControl` in which we are searching or `None`. - """ - # Not every `UIControl` is a `BufferControl`. This only applies to - # `BufferControl`. - control = self.current_control - - if isinstance(control, SearchBufferControl): - return self.search_links.get(control) - else: - return None - - def get_focusable_windows(self) -> Iterable[Window]: - """ - Return all the :class:`.Window` objects which are focusable (in the - 'modal' area). - """ - for w in self.walk_through_modal_area(): - if isinstance(w, Window) and w.content.is_focusable(): - yield w - - def get_visible_focusable_windows(self) -> List[Window]: - """ - Return a list of :class:`.Window` objects that are focusable. - """ - # focusable windows are windows that are visible, but also part of the - # modal container. Make sure to keep the ordering. - visible_windows = self.visible_windows - return [w for w in self.get_focusable_windows() if w in visible_windows] - - @property - def current_buffer(self) -> Optional[Buffer]: - """ - The currently focused :class:`~.Buffer` or `None`. - """ - ui_control = self.current_control - if isinstance(ui_control, BufferControl): - return ui_control.buffer - return None - - def get_buffer_by_name(self, buffer_name: str) -> Optional[Buffer]: - """ - Look in the layout for a buffer with the given name. - Return `None` when nothing was found. - """ - for w in self.walk(): - if isinstance(w, Window) and isinstance(w.content, BufferControl): - if w.content.buffer.name == buffer_name: - return w.content.buffer - return None - - @property - def buffer_has_focus(self) -> bool: - """ - Return `True` if the currently focused control is a - :class:`.BufferControl`. (For instance, used to determine whether the - default key bindings should be active or not.) - """ - ui_control = self.current_control - return isinstance(ui_control, BufferControl) - - @property - def previous_control(self) -> UIControl: - """ - Get the :class:`.UIControl` to previously had the focus. - """ - try: - return self._stack[-2].content - except IndexError: - return self._stack[-1].content - - def focus_last(self) -> None: - """ - Give the focus to the last focused control. - """ - if len(self._stack) > 1: - self._stack = self._stack[:-1] - - def focus_next(self) -> None: - """ - Focus the next visible/focusable Window. - """ - windows = self.get_visible_focusable_windows() - - if len(windows) > 0: - try: - index = windows.index(self.current_window) - except ValueError: - index = 0 - else: - index = (index + 1) % len(windows) - - self.focus(windows[index]) - - def focus_previous(self) -> None: - """ - Focus the previous visible/focusable Window. - """ - windows = self.get_visible_focusable_windows() - - if len(windows) > 0: - try: - index = windows.index(self.current_window) - except ValueError: - index = 0 - else: - index = (index - 1) % len(windows) - - self.focus(windows[index]) - - def walk(self) -> Iterable[Container]: - """ - Walk through all the layout nodes (and their children) and yield them. - """ - for i in walk(self.container): - yield i - - def walk_through_modal_area(self) -> Iterable[Container]: - """ - Walk through all the containers which are in the current 'modal' part - of the layout. - """ - # Go up in the tree, and find the root. (it will be a part of the - # layout, if the focus is in a modal part.) - root: Container = self.current_window - while not root.is_modal() and root in self._child_to_parent: - root = self._child_to_parent[root] - - for container in walk(root): - yield container - - def update_parents_relations(self) -> None: - """ - Update child->parent relationships mapping. - """ - parents = {} - - def walk(e: Container) -> None: - for c in e.get_children(): - parents[c] = e - walk(c) - - walk(self.container) - - self._child_to_parent = parents - - def reset(self) -> None: - # Remove all search links when the UI starts. - # (Important, for instance when control-c is been pressed while - # searching. The prompt cancels, but next `run()` call the search - # links are still there.) - self.search_links.clear() - - self.container.reset() - - def get_parent(self, container: Container) -> Optional[Container]: - """ - Return the parent container for the given container, or ``None``, if it - wasn't found. - """ - try: - return self._child_to_parent[container] - except KeyError: - return None - - -class InvalidLayoutError(Exception): - pass - - -def walk(container: Container, skip_hidden: bool = False) -> Iterable[Container]: - """ - Walk through layout, starting at this container. - """ - # When `skip_hidden` is set, don't go into disabled ConditionalContainer containers. - if ( - skip_hidden - and isinstance(container, ConditionalContainer) - and not container.filter() - ): - return - - yield container - - for c in container.get_children(): - # yield from walk(c) - yield from walk(c, skip_hidden=skip_hidden) +""" +Wrapper for the layout. +""" +from typing import Dict, Generator, Iterable, List, Optional, Union + +from prompt_toolkit.buffer import Buffer + +from .containers import ( + AnyContainer, + ConditionalContainer, + Container, + Window, + to_container, +) +from .controls import BufferControl, SearchBufferControl, UIControl + +__all__ = [ + "Layout", + "InvalidLayoutError", + "walk", +] + +FocusableElement = Union[str, Buffer, UIControl, AnyContainer] + + +class Layout: + """ + The layout for a prompt_toolkit + :class:`~prompt_toolkit.application.Application`. + This also keeps track of which user control is focused. + + :param container: The "root" container for the layout. + :param focused_element: element to be focused initially. (Can be anything + the `focus` function accepts.) + """ + + def __init__( + self, + container: AnyContainer, + focused_element: Optional[FocusableElement] = None, + ) -> None: + + self.container = to_container(container) + self._stack: List[Window] = [] + + # Map search BufferControl back to the original BufferControl. + # This is used to keep track of when exactly we are searching, and for + # applying the search. + # When a link exists in this dictionary, that means the search is + # currently active. + # Map: search_buffer_control -> original buffer control. + self.search_links: Dict[SearchBufferControl, BufferControl] = {} + + # Mapping that maps the children in the layout to their parent. + # This relationship is calculated dynamically, each time when the UI + # is rendered. (UI elements have only references to their children.) + self._child_to_parent: Dict[Container, Container] = {} + + if focused_element is None: + try: + self._stack.append(next(self.find_all_windows())) + except StopIteration as e: + raise InvalidLayoutError( + "Invalid layout. The layout does not contain any Window object." + ) from e + else: + self.focus(focused_element) + + # List of visible windows. + self.visible_windows: List[Window] = [] # List of `Window` objects. + + def __repr__(self) -> str: + return "Layout(%r, current_window=%r)" % (self.container, self.current_window) + + def find_all_windows(self) -> Generator[Window, None, None]: + """ + Find all the :class:`.UIControl` objects in this layout. + """ + for item in self.walk(): + if isinstance(item, Window): + yield item + + def find_all_controls(self) -> Iterable[UIControl]: + for container in self.find_all_windows(): + yield container.content + + def focus(self, value: FocusableElement) -> None: + """ + Focus the given UI element. + + `value` can be either: + + - a :class:`.UIControl` + - a :class:`.Buffer` instance or the name of a :class:`.Buffer` + - a :class:`.Window` + - Any container object. In this case we will focus the :class:`.Window` + from this container that was focused most recent, or the very first + focusable :class:`.Window` of the container. + """ + # BufferControl by buffer name. + if isinstance(value, str): + for control in self.find_all_controls(): + if isinstance(control, BufferControl) and control.buffer.name == value: + self.focus(control) + return + raise ValueError( + "Couldn't find Buffer in the current layout: %r." % (value,) + ) + + # BufferControl by buffer object. + elif isinstance(value, Buffer): + for control in self.find_all_controls(): + if isinstance(control, BufferControl) and control.buffer == value: + self.focus(control) + return + raise ValueError( + "Couldn't find Buffer in the current layout: %r." % (value,) + ) + + # Focus UIControl. + elif isinstance(value, UIControl): + if value not in self.find_all_controls(): + raise ValueError( + "Invalid value. Container does not appear in the layout." + ) + if not value.is_focusable(): + raise ValueError("Invalid value. UIControl is not focusable.") + + self.current_control = value + + # Otherwise, expecting any Container object. + else: + value = to_container(value) + + if isinstance(value, Window): + # This is a `Window`: focus that. + if value not in self.find_all_windows(): + raise ValueError( + "Invalid value. Window does not appear in the layout: %r" + % (value,) + ) + + self.current_window = value + else: + # Focus a window in this container. + # If we have many windows as part of this container, and some + # of them have been focused before, take the last focused + # item. (This is very useful when the UI is composed of more + # complex sub components.) + windows = [] + for c in walk(value, skip_hidden=True): + if isinstance(c, Window) and c.content.is_focusable(): + windows.append(c) + + # Take the first one that was focused before. + for w in reversed(self._stack): + if w in windows: + self.current_window = w + return + + # None was focused before: take the very first focusable window. + if windows: + self.current_window = windows[0] + return + + raise ValueError( + "Invalid value. Container cannot be focused: %r" % (value,) + ) + + def has_focus(self, value: FocusableElement) -> bool: + """ + Check whether the given control has the focus. + :param value: :class:`.UIControl` or :class:`.Window` instance. + """ + if isinstance(value, str): + if self.current_buffer is None: + return False + return self.current_buffer.name == value + if isinstance(value, Buffer): + return self.current_buffer == value + if isinstance(value, UIControl): + return self.current_control == value + else: + value = to_container(value) + if isinstance(value, Window): + return self.current_window == value + else: + # Check whether this "container" is focused. This is true if + # one of the elements inside is focused. + for element in walk(value): + if element == self.current_window: + return True + return False + + @property + def current_control(self) -> UIControl: + """ + Get the :class:`.UIControl` to currently has the focus. + """ + return self._stack[-1].content + + @current_control.setter + def current_control(self, control: UIControl) -> None: + """ + Set the :class:`.UIControl` to receive the focus. + """ + for window in self.find_all_windows(): + if window.content == control: + self.current_window = window + return + + raise ValueError("Control not found in the user interface.") + + @property + def current_window(self) -> Window: + "Return the :class:`.Window` object that is currently focused." + return self._stack[-1] + + @current_window.setter + def current_window(self, value: Window) -> None: + "Set the :class:`.Window` object to be currently focused." + self._stack.append(value) + + @property + def is_searching(self) -> bool: + "True if we are searching right now." + return self.current_control in self.search_links + + @property + def search_target_buffer_control(self) -> Optional[BufferControl]: + """ + Return the :class:`.BufferControl` in which we are searching or `None`. + """ + # Not every `UIControl` is a `BufferControl`. This only applies to + # `BufferControl`. + control = self.current_control + + if isinstance(control, SearchBufferControl): + return self.search_links.get(control) + else: + return None + + def get_focusable_windows(self) -> Iterable[Window]: + """ + Return all the :class:`.Window` objects which are focusable (in the + 'modal' area). + """ + for w in self.walk_through_modal_area(): + if isinstance(w, Window) and w.content.is_focusable(): + yield w + + def get_visible_focusable_windows(self) -> List[Window]: + """ + Return a list of :class:`.Window` objects that are focusable. + """ + # focusable windows are windows that are visible, but also part of the + # modal container. Make sure to keep the ordering. + visible_windows = self.visible_windows + return [w for w in self.get_focusable_windows() if w in visible_windows] + + @property + def current_buffer(self) -> Optional[Buffer]: + """ + The currently focused :class:`~.Buffer` or `None`. + """ + ui_control = self.current_control + if isinstance(ui_control, BufferControl): + return ui_control.buffer + return None + + def get_buffer_by_name(self, buffer_name: str) -> Optional[Buffer]: + """ + Look in the layout for a buffer with the given name. + Return `None` when nothing was found. + """ + for w in self.walk(): + if isinstance(w, Window) and isinstance(w.content, BufferControl): + if w.content.buffer.name == buffer_name: + return w.content.buffer + return None + + @property + def buffer_has_focus(self) -> bool: + """ + Return `True` if the currently focused control is a + :class:`.BufferControl`. (For instance, used to determine whether the + default key bindings should be active or not.) + """ + ui_control = self.current_control + return isinstance(ui_control, BufferControl) + + @property + def previous_control(self) -> UIControl: + """ + Get the :class:`.UIControl` to previously had the focus. + """ + try: + return self._stack[-2].content + except IndexError: + return self._stack[-1].content + + def focus_last(self) -> None: + """ + Give the focus to the last focused control. + """ + if len(self._stack) > 1: + self._stack = self._stack[:-1] + + def focus_next(self) -> None: + """ + Focus the next visible/focusable Window. + """ + windows = self.get_visible_focusable_windows() + + if len(windows) > 0: + try: + index = windows.index(self.current_window) + except ValueError: + index = 0 + else: + index = (index + 1) % len(windows) + + self.focus(windows[index]) + + def focus_previous(self) -> None: + """ + Focus the previous visible/focusable Window. + """ + windows = self.get_visible_focusable_windows() + + if len(windows) > 0: + try: + index = windows.index(self.current_window) + except ValueError: + index = 0 + else: + index = (index - 1) % len(windows) + + self.focus(windows[index]) + + def walk(self) -> Iterable[Container]: + """ + Walk through all the layout nodes (and their children) and yield them. + """ + for i in walk(self.container): + yield i + + def walk_through_modal_area(self) -> Iterable[Container]: + """ + Walk through all the containers which are in the current 'modal' part + of the layout. + """ + # Go up in the tree, and find the root. (it will be a part of the + # layout, if the focus is in a modal part.) + root: Container = self.current_window + while not root.is_modal() and root in self._child_to_parent: + root = self._child_to_parent[root] + + for container in walk(root): + yield container + + def update_parents_relations(self) -> None: + """ + Update child->parent relationships mapping. + """ + parents = {} + + def walk(e: Container) -> None: + for c in e.get_children(): + parents[c] = e + walk(c) + + walk(self.container) + + self._child_to_parent = parents + + def reset(self) -> None: + # Remove all search links when the UI starts. + # (Important, for instance when control-c is been pressed while + # searching. The prompt cancels, but next `run()` call the search + # links are still there.) + self.search_links.clear() + + self.container.reset() + + def get_parent(self, container: Container) -> Optional[Container]: + """ + Return the parent container for the given container, or ``None``, if it + wasn't found. + """ + try: + return self._child_to_parent[container] + except KeyError: + return None + + +class InvalidLayoutError(Exception): + pass + + +def walk(container: Container, skip_hidden: bool = False) -> Iterable[Container]: + """ + Walk through layout, starting at this container. + """ + # When `skip_hidden` is set, don't go into disabled ConditionalContainer containers. + if ( + skip_hidden + and isinstance(container, ConditionalContainer) + and not container.filter() + ): + return + + yield container + + for c in container.get_children(): + # yield from walk(c) + yield from walk(c, skip_hidden=skip_hidden) diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/margins.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/margins.py index 7c46819c24..26205232c0 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/margins.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/margins.py @@ -1,305 +1,305 @@ -""" -Margin implementations for a :class:`~prompt_toolkit.layout.containers.Window`. -""" -from abc import ABCMeta, abstractmethod -from typing import TYPE_CHECKING, Callable, Optional - -from prompt_toolkit.filters import FilterOrBool, to_filter -from prompt_toolkit.formatted_text import ( - StyleAndTextTuples, - fragment_list_to_text, - to_formatted_text, -) -from prompt_toolkit.utils import get_cwidth - -from .controls import UIContent - -if TYPE_CHECKING: - from .containers import WindowRenderInfo - -__all__ = [ - "Margin", - "NumberedMargin", - "ScrollbarMargin", - "ConditionalMargin", - "PromptMargin", -] - - -class Margin(metaclass=ABCMeta): - """ - Base interface for a margin. - """ - - @abstractmethod - def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: - """ - Return the width that this margin is going to consume. - - :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, window_render_info: "WindowRenderInfo", width: int, height: int - ) -> StyleAndTextTuples: - """ - Creates a margin. - This should return a list of (style_str, text) tuples. - - :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 NumberedMargin(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: FilterOrBool = False, display_tildes: FilterOrBool = False - ) -> None: - - self.relative = to_filter(relative) - self.display_tildes = to_filter(display_tildes) - - def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: - line_count = get_ui_content().line_count - return max(3, len("%s" % line_count) + 1) - - def create_margin( - self, window_render_info: "WindowRenderInfo", width: int, height: int - ) -> StyleAndTextTuples: - relative = self.relative() - - style = "class:line-number" - style_current = "class:line-number.current" - - # Get current line number. - current_lineno = window_render_info.ui_content.cursor_position.y - - # Construct margin. - result: StyleAndTextTuples = [] - 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((style_current, "%i" % (lineno + 1))) - else: - result.append( - (style_current, ("%i " % (lineno + 1)).rjust(width)) - ) - else: - # Other lines. - if relative: - lineno = abs(lineno - current_lineno) - 1 - - result.append((style, ("%i " % (lineno + 1)).rjust(width))) - - last_lineno = lineno - result.append(("", "\n")) - - # Fill with tildes. - if self.display_tildes(): - while y < window_render_info.window_height: - result.append(("class:tilde", "~\n")) - y += 1 - - return result - - -class ConditionalMargin(Margin): - """ - Wrapper around other :class:`.Margin` classes to show/hide them. - """ - - def __init__(self, margin: Margin, filter: FilterOrBool) -> None: - self.margin = margin - self.filter = to_filter(filter) - - def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: - if self.filter(): - return self.margin.get_width(get_ui_content) - else: - return 0 - - def create_margin( - self, window_render_info: "WindowRenderInfo", width: int, height: int - ) -> StyleAndTextTuples: - if width and self.filter(): - return self.margin.create_margin(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: FilterOrBool = False, - up_arrow_symbol: str = "^", - down_arrow_symbol: str = "v", - ) -> None: - - self.display_arrows = to_filter(display_arrows) - self.up_arrow_symbol = up_arrow_symbol - self.down_arrow_symbol = down_arrow_symbol - - def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: - return 1 - - def create_margin( - self, window_render_info: "WindowRenderInfo", width: int, height: int - ) -> StyleAndTextTuples: - content_height = window_render_info.content_height - window_height = window_render_info.window_height - display_arrows = self.display_arrows() - - if display_arrows: - window_height -= 2 - - try: - fraction_visible = len(window_render_info.displayed_lines) / float( - content_height - ) - fraction_above = window_render_info.vertical_scroll / float(content_height) - - scrollbar_height = int( - min(window_height, max(1, window_height * fraction_visible)) - ) - scrollbar_top = int(window_height * fraction_above) - except ZeroDivisionError: - return [] - else: - - def is_scroll_button(row: int) -> bool: - "True if we should display a button on this row." - return scrollbar_top <= row <= scrollbar_top + scrollbar_height - - # Up arrow. - result: StyleAndTextTuples = [] - if display_arrows: - result.extend( - [ - ("class:scrollbar.arrow", self.up_arrow_symbol), - ("class:scrollbar", "\n"), - ] - ) - - # Scrollbar body. - scrollbar_background = "class:scrollbar.background" - scrollbar_background_start = "class:scrollbar.background,scrollbar.start" - scrollbar_button = "class:scrollbar.button" - scrollbar_button_end = "class:scrollbar.button,scrollbar.end" - - for i in range(window_height): - if is_scroll_button(i): - if not is_scroll_button(i + 1): - # Give the last cell a different style, because we - # want to underline this. - result.append((scrollbar_button_end, " ")) - else: - result.append((scrollbar_button, " ")) - else: - if is_scroll_button(i + 1): - result.append((scrollbar_background_start, " ")) - else: - result.append((scrollbar_background, " ")) - result.append(("", "\n")) - - # Down arrow - if display_arrows: - result.append(("class:scrollbar.arrow", self.down_arrow_symbol)) - - return result - - -class PromptMargin(Margin): - """ - [Deprecated] - - 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. - - This `PromptMargin` implementation has been largely superseded in favor of - the `get_line_prefix` attribute of `Window`. The reason is that a margin is - always a fixed width, while `get_line_prefix` can return a variable width - prefix in front of every line, making it more powerful, especially for line - continuations. - - :param get_prompt: Callable returns formatted text or a list of - `(style_str, type)` tuples to be shown as the prompt at the first line. - :param get_continuation: Callable that takes three inputs. The width (int), - line_number (int), and is_soft_wrap (bool). It should return formatted - text or a list of `(style_str, type)` tuples for the next lines of the - input. - """ - - def __init__( - self, - get_prompt: Callable[[], StyleAndTextTuples], - get_continuation: Optional[ - Callable[[int, int, bool], StyleAndTextTuples] - ] = None, - ) -> None: - - self.get_prompt = get_prompt - self.get_continuation = get_continuation - - def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: - "Width to report to the `Window`." - # Take the width from the first line. - text = fragment_list_to_text(self.get_prompt()) - return get_cwidth(text) - - def create_margin( - self, window_render_info: "WindowRenderInfo", width: int, height: int - ) -> StyleAndTextTuples: - get_continuation = self.get_continuation - result: StyleAndTextTuples = [] - - # First line. - result.extend(to_formatted_text(self.get_prompt())) - - # Next lines. - if get_continuation: - last_y = None - - for y in window_render_info.displayed_lines[1:]: - result.append(("", "\n")) - result.extend( - to_formatted_text(get_continuation(width, y, y == last_y)) - ) - last_y = y - - return result +""" +Margin implementations for a :class:`~prompt_toolkit.layout.containers.Window`. +""" +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable, Optional + +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.formatted_text import ( + StyleAndTextTuples, + fragment_list_to_text, + to_formatted_text, +) +from prompt_toolkit.utils import get_cwidth + +from .controls import UIContent + +if TYPE_CHECKING: + from .containers import WindowRenderInfo + +__all__ = [ + "Margin", + "NumberedMargin", + "ScrollbarMargin", + "ConditionalMargin", + "PromptMargin", +] + + +class Margin(metaclass=ABCMeta): + """ + Base interface for a margin. + """ + + @abstractmethod + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + """ + Return the width that this margin is going to consume. + + :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, window_render_info: "WindowRenderInfo", width: int, height: int + ) -> StyleAndTextTuples: + """ + Creates a margin. + This should return a list of (style_str, text) tuples. + + :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 NumberedMargin(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: FilterOrBool = False, display_tildes: FilterOrBool = False + ) -> None: + + self.relative = to_filter(relative) + self.display_tildes = to_filter(display_tildes) + + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + line_count = get_ui_content().line_count + return max(3, len("%s" % line_count) + 1) + + def create_margin( + self, window_render_info: "WindowRenderInfo", width: int, height: int + ) -> StyleAndTextTuples: + relative = self.relative() + + style = "class:line-number" + style_current = "class:line-number.current" + + # Get current line number. + current_lineno = window_render_info.ui_content.cursor_position.y + + # Construct margin. + result: StyleAndTextTuples = [] + 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((style_current, "%i" % (lineno + 1))) + else: + result.append( + (style_current, ("%i " % (lineno + 1)).rjust(width)) + ) + else: + # Other lines. + if relative: + lineno = abs(lineno - current_lineno) - 1 + + result.append((style, ("%i " % (lineno + 1)).rjust(width))) + + last_lineno = lineno + result.append(("", "\n")) + + # Fill with tildes. + if self.display_tildes(): + while y < window_render_info.window_height: + result.append(("class:tilde", "~\n")) + y += 1 + + return result + + +class ConditionalMargin(Margin): + """ + Wrapper around other :class:`.Margin` classes to show/hide them. + """ + + def __init__(self, margin: Margin, filter: FilterOrBool) -> None: + self.margin = margin + self.filter = to_filter(filter) + + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + if self.filter(): + return self.margin.get_width(get_ui_content) + else: + return 0 + + def create_margin( + self, window_render_info: "WindowRenderInfo", width: int, height: int + ) -> StyleAndTextTuples: + if width and self.filter(): + return self.margin.create_margin(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: FilterOrBool = False, + up_arrow_symbol: str = "^", + down_arrow_symbol: str = "v", + ) -> None: + + self.display_arrows = to_filter(display_arrows) + self.up_arrow_symbol = up_arrow_symbol + self.down_arrow_symbol = down_arrow_symbol + + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + return 1 + + def create_margin( + self, window_render_info: "WindowRenderInfo", width: int, height: int + ) -> StyleAndTextTuples: + content_height = window_render_info.content_height + window_height = window_render_info.window_height + display_arrows = self.display_arrows() + + if display_arrows: + window_height -= 2 + + try: + fraction_visible = len(window_render_info.displayed_lines) / float( + content_height + ) + fraction_above = window_render_info.vertical_scroll / float(content_height) + + scrollbar_height = int( + min(window_height, max(1, window_height * fraction_visible)) + ) + scrollbar_top = int(window_height * fraction_above) + except ZeroDivisionError: + return [] + else: + + def is_scroll_button(row: int) -> bool: + "True if we should display a button on this row." + return scrollbar_top <= row <= scrollbar_top + scrollbar_height + + # Up arrow. + result: StyleAndTextTuples = [] + if display_arrows: + result.extend( + [ + ("class:scrollbar.arrow", self.up_arrow_symbol), + ("class:scrollbar", "\n"), + ] + ) + + # Scrollbar body. + scrollbar_background = "class:scrollbar.background" + scrollbar_background_start = "class:scrollbar.background,scrollbar.start" + scrollbar_button = "class:scrollbar.button" + scrollbar_button_end = "class:scrollbar.button,scrollbar.end" + + for i in range(window_height): + if is_scroll_button(i): + if not is_scroll_button(i + 1): + # Give the last cell a different style, because we + # want to underline this. + result.append((scrollbar_button_end, " ")) + else: + result.append((scrollbar_button, " ")) + else: + if is_scroll_button(i + 1): + result.append((scrollbar_background_start, " ")) + else: + result.append((scrollbar_background, " ")) + result.append(("", "\n")) + + # Down arrow + if display_arrows: + result.append(("class:scrollbar.arrow", self.down_arrow_symbol)) + + return result + + +class PromptMargin(Margin): + """ + [Deprecated] + + 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. + + This `PromptMargin` implementation has been largely superseded in favor of + the `get_line_prefix` attribute of `Window`. The reason is that a margin is + always a fixed width, while `get_line_prefix` can return a variable width + prefix in front of every line, making it more powerful, especially for line + continuations. + + :param get_prompt: Callable returns formatted text or a list of + `(style_str, type)` tuples to be shown as the prompt at the first line. + :param get_continuation: Callable that takes three inputs. The width (int), + line_number (int), and is_soft_wrap (bool). It should return formatted + text or a list of `(style_str, type)` tuples for the next lines of the + input. + """ + + def __init__( + self, + get_prompt: Callable[[], StyleAndTextTuples], + get_continuation: Optional[ + Callable[[int, int, bool], StyleAndTextTuples] + ] = None, + ) -> None: + + self.get_prompt = get_prompt + self.get_continuation = get_continuation + + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + "Width to report to the `Window`." + # Take the width from the first line. + text = fragment_list_to_text(self.get_prompt()) + return get_cwidth(text) + + def create_margin( + self, window_render_info: "WindowRenderInfo", width: int, height: int + ) -> StyleAndTextTuples: + get_continuation = self.get_continuation + result: StyleAndTextTuples = [] + + # First line. + result.extend(to_formatted_text(self.get_prompt())) + + # Next lines. + if get_continuation: + last_y = None + + for y in window_render_info.displayed_lines[1:]: + result.append(("", "\n")) + result.extend( + to_formatted_text(get_continuation(width, y, y == last_y)) + ) + last_y = y + + return result diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/menus.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/menus.py index 557450c000..8998f5ed1d 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/menus.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/menus.py @@ -1,722 +1,722 @@ -import math -from itertools import zip_longest -from typing import ( - TYPE_CHECKING, - Callable, - Dict, - Iterable, - List, - Optional, - Tuple, - TypeVar, - Union, - cast, -) - -from prompt_toolkit.application.current import get_app -from prompt_toolkit.buffer import CompletionState -from prompt_toolkit.completion import Completion -from prompt_toolkit.data_structures import Point -from prompt_toolkit.filters import ( - Condition, - FilterOrBool, - has_completions, - is_done, - to_filter, -) -from prompt_toolkit.formatted_text import ( - StyleAndTextTuples, - fragment_list_width, - to_formatted_text, -) -from prompt_toolkit.key_binding.key_processor import KeyPressEvent -from prompt_toolkit.layout.utils import explode_text_fragments -from prompt_toolkit.mouse_events import MouseEvent, MouseEventType -from prompt_toolkit.utils import get_cwidth - -from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window -from .controls import GetLinePrefixCallable, UIContent, UIControl -from .dimension import Dimension -from .margins import ScrollbarMargin - -if TYPE_CHECKING: - from prompt_toolkit.key_binding.key_bindings import ( - KeyBindings, - NotImplementedOrNone, - ) - - -__all__ = [ - "CompletionsMenu", - "MultiColumnCompletionsMenu", -] - -E = KeyPressEvent - - -class CompletionsMenuControl(UIControl): - """ - Helper for drawing the complete menu to the screen. - - :param scroll_offset: Number (integer) representing the preferred amount of - completions to be displayed before and after the current one. When this - is a very high number, the current completion will be shown in the - middle most of the time. - """ - - # Preferred minimum size of the menu control. - # The CompletionsMenu class defines a width of 8, and there is a scrollbar - # of 1.) - MIN_WIDTH = 7 - - def has_focus(self) -> bool: - return False - - def preferred_width(self, max_available_width: int) -> Optional[int]: - complete_state = get_app().current_buffer.complete_state - if complete_state: - menu_width = self._get_menu_width(500, complete_state) - menu_meta_width = self._get_menu_meta_width(500, complete_state) - - return menu_width + menu_meta_width - else: - return 0 - - def preferred_height( - self, - width: int, - max_available_height: int, - wrap_lines: bool, - get_line_prefix: Optional[GetLinePrefixCallable], - ) -> Optional[int]: - - complete_state = get_app().current_buffer.complete_state - if complete_state: - return len(complete_state.completions) - else: - return 0 - - def create_content(self, width: int, height: int) -> UIContent: - """ - Create a UIContent object for this control. - """ - complete_state = get_app().current_buffer.complete_state - if complete_state: - completions = complete_state.completions - index = complete_state.complete_index # Can be None! - - # Calculate width of completions menu. - menu_width = self._get_menu_width(width, complete_state) - menu_meta_width = self._get_menu_meta_width( - width - menu_width, complete_state - ) - show_meta = self._show_meta(complete_state) - - def get_line(i: int) -> StyleAndTextTuples: - c = completions[i] - is_current_completion = i == index - result = _get_menu_item_fragments( - c, is_current_completion, menu_width, space_after=True - ) - - if show_meta: - result += self._get_menu_item_meta_fragments( - c, is_current_completion, menu_meta_width - ) - return result - - return UIContent( - get_line=get_line, - cursor_position=Point(x=0, y=index or 0), - line_count=len(completions), - ) - - return UIContent() - - def _show_meta(self, complete_state: CompletionState) -> bool: - """ - Return ``True`` if we need to show a column with meta information. - """ - return any(c.display_meta_text for c in complete_state.completions) - - def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int: - """ - Return the width of the main column. - """ - return min( - max_width, - max( - self.MIN_WIDTH, - max(get_cwidth(c.display_text) for c in complete_state.completions) + 2, - ), - ) - - def _get_menu_meta_width( - self, max_width: int, complete_state: CompletionState - ) -> int: - """ - Return the width of the meta column. - """ - - def meta_width(completion: Completion) -> int: - return get_cwidth(completion.display_meta_text) - - if self._show_meta(complete_state): - return min( - max_width, max(meta_width(c) for c in complete_state.completions) + 2 - ) - else: - return 0 - - def _get_menu_item_meta_fragments( - self, completion: Completion, is_current_completion: bool, width: int - ) -> StyleAndTextTuples: - - if is_current_completion: - style_str = "class:completion-menu.meta.completion.current" - else: - style_str = "class:completion-menu.meta.completion" - - text, tw = _trim_formatted_text(completion.display_meta, width - 2) - padding = " " * (width - 1 - tw) - - return to_formatted_text( - cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)], - style=style_str, - ) - - def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone": - """ - Handle mouse events: clicking and scrolling. - """ - b = get_app().current_buffer - - if mouse_event.event_type == MouseEventType.MOUSE_UP: - # Select completion. - b.go_to_completion(mouse_event.position.y) - b.complete_state = None - - elif mouse_event.event_type == MouseEventType.SCROLL_DOWN: - # Scroll up. - b.complete_next(count=3, disable_wrap_around=True) - - elif mouse_event.event_type == MouseEventType.SCROLL_UP: - # Scroll down. - b.complete_previous(count=3, disable_wrap_around=True) - - return None - - -def _get_menu_item_fragments( - completion: Completion, - is_current_completion: bool, - width: int, - space_after: bool = False, -) -> StyleAndTextTuples: - """ - Get the style/text tuples for a menu item, styled and trimmed to the given - width. - """ - if is_current_completion: - style_str = "class:completion-menu.completion.current %s %s" % ( - completion.style, - completion.selected_style, - ) - else: - style_str = "class:completion-menu.completion " + completion.style - - text, tw = _trim_formatted_text( - completion.display, (width - 2 if space_after else width - 1) - ) - - padding = " " * (width - 1 - tw) - - return to_formatted_text( - cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)], - style=style_str, - ) - - -def _trim_formatted_text( - formatted_text: StyleAndTextTuples, max_width: int -) -> Tuple[StyleAndTextTuples, int]: - """ - Trim the text to `max_width`, append dots when the text is too long. - Returns (text, width) tuple. - """ - width = fragment_list_width(formatted_text) - - # When the text is too wide, trim it. - if width > max_width: - result = [] # Text fragments. - remaining_width = max_width - 3 - - for style_and_ch in explode_text_fragments(formatted_text): - ch_width = get_cwidth(style_and_ch[1]) - - if ch_width <= remaining_width: - result.append(style_and_ch) - remaining_width -= ch_width - else: - break - - result.append(("", "...")) - - return result, max_width - remaining_width - else: - return formatted_text, width - - -class CompletionsMenu(ConditionalContainer): - # NOTE: We use a pretty big z_index by default. Menus are supposed to be - # above anything else. We also want to make sure that the content is - # visible at the point where we draw this menu. - def __init__( - self, - max_height: Optional[int] = None, - scroll_offset: Union[int, Callable[[], int]] = 0, - extra_filter: FilterOrBool = True, - display_arrows: FilterOrBool = False, +import math +from itertools import zip_longest +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + Iterable, + List, + Optional, + Tuple, + TypeVar, + Union, + cast, +) + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.buffer import CompletionState +from prompt_toolkit.completion import Completion +from prompt_toolkit.data_structures import Point +from prompt_toolkit.filters import ( + Condition, + FilterOrBool, + has_completions, + is_done, + to_filter, +) +from prompt_toolkit.formatted_text import ( + StyleAndTextTuples, + fragment_list_width, + to_formatted_text, +) +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.layout.utils import explode_text_fragments +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from prompt_toolkit.utils import get_cwidth + +from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window +from .controls import GetLinePrefixCallable, UIContent, UIControl +from .dimension import Dimension +from .margins import ScrollbarMargin + +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_bindings import ( + KeyBindings, + NotImplementedOrNone, + ) + + +__all__ = [ + "CompletionsMenu", + "MultiColumnCompletionsMenu", +] + +E = KeyPressEvent + + +class CompletionsMenuControl(UIControl): + """ + Helper for drawing the complete menu to the screen. + + :param scroll_offset: Number (integer) representing the preferred amount of + completions to be displayed before and after the current one. When this + is a very high number, the current completion will be shown in the + middle most of the time. + """ + + # Preferred minimum size of the menu control. + # The CompletionsMenu class defines a width of 8, and there is a scrollbar + # of 1.) + MIN_WIDTH = 7 + + def has_focus(self) -> bool: + return False + + def preferred_width(self, max_available_width: int) -> Optional[int]: + complete_state = get_app().current_buffer.complete_state + if complete_state: + menu_width = self._get_menu_width(500, complete_state) + menu_meta_width = self._get_menu_meta_width(500, complete_state) + + return menu_width + menu_meta_width + else: + return 0 + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: Optional[GetLinePrefixCallable], + ) -> Optional[int]: + + complete_state = get_app().current_buffer.complete_state + if complete_state: + return len(complete_state.completions) + else: + return 0 + + def create_content(self, width: int, height: int) -> UIContent: + """ + Create a UIContent object for this control. + """ + complete_state = get_app().current_buffer.complete_state + if complete_state: + completions = complete_state.completions + index = complete_state.complete_index # Can be None! + + # Calculate width of completions menu. + menu_width = self._get_menu_width(width, complete_state) + menu_meta_width = self._get_menu_meta_width( + width - menu_width, complete_state + ) + show_meta = self._show_meta(complete_state) + + def get_line(i: int) -> StyleAndTextTuples: + c = completions[i] + is_current_completion = i == index + result = _get_menu_item_fragments( + c, is_current_completion, menu_width, space_after=True + ) + + if show_meta: + result += self._get_menu_item_meta_fragments( + c, is_current_completion, menu_meta_width + ) + return result + + return UIContent( + get_line=get_line, + cursor_position=Point(x=0, y=index or 0), + line_count=len(completions), + ) + + return UIContent() + + def _show_meta(self, complete_state: CompletionState) -> bool: + """ + Return ``True`` if we need to show a column with meta information. + """ + return any(c.display_meta_text for c in complete_state.completions) + + def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int: + """ + Return the width of the main column. + """ + return min( + max_width, + max( + self.MIN_WIDTH, + max(get_cwidth(c.display_text) for c in complete_state.completions) + 2, + ), + ) + + def _get_menu_meta_width( + self, max_width: int, complete_state: CompletionState + ) -> int: + """ + Return the width of the meta column. + """ + + def meta_width(completion: Completion) -> int: + return get_cwidth(completion.display_meta_text) + + if self._show_meta(complete_state): + return min( + max_width, max(meta_width(c) for c in complete_state.completions) + 2 + ) + else: + return 0 + + def _get_menu_item_meta_fragments( + self, completion: Completion, is_current_completion: bool, width: int + ) -> StyleAndTextTuples: + + if is_current_completion: + style_str = "class:completion-menu.meta.completion.current" + else: + style_str = "class:completion-menu.meta.completion" + + text, tw = _trim_formatted_text(completion.display_meta, width - 2) + padding = " " * (width - 1 - tw) + + return to_formatted_text( + cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)], + style=style_str, + ) + + def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone": + """ + Handle mouse events: clicking and scrolling. + """ + b = get_app().current_buffer + + if mouse_event.event_type == MouseEventType.MOUSE_UP: + # Select completion. + b.go_to_completion(mouse_event.position.y) + b.complete_state = None + + elif mouse_event.event_type == MouseEventType.SCROLL_DOWN: + # Scroll up. + b.complete_next(count=3, disable_wrap_around=True) + + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + # Scroll down. + b.complete_previous(count=3, disable_wrap_around=True) + + return None + + +def _get_menu_item_fragments( + completion: Completion, + is_current_completion: bool, + width: int, + space_after: bool = False, +) -> StyleAndTextTuples: + """ + Get the style/text tuples for a menu item, styled and trimmed to the given + width. + """ + if is_current_completion: + style_str = "class:completion-menu.completion.current %s %s" % ( + completion.style, + completion.selected_style, + ) + else: + style_str = "class:completion-menu.completion " + completion.style + + text, tw = _trim_formatted_text( + completion.display, (width - 2 if space_after else width - 1) + ) + + padding = " " * (width - 1 - tw) + + return to_formatted_text( + cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)], + style=style_str, + ) + + +def _trim_formatted_text( + formatted_text: StyleAndTextTuples, max_width: int +) -> Tuple[StyleAndTextTuples, int]: + """ + Trim the text to `max_width`, append dots when the text is too long. + Returns (text, width) tuple. + """ + width = fragment_list_width(formatted_text) + + # When the text is too wide, trim it. + if width > max_width: + result = [] # Text fragments. + remaining_width = max_width - 3 + + for style_and_ch in explode_text_fragments(formatted_text): + ch_width = get_cwidth(style_and_ch[1]) + + if ch_width <= remaining_width: + result.append(style_and_ch) + remaining_width -= ch_width + else: + break + + result.append(("", "...")) + + return result, max_width - remaining_width + else: + return formatted_text, width + + +class CompletionsMenu(ConditionalContainer): + # NOTE: We use a pretty big z_index by default. Menus are supposed to be + # above anything else. We also want to make sure that the content is + # visible at the point where we draw this menu. + def __init__( + self, + max_height: Optional[int] = None, + scroll_offset: Union[int, Callable[[], int]] = 0, + extra_filter: FilterOrBool = True, + display_arrows: FilterOrBool = False, z_index: int = 10**8, - ) -> None: - - extra_filter = to_filter(extra_filter) - display_arrows = to_filter(display_arrows) - - super().__init__( - content=Window( - content=CompletionsMenuControl(), - width=Dimension(min=8), - height=Dimension(min=1, max=max_height), - scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset), - right_margins=[ScrollbarMargin(display_arrows=display_arrows)], - dont_extend_width=True, - style="class:completion-menu", - z_index=z_index, - ), - # Show when there are completions but not at the point we are - # returning the input. - filter=has_completions & ~is_done & 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 larger than one, it will try to use less columns and more - rows until this value is reached. - Be careful passing in a too big value, if less than the given amount of - rows are available, more columns would have been required, but - `preferred_width` doesn't know about that and reports a too small value. - This results in less completions displayed and additional scrolling. - (It's a limitation of how the layout engine currently works: first the - widths are calculated, then the heights.) - - :param suggested_max_column_width: The suggested max width of a column. - The column can still be bigger than this, but if there is place for two - columns of this width, we will display two columns. This to avoid that - if there is one very wide completion, that it doesn't significantly - reduce the amount of columns. - """ - - _required_margin = 3 # One extra padding on the right + space for arrows. - - def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None: - assert min_rows >= 1 - - self.min_rows = min_rows - self.suggested_max_column_width = suggested_max_column_width - self.scroll = 0 - - # Info of last rendering. - self._rendered_rows = 0 - self._rendered_columns = 0 - self._total_columns = 0 - self._render_pos_to_completion: Dict[Tuple[int, int], Completion] = {} - self._render_left_arrow = False - self._render_right_arrow = False - self._render_width = 0 - - def reset(self) -> None: - self.scroll = 0 - - def has_focus(self) -> bool: - return False - - def preferred_width(self, max_available_width: int) -> Optional[int]: - """ - Preferred width: prefer to use at least min_rows, but otherwise as much - as possible horizontally. - """ - complete_state = get_app().current_buffer.complete_state - if complete_state is None: - return 0 - - column_width = self._get_column_width(complete_state) - result = int( - column_width - * math.ceil(len(complete_state.completions) / float(self.min_rows)) - ) - - # When the desired width is still more than the maximum available, - # reduce by removing columns until we are less than the available - # width. - while ( - result > column_width - and result > max_available_width - self._required_margin - ): - result -= column_width - return result + self._required_margin - - def preferred_height( - self, - width: int, - max_available_height: int, - wrap_lines: bool, - get_line_prefix: Optional[GetLinePrefixCallable], - ) -> Optional[int]: - """ - Preferred height: as much as needed in order to display all the completions. - """ - complete_state = get_app().current_buffer.complete_state - if complete_state is None: - return 0 - - column_width = self._get_column_width(complete_state) - column_count = max(1, (width - self._required_margin) // column_width) - - return int(math.ceil(len(complete_state.completions) / float(column_count))) - - def create_content(self, width: int, height: int) -> UIContent: - """ - Create a UIContent object for this menu. - """ - complete_state = get_app().current_buffer.complete_state - if complete_state is None: - return UIContent() - - column_width = self._get_column_width(complete_state) - self._render_pos_to_completion = {} - - _T = TypeVar("_T") - - def grouper( - n: int, iterable: Iterable[_T], fillvalue: Optional[_T] = None - ) -> Iterable[List[_T]]: - "grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx" - args = [iter(iterable)] * n - return zip_longest(fillvalue=fillvalue, *args) - - def is_current_completion(completion: Completion) -> bool: - "Returns True when this completion is the currently selected one." - return ( - complete_state is not None - and complete_state.complete_index is not None - and c == complete_state.current_completion - ) - - # Space required outside of the regular columns, for displaying the - # left and right arrow. - HORIZONTAL_MARGIN_REQUIRED = 3 - - # There should be at least one column, but it cannot be wider than - # the available width. - column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width) - - # However, when the columns tend to be very wide, because there are - # some very wide entries, shrink it anyway. - if column_width > self.suggested_max_column_width: - # `column_width` can still be bigger that `suggested_max_column_width`, - # but if there is place for two columns, we divide by two. - column_width //= column_width // self.suggested_max_column_width - - visible_columns = max(1, (width - self._required_margin) // column_width) - - columns_ = list(grouper(height, complete_state.completions)) - rows_ = list(zip(*columns_)) - - # Make sure the current completion is always visible: update scroll offset. - selected_column = (complete_state.complete_index or 0) // height - self.scroll = min( - selected_column, max(self.scroll, selected_column - visible_columns + 1) - ) - - render_left_arrow = self.scroll > 0 - render_right_arrow = self.scroll < len(rows_[0]) - visible_columns - - # Write completions to screen. - fragments_for_line = [] - - for row_index, row in enumerate(rows_): - fragments: StyleAndTextTuples = [] - middle_row = row_index == len(rows_) // 2 - - # Draw left arrow if we have hidden completions on the left. - if render_left_arrow: - fragments.append(("class:scrollbar", "<" if middle_row else " ")) - elif render_right_arrow: - # Reserve one column empty space. (If there is a right - # arrow right now, there can be a left arrow as well.) - fragments.append(("", " ")) - - # Draw row content. - for column_index, c in enumerate(row[self.scroll :][:visible_columns]): - if c is not None: - fragments += _get_menu_item_fragments( - c, is_current_completion(c), column_width, space_after=False - ) - - # Remember render position for mouse click handler. - for x in range(column_width): - self._render_pos_to_completion[ - (column_index * column_width + x, row_index) - ] = c - else: - fragments.append(("class:completion", " " * column_width)) - - # Draw trailing padding for this row. - # (_get_menu_item_fragments only returns padding on the left.) - if render_left_arrow or render_right_arrow: - fragments.append(("class:completion", " ")) - - # Draw right arrow if we have hidden completions on the right. - if render_right_arrow: - fragments.append(("class:scrollbar", ">" if middle_row else " ")) - elif render_left_arrow: - fragments.append(("class:completion", " ")) - - # Add line. - fragments_for_line.append( - to_formatted_text(fragments, style="class:completion-menu") - ) - - self._rendered_rows = height - self._rendered_columns = visible_columns - self._total_columns = len(columns_) - self._render_left_arrow = render_left_arrow - self._render_right_arrow = render_right_arrow - self._render_width = ( - column_width * visible_columns + render_left_arrow + render_right_arrow + 1 - ) - - def get_line(i: int) -> StyleAndTextTuples: - return fragments_for_line[i] - - return UIContent(get_line=get_line, line_count=len(rows_)) - - def _get_column_width(self, complete_state: CompletionState) -> int: - """ - Return the width of each column. - """ - return max(get_cwidth(c.display_text) for c in complete_state.completions) + 1 - - def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone": - """ - Handle scroll and click events. - """ - b = get_app().current_buffer - - def scroll_left() -> None: - b.complete_previous(count=self._rendered_rows, disable_wrap_around=True) - self.scroll = max(0, self.scroll - 1) - - def scroll_right() -> None: - b.complete_next(count=self._rendered_rows, disable_wrap_around=True) - self.scroll = min( - self._total_columns - self._rendered_columns, self.scroll + 1 - ) - - if mouse_event.event_type == MouseEventType.SCROLL_DOWN: - scroll_right() - - elif mouse_event.event_type == MouseEventType.SCROLL_UP: - scroll_left() - - elif mouse_event.event_type == MouseEventType.MOUSE_UP: - x = mouse_event.position.x - y = mouse_event.position.y - - # Mouse click on left arrow. - if x == 0: - if self._render_left_arrow: - scroll_left() - - # Mouse click on right arrow. - elif x == self._render_width - 1: - if self._render_right_arrow: - scroll_right() - - # Mouse click on completion. - else: - completion = self._render_pos_to_completion.get((x, y)) - if completion: - b.apply_completion(completion) - - return None - - def get_key_bindings(self) -> "KeyBindings": - """ - Expose key bindings that handle the left/right arrow keys when the menu - is displayed. - """ - from prompt_toolkit.key_binding.key_bindings import KeyBindings - - kb = KeyBindings() - - @Condition - def filter() -> bool: - "Only handle key bindings if this menu is visible." - app = get_app() - complete_state = app.current_buffer.complete_state - - # There need to be completions, and one needs to be selected. - if complete_state is None or complete_state.complete_index is None: - return False - - # This menu needs to be visible. - return any(window.content == self for window in app.layout.visible_windows) - - def move(right: bool = False) -> None: - buff = get_app().current_buffer - complete_state = buff.complete_state - - if complete_state is not None and complete_state.complete_index is not None: - # Calculate new complete index. - new_index = complete_state.complete_index - if right: - new_index += self._rendered_rows - else: - new_index -= self._rendered_rows - - if 0 <= new_index < len(complete_state.completions): - buff.go_to_completion(new_index) - - # NOTE: the is_global is required because the completion menu will - # never be focussed. - - @kb.add("left", is_global=True, filter=filter) - def _left(event: E) -> None: - move() - - @kb.add("right", is_global=True, filter=filter) - def _right(event: E) -> None: - move(True) - - return kb - - -class MultiColumnCompletionsMenu(HSplit): - """ - Container that displays the completions in several columns. - When `show_meta` (a :class:`~prompt_toolkit.filters.Filter`) evaluates - to True, it shows the meta information at the bottom. - """ - - def __init__( - self, - min_rows: int = 3, - suggested_max_column_width: int = 30, - show_meta: FilterOrBool = True, - extra_filter: FilterOrBool = True, + ) -> None: + + extra_filter = to_filter(extra_filter) + display_arrows = to_filter(display_arrows) + + super().__init__( + content=Window( + content=CompletionsMenuControl(), + width=Dimension(min=8), + height=Dimension(min=1, max=max_height), + scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset), + right_margins=[ScrollbarMargin(display_arrows=display_arrows)], + dont_extend_width=True, + style="class:completion-menu", + z_index=z_index, + ), + # Show when there are completions but not at the point we are + # returning the input. + filter=has_completions & ~is_done & 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 larger than one, it will try to use less columns and more + rows until this value is reached. + Be careful passing in a too big value, if less than the given amount of + rows are available, more columns would have been required, but + `preferred_width` doesn't know about that and reports a too small value. + This results in less completions displayed and additional scrolling. + (It's a limitation of how the layout engine currently works: first the + widths are calculated, then the heights.) + + :param suggested_max_column_width: The suggested max width of a column. + The column can still be bigger than this, but if there is place for two + columns of this width, we will display two columns. This to avoid that + if there is one very wide completion, that it doesn't significantly + reduce the amount of columns. + """ + + _required_margin = 3 # One extra padding on the right + space for arrows. + + def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None: + assert min_rows >= 1 + + self.min_rows = min_rows + self.suggested_max_column_width = suggested_max_column_width + self.scroll = 0 + + # Info of last rendering. + self._rendered_rows = 0 + self._rendered_columns = 0 + self._total_columns = 0 + self._render_pos_to_completion: Dict[Tuple[int, int], Completion] = {} + self._render_left_arrow = False + self._render_right_arrow = False + self._render_width = 0 + + def reset(self) -> None: + self.scroll = 0 + + def has_focus(self) -> bool: + return False + + def preferred_width(self, max_available_width: int) -> Optional[int]: + """ + Preferred width: prefer to use at least min_rows, but otherwise as much + as possible horizontally. + """ + complete_state = get_app().current_buffer.complete_state + if complete_state is None: + return 0 + + column_width = self._get_column_width(complete_state) + result = int( + column_width + * math.ceil(len(complete_state.completions) / float(self.min_rows)) + ) + + # When the desired width is still more than the maximum available, + # reduce by removing columns until we are less than the available + # width. + while ( + result > column_width + and result > max_available_width - self._required_margin + ): + result -= column_width + return result + self._required_margin + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: Optional[GetLinePrefixCallable], + ) -> Optional[int]: + """ + Preferred height: as much as needed in order to display all the completions. + """ + complete_state = get_app().current_buffer.complete_state + if complete_state is None: + return 0 + + column_width = self._get_column_width(complete_state) + column_count = max(1, (width - self._required_margin) // column_width) + + return int(math.ceil(len(complete_state.completions) / float(column_count))) + + def create_content(self, width: int, height: int) -> UIContent: + """ + Create a UIContent object for this menu. + """ + complete_state = get_app().current_buffer.complete_state + if complete_state is None: + return UIContent() + + column_width = self._get_column_width(complete_state) + self._render_pos_to_completion = {} + + _T = TypeVar("_T") + + def grouper( + n: int, iterable: Iterable[_T], fillvalue: Optional[_T] = None + ) -> Iterable[List[_T]]: + "grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx" + args = [iter(iterable)] * n + return zip_longest(fillvalue=fillvalue, *args) + + def is_current_completion(completion: Completion) -> bool: + "Returns True when this completion is the currently selected one." + return ( + complete_state is not None + and complete_state.complete_index is not None + and c == complete_state.current_completion + ) + + # Space required outside of the regular columns, for displaying the + # left and right arrow. + HORIZONTAL_MARGIN_REQUIRED = 3 + + # There should be at least one column, but it cannot be wider than + # the available width. + column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width) + + # However, when the columns tend to be very wide, because there are + # some very wide entries, shrink it anyway. + if column_width > self.suggested_max_column_width: + # `column_width` can still be bigger that `suggested_max_column_width`, + # but if there is place for two columns, we divide by two. + column_width //= column_width // self.suggested_max_column_width + + visible_columns = max(1, (width - self._required_margin) // column_width) + + columns_ = list(grouper(height, complete_state.completions)) + rows_ = list(zip(*columns_)) + + # Make sure the current completion is always visible: update scroll offset. + selected_column = (complete_state.complete_index or 0) // height + self.scroll = min( + selected_column, max(self.scroll, selected_column - visible_columns + 1) + ) + + render_left_arrow = self.scroll > 0 + render_right_arrow = self.scroll < len(rows_[0]) - visible_columns + + # Write completions to screen. + fragments_for_line = [] + + for row_index, row in enumerate(rows_): + fragments: StyleAndTextTuples = [] + middle_row = row_index == len(rows_) // 2 + + # Draw left arrow if we have hidden completions on the left. + if render_left_arrow: + fragments.append(("class:scrollbar", "<" if middle_row else " ")) + elif render_right_arrow: + # Reserve one column empty space. (If there is a right + # arrow right now, there can be a left arrow as well.) + fragments.append(("", " ")) + + # Draw row content. + for column_index, c in enumerate(row[self.scroll :][:visible_columns]): + if c is not None: + fragments += _get_menu_item_fragments( + c, is_current_completion(c), column_width, space_after=False + ) + + # Remember render position for mouse click handler. + for x in range(column_width): + self._render_pos_to_completion[ + (column_index * column_width + x, row_index) + ] = c + else: + fragments.append(("class:completion", " " * column_width)) + + # Draw trailing padding for this row. + # (_get_menu_item_fragments only returns padding on the left.) + if render_left_arrow or render_right_arrow: + fragments.append(("class:completion", " ")) + + # Draw right arrow if we have hidden completions on the right. + if render_right_arrow: + fragments.append(("class:scrollbar", ">" if middle_row else " ")) + elif render_left_arrow: + fragments.append(("class:completion", " ")) + + # Add line. + fragments_for_line.append( + to_formatted_text(fragments, style="class:completion-menu") + ) + + self._rendered_rows = height + self._rendered_columns = visible_columns + self._total_columns = len(columns_) + self._render_left_arrow = render_left_arrow + self._render_right_arrow = render_right_arrow + self._render_width = ( + column_width * visible_columns + render_left_arrow + render_right_arrow + 1 + ) + + def get_line(i: int) -> StyleAndTextTuples: + return fragments_for_line[i] + + return UIContent(get_line=get_line, line_count=len(rows_)) + + def _get_column_width(self, complete_state: CompletionState) -> int: + """ + Return the width of each column. + """ + return max(get_cwidth(c.display_text) for c in complete_state.completions) + 1 + + def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone": + """ + Handle scroll and click events. + """ + b = get_app().current_buffer + + def scroll_left() -> None: + b.complete_previous(count=self._rendered_rows, disable_wrap_around=True) + self.scroll = max(0, self.scroll - 1) + + def scroll_right() -> None: + b.complete_next(count=self._rendered_rows, disable_wrap_around=True) + self.scroll = min( + self._total_columns - self._rendered_columns, self.scroll + 1 + ) + + if mouse_event.event_type == MouseEventType.SCROLL_DOWN: + scroll_right() + + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + scroll_left() + + elif mouse_event.event_type == MouseEventType.MOUSE_UP: + x = mouse_event.position.x + y = mouse_event.position.y + + # Mouse click on left arrow. + if x == 0: + if self._render_left_arrow: + scroll_left() + + # Mouse click on right arrow. + elif x == self._render_width - 1: + if self._render_right_arrow: + scroll_right() + + # Mouse click on completion. + else: + completion = self._render_pos_to_completion.get((x, y)) + if completion: + b.apply_completion(completion) + + return None + + def get_key_bindings(self) -> "KeyBindings": + """ + Expose key bindings that handle the left/right arrow keys when the menu + is displayed. + """ + from prompt_toolkit.key_binding.key_bindings import KeyBindings + + kb = KeyBindings() + + @Condition + def filter() -> bool: + "Only handle key bindings if this menu is visible." + app = get_app() + complete_state = app.current_buffer.complete_state + + # There need to be completions, and one needs to be selected. + if complete_state is None or complete_state.complete_index is None: + return False + + # This menu needs to be visible. + return any(window.content == self for window in app.layout.visible_windows) + + def move(right: bool = False) -> None: + buff = get_app().current_buffer + complete_state = buff.complete_state + + if complete_state is not None and complete_state.complete_index is not None: + # Calculate new complete index. + new_index = complete_state.complete_index + if right: + new_index += self._rendered_rows + else: + new_index -= self._rendered_rows + + if 0 <= new_index < len(complete_state.completions): + buff.go_to_completion(new_index) + + # NOTE: the is_global is required because the completion menu will + # never be focussed. + + @kb.add("left", is_global=True, filter=filter) + def _left(event: E) -> None: + move() + + @kb.add("right", is_global=True, filter=filter) + def _right(event: E) -> None: + move(True) + + return kb + + +class MultiColumnCompletionsMenu(HSplit): + """ + Container that displays the completions in several columns. + When `show_meta` (a :class:`~prompt_toolkit.filters.Filter`) evaluates + to True, it shows the meta information at the bottom. + """ + + def __init__( + self, + min_rows: int = 3, + suggested_max_column_width: int = 30, + show_meta: FilterOrBool = True, + extra_filter: FilterOrBool = True, z_index: int = 10**8, - ) -> None: - - show_meta = to_filter(show_meta) - extra_filter = to_filter(extra_filter) - - # Display filter: show when there are completions but not at the point - # we are returning the input. - full_filter = has_completions & ~is_done & extra_filter - - @Condition - def any_completion_has_meta() -> bool: - complete_state = get_app().current_buffer.complete_state - return complete_state is not None and any( - c.display_meta for c in complete_state.completions - ) - - # Create child windows. - # NOTE: We don't set style='class:completion-menu' to the - # `MultiColumnCompletionMenuControl`, because this is used in a - # Float that is made transparent, and the size of the control - # doesn't always correspond exactly with the size of the - # generated content. - completions_window = ConditionalContainer( - content=Window( - content=MultiColumnCompletionMenuControl( - min_rows=min_rows, - suggested_max_column_width=suggested_max_column_width, - ), - width=Dimension(min=8), - height=Dimension(min=1), - ), - filter=full_filter, - ) - - meta_window = ConditionalContainer( - content=Window(content=_SelectedCompletionMetaControl()), - filter=show_meta & full_filter & any_completion_has_meta, - ) - - # Initialise split. - super().__init__([completions_window, meta_window], z_index=z_index) - - -class _SelectedCompletionMetaControl(UIControl): - """ - Control that shows the meta information of the selected completion. - """ - - def preferred_width(self, max_available_width: int) -> Optional[int]: - """ - Report the width of the longest meta text as the preferred width of this control. - - It could be that we use less width, but this way, we're sure that the - layout doesn't change when we select another completion (E.g. that - completions are suddenly shown in more or fewer columns.) - """ - app = get_app() - if app.current_buffer.complete_state: - state = app.current_buffer.complete_state - return 2 + max(get_cwidth(c.display_meta_text) for c in state.completions) - else: - return 0 - - def preferred_height( - self, - width: int, - max_available_height: int, - wrap_lines: bool, - get_line_prefix: Optional[GetLinePrefixCallable], - ) -> Optional[int]: - return 1 - - def create_content(self, width: int, height: int) -> UIContent: - fragments = self._get_text_fragments() - - def get_line(i: int) -> StyleAndTextTuples: - return fragments - - return UIContent(get_line=get_line, line_count=1 if fragments else 0) - - def _get_text_fragments(self) -> StyleAndTextTuples: - style = "class:completion-menu.multi-column-meta" - state = get_app().current_buffer.complete_state - - if ( - state - and state.current_completion - and state.current_completion.display_meta_text - ): - return to_formatted_text( - cast(StyleAndTextTuples, [("", " ")]) - + state.current_completion.display_meta - + [("", " ")], - style=style, - ) - - return [] + ) -> None: + + show_meta = to_filter(show_meta) + extra_filter = to_filter(extra_filter) + + # Display filter: show when there are completions but not at the point + # we are returning the input. + full_filter = has_completions & ~is_done & extra_filter + + @Condition + def any_completion_has_meta() -> bool: + complete_state = get_app().current_buffer.complete_state + return complete_state is not None and any( + c.display_meta for c in complete_state.completions + ) + + # Create child windows. + # NOTE: We don't set style='class:completion-menu' to the + # `MultiColumnCompletionMenuControl`, because this is used in a + # Float that is made transparent, and the size of the control + # doesn't always correspond exactly with the size of the + # generated content. + completions_window = ConditionalContainer( + content=Window( + content=MultiColumnCompletionMenuControl( + min_rows=min_rows, + suggested_max_column_width=suggested_max_column_width, + ), + width=Dimension(min=8), + height=Dimension(min=1), + ), + filter=full_filter, + ) + + meta_window = ConditionalContainer( + content=Window(content=_SelectedCompletionMetaControl()), + filter=show_meta & full_filter & any_completion_has_meta, + ) + + # Initialise split. + super().__init__([completions_window, meta_window], z_index=z_index) + + +class _SelectedCompletionMetaControl(UIControl): + """ + Control that shows the meta information of the selected completion. + """ + + def preferred_width(self, max_available_width: int) -> Optional[int]: + """ + Report the width of the longest meta text as the preferred width of this control. + + It could be that we use less width, but this way, we're sure that the + layout doesn't change when we select another completion (E.g. that + completions are suddenly shown in more or fewer columns.) + """ + app = get_app() + if app.current_buffer.complete_state: + state = app.current_buffer.complete_state + return 2 + max(get_cwidth(c.display_meta_text) for c in state.completions) + else: + return 0 + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: Optional[GetLinePrefixCallable], + ) -> Optional[int]: + return 1 + + def create_content(self, width: int, height: int) -> UIContent: + fragments = self._get_text_fragments() + + def get_line(i: int) -> StyleAndTextTuples: + return fragments + + return UIContent(get_line=get_line, line_count=1 if fragments else 0) + + def _get_text_fragments(self) -> StyleAndTextTuples: + style = "class:completion-menu.multi-column-meta" + state = get_app().current_buffer.complete_state + + if ( + state + and state.current_completion + and state.current_completion.display_meta_text + ): + return to_formatted_text( + cast(StyleAndTextTuples, [("", " ")]) + + state.current_completion.display_meta + + [("", " ")], + style=style, + ) + + return [] diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/mouse_handlers.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/mouse_handlers.py index 256231793a..d1dbeca6a9 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/mouse_handlers.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/mouse_handlers.py @@ -1,54 +1,54 @@ -from collections import defaultdict -from typing import TYPE_CHECKING, Callable, DefaultDict - -from prompt_toolkit.mouse_events import MouseEvent - -if TYPE_CHECKING: - from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone - -__all__ = [ - "MouseHandler", - "MouseHandlers", -] - - -MouseHandler = Callable[[MouseEvent], "NotImplementedOrNone"] - - -class MouseHandlers: - """ - Two dimensional raster of callbacks for mouse events. - """ - - def __init__(self) -> None: - def dummy_callback(mouse_event: MouseEvent) -> "NotImplementedOrNone": - """ - :param mouse_event: `MouseEvent` instance. - """ - return NotImplemented - - # NOTE: Previously, the data structure was a dictionary mapping (x,y) - # to the handlers. This however would be more inefficient when copying - # over the mouse handlers of the visible region in the scrollable pane. - - # Map y (row) to x (column) to handlers. - self.mouse_handlers: DefaultDict[ - int, DefaultDict[int, MouseHandler] - ] = defaultdict(lambda: defaultdict(lambda: dummy_callback)) - - def set_mouse_handler_for_range( - self, - x_min: int, - x_max: int, - y_min: int, - y_max: int, - handler: Callable[[MouseEvent], "NotImplementedOrNone"], - ) -> None: - """ - Set mouse handler for a region. - """ - for y in range(y_min, y_max): - row = self.mouse_handlers[y] - - for x in range(x_min, x_max): - row[x] = handler +from collections import defaultdict +from typing import TYPE_CHECKING, Callable, DefaultDict + +from prompt_toolkit.mouse_events import MouseEvent + +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone + +__all__ = [ + "MouseHandler", + "MouseHandlers", +] + + +MouseHandler = Callable[[MouseEvent], "NotImplementedOrNone"] + + +class MouseHandlers: + """ + Two dimensional raster of callbacks for mouse events. + """ + + def __init__(self) -> None: + def dummy_callback(mouse_event: MouseEvent) -> "NotImplementedOrNone": + """ + :param mouse_event: `MouseEvent` instance. + """ + return NotImplemented + + # NOTE: Previously, the data structure was a dictionary mapping (x,y) + # to the handlers. This however would be more inefficient when copying + # over the mouse handlers of the visible region in the scrollable pane. + + # Map y (row) to x (column) to handlers. + self.mouse_handlers: DefaultDict[ + int, DefaultDict[int, MouseHandler] + ] = defaultdict(lambda: defaultdict(lambda: dummy_callback)) + + def set_mouse_handler_for_range( + self, + x_min: int, + x_max: int, + y_min: int, + y_max: int, + handler: Callable[[MouseEvent], "NotImplementedOrNone"], + ) -> None: + """ + Set mouse handler for a region. + """ + for y in range(y_min, y_max): + row = self.mouse_handlers[y] + + for x in range(x_min, x_max): + row[x] = handler diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/processors.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/processors.py index 571e952971..d69af5d0d4 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/processors.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/processors.py @@ -1,1029 +1,1029 @@ -""" -Processors are little transformation blocks that transform the fragments list -from a buffer before the BufferControl will render it to the screen. - -They can insert fragments before or after, or highlight fragments by replacing the -fragment types. -""" -import re -from abc import ABCMeta, abstractmethod -from typing import ( - TYPE_CHECKING, - Callable, - Hashable, - List, - Optional, - Tuple, - Type, - Union, - cast, -) - -from prompt_toolkit.application.current import get_app -from prompt_toolkit.cache import SimpleCache -from prompt_toolkit.document import Document -from prompt_toolkit.filters import FilterOrBool, to_filter, vi_insert_multiple_mode -from prompt_toolkit.formatted_text import ( - AnyFormattedText, - StyleAndTextTuples, - to_formatted_text, -) -from prompt_toolkit.formatted_text.utils import fragment_list_len, fragment_list_to_text -from prompt_toolkit.search import SearchDirection -from prompt_toolkit.utils import to_int, to_str - -from .utils import explode_text_fragments - -if TYPE_CHECKING: - from .controls import BufferControl, UIContent - -__all__ = [ - "Processor", - "TransformationInput", - "Transformation", - "DummyProcessor", - "HighlightSearchProcessor", - "HighlightIncrementalSearchProcessor", - "HighlightSelectionProcessor", - "PasswordProcessor", - "HighlightMatchingBracketProcessor", - "DisplayMultipleCursors", - "BeforeInput", - "ShowArg", - "AfterInput", - "AppendAutoSuggestion", - "ConditionalProcessor", - "ShowLeadingWhiteSpaceProcessor", - "ShowTrailingWhiteSpaceProcessor", - "TabsProcessor", - "ReverseSearchProcessor", - "DynamicProcessor", - "merge_processors", -] - - -class Processor(metaclass=ABCMeta): - """ - Manipulate the fragments for a given line in a - :class:`~prompt_toolkit.layout.controls.BufferControl`. - """ - - @abstractmethod - def apply_transformation( - self, transformation_input: "TransformationInput" - ) -> "Transformation": - """ - Apply transformation. Returns a :class:`.Transformation` instance. - - :param transformation_input: :class:`.TransformationInput` object. - """ - return Transformation(transformation_input.fragments) - - -SourceToDisplay = Callable[[int], int] -DisplayToSource = Callable[[int], int] - - -class TransformationInput: - """ - :param buffer_control: :class:`.BufferControl` 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 - `fragments` for any position in the source string. (This takes - previous processors into account.) - :param fragments: List of fragments that we can transform. (Received from the - previous processor.) - """ - - def __init__( - self, - buffer_control: "BufferControl", - document: Document, - lineno: int, - source_to_display: SourceToDisplay, - fragments: StyleAndTextTuples, - width: int, - height: int, - ) -> None: - - self.buffer_control = buffer_control - self.document = document - self.lineno = lineno - self.source_to_display = source_to_display - self.fragments = fragments - self.width = width - self.height = height - - def unpack( - self, - ) -> Tuple[ - "BufferControl", Document, int, SourceToDisplay, StyleAndTextTuples, int, int - ]: - return ( - self.buffer_control, - self.document, - self.lineno, - self.source_to_display, - self.fragments, - self.width, - self.height, - ) - - -class Transformation: - """ - 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 `fragments`! - - :param fragments: The transformed fragments. 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, - fragments: StyleAndTextTuples, - source_to_display: Optional[SourceToDisplay] = None, - display_to_source: Optional[DisplayToSource] = None, - ) -> None: - - self.fragments = fragments - self.source_to_display = source_to_display or (lambda i: i) - self.display_to_source = display_to_source or (lambda i: i) - - -class DummyProcessor(Processor): - """ - A `Processor` that doesn't do anything. - """ - - def apply_transformation( - self, transformation_input: TransformationInput - ) -> Transformation: - return Transformation(transformation_input.fragments) - - -class HighlightSearchProcessor(Processor): - """ - Processor that highlights search matches in the document. - Note that this doesn't support multiline search matches yet. - - The style classes 'search' and 'search.current' will be applied to the - content. - """ - - _classname = "search" - _classname_current = "search.current" - - def _get_search_text(self, buffer_control: "BufferControl") -> str: - """ - The text we are searching for. - """ - return buffer_control.search_state.text - - def apply_transformation( - self, transformation_input: TransformationInput - ) -> Transformation: - - ( - buffer_control, - document, - lineno, - source_to_display, - fragments, - _, - _, - ) = transformation_input.unpack() - - search_text = self._get_search_text(buffer_control) - searchmatch_fragment = " class:%s " % (self._classname,) - searchmatch_current_fragment = " class:%s " % (self._classname_current,) - - if search_text and not get_app().is_done: - # For each search match, replace the style string. - line_text = fragment_list_to_text(fragments) - fragments = explode_text_fragments(fragments) - - if buffer_control.search_state.ignore_case(): - flags = re.IGNORECASE - else: - flags = re.RegexFlag(0) - - # Get cursor column. - cursor_column: Optional[int] - 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_fragment, text, *_ = fragments[i] - if on_cursor: - fragments[i] = ( - old_fragment + searchmatch_current_fragment, - fragments[i][1], - ) - else: - fragments[i] = ( - old_fragment + searchmatch_fragment, - fragments[i][1], - ) - - return Transformation(fragments) - - -class HighlightIncrementalSearchProcessor(HighlightSearchProcessor): - """ - Highlight the search terms that are used for highlighting the incremental - search. The style class 'incsearch' will be applied to the content. - - Important: this requires the `preview_search=True` flag to be set for the - `BufferControl`. Otherwise, the cursor position won't be set to the search - match while searching, and nothing happens. - """ - - _classname = "incsearch" - _classname_current = "incsearch.current" - - def _get_search_text(self, buffer_control: "BufferControl") -> str: - """ - The text we are searching for. - """ - # When the search buffer has focus, take that text. - search_buffer = buffer_control.search_buffer - if search_buffer is not None and search_buffer.text: - return search_buffer.text - return "" - - -class HighlightSelectionProcessor(Processor): - """ - Processor that highlights the selection in the document. - """ - - def apply_transformation( - self, transformation_input: TransformationInput - ) -> Transformation: - ( - buffer_control, - document, - lineno, - source_to_display, - fragments, - _, - _, - ) = transformation_input.unpack() - - selected_fragment = " class:selected " - - # 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) - - fragments = explode_text_fragments(fragments) - - if from_ == 0 and to == 0 and len(fragments) == 0: - # When this is an empty line, insert a space in order to - # visualise the selection. - return Transformation([(selected_fragment, " ")]) - else: - for i in range(from_, to): - if i < len(fragments): - old_fragment, old_text, *_ = fragments[i] - fragments[i] = (old_fragment + selected_fragment, old_text) - elif i == len(fragments): - fragments.append((selected_fragment, " ")) - - return Transformation(fragments) - - -class PasswordProcessor(Processor): - """ - Processor that masks the input. (For passwords.) - - :param char: (string) Character to be used. "*" by default. - """ - - def __init__(self, char: str = "*") -> None: - self.char = char - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - fragments: StyleAndTextTuples = cast( - StyleAndTextTuples, - [ - (style, self.char * len(text), *handler) - for style, text, *handler in ti.fragments - ], - ) - - return Transformation(fragments) - - -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: str = "[](){}<>", max_cursor_distance: int = 1000 - ) -> None: - self.chars = chars - self.max_cursor_distance = max_cursor_distance - - self._positions_cache: SimpleCache[ - Hashable, List[Tuple[int, int]] - ] = SimpleCache(maxsize=8) - - def _get_positions_to_highlight(self, document: Document) -> List[Tuple[int, int]]: - """ - Return a list of (row, col) tuples that need to be highlighted. - """ - pos: Optional[int] - - # 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, transformation_input: TransformationInput - ) -> Transformation: - - ( - buffer_control, - document, - lineno, - source_to_display, - fragments, - _, - _, - ) = transformation_input.unpack() - - # When the application is in the 'done' state, don't highlight. - if get_app().is_done: - return Transformation(fragments) - - # Get the highlight positions. - key = (get_app().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) - fragments = explode_text_fragments(fragments) - style, text, *_ = fragments[col] - - if col == document.cursor_position_col: - style += " class:matching-bracket.cursor " - else: - style += " class:matching-bracket.other " - - fragments[col] = (style, text) - - return Transformation(fragments) - - -class DisplayMultipleCursors(Processor): - """ - When we're in Vi block insert mode, display all the cursors. - """ - - def apply_transformation( - self, transformation_input: TransformationInput - ) -> Transformation: - - ( - buffer_control, - document, - lineno, - source_to_display, - fragments, - _, - _, - ) = transformation_input.unpack() - - buff = buffer_control.buffer - - if vi_insert_multiple_mode(): - cursor_positions = buff.multiple_cursor_positions - fragments = explode_text_fragments(fragments) - - # 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]) - - fragment_suffix = " class:multiple-cursors" - - for p in cursor_positions: - if start_pos <= p <= end_pos: - column = source_to_display(p - start_pos) - - # Replace fragment. - try: - style, text, *_ = fragments[column] - except IndexError: - # Cursor needs to be displayed after the current text. - fragments.append((fragment_suffix, " ")) - else: - style += fragment_suffix - fragments[column] = (style, text) - - return Transformation(fragments) - else: - return Transformation(fragments) - - -class BeforeInput(Processor): - """ - Insert text before the input. - - :param text: This can be either plain text or formatted text - (or a callable that returns any of those). - :param style: style to be applied to this prompt/prefix. - """ - - def __init__(self, text: AnyFormattedText, style: str = "") -> None: - self.text = text - self.style = style - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - source_to_display: Optional[SourceToDisplay] - display_to_source: Optional[DisplayToSource] - - if ti.lineno == 0: - # Get fragments. - fragments_before = to_formatted_text(self.text, self.style) - fragments = fragments_before + ti.fragments - - shift_position = fragment_list_len(fragments_before) - source_to_display = lambda i: i + shift_position - display_to_source = lambda i: i - shift_position - else: - fragments = ti.fragments - source_to_display = None - display_to_source = None - - return Transformation( - fragments, - source_to_display=source_to_display, - display_to_source=display_to_source, - ) - - def __repr__(self) -> str: - return "BeforeInput(%r, %r)" % (self.text, self.style) - - -class ShowArg(BeforeInput): - """ - Display the 'arg' in front of the input. - - This was used by the `PromptSession`, but now it uses the - `Window.get_line_prefix` function instead. - """ - - def __init__(self) -> None: - super().__init__(self._get_text_fragments) - - def _get_text_fragments(self) -> StyleAndTextTuples: - app = get_app() - if app.key_processor.arg is None: - return [] - else: - arg = app.key_processor.arg - - return [ - ("class:prompt.arg", "(arg: "), - ("class:prompt.arg.text", str(arg)), - ("class:prompt.arg", ") "), - ] - - def __repr__(self) -> str: - return "ShowArg()" - - -class AfterInput(Processor): - """ - Insert text after the input. - - :param text: This can be either plain text or formatted text - (or a callable that returns any of those). - :param style: style to be applied to this prompt/prefix. - """ - - def __init__(self, text: AnyFormattedText, style: str = "") -> None: - self.text = text - self.style = style - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - # Insert fragments after the last line. - if ti.lineno == ti.document.line_count - 1: - # Get fragments. - fragments_after = to_formatted_text(self.text, self.style) - return Transformation(fragments=ti.fragments + fragments_after) - else: - return Transformation(fragments=ti.fragments) - - def __repr__(self) -> str: - return "%s(%r, style=%r)" % (self.__class__.__name__, self.text, self.style) - - -class AppendAutoSuggestion(Processor): - """ - Append the auto suggestion to the input. - (The user can then press the right arrow the insert the suggestion.) - """ - - def __init__(self, style: str = "class:auto-suggestion") -> None: - self.style = style - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - # Insert fragments after the last line. - if ti.lineno == ti.document.line_count - 1: - buffer = ti.buffer_control.buffer - - if buffer.suggestion and ti.document.is_cursor_at_the_end: - suggestion = buffer.suggestion.text - else: - suggestion = "" - - return Transformation(fragments=ti.fragments + [(self.style, suggestion)]) - else: - return Transformation(fragments=ti.fragments) - - -class ShowLeadingWhiteSpaceProcessor(Processor): - """ - Make leading whitespace visible. - - :param get_char: Callable that returns one character. - """ - - def __init__( - self, - get_char: Optional[Callable[[], str]] = None, - style: str = "class:leading-whitespace", - ) -> None: - def default_get_char() -> str: - if "\xb7".encode(get_app().output.encoding(), "replace") == b"?": - return "." - else: - return "\xb7" - - self.style = style - self.get_char = get_char or default_get_char - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - fragments = ti.fragments - - # Walk through all te fragments. - if fragments and fragment_list_to_text(fragments).startswith(" "): - t = (self.style, self.get_char()) - fragments = explode_text_fragments(fragments) - - for i in range(len(fragments)): - if fragments[i][1] == " ": - fragments[i] = t - else: - break - - return Transformation(fragments) - - -class ShowTrailingWhiteSpaceProcessor(Processor): - """ - Make trailing whitespace visible. - - :param get_char: Callable that returns one character. - """ - - def __init__( - self, - get_char: Optional[Callable[[], str]] = None, - style: str = "class:training-whitespace", - ) -> None: - def default_get_char() -> str: - if "\xb7".encode(get_app().output.encoding(), "replace") == b"?": - return "." - else: - return "\xb7" - - self.style = style - self.get_char = get_char or default_get_char - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - fragments = ti.fragments - - if fragments and fragments[-1][1].endswith(" "): - t = (self.style, self.get_char()) - fragments = explode_text_fragments(fragments) - - # Walk backwards through all te fragments and replace whitespace. - for i in range(len(fragments) - 1, -1, -1): - char = fragments[i][1] - if char == " ": - fragments[i] = t - else: - break - - return Transformation(fragments) - - -class TabsProcessor(Processor): - """ - Render tabs as spaces (instead of ^I) or make them visible (for instance, - by replacing them with dots.) - - :param tabstop: Horizontal space taken by a tab. (`int` or callable that - returns an `int`). - :param char1: Character or callable that returns a character (text of - length one). This one is used for the first space taken by the tab. - :param char2: Like `char1`, but for the rest of the space. - """ - - def __init__( - self, - tabstop: Union[int, Callable[[], int]] = 4, - char1: Union[str, Callable[[], str]] = "|", - char2: Union[str, Callable[[], str]] = "\u2508", - style: str = "class:tab", - ) -> None: - - self.char1 = char1 - self.char2 = char2 - self.tabstop = tabstop - self.style = style - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - tabstop = to_int(self.tabstop) - style = self.style - - # Create separator for tabs. - separator1 = to_str(self.char1) - separator2 = to_str(self.char2) - - # Transform fragments. - fragments = explode_text_fragments(ti.fragments) - - position_mappings = {} - result_fragments: StyleAndTextTuples = [] - pos = 0 - - for i, fragment_and_text in enumerate(fragments): - position_mappings[i] = pos - - if fragment_and_text[1] == "\t": - # Calculate how many characters we have to insert. - count = tabstop - (pos % tabstop) - if count == 0: - count = tabstop - - # Insert tab. - result_fragments.append((style, separator1)) - result_fragments.append((style, separator2 * (count - 1))) - pos += count - else: - result_fragments.append(fragment_and_text) - pos += 1 - - position_mappings[len(fragments)] = pos - # Add `pos+1` to mapping, because the cursor can be right after the - # line as well. - position_mappings[len(fragments) + 1] = pos + 1 - - def source_to_display(from_position: int) -> int: - "Maps original cursor position to the new one." - return position_mappings[from_position] - - def display_to_source(display_pos: int) -> int: - "Maps display cursor position to the original one." - position_mappings_reversed = {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_fragments, - source_to_display=source_to_display, - display_to_source=display_to_source, - ) - - -class ReverseSearchProcessor(Processor): - """ - Process to display the "(reverse-i-search)`...`:..." stuff around - the search buffer. - - Note: This processor is meant to be applied to the BufferControl that - contains the search buffer, it's not meant for the original input. - """ - - _excluded_input_processors: List[Type[Processor]] = [ - HighlightSearchProcessor, - HighlightSelectionProcessor, - BeforeInput, - AfterInput, - ] - - def _get_main_buffer( - self, buffer_control: "BufferControl" - ) -> Optional["BufferControl"]: - from prompt_toolkit.layout.controls import BufferControl - - prev_control = get_app().layout.search_target_buffer_control - if ( - isinstance(prev_control, BufferControl) - and prev_control.search_buffer_control == buffer_control - ): - return prev_control - return None - - def _content( - self, main_control: "BufferControl", ti: TransformationInput - ) -> "UIContent": - from prompt_toolkit.layout.controls import BufferControl - - # Emulate the BufferControl through which we are searching. - # For this we filter out some of the input processors. - excluded_processors = tuple(self._excluded_input_processors) - - def filter_processor(item: Processor) -> Optional[Processor]: - """Filter processors from the main control that we want to disable - here. This returns either an accepted processor or None.""" - # For a `_MergedProcessor`, check each individual processor, recursively. - if isinstance(item, _MergedProcessor): - accepted_processors = [filter_processor(p) for p in item.processors] - return merge_processors( - [p for p in accepted_processors if p is not None] - ) - - # For a `ConditionalProcessor`, check the body. - elif isinstance(item, ConditionalProcessor): - p = filter_processor(item.processor) - if p: - return ConditionalProcessor(p, item.filter) - - # Otherwise, check the processor itself. - else: - if not isinstance(item, excluded_processors): - return item - - return None - - filtered_processor = filter_processor( - merge_processors(main_control.input_processors or []) - ) - highlight_processor = HighlightIncrementalSearchProcessor() - - if filtered_processor: - new_processors = [filtered_processor, highlight_processor] - else: - new_processors = [highlight_processor] - - from .controls import SearchBufferControl - - assert isinstance(ti.buffer_control, SearchBufferControl) - - buffer_control = BufferControl( - buffer=main_control.buffer, - input_processors=new_processors, - include_default_input_processors=False, - lexer=main_control.lexer, - preview_search=True, - search_buffer_control=ti.buffer_control, - ) - - return buffer_control.create_content(ti.width, ti.height, preview_search=True) - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - from .controls import SearchBufferControl - - assert isinstance( - ti.buffer_control, SearchBufferControl - ), "`ReverseSearchProcessor` should be applied to a `SearchBufferControl` only." - - source_to_display: Optional[SourceToDisplay] - display_to_source: Optional[DisplayToSource] - - main_control = self._get_main_buffer(ti.buffer_control) - - if ti.lineno == 0 and main_control: - content = self._content(main_control, ti) - - # Get the line from the original document for this search. - line_fragments = content.get_line(content.cursor_position.y) - - if main_control.search_state.direction == SearchDirection.FORWARD: - direction_text = "i-search" - else: - direction_text = "reverse-i-search" - - fragments_before: StyleAndTextTuples = [ - ("class:prompt.search", "("), - ("class:prompt.search", direction_text), - ("class:prompt.search", ")`"), - ] - - fragments = ( - fragments_before - + [ - ("class:prompt.search.text", fragment_list_to_text(ti.fragments)), - ("", "': "), - ] - + line_fragments - ) - - shift_position = fragment_list_len(fragments_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 - fragments = ti.fragments - - return Transformation( - fragments, - 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(): - return true_or_false - - # Wrapped 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.Filter` instance. - """ - - def __init__(self, processor: Processor, filter: FilterOrBool) -> None: - self.processor = processor - self.filter = to_filter(filter) - - def apply_transformation( - self, transformation_input: TransformationInput - ) -> Transformation: - # Run processor when enabled. - if self.filter(): - return self.processor.apply_transformation(transformation_input) - else: - return Transformation(transformation_input.fragments) - - def __repr__(self) -> str: - return "%s(processor=%r, filter=%r)" % ( - self.__class__.__name__, - self.processor, - self.filter, - ) - - -class DynamicProcessor(Processor): - """ - Processor class that dynamically returns any Processor. - - :param get_processor: Callable that returns a :class:`.Processor` instance. - """ - - def __init__(self, get_processor: Callable[[], Optional[Processor]]) -> None: - self.get_processor = get_processor - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - processor = self.get_processor() or DummyProcessor() - return processor.apply_transformation(ti) - - -def merge_processors(processors: List[Processor]) -> Processor: - """ - Merge multiple `Processor` objects into one. - """ - if len(processors) == 0: - return DummyProcessor() - - if len(processors) == 1: - return processors[0] # Nothing to merge. - - return _MergedProcessor(processors) - - -class _MergedProcessor(Processor): - """ - Processor that groups multiple other `Processor` objects, but exposes an - API as if it is one `Processor`. - """ - - def __init__(self, processors: List[Processor]): - self.processors = processors - - def apply_transformation(self, ti: TransformationInput) -> Transformation: - source_to_display_functions = [ti.source_to_display] - display_to_source_functions = [] - fragments = ti.fragments - - def source_to_display(i: int) -> int: - """Translate x position from the buffer to the x position in the - processor fragments list.""" - for f in source_to_display_functions: - i = f(i) - return i - - for p in self.processors: - transformation = p.apply_transformation( - TransformationInput( - ti.buffer_control, - ti.document, - ti.lineno, - source_to_display, - fragments, - ti.width, - ti.height, - ) - ) - fragments = transformation.fragments - display_to_source_functions.append(transformation.display_to_source) - source_to_display_functions.append(transformation.source_to_display) - - def display_to_source(i: int) -> int: - for f in reversed(display_to_source_functions): - i = f(i) - return i - - # In the case of a nested _MergedProcessor, each processor wants to - # receive a 'source_to_display' function (as part of the - # TransformationInput) that has everything in the chain before - # included, because it can be called as part of the - # `apply_transformation` function. However, this first - # `source_to_display` should not be part of the output that we are - # returning. (This is the most consistent with `display_to_source`.) - del source_to_display_functions[:1] - - return Transformation(fragments, source_to_display, display_to_source) +""" +Processors are little transformation blocks that transform the fragments list +from a buffer before the BufferControl will render it to the screen. + +They can insert fragments before or after, or highlight fragments by replacing the +fragment types. +""" +import re +from abc import ABCMeta, abstractmethod +from typing import ( + TYPE_CHECKING, + Callable, + Hashable, + List, + Optional, + Tuple, + Type, + Union, + cast, +) + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.document import Document +from prompt_toolkit.filters import FilterOrBool, to_filter, vi_insert_multiple_mode +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import fragment_list_len, fragment_list_to_text +from prompt_toolkit.search import SearchDirection +from prompt_toolkit.utils import to_int, to_str + +from .utils import explode_text_fragments + +if TYPE_CHECKING: + from .controls import BufferControl, UIContent + +__all__ = [ + "Processor", + "TransformationInput", + "Transformation", + "DummyProcessor", + "HighlightSearchProcessor", + "HighlightIncrementalSearchProcessor", + "HighlightSelectionProcessor", + "PasswordProcessor", + "HighlightMatchingBracketProcessor", + "DisplayMultipleCursors", + "BeforeInput", + "ShowArg", + "AfterInput", + "AppendAutoSuggestion", + "ConditionalProcessor", + "ShowLeadingWhiteSpaceProcessor", + "ShowTrailingWhiteSpaceProcessor", + "TabsProcessor", + "ReverseSearchProcessor", + "DynamicProcessor", + "merge_processors", +] + + +class Processor(metaclass=ABCMeta): + """ + Manipulate the fragments for a given line in a + :class:`~prompt_toolkit.layout.controls.BufferControl`. + """ + + @abstractmethod + def apply_transformation( + self, transformation_input: "TransformationInput" + ) -> "Transformation": + """ + Apply transformation. Returns a :class:`.Transformation` instance. + + :param transformation_input: :class:`.TransformationInput` object. + """ + return Transformation(transformation_input.fragments) + + +SourceToDisplay = Callable[[int], int] +DisplayToSource = Callable[[int], int] + + +class TransformationInput: + """ + :param buffer_control: :class:`.BufferControl` 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 + `fragments` for any position in the source string. (This takes + previous processors into account.) + :param fragments: List of fragments that we can transform. (Received from the + previous processor.) + """ + + def __init__( + self, + buffer_control: "BufferControl", + document: Document, + lineno: int, + source_to_display: SourceToDisplay, + fragments: StyleAndTextTuples, + width: int, + height: int, + ) -> None: + + self.buffer_control = buffer_control + self.document = document + self.lineno = lineno + self.source_to_display = source_to_display + self.fragments = fragments + self.width = width + self.height = height + + def unpack( + self, + ) -> Tuple[ + "BufferControl", Document, int, SourceToDisplay, StyleAndTextTuples, int, int + ]: + return ( + self.buffer_control, + self.document, + self.lineno, + self.source_to_display, + self.fragments, + self.width, + self.height, + ) + + +class Transformation: + """ + 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 `fragments`! + + :param fragments: The transformed fragments. 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, + fragments: StyleAndTextTuples, + source_to_display: Optional[SourceToDisplay] = None, + display_to_source: Optional[DisplayToSource] = None, + ) -> None: + + self.fragments = fragments + self.source_to_display = source_to_display or (lambda i: i) + self.display_to_source = display_to_source or (lambda i: i) + + +class DummyProcessor(Processor): + """ + A `Processor` that doesn't do anything. + """ + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + return Transformation(transformation_input.fragments) + + +class HighlightSearchProcessor(Processor): + """ + Processor that highlights search matches in the document. + Note that this doesn't support multiline search matches yet. + + The style classes 'search' and 'search.current' will be applied to the + content. + """ + + _classname = "search" + _classname_current = "search.current" + + def _get_search_text(self, buffer_control: "BufferControl") -> str: + """ + The text we are searching for. + """ + return buffer_control.search_state.text + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + + ( + buffer_control, + document, + lineno, + source_to_display, + fragments, + _, + _, + ) = transformation_input.unpack() + + search_text = self._get_search_text(buffer_control) + searchmatch_fragment = " class:%s " % (self._classname,) + searchmatch_current_fragment = " class:%s " % (self._classname_current,) + + if search_text and not get_app().is_done: + # For each search match, replace the style string. + line_text = fragment_list_to_text(fragments) + fragments = explode_text_fragments(fragments) + + if buffer_control.search_state.ignore_case(): + flags = re.IGNORECASE + else: + flags = re.RegexFlag(0) + + # Get cursor column. + cursor_column: Optional[int] + 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_fragment, text, *_ = fragments[i] + if on_cursor: + fragments[i] = ( + old_fragment + searchmatch_current_fragment, + fragments[i][1], + ) + else: + fragments[i] = ( + old_fragment + searchmatch_fragment, + fragments[i][1], + ) + + return Transformation(fragments) + + +class HighlightIncrementalSearchProcessor(HighlightSearchProcessor): + """ + Highlight the search terms that are used for highlighting the incremental + search. The style class 'incsearch' will be applied to the content. + + Important: this requires the `preview_search=True` flag to be set for the + `BufferControl`. Otherwise, the cursor position won't be set to the search + match while searching, and nothing happens. + """ + + _classname = "incsearch" + _classname_current = "incsearch.current" + + def _get_search_text(self, buffer_control: "BufferControl") -> str: + """ + The text we are searching for. + """ + # When the search buffer has focus, take that text. + search_buffer = buffer_control.search_buffer + if search_buffer is not None and search_buffer.text: + return search_buffer.text + return "" + + +class HighlightSelectionProcessor(Processor): + """ + Processor that highlights the selection in the document. + """ + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + ( + buffer_control, + document, + lineno, + source_to_display, + fragments, + _, + _, + ) = transformation_input.unpack() + + selected_fragment = " class:selected " + + # 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) + + fragments = explode_text_fragments(fragments) + + if from_ == 0 and to == 0 and len(fragments) == 0: + # When this is an empty line, insert a space in order to + # visualise the selection. + return Transformation([(selected_fragment, " ")]) + else: + for i in range(from_, to): + if i < len(fragments): + old_fragment, old_text, *_ = fragments[i] + fragments[i] = (old_fragment + selected_fragment, old_text) + elif i == len(fragments): + fragments.append((selected_fragment, " ")) + + return Transformation(fragments) + + +class PasswordProcessor(Processor): + """ + Processor that masks the input. (For passwords.) + + :param char: (string) Character to be used. "*" by default. + """ + + def __init__(self, char: str = "*") -> None: + self.char = char + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + fragments: StyleAndTextTuples = cast( + StyleAndTextTuples, + [ + (style, self.char * len(text), *handler) + for style, text, *handler in ti.fragments + ], + ) + + return Transformation(fragments) + + +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: str = "[](){}<>", max_cursor_distance: int = 1000 + ) -> None: + self.chars = chars + self.max_cursor_distance = max_cursor_distance + + self._positions_cache: SimpleCache[ + Hashable, List[Tuple[int, int]] + ] = SimpleCache(maxsize=8) + + def _get_positions_to_highlight(self, document: Document) -> List[Tuple[int, int]]: + """ + Return a list of (row, col) tuples that need to be highlighted. + """ + pos: Optional[int] + + # 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, transformation_input: TransformationInput + ) -> Transformation: + + ( + buffer_control, + document, + lineno, + source_to_display, + fragments, + _, + _, + ) = transformation_input.unpack() + + # When the application is in the 'done' state, don't highlight. + if get_app().is_done: + return Transformation(fragments) + + # Get the highlight positions. + key = (get_app().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) + fragments = explode_text_fragments(fragments) + style, text, *_ = fragments[col] + + if col == document.cursor_position_col: + style += " class:matching-bracket.cursor " + else: + style += " class:matching-bracket.other " + + fragments[col] = (style, text) + + return Transformation(fragments) + + +class DisplayMultipleCursors(Processor): + """ + When we're in Vi block insert mode, display all the cursors. + """ + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + + ( + buffer_control, + document, + lineno, + source_to_display, + fragments, + _, + _, + ) = transformation_input.unpack() + + buff = buffer_control.buffer + + if vi_insert_multiple_mode(): + cursor_positions = buff.multiple_cursor_positions + fragments = explode_text_fragments(fragments) + + # 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]) + + fragment_suffix = " class:multiple-cursors" + + for p in cursor_positions: + if start_pos <= p <= end_pos: + column = source_to_display(p - start_pos) + + # Replace fragment. + try: + style, text, *_ = fragments[column] + except IndexError: + # Cursor needs to be displayed after the current text. + fragments.append((fragment_suffix, " ")) + else: + style += fragment_suffix + fragments[column] = (style, text) + + return Transformation(fragments) + else: + return Transformation(fragments) + + +class BeforeInput(Processor): + """ + Insert text before the input. + + :param text: This can be either plain text or formatted text + (or a callable that returns any of those). + :param style: style to be applied to this prompt/prefix. + """ + + def __init__(self, text: AnyFormattedText, style: str = "") -> None: + self.text = text + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + source_to_display: Optional[SourceToDisplay] + display_to_source: Optional[DisplayToSource] + + if ti.lineno == 0: + # Get fragments. + fragments_before = to_formatted_text(self.text, self.style) + fragments = fragments_before + ti.fragments + + shift_position = fragment_list_len(fragments_before) + source_to_display = lambda i: i + shift_position + display_to_source = lambda i: i - shift_position + else: + fragments = ti.fragments + source_to_display = None + display_to_source = None + + return Transformation( + fragments, + source_to_display=source_to_display, + display_to_source=display_to_source, + ) + + def __repr__(self) -> str: + return "BeforeInput(%r, %r)" % (self.text, self.style) + + +class ShowArg(BeforeInput): + """ + Display the 'arg' in front of the input. + + This was used by the `PromptSession`, but now it uses the + `Window.get_line_prefix` function instead. + """ + + def __init__(self) -> None: + super().__init__(self._get_text_fragments) + + def _get_text_fragments(self) -> StyleAndTextTuples: + app = get_app() + if app.key_processor.arg is None: + return [] + else: + arg = app.key_processor.arg + + return [ + ("class:prompt.arg", "(arg: "), + ("class:prompt.arg.text", str(arg)), + ("class:prompt.arg", ") "), + ] + + def __repr__(self) -> str: + return "ShowArg()" + + +class AfterInput(Processor): + """ + Insert text after the input. + + :param text: This can be either plain text or formatted text + (or a callable that returns any of those). + :param style: style to be applied to this prompt/prefix. + """ + + def __init__(self, text: AnyFormattedText, style: str = "") -> None: + self.text = text + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + # Insert fragments after the last line. + if ti.lineno == ti.document.line_count - 1: + # Get fragments. + fragments_after = to_formatted_text(self.text, self.style) + return Transformation(fragments=ti.fragments + fragments_after) + else: + return Transformation(fragments=ti.fragments) + + def __repr__(self) -> str: + return "%s(%r, style=%r)" % (self.__class__.__name__, self.text, self.style) + + +class AppendAutoSuggestion(Processor): + """ + Append the auto suggestion to the input. + (The user can then press the right arrow the insert the suggestion.) + """ + + def __init__(self, style: str = "class:auto-suggestion") -> None: + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + # Insert fragments after the last line. + if ti.lineno == ti.document.line_count - 1: + buffer = ti.buffer_control.buffer + + if buffer.suggestion and ti.document.is_cursor_at_the_end: + suggestion = buffer.suggestion.text + else: + suggestion = "" + + return Transformation(fragments=ti.fragments + [(self.style, suggestion)]) + else: + return Transformation(fragments=ti.fragments) + + +class ShowLeadingWhiteSpaceProcessor(Processor): + """ + Make leading whitespace visible. + + :param get_char: Callable that returns one character. + """ + + def __init__( + self, + get_char: Optional[Callable[[], str]] = None, + style: str = "class:leading-whitespace", + ) -> None: + def default_get_char() -> str: + if "\xb7".encode(get_app().output.encoding(), "replace") == b"?": + return "." + else: + return "\xb7" + + self.style = style + self.get_char = get_char or default_get_char + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + fragments = ti.fragments + + # Walk through all te fragments. + if fragments and fragment_list_to_text(fragments).startswith(" "): + t = (self.style, self.get_char()) + fragments = explode_text_fragments(fragments) + + for i in range(len(fragments)): + if fragments[i][1] == " ": + fragments[i] = t + else: + break + + return Transformation(fragments) + + +class ShowTrailingWhiteSpaceProcessor(Processor): + """ + Make trailing whitespace visible. + + :param get_char: Callable that returns one character. + """ + + def __init__( + self, + get_char: Optional[Callable[[], str]] = None, + style: str = "class:training-whitespace", + ) -> None: + def default_get_char() -> str: + if "\xb7".encode(get_app().output.encoding(), "replace") == b"?": + return "." + else: + return "\xb7" + + self.style = style + self.get_char = get_char or default_get_char + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + fragments = ti.fragments + + if fragments and fragments[-1][1].endswith(" "): + t = (self.style, self.get_char()) + fragments = explode_text_fragments(fragments) + + # Walk backwards through all te fragments and replace whitespace. + for i in range(len(fragments) - 1, -1, -1): + char = fragments[i][1] + if char == " ": + fragments[i] = t + else: + break + + return Transformation(fragments) + + +class TabsProcessor(Processor): + """ + Render tabs as spaces (instead of ^I) or make them visible (for instance, + by replacing them with dots.) + + :param tabstop: Horizontal space taken by a tab. (`int` or callable that + returns an `int`). + :param char1: Character or callable that returns a character (text of + length one). This one is used for the first space taken by the tab. + :param char2: Like `char1`, but for the rest of the space. + """ + + def __init__( + self, + tabstop: Union[int, Callable[[], int]] = 4, + char1: Union[str, Callable[[], str]] = "|", + char2: Union[str, Callable[[], str]] = "\u2508", + style: str = "class:tab", + ) -> None: + + self.char1 = char1 + self.char2 = char2 + self.tabstop = tabstop + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + tabstop = to_int(self.tabstop) + style = self.style + + # Create separator for tabs. + separator1 = to_str(self.char1) + separator2 = to_str(self.char2) + + # Transform fragments. + fragments = explode_text_fragments(ti.fragments) + + position_mappings = {} + result_fragments: StyleAndTextTuples = [] + pos = 0 + + for i, fragment_and_text in enumerate(fragments): + position_mappings[i] = pos + + if fragment_and_text[1] == "\t": + # Calculate how many characters we have to insert. + count = tabstop - (pos % tabstop) + if count == 0: + count = tabstop + + # Insert tab. + result_fragments.append((style, separator1)) + result_fragments.append((style, separator2 * (count - 1))) + pos += count + else: + result_fragments.append(fragment_and_text) + pos += 1 + + position_mappings[len(fragments)] = pos + # Add `pos+1` to mapping, because the cursor can be right after the + # line as well. + position_mappings[len(fragments) + 1] = pos + 1 + + def source_to_display(from_position: int) -> int: + "Maps original cursor position to the new one." + return position_mappings[from_position] + + def display_to_source(display_pos: int) -> int: + "Maps display cursor position to the original one." + position_mappings_reversed = {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_fragments, + source_to_display=source_to_display, + display_to_source=display_to_source, + ) + + +class ReverseSearchProcessor(Processor): + """ + Process to display the "(reverse-i-search)`...`:..." stuff around + the search buffer. + + Note: This processor is meant to be applied to the BufferControl that + contains the search buffer, it's not meant for the original input. + """ + + _excluded_input_processors: List[Type[Processor]] = [ + HighlightSearchProcessor, + HighlightSelectionProcessor, + BeforeInput, + AfterInput, + ] + + def _get_main_buffer( + self, buffer_control: "BufferControl" + ) -> Optional["BufferControl"]: + from prompt_toolkit.layout.controls import BufferControl + + prev_control = get_app().layout.search_target_buffer_control + if ( + isinstance(prev_control, BufferControl) + and prev_control.search_buffer_control == buffer_control + ): + return prev_control + return None + + def _content( + self, main_control: "BufferControl", ti: TransformationInput + ) -> "UIContent": + from prompt_toolkit.layout.controls import BufferControl + + # Emulate the BufferControl through which we are searching. + # For this we filter out some of the input processors. + excluded_processors = tuple(self._excluded_input_processors) + + def filter_processor(item: Processor) -> Optional[Processor]: + """Filter processors from the main control that we want to disable + here. This returns either an accepted processor or None.""" + # For a `_MergedProcessor`, check each individual processor, recursively. + if isinstance(item, _MergedProcessor): + accepted_processors = [filter_processor(p) for p in item.processors] + return merge_processors( + [p for p in accepted_processors if p is not None] + ) + + # For a `ConditionalProcessor`, check the body. + elif isinstance(item, ConditionalProcessor): + p = filter_processor(item.processor) + if p: + return ConditionalProcessor(p, item.filter) + + # Otherwise, check the processor itself. + else: + if not isinstance(item, excluded_processors): + return item + + return None + + filtered_processor = filter_processor( + merge_processors(main_control.input_processors or []) + ) + highlight_processor = HighlightIncrementalSearchProcessor() + + if filtered_processor: + new_processors = [filtered_processor, highlight_processor] + else: + new_processors = [highlight_processor] + + from .controls import SearchBufferControl + + assert isinstance(ti.buffer_control, SearchBufferControl) + + buffer_control = BufferControl( + buffer=main_control.buffer, + input_processors=new_processors, + include_default_input_processors=False, + lexer=main_control.lexer, + preview_search=True, + search_buffer_control=ti.buffer_control, + ) + + return buffer_control.create_content(ti.width, ti.height, preview_search=True) + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + from .controls import SearchBufferControl + + assert isinstance( + ti.buffer_control, SearchBufferControl + ), "`ReverseSearchProcessor` should be applied to a `SearchBufferControl` only." + + source_to_display: Optional[SourceToDisplay] + display_to_source: Optional[DisplayToSource] + + main_control = self._get_main_buffer(ti.buffer_control) + + if ti.lineno == 0 and main_control: + content = self._content(main_control, ti) + + # Get the line from the original document for this search. + line_fragments = content.get_line(content.cursor_position.y) + + if main_control.search_state.direction == SearchDirection.FORWARD: + direction_text = "i-search" + else: + direction_text = "reverse-i-search" + + fragments_before: StyleAndTextTuples = [ + ("class:prompt.search", "("), + ("class:prompt.search", direction_text), + ("class:prompt.search", ")`"), + ] + + fragments = ( + fragments_before + + [ + ("class:prompt.search.text", fragment_list_to_text(ti.fragments)), + ("", "': "), + ] + + line_fragments + ) + + shift_position = fragment_list_len(fragments_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 + fragments = ti.fragments + + return Transformation( + fragments, + 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(): + return true_or_false + + # Wrapped 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.Filter` instance. + """ + + def __init__(self, processor: Processor, filter: FilterOrBool) -> None: + self.processor = processor + self.filter = to_filter(filter) + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + # Run processor when enabled. + if self.filter(): + return self.processor.apply_transformation(transformation_input) + else: + return Transformation(transformation_input.fragments) + + def __repr__(self) -> str: + return "%s(processor=%r, filter=%r)" % ( + self.__class__.__name__, + self.processor, + self.filter, + ) + + +class DynamicProcessor(Processor): + """ + Processor class that dynamically returns any Processor. + + :param get_processor: Callable that returns a :class:`.Processor` instance. + """ + + def __init__(self, get_processor: Callable[[], Optional[Processor]]) -> None: + self.get_processor = get_processor + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + processor = self.get_processor() or DummyProcessor() + return processor.apply_transformation(ti) + + +def merge_processors(processors: List[Processor]) -> Processor: + """ + Merge multiple `Processor` objects into one. + """ + if len(processors) == 0: + return DummyProcessor() + + if len(processors) == 1: + return processors[0] # Nothing to merge. + + return _MergedProcessor(processors) + + +class _MergedProcessor(Processor): + """ + Processor that groups multiple other `Processor` objects, but exposes an + API as if it is one `Processor`. + """ + + def __init__(self, processors: List[Processor]): + self.processors = processors + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + source_to_display_functions = [ti.source_to_display] + display_to_source_functions = [] + fragments = ti.fragments + + def source_to_display(i: int) -> int: + """Translate x position from the buffer to the x position in the + processor fragments list.""" + for f in source_to_display_functions: + i = f(i) + return i + + for p in self.processors: + transformation = p.apply_transformation( + TransformationInput( + ti.buffer_control, + ti.document, + ti.lineno, + source_to_display, + fragments, + ti.width, + ti.height, + ) + ) + fragments = transformation.fragments + display_to_source_functions.append(transformation.display_to_source) + source_to_display_functions.append(transformation.source_to_display) + + def display_to_source(i: int) -> int: + for f in reversed(display_to_source_functions): + i = f(i) + return i + + # In the case of a nested _MergedProcessor, each processor wants to + # receive a 'source_to_display' function (as part of the + # TransformationInput) that has everything in the chain before + # included, because it can be called as part of the + # `apply_transformation` function. However, this first + # `source_to_display` should not be part of the output that we are + # returning. (This is the most consistent with `display_to_source`.) + del source_to_display_functions[:1] + + return Transformation(fragments, source_to_display, display_to_source) diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/screen.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/screen.py index deb2f43222..aad253e8ad 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/screen.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/screen.py @@ -1,327 +1,327 @@ -from collections import defaultdict -from typing import TYPE_CHECKING, Callable, DefaultDict, Dict, List, Optional, Tuple - -from prompt_toolkit.cache import FastDictCache -from prompt_toolkit.data_structures import Point -from prompt_toolkit.utils import get_cwidth - -if TYPE_CHECKING: - from .containers import Window - - -__all__ = [ - "Screen", - "Char", -] - - -class Char: - """ - Represent a single character in a :class:`.Screen`. - - This should be considered immutable. - - :param char: A single character (can be a double-width character). - :param style: A style string. (Can contain classnames.) - """ - - __slots__ = ("char", "style", "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: Dict[str, str] = { - "\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": "^?", # ASCII Delete (backspace). - # Special characters. All visualized like Vim does. - "\x80": "<80>", - "\x81": "<81>", - "\x82": "<82>", - "\x83": "<83>", - "\x84": "<84>", - "\x85": "<85>", - "\x86": "<86>", - "\x87": "<87>", - "\x88": "<88>", - "\x89": "<89>", - "\x8a": "<8a>", - "\x8b": "<8b>", - "\x8c": "<8c>", - "\x8d": "<8d>", - "\x8e": "<8e>", - "\x8f": "<8f>", - "\x90": "<90>", - "\x91": "<91>", - "\x92": "<92>", - "\x93": "<93>", - "\x94": "<94>", - "\x95": "<95>", - "\x96": "<96>", - "\x97": "<97>", - "\x98": "<98>", - "\x99": "<99>", - "\x9a": "<9a>", - "\x9b": "<9b>", - "\x9c": "<9c>", - "\x9d": "<9d>", - "\x9e": "<9e>", - "\x9f": "<9f>", - # For the non-breaking space: visualize like Emacs does by default. - # (Print a space, but attach the 'nbsp' class that applies the - # underline style.) - "\xa0": " ", - } - - def __init__(self, char: str = " ", style: str = "") -> None: - # If this character has to be displayed otherwise, take that one. - if char in self.display_mappings: - if char == "\xa0": - style += " class:nbsp " # Will be underlined. - else: - style += " class:control-character " - - char = self.display_mappings[char] - - self.char = char - self.style = style - - # Calculate width. (We always need this, so better to store it directly - # as a member for performance.) - self.width = get_cwidth(char) - - # In theory, `other` can be any type of object, but because of performance - # we don't want to do an `isinstance` check every time. We assume "other" - # is always a "Char". - def _equal(self, other: "Char") -> bool: - return self.char == other.char and self.style == other.style - - def _not_equal(self, other: "Char") -> bool: - # 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.style != other.style - - if not TYPE_CHECKING: - __eq__ = _equal - __ne__ = _not_equal - - def __repr__(self) -> str: - return "%s(%r, %r)" % (self.__class__.__name__, self.char, self.style) - - -_CHAR_CACHE: FastDictCache[Tuple[str, str], Char] = FastDictCache( - Char, size=1000 * 1000 -) -Transparent = "[transparent]" - - -class Screen: - """ - Two dimensional buffer of :class:`.Char` instances. - """ - - def __init__( - self, - default_char: Optional[Char] = None, - initial_width: int = 0, - initial_height: int = 0, - ) -> None: - - if default_char is None: - default_char2 = _CHAR_CACHE[" ", Transparent] - else: - default_char2 = default_char - - self.data_buffer: DefaultDict[int, DefaultDict[int, Char]] = defaultdict( - lambda: defaultdict(lambda: default_char2) - ) - - #: Escape sequences to be injected. - self.zero_width_escapes: DefaultDict[int, DefaultDict[int, str]] = defaultdict( - lambda: defaultdict(lambda: "") - ) - - #: Position of the cursor. - self.cursor_positions: Dict[ - "Window", Point - ] = {} # Map `Window` objects to `Point` objects. - - #: 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_positions: Dict[ - "Window", Point - ] = {} # Map `Window` objects to `Point` objects. - - #: 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 - - # Windows that have been drawn. (Each `Window` class will add itself to - # this list.) - self.visible_windows_to_write_positions: Dict["Window", "WritePosition"] = {} - - # List of (z_index, draw_func) - self._draw_float_functions: List[Tuple[int, Callable[[], None]]] = [] - - @property - def visible_windows(self) -> List["Window"]: - return list(self.visible_windows_to_write_positions.keys()) - - def set_cursor_position(self, window: "Window", position: Point) -> None: - """ - Set the cursor position for a given window. - """ - self.cursor_positions[window] = position - - def set_menu_position(self, window: "Window", position: Point) -> None: - """ - Set the cursor position for a given window. - """ - self.menu_positions[window] = position - - def get_cursor_position(self, window: "Window") -> Point: - """ - Get the cursor position for a given window. - Returns a `Point`. - """ - try: - return self.cursor_positions[window] - except KeyError: - return Point(x=0, y=0) - - def get_menu_position(self, window: "Window") -> Point: - """ - Get the menu position for a given window. - (This falls back to the cursor position if no menu position was set.) - """ - try: - return self.menu_positions[window] - except KeyError: - try: - return self.cursor_positions[window] - except KeyError: - return Point(x=0, y=0) - - def draw_with_z_index(self, z_index: int, draw_func: Callable[[], None]) -> None: - """ - Add a draw-function for a `Window` which has a >= 0 z_index. - This will be postponed until `draw_all_floats` is called. - """ - self._draw_float_functions.append((z_index, draw_func)) - - def draw_all_floats(self) -> None: - """ - Draw all float functions in order of z-index. - """ - # We keep looping because some draw functions could add new functions - # to this list. See `FloatContainer`. - while self._draw_float_functions: - # Sort the floats that we have so far by z_index. - functions = sorted(self._draw_float_functions, key=lambda item: item[0]) - - # Draw only one at a time, then sort everything again. Now floats - # might have been added. - self._draw_float_functions = functions[1:] - functions[0][1]() - - def append_style_to_content(self, style_str: str) -> None: - """ - For all the characters in the screen. - Set the style string to the given `style_str`. - """ - b = self.data_buffer - char_cache = _CHAR_CACHE - - append_style = " " + style_str - - for y, row in b.items(): - for x, char in row.items(): - b[y][x] = char_cache[char.char, char.style + append_style] - - def fill_area( - self, write_position: "WritePosition", style: str = "", after: bool = False - ) -> None: - """ - Fill the content of this area, using the given `style`. - The style is prepended before whatever was here before. - """ - if not style.strip(): - return - - xmin = write_position.xpos - xmax = write_position.xpos + write_position.width - char_cache = _CHAR_CACHE - data_buffer = self.data_buffer - - if after: - append_style = " " + style - prepend_style = "" - else: - append_style = "" - prepend_style = style + " " - - for y in range( - write_position.ypos, write_position.ypos + write_position.height - ): - row = data_buffer[y] - for x in range(xmin, xmax): - cell = row[x] - row[x] = char_cache[ - cell.char, prepend_style + cell.style + append_style - ] - - -class WritePosition: - def __init__(self, xpos: int, ypos: int, width: int, height: int) -> None: - assert 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 - - def __repr__(self) -> str: - return "%s(x=%r, y=%r, width=%r, height=%r)" % ( - self.__class__.__name__, - self.xpos, - self.ypos, - self.width, - self.height, - ) +from collections import defaultdict +from typing import TYPE_CHECKING, Callable, DefaultDict, Dict, List, Optional, Tuple + +from prompt_toolkit.cache import FastDictCache +from prompt_toolkit.data_structures import Point +from prompt_toolkit.utils import get_cwidth + +if TYPE_CHECKING: + from .containers import Window + + +__all__ = [ + "Screen", + "Char", +] + + +class Char: + """ + Represent a single character in a :class:`.Screen`. + + This should be considered immutable. + + :param char: A single character (can be a double-width character). + :param style: A style string. (Can contain classnames.) + """ + + __slots__ = ("char", "style", "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: Dict[str, str] = { + "\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": "^?", # ASCII Delete (backspace). + # Special characters. All visualized like Vim does. + "\x80": "<80>", + "\x81": "<81>", + "\x82": "<82>", + "\x83": "<83>", + "\x84": "<84>", + "\x85": "<85>", + "\x86": "<86>", + "\x87": "<87>", + "\x88": "<88>", + "\x89": "<89>", + "\x8a": "<8a>", + "\x8b": "<8b>", + "\x8c": "<8c>", + "\x8d": "<8d>", + "\x8e": "<8e>", + "\x8f": "<8f>", + "\x90": "<90>", + "\x91": "<91>", + "\x92": "<92>", + "\x93": "<93>", + "\x94": "<94>", + "\x95": "<95>", + "\x96": "<96>", + "\x97": "<97>", + "\x98": "<98>", + "\x99": "<99>", + "\x9a": "<9a>", + "\x9b": "<9b>", + "\x9c": "<9c>", + "\x9d": "<9d>", + "\x9e": "<9e>", + "\x9f": "<9f>", + # For the non-breaking space: visualize like Emacs does by default. + # (Print a space, but attach the 'nbsp' class that applies the + # underline style.) + "\xa0": " ", + } + + def __init__(self, char: str = " ", style: str = "") -> None: + # If this character has to be displayed otherwise, take that one. + if char in self.display_mappings: + if char == "\xa0": + style += " class:nbsp " # Will be underlined. + else: + style += " class:control-character " + + char = self.display_mappings[char] + + self.char = char + self.style = style + + # Calculate width. (We always need this, so better to store it directly + # as a member for performance.) + self.width = get_cwidth(char) + + # In theory, `other` can be any type of object, but because of performance + # we don't want to do an `isinstance` check every time. We assume "other" + # is always a "Char". + def _equal(self, other: "Char") -> bool: + return self.char == other.char and self.style == other.style + + def _not_equal(self, other: "Char") -> bool: + # 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.style != other.style + + if not TYPE_CHECKING: + __eq__ = _equal + __ne__ = _not_equal + + def __repr__(self) -> str: + return "%s(%r, %r)" % (self.__class__.__name__, self.char, self.style) + + +_CHAR_CACHE: FastDictCache[Tuple[str, str], Char] = FastDictCache( + Char, size=1000 * 1000 +) +Transparent = "[transparent]" + + +class Screen: + """ + Two dimensional buffer of :class:`.Char` instances. + """ + + def __init__( + self, + default_char: Optional[Char] = None, + initial_width: int = 0, + initial_height: int = 0, + ) -> None: + + if default_char is None: + default_char2 = _CHAR_CACHE[" ", Transparent] + else: + default_char2 = default_char + + self.data_buffer: DefaultDict[int, DefaultDict[int, Char]] = defaultdict( + lambda: defaultdict(lambda: default_char2) + ) + + #: Escape sequences to be injected. + self.zero_width_escapes: DefaultDict[int, DefaultDict[int, str]] = defaultdict( + lambda: defaultdict(lambda: "") + ) + + #: Position of the cursor. + self.cursor_positions: Dict[ + "Window", Point + ] = {} # Map `Window` objects to `Point` objects. + + #: 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_positions: Dict[ + "Window", Point + ] = {} # Map `Window` objects to `Point` objects. + + #: 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 + + # Windows that have been drawn. (Each `Window` class will add itself to + # this list.) + self.visible_windows_to_write_positions: Dict["Window", "WritePosition"] = {} + + # List of (z_index, draw_func) + self._draw_float_functions: List[Tuple[int, Callable[[], None]]] = [] + + @property + def visible_windows(self) -> List["Window"]: + return list(self.visible_windows_to_write_positions.keys()) + + def set_cursor_position(self, window: "Window", position: Point) -> None: + """ + Set the cursor position for a given window. + """ + self.cursor_positions[window] = position + + def set_menu_position(self, window: "Window", position: Point) -> None: + """ + Set the cursor position for a given window. + """ + self.menu_positions[window] = position + + def get_cursor_position(self, window: "Window") -> Point: + """ + Get the cursor position for a given window. + Returns a `Point`. + """ + try: + return self.cursor_positions[window] + except KeyError: + return Point(x=0, y=0) + + def get_menu_position(self, window: "Window") -> Point: + """ + Get the menu position for a given window. + (This falls back to the cursor position if no menu position was set.) + """ + try: + return self.menu_positions[window] + except KeyError: + try: + return self.cursor_positions[window] + except KeyError: + return Point(x=0, y=0) + + def draw_with_z_index(self, z_index: int, draw_func: Callable[[], None]) -> None: + """ + Add a draw-function for a `Window` which has a >= 0 z_index. + This will be postponed until `draw_all_floats` is called. + """ + self._draw_float_functions.append((z_index, draw_func)) + + def draw_all_floats(self) -> None: + """ + Draw all float functions in order of z-index. + """ + # We keep looping because some draw functions could add new functions + # to this list. See `FloatContainer`. + while self._draw_float_functions: + # Sort the floats that we have so far by z_index. + functions = sorted(self._draw_float_functions, key=lambda item: item[0]) + + # Draw only one at a time, then sort everything again. Now floats + # might have been added. + self._draw_float_functions = functions[1:] + functions[0][1]() + + def append_style_to_content(self, style_str: str) -> None: + """ + For all the characters in the screen. + Set the style string to the given `style_str`. + """ + b = self.data_buffer + char_cache = _CHAR_CACHE + + append_style = " " + style_str + + for y, row in b.items(): + for x, char in row.items(): + b[y][x] = char_cache[char.char, char.style + append_style] + + def fill_area( + self, write_position: "WritePosition", style: str = "", after: bool = False + ) -> None: + """ + Fill the content of this area, using the given `style`. + The style is prepended before whatever was here before. + """ + if not style.strip(): + return + + xmin = write_position.xpos + xmax = write_position.xpos + write_position.width + char_cache = _CHAR_CACHE + data_buffer = self.data_buffer + + if after: + append_style = " " + style + prepend_style = "" + else: + append_style = "" + prepend_style = style + " " + + for y in range( + write_position.ypos, write_position.ypos + write_position.height + ): + row = data_buffer[y] + for x in range(xmin, xmax): + cell = row[x] + row[x] = char_cache[ + cell.char, prepend_style + cell.style + append_style + ] + + +class WritePosition: + def __init__(self, xpos: int, ypos: int, width: int, height: int) -> None: + assert 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 + + def __repr__(self) -> str: + return "%s(x=%r, y=%r, width=%r, height=%r)" % ( + self.__class__.__name__, + self.xpos, + self.ypos, + self.width, + self.height, + ) diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/scrollable_pane.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/scrollable_pane.py index a5500d7f7c..57d813d511 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/scrollable_pane.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/scrollable_pane.py @@ -1,493 +1,493 @@ -from typing import Dict, List, Optional - -from prompt_toolkit.data_structures import Point -from prompt_toolkit.filters import FilterOrBool, to_filter -from prompt_toolkit.key_binding import KeyBindingsBase -from prompt_toolkit.mouse_events import MouseEvent - -from .containers import Container, ScrollOffsets -from .dimension import AnyDimension, Dimension, sum_layout_dimensions, to_dimension -from .mouse_handlers import MouseHandler, MouseHandlers -from .screen import Char, Screen, WritePosition - -__all__ = ["ScrollablePane"] - -# Never go beyond this height, because performance will degrade. -MAX_AVAILABLE_HEIGHT = 10_000 - - -class ScrollablePane(Container): - """ - Container widget that exposes a larger virtual screen to its content and - displays it in a vertical scrollbale region. - - Typically this is wrapped in a large `HSplit` container. Make sure in that - case to not specify a `height` dimension of the `HSplit`, so that it will - scale according to the content. - - .. note:: - - If you want to display a completion menu for widgets in this - `ScrollablePane`, then it's still a good practice to use a - `FloatContainer` with a `CompletionsMenu` in a `Float` at the top-level - of the layout hierarchy, rather then nesting a `FloatContainer` in this - `ScrollablePane`. (Otherwise, it's possible that the completion menu - is clipped.) - - :param content: The content container. - :param scrolloffset: Try to keep the cursor within this distance from the - top/bottom (left/right offset is not used). - :param keep_cursor_visible: When `True`, automatically scroll the pane so - that the cursor (of the focused window) is always visible. - :param keep_focused_window_visible: When `True`, automatically scroll th e - pane so that the focused window is visible, or as much visible as - possible if it doen't completely fit the screen. - :param max_available_height: Always constraint the height to this amount - for performance reasons. - :param width: When given, use this width instead of looking at the children. - :param height: When given, use this height instead of looking at the children. - :param show_scrollbar: When `True` display a scrollbar on the right. - """ - - def __init__( - self, - content: Container, - scroll_offsets: Optional[ScrollOffsets] = None, - keep_cursor_visible: FilterOrBool = True, - keep_focused_window_visible: FilterOrBool = True, - max_available_height: int = MAX_AVAILABLE_HEIGHT, - width: AnyDimension = None, - height: AnyDimension = None, - show_scrollbar: FilterOrBool = True, - display_arrows: FilterOrBool = True, - up_arrow_symbol: str = "^", - down_arrow_symbol: str = "v", - ) -> None: - self.content = content - self.scroll_offsets = scroll_offsets or ScrollOffsets(top=1, bottom=1) - self.keep_cursor_visible = to_filter(keep_cursor_visible) - self.keep_focused_window_visible = to_filter(keep_focused_window_visible) - self.max_available_height = max_available_height - self.width = width - self.height = height - self.show_scrollbar = to_filter(show_scrollbar) - self.display_arrows = to_filter(display_arrows) - self.up_arrow_symbol = up_arrow_symbol - self.down_arrow_symbol = down_arrow_symbol - - self.vertical_scroll = 0 - - def __repr__(self) -> str: - return f"ScrollablePane({self.content!r})" - - def reset(self) -> None: - self.content.reset() - - def preferred_width(self, max_available_width: int) -> Dimension: - if self.width is not None: - return to_dimension(self.width) - - # We're only scrolling vertical. So the preferred width is equal to - # that of the content. - content_width = self.content.preferred_width(max_available_width) - - # If a scrollbar needs to be displayed, add +1 to the content width. - if self.show_scrollbar(): - return sum_layout_dimensions([Dimension.exact(1), content_width]) - - return content_width - - def preferred_height(self, width: int, max_available_height: int) -> Dimension: - if self.height is not None: - return to_dimension(self.height) - - # Prefer a height large enough so that it fits all the content. If not, - # we'll make the pane scrollable. - if self.show_scrollbar(): - # If `show_scrollbar` is set. Always reserve space for the scrollbar. - width -= 1 - - dimension = self.content.preferred_height(width, self.max_available_height) - - # Only take 'preferred' into account. Min/max can be anything. - return Dimension(min=0, preferred=dimension.preferred) - - def write_to_screen( - self, - screen: Screen, - mouse_handlers: MouseHandlers, - write_position: WritePosition, - parent_style: str, - erase_bg: bool, - z_index: Optional[int], - ) -> None: - """ - Render scrollable pane content. - - This works by rendering on an off-screen canvas, and copying over the - visible region. - """ - show_scrollbar = self.show_scrollbar() - - if show_scrollbar: - virtual_width = write_position.width - 1 - else: - virtual_width = write_position.width - - # Compute preferred height again. - virtual_height = self.content.preferred_height( - virtual_width, self.max_available_height - ).preferred - - # Ensure virtual height is at least the available height. - virtual_height = max(virtual_height, write_position.height) - virtual_height = min(virtual_height, self.max_available_height) - - # First, write the content to a virtual screen, then copy over the - # visible part to the real screen. - temp_screen = Screen(default_char=Char(char=" ", style=parent_style)) - temp_write_position = WritePosition( - xpos=0, ypos=0, width=virtual_width, height=virtual_height - ) - - temp_mouse_handlers = MouseHandlers() - - self.content.write_to_screen( - temp_screen, - temp_mouse_handlers, - temp_write_position, - parent_style, - erase_bg, - z_index, - ) - temp_screen.draw_all_floats() - - # If anything in the virtual screen is focused, move vertical scroll to - from prompt_toolkit.application import get_app - - focused_window = get_app().layout.current_window - - try: - visible_win_write_pos = temp_screen.visible_windows_to_write_positions[ - focused_window - ] - except KeyError: - pass # No window focused here. Don't scroll. - else: - # Make sure this window is visible. - self._make_window_visible( - write_position.height, - virtual_height, - visible_win_write_pos, - temp_screen.cursor_positions.get(focused_window), - ) - - # Copy over virtual screen and zero width escapes to real screen. - self._copy_over_screen(screen, temp_screen, write_position, virtual_width) - - # Copy over mouse handlers. - self._copy_over_mouse_handlers( - mouse_handlers, temp_mouse_handlers, write_position, virtual_width - ) - - # Set screen.width/height. - ypos = write_position.ypos - xpos = write_position.xpos - - screen.width = max(screen.width, xpos + virtual_width) - screen.height = max(screen.height, ypos + write_position.height) - - # Copy over window write positions. - self._copy_over_write_positions(screen, temp_screen, write_position) - - if temp_screen.show_cursor: - screen.show_cursor = True - - # Copy over cursor positions, if they are visible. - for window, point in temp_screen.cursor_positions.items(): - if ( - 0 <= point.x < write_position.width - and self.vertical_scroll - <= point.y - < write_position.height + self.vertical_scroll - ): - screen.cursor_positions[window] = Point( - x=point.x + xpos, y=point.y + ypos - self.vertical_scroll - ) - - # Copy over menu positions, but clip them to the visible area. - for window, point in temp_screen.menu_positions.items(): - screen.menu_positions[window] = self._clip_point_to_visible_area( - Point(x=point.x + xpos, y=point.y + ypos - self.vertical_scroll), - write_position, - ) - - # Draw scrollbar. - if show_scrollbar: - self._draw_scrollbar( - write_position, - virtual_height, - screen, - ) - - def _clip_point_to_visible_area( - self, point: Point, write_position: WritePosition - ) -> Point: - """ - Ensure that the cursor and menu positions always are always reported - """ - if point.x < write_position.xpos: - point = point._replace(x=write_position.xpos) - if point.y < write_position.ypos: - point = point._replace(y=write_position.ypos) - if point.x >= write_position.xpos + write_position.width: - point = point._replace(x=write_position.xpos + write_position.width - 1) - if point.y >= write_position.ypos + write_position.height: - point = point._replace(y=write_position.ypos + write_position.height - 1) - - return point - - def _copy_over_screen( - self, - screen: Screen, - temp_screen: Screen, - write_position: WritePosition, - virtual_width: int, - ) -> None: - """ - Copy over visible screen content and "zero width escape sequences". - """ - ypos = write_position.ypos - xpos = write_position.xpos - - for y in range(write_position.height): - temp_row = temp_screen.data_buffer[y + self.vertical_scroll] - row = screen.data_buffer[y + ypos] - temp_zero_width_escapes = temp_screen.zero_width_escapes[ - y + self.vertical_scroll - ] - zero_width_escapes = screen.zero_width_escapes[y + ypos] - - for x in range(virtual_width): - row[x + xpos] = temp_row[x] - - if x in temp_zero_width_escapes: - zero_width_escapes[x + xpos] = temp_zero_width_escapes[x] - - def _copy_over_mouse_handlers( - self, - mouse_handlers: MouseHandlers, - temp_mouse_handlers: MouseHandlers, - write_position: WritePosition, - virtual_width: int, - ) -> None: - """ - Copy over mouse handlers from virtual screen to real screen. - - Note: we take `virtual_width` because we don't want to copy over mouse - handlers that we possibly have behind the scrollbar. - """ - ypos = write_position.ypos - xpos = write_position.xpos - - # Cache mouse handlers when wrapping them. Very often the same mouse - # handler is registered for many positions. - mouse_handler_wrappers: Dict[MouseHandler, MouseHandler] = {} - - def wrap_mouse_handler(handler: MouseHandler) -> MouseHandler: - "Wrap mouse handler. Translate coordinates in `MouseEvent`." - if handler not in mouse_handler_wrappers: - - def new_handler(event: MouseEvent) -> None: - new_event = MouseEvent( - position=Point( - x=event.position.x - xpos, - y=event.position.y + self.vertical_scroll - ypos, - ), - event_type=event.event_type, - button=event.button, - modifiers=event.modifiers, - ) - handler(new_event) - - mouse_handler_wrappers[handler] = new_handler - return mouse_handler_wrappers[handler] - - # Copy handlers. - mouse_handlers_dict = mouse_handlers.mouse_handlers - temp_mouse_handlers_dict = temp_mouse_handlers.mouse_handlers - - for y in range(write_position.height): - if y in temp_mouse_handlers_dict: - temp_mouse_row = temp_mouse_handlers_dict[y + self.vertical_scroll] - mouse_row = mouse_handlers_dict[y + ypos] - for x in range(virtual_width): - if x in temp_mouse_row: - mouse_row[x + xpos] = wrap_mouse_handler(temp_mouse_row[x]) - - def _copy_over_write_positions( - self, screen: Screen, temp_screen: Screen, write_position: WritePosition - ) -> None: - """ - Copy over window write positions. - """ - ypos = write_position.ypos - xpos = write_position.xpos - - for win, write_pos in temp_screen.visible_windows_to_write_positions.items(): - screen.visible_windows_to_write_positions[win] = WritePosition( - xpos=write_pos.xpos + xpos, - ypos=write_pos.ypos + ypos - self.vertical_scroll, - # TODO: if the window is only partly visible, then truncate width/height. - # This could be important if we have nested ScrollablePanes. - height=write_pos.height, - width=write_pos.width, - ) - - def is_modal(self) -> bool: - return self.content.is_modal() - - def get_key_bindings(self) -> Optional[KeyBindingsBase]: - return self.content.get_key_bindings() - - def get_children(self) -> List["Container"]: - return [self.content] - - def _make_window_visible( - self, - visible_height: int, - virtual_height: int, - visible_win_write_pos: WritePosition, - cursor_position: Optional[Point], - ) -> None: - """ - Scroll the scrollable pane, so that this window becomes visible. - - :param visible_height: Height of this `ScrollablePane` that is rendered. - :param virtual_height: Height of the virtual, temp screen. - :param visible_win_write_pos: `WritePosition` of the nested window on the - temp screen. - :param cursor_position: The location of the cursor position of this - window on the temp screen. - """ - # Start with maximum allowed scroll range, and then reduce according to - # the focused window and cursor position. - min_scroll = 0 - max_scroll = virtual_height - visible_height - - if self.keep_cursor_visible(): - # Reduce min/max scroll according to the cursor in the focused window. - if cursor_position is not None: - offsets = self.scroll_offsets - cpos_min_scroll = ( - cursor_position.y - visible_height + 1 + offsets.bottom - ) - cpos_max_scroll = cursor_position.y - offsets.top - min_scroll = max(min_scroll, cpos_min_scroll) - max_scroll = max(0, min(max_scroll, cpos_max_scroll)) - - if self.keep_focused_window_visible(): - # Reduce min/max scroll according to focused window position. - # If the window is small enough, bot the top and bottom of the window - # should be visible. - if visible_win_write_pos.height <= visible_height: - window_min_scroll = ( - visible_win_write_pos.ypos - + visible_win_write_pos.height - - visible_height - ) - window_max_scroll = visible_win_write_pos.ypos - else: - # Window does not fit on the screen. Make sure at least the whole - # screen is occupied with this window, and nothing else is shown. - window_min_scroll = visible_win_write_pos.ypos - window_max_scroll = ( - visible_win_write_pos.ypos - + visible_win_write_pos.height - - visible_height - ) - - min_scroll = max(min_scroll, window_min_scroll) - max_scroll = min(max_scroll, window_max_scroll) - - if min_scroll > max_scroll: - min_scroll = max_scroll # Should not happen. - - # Finally, properly clip the vertical scroll. - if self.vertical_scroll > max_scroll: - self.vertical_scroll = max_scroll - if self.vertical_scroll < min_scroll: - self.vertical_scroll = min_scroll - - def _draw_scrollbar( - self, write_position: WritePosition, content_height: int, screen: Screen - ) -> None: - """ - Draw the scrollbar on the screen. - - Note: There is some code duplication with the `ScrollbarMargin` - implementation. - """ - - window_height = write_position.height - display_arrows = self.display_arrows() - - if display_arrows: - window_height -= 2 - - try: - fraction_visible = write_position.height / float(content_height) - fraction_above = self.vertical_scroll / float(content_height) - - scrollbar_height = int( - min(window_height, max(1, window_height * fraction_visible)) - ) - scrollbar_top = int(window_height * fraction_above) - except ZeroDivisionError: - return - else: - - def is_scroll_button(row: int) -> bool: - "True if we should display a button on this row." - return scrollbar_top <= row <= scrollbar_top + scrollbar_height - - xpos = write_position.xpos + write_position.width - 1 - ypos = write_position.ypos - data_buffer = screen.data_buffer - - # Up arrow. - if display_arrows: - data_buffer[ypos][xpos] = Char( - self.up_arrow_symbol, "class:scrollbar.arrow" - ) - ypos += 1 - - # Scrollbar body. - scrollbar_background = "class:scrollbar.background" - scrollbar_background_start = "class:scrollbar.background,scrollbar.start" - scrollbar_button = "class:scrollbar.button" - scrollbar_button_end = "class:scrollbar.button,scrollbar.end" - - for i in range(window_height): - style = "" - if is_scroll_button(i): - if not is_scroll_button(i + 1): - # Give the last cell a different style, because we want - # to underline this. - style = scrollbar_button_end - else: - style = scrollbar_button - else: - if is_scroll_button(i + 1): - style = scrollbar_background_start - else: - style = scrollbar_background - - data_buffer[ypos][xpos] = Char(" ", style) - ypos += 1 - - # Down arrow - if display_arrows: - data_buffer[ypos][xpos] = Char( - self.down_arrow_symbol, "class:scrollbar.arrow" - ) +from typing import Dict, List, Optional + +from prompt_toolkit.data_structures import Point +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.key_binding import KeyBindingsBase +from prompt_toolkit.mouse_events import MouseEvent + +from .containers import Container, ScrollOffsets +from .dimension import AnyDimension, Dimension, sum_layout_dimensions, to_dimension +from .mouse_handlers import MouseHandler, MouseHandlers +from .screen import Char, Screen, WritePosition + +__all__ = ["ScrollablePane"] + +# Never go beyond this height, because performance will degrade. +MAX_AVAILABLE_HEIGHT = 10_000 + + +class ScrollablePane(Container): + """ + Container widget that exposes a larger virtual screen to its content and + displays it in a vertical scrollbale region. + + Typically this is wrapped in a large `HSplit` container. Make sure in that + case to not specify a `height` dimension of the `HSplit`, so that it will + scale according to the content. + + .. note:: + + If you want to display a completion menu for widgets in this + `ScrollablePane`, then it's still a good practice to use a + `FloatContainer` with a `CompletionsMenu` in a `Float` at the top-level + of the layout hierarchy, rather then nesting a `FloatContainer` in this + `ScrollablePane`. (Otherwise, it's possible that the completion menu + is clipped.) + + :param content: The content container. + :param scrolloffset: Try to keep the cursor within this distance from the + top/bottom (left/right offset is not used). + :param keep_cursor_visible: When `True`, automatically scroll the pane so + that the cursor (of the focused window) is always visible. + :param keep_focused_window_visible: When `True`, automatically scroll th e + pane so that the focused window is visible, or as much visible as + possible if it doen't completely fit the screen. + :param max_available_height: Always constraint the height to this amount + for performance reasons. + :param width: When given, use this width instead of looking at the children. + :param height: When given, use this height instead of looking at the children. + :param show_scrollbar: When `True` display a scrollbar on the right. + """ + + def __init__( + self, + content: Container, + scroll_offsets: Optional[ScrollOffsets] = None, + keep_cursor_visible: FilterOrBool = True, + keep_focused_window_visible: FilterOrBool = True, + max_available_height: int = MAX_AVAILABLE_HEIGHT, + width: AnyDimension = None, + height: AnyDimension = None, + show_scrollbar: FilterOrBool = True, + display_arrows: FilterOrBool = True, + up_arrow_symbol: str = "^", + down_arrow_symbol: str = "v", + ) -> None: + self.content = content + self.scroll_offsets = scroll_offsets or ScrollOffsets(top=1, bottom=1) + self.keep_cursor_visible = to_filter(keep_cursor_visible) + self.keep_focused_window_visible = to_filter(keep_focused_window_visible) + self.max_available_height = max_available_height + self.width = width + self.height = height + self.show_scrollbar = to_filter(show_scrollbar) + self.display_arrows = to_filter(display_arrows) + self.up_arrow_symbol = up_arrow_symbol + self.down_arrow_symbol = down_arrow_symbol + + self.vertical_scroll = 0 + + def __repr__(self) -> str: + return f"ScrollablePane({self.content!r})" + + def reset(self) -> None: + self.content.reset() + + def preferred_width(self, max_available_width: int) -> Dimension: + if self.width is not None: + return to_dimension(self.width) + + # We're only scrolling vertical. So the preferred width is equal to + # that of the content. + content_width = self.content.preferred_width(max_available_width) + + # If a scrollbar needs to be displayed, add +1 to the content width. + if self.show_scrollbar(): + return sum_layout_dimensions([Dimension.exact(1), content_width]) + + return content_width + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + if self.height is not None: + return to_dimension(self.height) + + # Prefer a height large enough so that it fits all the content. If not, + # we'll make the pane scrollable. + if self.show_scrollbar(): + # If `show_scrollbar` is set. Always reserve space for the scrollbar. + width -= 1 + + dimension = self.content.preferred_height(width, self.max_available_height) + + # Only take 'preferred' into account. Min/max can be anything. + return Dimension(min=0, preferred=dimension.preferred) + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: Optional[int], + ) -> None: + """ + Render scrollable pane content. + + This works by rendering on an off-screen canvas, and copying over the + visible region. + """ + show_scrollbar = self.show_scrollbar() + + if show_scrollbar: + virtual_width = write_position.width - 1 + else: + virtual_width = write_position.width + + # Compute preferred height again. + virtual_height = self.content.preferred_height( + virtual_width, self.max_available_height + ).preferred + + # Ensure virtual height is at least the available height. + virtual_height = max(virtual_height, write_position.height) + virtual_height = min(virtual_height, self.max_available_height) + + # First, write the content to a virtual screen, then copy over the + # visible part to the real screen. + temp_screen = Screen(default_char=Char(char=" ", style=parent_style)) + temp_write_position = WritePosition( + xpos=0, ypos=0, width=virtual_width, height=virtual_height + ) + + temp_mouse_handlers = MouseHandlers() + + self.content.write_to_screen( + temp_screen, + temp_mouse_handlers, + temp_write_position, + parent_style, + erase_bg, + z_index, + ) + temp_screen.draw_all_floats() + + # If anything in the virtual screen is focused, move vertical scroll to + from prompt_toolkit.application import get_app + + focused_window = get_app().layout.current_window + + try: + visible_win_write_pos = temp_screen.visible_windows_to_write_positions[ + focused_window + ] + except KeyError: + pass # No window focused here. Don't scroll. + else: + # Make sure this window is visible. + self._make_window_visible( + write_position.height, + virtual_height, + visible_win_write_pos, + temp_screen.cursor_positions.get(focused_window), + ) + + # Copy over virtual screen and zero width escapes to real screen. + self._copy_over_screen(screen, temp_screen, write_position, virtual_width) + + # Copy over mouse handlers. + self._copy_over_mouse_handlers( + mouse_handlers, temp_mouse_handlers, write_position, virtual_width + ) + + # Set screen.width/height. + ypos = write_position.ypos + xpos = write_position.xpos + + screen.width = max(screen.width, xpos + virtual_width) + screen.height = max(screen.height, ypos + write_position.height) + + # Copy over window write positions. + self._copy_over_write_positions(screen, temp_screen, write_position) + + if temp_screen.show_cursor: + screen.show_cursor = True + + # Copy over cursor positions, if they are visible. + for window, point in temp_screen.cursor_positions.items(): + if ( + 0 <= point.x < write_position.width + and self.vertical_scroll + <= point.y + < write_position.height + self.vertical_scroll + ): + screen.cursor_positions[window] = Point( + x=point.x + xpos, y=point.y + ypos - self.vertical_scroll + ) + + # Copy over menu positions, but clip them to the visible area. + for window, point in temp_screen.menu_positions.items(): + screen.menu_positions[window] = self._clip_point_to_visible_area( + Point(x=point.x + xpos, y=point.y + ypos - self.vertical_scroll), + write_position, + ) + + # Draw scrollbar. + if show_scrollbar: + self._draw_scrollbar( + write_position, + virtual_height, + screen, + ) + + def _clip_point_to_visible_area( + self, point: Point, write_position: WritePosition + ) -> Point: + """ + Ensure that the cursor and menu positions always are always reported + """ + if point.x < write_position.xpos: + point = point._replace(x=write_position.xpos) + if point.y < write_position.ypos: + point = point._replace(y=write_position.ypos) + if point.x >= write_position.xpos + write_position.width: + point = point._replace(x=write_position.xpos + write_position.width - 1) + if point.y >= write_position.ypos + write_position.height: + point = point._replace(y=write_position.ypos + write_position.height - 1) + + return point + + def _copy_over_screen( + self, + screen: Screen, + temp_screen: Screen, + write_position: WritePosition, + virtual_width: int, + ) -> None: + """ + Copy over visible screen content and "zero width escape sequences". + """ + ypos = write_position.ypos + xpos = write_position.xpos + + for y in range(write_position.height): + temp_row = temp_screen.data_buffer[y + self.vertical_scroll] + row = screen.data_buffer[y + ypos] + temp_zero_width_escapes = temp_screen.zero_width_escapes[ + y + self.vertical_scroll + ] + zero_width_escapes = screen.zero_width_escapes[y + ypos] + + for x in range(virtual_width): + row[x + xpos] = temp_row[x] + + if x in temp_zero_width_escapes: + zero_width_escapes[x + xpos] = temp_zero_width_escapes[x] + + def _copy_over_mouse_handlers( + self, + mouse_handlers: MouseHandlers, + temp_mouse_handlers: MouseHandlers, + write_position: WritePosition, + virtual_width: int, + ) -> None: + """ + Copy over mouse handlers from virtual screen to real screen. + + Note: we take `virtual_width` because we don't want to copy over mouse + handlers that we possibly have behind the scrollbar. + """ + ypos = write_position.ypos + xpos = write_position.xpos + + # Cache mouse handlers when wrapping them. Very often the same mouse + # handler is registered for many positions. + mouse_handler_wrappers: Dict[MouseHandler, MouseHandler] = {} + + def wrap_mouse_handler(handler: MouseHandler) -> MouseHandler: + "Wrap mouse handler. Translate coordinates in `MouseEvent`." + if handler not in mouse_handler_wrappers: + + def new_handler(event: MouseEvent) -> None: + new_event = MouseEvent( + position=Point( + x=event.position.x - xpos, + y=event.position.y + self.vertical_scroll - ypos, + ), + event_type=event.event_type, + button=event.button, + modifiers=event.modifiers, + ) + handler(new_event) + + mouse_handler_wrappers[handler] = new_handler + return mouse_handler_wrappers[handler] + + # Copy handlers. + mouse_handlers_dict = mouse_handlers.mouse_handlers + temp_mouse_handlers_dict = temp_mouse_handlers.mouse_handlers + + for y in range(write_position.height): + if y in temp_mouse_handlers_dict: + temp_mouse_row = temp_mouse_handlers_dict[y + self.vertical_scroll] + mouse_row = mouse_handlers_dict[y + ypos] + for x in range(virtual_width): + if x in temp_mouse_row: + mouse_row[x + xpos] = wrap_mouse_handler(temp_mouse_row[x]) + + def _copy_over_write_positions( + self, screen: Screen, temp_screen: Screen, write_position: WritePosition + ) -> None: + """ + Copy over window write positions. + """ + ypos = write_position.ypos + xpos = write_position.xpos + + for win, write_pos in temp_screen.visible_windows_to_write_positions.items(): + screen.visible_windows_to_write_positions[win] = WritePosition( + xpos=write_pos.xpos + xpos, + ypos=write_pos.ypos + ypos - self.vertical_scroll, + # TODO: if the window is only partly visible, then truncate width/height. + # This could be important if we have nested ScrollablePanes. + height=write_pos.height, + width=write_pos.width, + ) + + def is_modal(self) -> bool: + return self.content.is_modal() + + def get_key_bindings(self) -> Optional[KeyBindingsBase]: + return self.content.get_key_bindings() + + def get_children(self) -> List["Container"]: + return [self.content] + + def _make_window_visible( + self, + visible_height: int, + virtual_height: int, + visible_win_write_pos: WritePosition, + cursor_position: Optional[Point], + ) -> None: + """ + Scroll the scrollable pane, so that this window becomes visible. + + :param visible_height: Height of this `ScrollablePane` that is rendered. + :param virtual_height: Height of the virtual, temp screen. + :param visible_win_write_pos: `WritePosition` of the nested window on the + temp screen. + :param cursor_position: The location of the cursor position of this + window on the temp screen. + """ + # Start with maximum allowed scroll range, and then reduce according to + # the focused window and cursor position. + min_scroll = 0 + max_scroll = virtual_height - visible_height + + if self.keep_cursor_visible(): + # Reduce min/max scroll according to the cursor in the focused window. + if cursor_position is not None: + offsets = self.scroll_offsets + cpos_min_scroll = ( + cursor_position.y - visible_height + 1 + offsets.bottom + ) + cpos_max_scroll = cursor_position.y - offsets.top + min_scroll = max(min_scroll, cpos_min_scroll) + max_scroll = max(0, min(max_scroll, cpos_max_scroll)) + + if self.keep_focused_window_visible(): + # Reduce min/max scroll according to focused window position. + # If the window is small enough, bot the top and bottom of the window + # should be visible. + if visible_win_write_pos.height <= visible_height: + window_min_scroll = ( + visible_win_write_pos.ypos + + visible_win_write_pos.height + - visible_height + ) + window_max_scroll = visible_win_write_pos.ypos + else: + # Window does not fit on the screen. Make sure at least the whole + # screen is occupied with this window, and nothing else is shown. + window_min_scroll = visible_win_write_pos.ypos + window_max_scroll = ( + visible_win_write_pos.ypos + + visible_win_write_pos.height + - visible_height + ) + + min_scroll = max(min_scroll, window_min_scroll) + max_scroll = min(max_scroll, window_max_scroll) + + if min_scroll > max_scroll: + min_scroll = max_scroll # Should not happen. + + # Finally, properly clip the vertical scroll. + if self.vertical_scroll > max_scroll: + self.vertical_scroll = max_scroll + if self.vertical_scroll < min_scroll: + self.vertical_scroll = min_scroll + + def _draw_scrollbar( + self, write_position: WritePosition, content_height: int, screen: Screen + ) -> None: + """ + Draw the scrollbar on the screen. + + Note: There is some code duplication with the `ScrollbarMargin` + implementation. + """ + + window_height = write_position.height + display_arrows = self.display_arrows() + + if display_arrows: + window_height -= 2 + + try: + fraction_visible = write_position.height / float(content_height) + fraction_above = self.vertical_scroll / float(content_height) + + scrollbar_height = int( + min(window_height, max(1, window_height * fraction_visible)) + ) + scrollbar_top = int(window_height * fraction_above) + except ZeroDivisionError: + return + else: + + def is_scroll_button(row: int) -> bool: + "True if we should display a button on this row." + return scrollbar_top <= row <= scrollbar_top + scrollbar_height + + xpos = write_position.xpos + write_position.width - 1 + ypos = write_position.ypos + data_buffer = screen.data_buffer + + # Up arrow. + if display_arrows: + data_buffer[ypos][xpos] = Char( + self.up_arrow_symbol, "class:scrollbar.arrow" + ) + ypos += 1 + + # Scrollbar body. + scrollbar_background = "class:scrollbar.background" + scrollbar_background_start = "class:scrollbar.background,scrollbar.start" + scrollbar_button = "class:scrollbar.button" + scrollbar_button_end = "class:scrollbar.button,scrollbar.end" + + for i in range(window_height): + style = "" + if is_scroll_button(i): + if not is_scroll_button(i + 1): + # Give the last cell a different style, because we want + # to underline this. + style = scrollbar_button_end + else: + style = scrollbar_button + else: + if is_scroll_button(i + 1): + style = scrollbar_background_start + else: + style = scrollbar_background + + data_buffer[ypos][xpos] = Char(" ", style) + ypos += 1 + + # Down arrow + if display_arrows: + data_buffer[ypos][xpos] = Char( + self.down_arrow_symbol, "class:scrollbar.arrow" + ) diff --git a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/utils.py b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/utils.py index 2e0f34388b..6d9eb196c8 100644 --- a/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/utils.py +++ b/contrib/python/prompt-toolkit/py3/prompt_toolkit/layout/utils.py @@ -1,80 +1,80 @@ -from typing import TYPE_CHECKING, Iterable, List, TypeVar, Union, cast, overload - -from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple - -if TYPE_CHECKING: - from typing_extensions import SupportsIndex - -__all__ = [ - "explode_text_fragments", -] - -_T = TypeVar("_T", bound=OneStyleAndTextTuple) - - -class _ExplodedList(List[_T]): - """ - 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. - """ - - exploded = True - - def append(self, item: _T) -> None: - self.extend([item]) - - def extend(self, lst: Iterable[_T]) -> None: - super().extend(explode_text_fragments(lst)) - +from typing import TYPE_CHECKING, Iterable, List, TypeVar, Union, cast, overload + +from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple + +if TYPE_CHECKING: + from typing_extensions import SupportsIndex + +__all__ = [ + "explode_text_fragments", +] + +_T = TypeVar("_T", bound=OneStyleAndTextTuple) + + +class _ExplodedList(List[_T]): + """ + 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. + """ + + exploded = True + + def append(self, item: _T) -> None: + self.extend([item]) + + def extend(self, lst: Iterable[_T]) -> None: + super().extend(explode_text_fragments(lst)) + def insert(self, index: "SupportsIndex", item: _T) -> None: - raise NotImplementedError # TODO - - # TODO: When creating a copy() or [:], return also an _ExplodedList. - - @overload - def __setitem__(self, index: "SupportsIndex", value: _T) -> None: - ... - - @overload - def __setitem__(self, index: slice, value: Iterable[_T]) -> None: - ... - - def __setitem__( - self, index: Union["SupportsIndex", slice], value: Union[_T, Iterable[_T]] - ) -> None: - """ - Ensure that when `(style_str, 'long string')` is set, the string will be - exploded. - """ - if not isinstance(index, slice): - int_index = index.__index__() - index = slice(int_index, int_index + 1) - if isinstance(value, tuple): # In case of `OneStyleAndTextTuple`. - value = cast("List[_T]", [value]) - - super().__setitem__(index, explode_text_fragments(cast("Iterable[_T]", value))) - - -def explode_text_fragments(fragments: Iterable[_T]) -> _ExplodedList[_T]: - """ - Turn a list of (style_str, 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 fragments: List of (style, text) tuples. - """ - # When the fragments is already exploded, don't explode again. - if isinstance(fragments, _ExplodedList): - return fragments - - result: List[_T] = [] - - for style, string, *rest in fragments: # type: ignore - for c in string: # type: ignore - result.append((style, c, *rest)) # type: ignore - - return _ExplodedList(result) + raise NotImplementedError # TODO + + # TODO: When creating a copy() or [:], return also an _ExplodedList. + + @overload + def __setitem__(self, index: "SupportsIndex", value: _T) -> None: + ... + + @overload + def __setitem__(self, index: slice, value: Iterable[_T]) -> None: + ... + + def __setitem__( + self, index: Union["SupportsIndex", slice], value: Union[_T, Iterable[_T]] + ) -> None: + """ + Ensure that when `(style_str, 'long string')` is set, the string will be + exploded. + """ + if not isinstance(index, slice): + int_index = index.__index__() + index = slice(int_index, int_index + 1) + if isinstance(value, tuple): # In case of `OneStyleAndTextTuple`. + value = cast("List[_T]", [value]) + + super().__setitem__(index, explode_text_fragments(cast("Iterable[_T]", value))) + + +def explode_text_fragments(fragments: Iterable[_T]) -> _ExplodedList[_T]: + """ + Turn a list of (style_str, 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 fragments: List of (style, text) tuples. + """ + # When the fragments is already exploded, don't explode again. + if isinstance(fragments, _ExplodedList): + return fragments + + result: List[_T] = [] + + for style, string, *rest in fragments: # type: ignore + for c in string: # type: ignore + result.append((style, c, *rest)) # type: ignore + + return _ExplodedList(result) |