summaryrefslogtreecommitdiffstats
path: root/src/prompt_toolkit/layout
diff options
context:
space:
mode:
Diffstat (limited to 'src/prompt_toolkit/layout')
-rw-r--r--src/prompt_toolkit/layout/__init__.py146
-rw-r--r--src/prompt_toolkit/layout/containers.py2743
-rw-r--r--src/prompt_toolkit/layout/controls.py944
-rw-r--r--src/prompt_toolkit/layout/dimension.py219
-rw-r--r--src/prompt_toolkit/layout/dummy.py39
-rw-r--r--src/prompt_toolkit/layout/layout.py411
-rw-r--r--src/prompt_toolkit/layout/margins.py303
-rw-r--r--src/prompt_toolkit/layout/menus.py751
-rw-r--r--src/prompt_toolkit/layout/mouse_handlers.py56
-rw-r--r--src/prompt_toolkit/layout/processors.py1013
-rw-r--r--src/prompt_toolkit/layout/screen.py329
-rw-r--r--src/prompt_toolkit/layout/scrollable_pane.py494
-rw-r--r--src/prompt_toolkit/layout/utils.py82
13 files changed, 7530 insertions, 0 deletions
diff --git a/src/prompt_toolkit/layout/__init__.py b/src/prompt_toolkit/layout/__init__.py
new file mode 100644
index 0000000..c5fce46
--- /dev/null
+++ b/src/prompt_toolkit/layout/__init__.py
@@ -0,0 +1,146 @@
+"""
+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 __future__ import annotations
+
+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/src/prompt_toolkit/layout/containers.py b/src/prompt_toolkit/layout/containers.py
new file mode 100644
index 0000000..100d4aa
--- /dev/null
+++ b/src/prompt_toolkit/layout/containers.py
@@ -0,0 +1,2743 @@
+"""
+Container for the layout.
+(Containers can contain other containers or user interface controls.)
+"""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from enum import Enum
+from functools import partial
+from typing import TYPE_CHECKING, Callable, Sequence, 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: int | None,
+ ) -> 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) -> KeyBindingsBase | None:
+ """
+ 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: Container | None = None,
+ padding: AnyDimension = Dimension.exact(0),
+ padding_char: str | None = None,
+ padding_style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: 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) -> KeyBindingsBase | None:
+ 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: Container | None = None,
+ align: VerticalAlign = VerticalAlign.JUSTIFY,
+ padding: AnyDimension = 0,
+ padding_char: str | None = None,
+ padding_style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: 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: int | None,
+ ) -> 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) -> list[int] | None:
+ """
+ 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: Container | None = None,
+ align: HorizontalAlign = HorizontalAlign.JUSTIFY,
+ padding: AnyDimension = 0,
+ padding_char: str | None = None,
+ padding_style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: 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) -> list[int] | None:
+ """
+ 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: int | None,
+ ) -> 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: KeyBindingsBase | None = None,
+ style: str | Callable[[], str] = "",
+ z_index: int | None = 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: int | None,
+ ) -> 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: int | None,
+ ) -> 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) -> KeyBindingsBase | None:
+ 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: int | None = None,
+ right: int | None = None,
+ bottom: int | None = None,
+ left: int | None = None,
+ width: int | Callable[[], int] | None = None,
+ height: int | Callable[[], int] | None = None,
+ xcursor: bool = False,
+ ycursor: bool = False,
+ attach_to_window: AnyContainer | None = 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) -> int | None:
+ if callable(self.width):
+ return self.width()
+ return self.width
+
+ def get_height(self) -> int | None:
+ 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: int | Callable[[], int] = 0,
+ bottom: int | Callable[[], int] = 0,
+ left: int | Callable[[], int] = 0,
+ right: 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})".format(
+ 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: UIControl | None = None,
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ dont_extend_width: FilterOrBool = False,
+ dont_extend_height: FilterOrBool = False,
+ ignore_content_width: FilterOrBool = False,
+ ignore_content_height: FilterOrBool = False,
+ left_margins: Sequence[Margin] | None = None,
+ right_margins: Sequence[Margin] | None = None,
+ scroll_offsets: ScrollOffsets | None = None,
+ allow_scroll_beyond_bottom: FilterOrBool = False,
+ wrap_lines: FilterOrBool = False,
+ get_vertical_scroll: Callable[[Window], int] | None = None,
+ get_horizontal_scroll: Callable[[Window], int] | None = None,
+ always_hide_cursor: FilterOrBool = False,
+ cursorline: FilterOrBool = False,
+ cursorcolumn: FilterOrBool = False,
+ colorcolumns: (
+ None | list[ColorColumn] | Callable[[], list[ColorColumn]]
+ ) = None,
+ align: WindowAlign | Callable[[], WindowAlign] = WindowAlign.LEFT,
+ style: str | Callable[[], str] = "",
+ char: None | str | Callable[[], str] = None,
+ get_line_prefix: GetLinePrefixCallable | None = 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: WindowRenderInfo | None = 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() -> int | None:
+ """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() -> int | None:
+ """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: Dimension | None,
+ get_preferred: Callable[[], int | None],
+ 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: int | None
+
+ 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_: int | None
+ min_: int | None
+
+ 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) -> str | None:
+ "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: int | None,
+ ) -> 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: Callable[[int, int], AnyFormattedText] | None = 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 neighbors 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: str | None
+ 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 scrolling 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 analyze 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) -> KeyBindingsBase | None:
+ 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 f"ConditionalContainer({self.content!r}, filter={self.filter!r})"
+
+ 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: int | None,
+ ) -> 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: int | None,
+ ) -> 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) -> KeyBindingsBase | None:
+ # 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(f"Not a container object: {container!r}")
+
+
+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(f"Not a Window object: {container!r}.")
+
+
+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/src/prompt_toolkit/layout/controls.py b/src/prompt_toolkit/layout/controls.py
new file mode 100644
index 0000000..c30c0ef
--- /dev/null
+++ b/src/prompt_toolkit/layout/controls.py
@@ -0,0 +1,944 @@
+"""
+User interface Controls for the layout.
+"""
+from __future__ import annotations
+
+import time
+from abc import ABCMeta, abstractmethod
+from typing import TYPE_CHECKING, Callable, Hashable, Iterable, NamedTuple
+
+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) -> int | None:
+ return None
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ 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) -> KeyBindingsBase | None:
+ """
+ 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: Point | None = None,
+ menu_position: Point | None = 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: GetLinePrefixCallable | None,
+ slice_stop: int | None = 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.
+ 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:
+ 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: KeyBindingsBase | None = None,
+ show_cursor: bool = True,
+ modal: bool = False,
+ get_cursor_position: Callable[[], Point | None] | None = 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: StyleAndTextTuples | None = None
+
+ def reset(self) -> None:
+ self._fragments = None
+
+ def is_focusable(self) -> bool:
+ return self.focusable()
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.text!r})"
+
+ 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: GetLinePrefixCallable | None,
+ ) -> int | None:
+ """
+ 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: int | None) -> 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]",
+ ) -> Point | None:
+ 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() -> Point | None:
+ 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]
+ 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) -> KeyBindingsBase | None:
+ 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
+
+
+class _ProcessedLine(NamedTuple):
+ fragments: StyleAndTextTuples
+ source_to_display: Callable[[int], int]
+ display_to_source: Callable[[int], int]
+
+
+class BufferControl(UIControl):
+ """
+ Control for visualizing 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: Buffer | None = None,
+ input_processors: list[Processor] | None = None,
+ include_default_input_processors: bool = True,
+ lexer: Lexer | None = None,
+ preview_search: FilterOrBool = False,
+ focusable: FilterOrBool = True,
+ search_buffer_control: (
+ None | SearchBufferControl | Callable[[], SearchBufferControl]
+ ) = None,
+ menu_position: Callable[[], int | None] | None = None,
+ focus_on_click: FilterOrBool = False,
+ key_bindings: KeyBindingsBase | None = 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: float | None = None
+ self._last_get_processed_line: Callable[[int], _ProcessedLine] | None = None
+
+ def __repr__(self) -> str:
+ return f"<{self.__class__.__name__} buffer={self.buffer!r} at {id(self)!r}>"
+
+ @property
+ def search_buffer_control(self) -> SearchBufferControl | None:
+ result: SearchBufferControl | None
+
+ 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) -> Buffer | None:
+ 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) -> int | None:
+ """
+ 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 behavior.
+ """
+ return None
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ # 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) -> KeyBindingsBase | None:
+ """
+ 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: Buffer | None = None,
+ input_processors: list[Processor] | None = None,
+ lexer: Lexer | None = None,
+ focus_on_click: FilterOrBool = False,
+ key_bindings: KeyBindingsBase | None = 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/src/prompt_toolkit/layout/dimension.py b/src/prompt_toolkit/layout/dimension.py
new file mode 100644
index 0000000..c1f05f9
--- /dev/null
+++ b/src/prompt_toolkit/layout/dimension.py
@@ -0,0 +1,219 @@
+"""
+Layout dimensions are used to give the minimum, maximum and preferred
+dimensions for containers and controls.
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Callable, 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: int | None = None,
+ max: int | None = None,
+ weight: int | None = None,
+ preferred: int | None = 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
diff --git a/src/prompt_toolkit/layout/dummy.py b/src/prompt_toolkit/layout/dummy.py
new file mode 100644
index 0000000..139f311
--- /dev/null
+++ b/src/prompt_toolkit/layout/dummy.py
@@ -0,0 +1,39 @@
+"""
+Dummy layout. Used when somebody creates an `Application` without specifying a
+`Layout`.
+"""
+from __future__ import annotations
+
+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/src/prompt_toolkit/layout/layout.py b/src/prompt_toolkit/layout/layout.py
new file mode 100644
index 0000000..a5e7a80
--- /dev/null
+++ b/src/prompt_toolkit/layout/layout.py
@@ -0,0 +1,411 @@
+"""
+Wrapper for the layout.
+"""
+from __future__ import annotations
+
+from typing import Generator, Iterable, 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: FocusableElement | None = 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 f"Layout({self.container!r}, current_window={self.current_window!r})"
+
+ 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(f"Couldn't find Buffer in the current layout: {value!r}.")
+
+ # 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(f"Couldn't find Buffer in the current layout: {value!r}.")
+
+ # 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(
+ f"Invalid value. Window does not appear in the layout: {value!r}"
+ )
+
+ 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(
+ f"Invalid value. Container cannot be focused: {value!r}"
+ )
+
+ 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) -> BufferControl | None:
+ """
+ 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) -> Buffer | None:
+ """
+ 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) -> Buffer | None:
+ """
+ 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.
+ """
+ yield from walk(self.container)
+
+ 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]
+
+ yield from walk(root)
+
+ 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) -> Container | None:
+ """
+ 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/src/prompt_toolkit/layout/margins.py b/src/prompt_toolkit/layout/margins.py
new file mode 100644
index 0000000..cc9dd96
--- /dev/null
+++ b/src/prompt_toolkit/layout/margins.py
@@ -0,0 +1,303 @@
+"""
+Margin implementations for a :class:`~prompt_toolkit.layout.containers.Window`.
+"""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import TYPE_CHECKING, Callable
+
+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: None
+ | (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/src/prompt_toolkit/layout/menus.py b/src/prompt_toolkit/layout/menus.py
new file mode 100644
index 0000000..2c2ccb6
--- /dev/null
+++ b/src/prompt_toolkit/layout/menus.py
@@ -0,0 +1,751 @@
+from __future__ import annotations
+
+import math
+from itertools import zip_longest
+from typing import TYPE_CHECKING, Callable, Iterable, Sequence, TypeVar, cast
+from weakref import WeakKeyDictionary
+
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.buffer import CompletionState
+from prompt_toolkit.completion import Completion
+from prompt_toolkit.data_structures import Point
+from prompt_toolkit.filters import (
+ Condition,
+ FilterOrBool,
+ has_completions,
+ is_done,
+ to_filter,
+)
+from prompt_toolkit.formatted_text import (
+ StyleAndTextTuples,
+ fragment_list_width,
+ to_formatted_text,
+)
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.layout.utils import explode_text_fragments
+from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
+from prompt_toolkit.utils import get_cwidth
+
+from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window
+from .controls import GetLinePrefixCallable, UIContent, UIControl
+from .dimension import Dimension
+from .margins import ScrollbarMargin
+
+if TYPE_CHECKING:
+ from prompt_toolkit.key_binding.key_bindings import (
+ KeyBindings,
+ NotImplementedOrNone,
+ )
+
+
+__all__ = [
+ "CompletionsMenu",
+ "MultiColumnCompletionsMenu",
+]
+
+E = KeyPressEvent
+
+
+class CompletionsMenuControl(UIControl):
+ """
+ Helper for drawing the complete menu to the screen.
+
+ :param scroll_offset: Number (integer) representing the preferred amount of
+ completions to be displayed before and after the current one. When this
+ is a very high number, the current completion will be shown in the
+ middle most of the time.
+ """
+
+ # Preferred minimum size of the menu control.
+ # The CompletionsMenu class defines a width of 8, and there is a scrollbar
+ # of 1.)
+ MIN_WIDTH = 7
+
+ def has_focus(self) -> bool:
+ return False
+
+ def preferred_width(self, max_available_width: int) -> int | None:
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state:
+ menu_width = self._get_menu_width(500, complete_state)
+ menu_meta_width = self._get_menu_meta_width(500, complete_state)
+
+ return menu_width + menu_meta_width
+ else:
+ return 0
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state:
+ return len(complete_state.completions)
+ else:
+ return 0
+
+ def create_content(self, width: int, height: int) -> UIContent:
+ """
+ Create a UIContent object for this control.
+ """
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state:
+ completions = complete_state.completions
+ index = complete_state.complete_index # Can be None!
+
+ # Calculate width of completions menu.
+ menu_width = self._get_menu_width(width, complete_state)
+ menu_meta_width = self._get_menu_meta_width(
+ width - menu_width, complete_state
+ )
+ show_meta = self._show_meta(complete_state)
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ c = completions[i]
+ is_current_completion = i == index
+ result = _get_menu_item_fragments(
+ c, is_current_completion, menu_width, space_after=True
+ )
+
+ if show_meta:
+ result += self._get_menu_item_meta_fragments(
+ c, is_current_completion, menu_meta_width
+ )
+ return result
+
+ return UIContent(
+ get_line=get_line,
+ cursor_position=Point(x=0, y=index or 0),
+ line_count=len(completions),
+ )
+
+ return UIContent()
+
+ def _show_meta(self, complete_state: CompletionState) -> bool:
+ """
+ Return ``True`` if we need to show a column with meta information.
+ """
+ return any(c.display_meta_text for c in complete_state.completions)
+
+ def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int:
+ """
+ Return the width of the main column.
+ """
+ return min(
+ max_width,
+ max(
+ self.MIN_WIDTH,
+ max(get_cwidth(c.display_text) for c in complete_state.completions) + 2,
+ ),
+ )
+
+ def _get_menu_meta_width(
+ self, max_width: int, complete_state: CompletionState
+ ) -> int:
+ """
+ Return the width of the meta column.
+ """
+
+ def meta_width(completion: Completion) -> int:
+ return get_cwidth(completion.display_meta_text)
+
+ if self._show_meta(complete_state):
+ # If the amount of completions is over 200, compute the width based
+ # on the first 200 completions, otherwise this can be very slow.
+ completions = complete_state.completions
+ if len(completions) > 200:
+ completions = completions[:200]
+
+ return min(max_width, max(meta_width(c) for c in completions) + 2)
+ else:
+ return 0
+
+ def _get_menu_item_meta_fragments(
+ self, completion: Completion, is_current_completion: bool, width: int
+ ) -> StyleAndTextTuples:
+ if is_current_completion:
+ style_str = "class:completion-menu.meta.completion.current"
+ else:
+ style_str = "class:completion-menu.meta.completion"
+
+ text, tw = _trim_formatted_text(completion.display_meta, width - 2)
+ padding = " " * (width - 1 - tw)
+
+ return to_formatted_text(
+ cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
+ style=style_str,
+ )
+
+ def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Handle mouse events: clicking and scrolling.
+ """
+ b = get_app().current_buffer
+
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
+ # Select completion.
+ b.go_to_completion(mouse_event.position.y)
+ b.complete_state = None
+
+ elif mouse_event.event_type == MouseEventType.SCROLL_DOWN:
+ # Scroll up.
+ b.complete_next(count=3, disable_wrap_around=True)
+
+ elif mouse_event.event_type == MouseEventType.SCROLL_UP:
+ # Scroll down.
+ b.complete_previous(count=3, disable_wrap_around=True)
+
+ return None
+
+
+def _get_menu_item_fragments(
+ completion: Completion,
+ is_current_completion: bool,
+ width: int,
+ space_after: bool = False,
+) -> StyleAndTextTuples:
+ """
+ Get the style/text tuples for a menu item, styled and trimmed to the given
+ width.
+ """
+ if is_current_completion:
+ style_str = "class:completion-menu.completion.current {} {}".format(
+ completion.style,
+ completion.selected_style,
+ )
+ else:
+ style_str = "class:completion-menu.completion " + completion.style
+
+ text, tw = _trim_formatted_text(
+ completion.display, (width - 2 if space_after else width - 1)
+ )
+
+ padding = " " * (width - 1 - tw)
+
+ return to_formatted_text(
+ cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
+ style=style_str,
+ )
+
+
+def _trim_formatted_text(
+ formatted_text: StyleAndTextTuples, max_width: int
+) -> tuple[StyleAndTextTuples, int]:
+ """
+ Trim the text to `max_width`, append dots when the text is too long.
+ Returns (text, width) tuple.
+ """
+ width = fragment_list_width(formatted_text)
+
+ # When the text is too wide, trim it.
+ if width > max_width:
+ result = [] # Text fragments.
+ remaining_width = max_width - 3
+
+ for style_and_ch in explode_text_fragments(formatted_text):
+ ch_width = get_cwidth(style_and_ch[1])
+
+ if ch_width <= remaining_width:
+ result.append(style_and_ch)
+ remaining_width -= ch_width
+ else:
+ break
+
+ result.append(("", "..."))
+
+ return result, max_width - remaining_width
+ else:
+ return formatted_text, width
+
+
+class CompletionsMenu(ConditionalContainer):
+ # NOTE: We use a pretty big z_index by default. Menus are supposed to be
+ # above anything else. We also want to make sure that the content is
+ # visible at the point where we draw this menu.
+ def __init__(
+ self,
+ max_height: int | None = None,
+ scroll_offset: int | Callable[[], int] = 0,
+ extra_filter: FilterOrBool = True,
+ display_arrows: FilterOrBool = False,
+ z_index: int = 10**8,
+ ) -> None:
+ extra_filter = to_filter(extra_filter)
+ display_arrows = to_filter(display_arrows)
+
+ super().__init__(
+ content=Window(
+ content=CompletionsMenuControl(),
+ width=Dimension(min=8),
+ height=Dimension(min=1, max=max_height),
+ scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset),
+ right_margins=[ScrollbarMargin(display_arrows=display_arrows)],
+ dont_extend_width=True,
+ style="class:completion-menu",
+ z_index=z_index,
+ ),
+ # Show when there are completions but not at the point we are
+ # returning the input.
+ filter=extra_filter & has_completions & ~is_done,
+ )
+
+
+class MultiColumnCompletionMenuControl(UIControl):
+ """
+ Completion menu that displays all the completions in several columns.
+ When there are more completions than space for them to be displayed, an
+ arrow is shown on the left or right side.
+
+ `min_rows` indicates how many rows will be available in any possible case.
+ When this is larger than one, it will try to use less columns and more
+ rows until this value is reached.
+ Be careful passing in a too big value, if less than the given amount of
+ rows are available, more columns would have been required, but
+ `preferred_width` doesn't know about that and reports a too small value.
+ This results in less completions displayed and additional scrolling.
+ (It's a limitation of how the layout engine currently works: first the
+ widths are calculated, then the heights.)
+
+ :param suggested_max_column_width: The suggested max width of a column.
+ The column can still be bigger than this, but if there is place for two
+ columns of this width, we will display two columns. This to avoid that
+ if there is one very wide completion, that it doesn't significantly
+ reduce the amount of columns.
+ """
+
+ _required_margin = 3 # One extra padding on the right + space for arrows.
+
+ def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None:
+ assert min_rows >= 1
+
+ self.min_rows = min_rows
+ self.suggested_max_column_width = suggested_max_column_width
+ self.scroll = 0
+
+ # Cache for column width computations. This computation is not cheap,
+ # so we don't want to do it over and over again while the user
+ # navigates through the completions.
+ # (map `completion_state` to `(completion_count, width)`. We remember
+ # the count, because a completer can add new completions to the
+ # `CompletionState` while loading.)
+ self._column_width_for_completion_state: WeakKeyDictionary[
+ CompletionState, tuple[int, int]
+ ] = WeakKeyDictionary()
+
+ # Info of last rendering.
+ self._rendered_rows = 0
+ self._rendered_columns = 0
+ self._total_columns = 0
+ self._render_pos_to_completion: dict[tuple[int, int], Completion] = {}
+ self._render_left_arrow = False
+ self._render_right_arrow = False
+ self._render_width = 0
+
+ def reset(self) -> None:
+ self.scroll = 0
+
+ def has_focus(self) -> bool:
+ return False
+
+ def preferred_width(self, max_available_width: int) -> int | None:
+ """
+ Preferred width: prefer to use at least min_rows, but otherwise as much
+ as possible horizontally.
+ """
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state is None:
+ return 0
+
+ column_width = self._get_column_width(complete_state)
+ result = int(
+ column_width
+ * math.ceil(len(complete_state.completions) / float(self.min_rows))
+ )
+
+ # When the desired width is still more than the maximum available,
+ # reduce by removing columns until we are less than the available
+ # width.
+ while (
+ result > column_width
+ and result > max_available_width - self._required_margin
+ ):
+ result -= column_width
+ return result + self._required_margin
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ """
+ Preferred height: as much as needed in order to display all the completions.
+ """
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state is None:
+ return 0
+
+ column_width = self._get_column_width(complete_state)
+ column_count = max(1, (width - self._required_margin) // column_width)
+
+ return int(math.ceil(len(complete_state.completions) / float(column_count)))
+
+ def create_content(self, width: int, height: int) -> UIContent:
+ """
+ Create a UIContent object for this menu.
+ """
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state is None:
+ return UIContent()
+
+ column_width = self._get_column_width(complete_state)
+ self._render_pos_to_completion = {}
+
+ _T = TypeVar("_T")
+
+ def grouper(
+ n: int, iterable: Iterable[_T], fillvalue: _T | None = None
+ ) -> Iterable[Sequence[_T | None]]:
+ "grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx"
+ args = [iter(iterable)] * n
+ return zip_longest(fillvalue=fillvalue, *args)
+
+ def is_current_completion(completion: Completion) -> bool:
+ "Returns True when this completion is the currently selected one."
+ return (
+ complete_state is not None
+ and complete_state.complete_index is not None
+ and c == complete_state.current_completion
+ )
+
+ # Space required outside of the regular columns, for displaying the
+ # left and right arrow.
+ HORIZONTAL_MARGIN_REQUIRED = 3
+
+ # There should be at least one column, but it cannot be wider than
+ # the available width.
+ column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width)
+
+ # However, when the columns tend to be very wide, because there are
+ # some very wide entries, shrink it anyway.
+ if column_width > self.suggested_max_column_width:
+ # `column_width` can still be bigger that `suggested_max_column_width`,
+ # but if there is place for two columns, we divide by two.
+ column_width //= column_width // self.suggested_max_column_width
+
+ visible_columns = max(1, (width - self._required_margin) // column_width)
+
+ columns_ = list(grouper(height, complete_state.completions))
+ rows_ = list(zip(*columns_))
+
+ # Make sure the current completion is always visible: update scroll offset.
+ selected_column = (complete_state.complete_index or 0) // height
+ self.scroll = min(
+ selected_column, max(self.scroll, selected_column - visible_columns + 1)
+ )
+
+ render_left_arrow = self.scroll > 0
+ render_right_arrow = self.scroll < len(rows_[0]) - visible_columns
+
+ # Write completions to screen.
+ fragments_for_line = []
+
+ for row_index, row in enumerate(rows_):
+ fragments: StyleAndTextTuples = []
+ middle_row = row_index == len(rows_) // 2
+
+ # Draw left arrow if we have hidden completions on the left.
+ if render_left_arrow:
+ fragments.append(("class:scrollbar", "<" if middle_row else " "))
+ elif render_right_arrow:
+ # Reserve one column empty space. (If there is a right
+ # arrow right now, there can be a left arrow as well.)
+ fragments.append(("", " "))
+
+ # Draw row content.
+ for column_index, c in enumerate(row[self.scroll :][:visible_columns]):
+ if c is not None:
+ fragments += _get_menu_item_fragments(
+ c, is_current_completion(c), column_width, space_after=False
+ )
+
+ # Remember render position for mouse click handler.
+ for x in range(column_width):
+ self._render_pos_to_completion[
+ (column_index * column_width + x, row_index)
+ ] = c
+ else:
+ fragments.append(("class:completion", " " * column_width))
+
+ # Draw trailing padding for this row.
+ # (_get_menu_item_fragments only returns padding on the left.)
+ if render_left_arrow or render_right_arrow:
+ fragments.append(("class:completion", " "))
+
+ # Draw right arrow if we have hidden completions on the right.
+ if render_right_arrow:
+ fragments.append(("class:scrollbar", ">" if middle_row else " "))
+ elif render_left_arrow:
+ fragments.append(("class:completion", " "))
+
+ # Add line.
+ fragments_for_line.append(
+ to_formatted_text(fragments, style="class:completion-menu")
+ )
+
+ self._rendered_rows = height
+ self._rendered_columns = visible_columns
+ self._total_columns = len(columns_)
+ self._render_left_arrow = render_left_arrow
+ self._render_right_arrow = render_right_arrow
+ self._render_width = (
+ column_width * visible_columns + render_left_arrow + render_right_arrow + 1
+ )
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ return fragments_for_line[i]
+
+ return UIContent(get_line=get_line, line_count=len(rows_))
+
+ def _get_column_width(self, completion_state: CompletionState) -> int:
+ """
+ Return the width of each column.
+ """
+ try:
+ count, width = self._column_width_for_completion_state[completion_state]
+ if count != len(completion_state.completions):
+ # Number of completions changed, recompute.
+ raise KeyError
+ return width
+ except KeyError:
+ result = (
+ max(get_cwidth(c.display_text) for c in completion_state.completions)
+ + 1
+ )
+ self._column_width_for_completion_state[completion_state] = (
+ len(completion_state.completions),
+ result,
+ )
+ return result
+
+ def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Handle scroll and click events.
+ """
+ b = get_app().current_buffer
+
+ def scroll_left() -> None:
+ b.complete_previous(count=self._rendered_rows, disable_wrap_around=True)
+ self.scroll = max(0, self.scroll - 1)
+
+ def scroll_right() -> None:
+ b.complete_next(count=self._rendered_rows, disable_wrap_around=True)
+ self.scroll = min(
+ self._total_columns - self._rendered_columns, self.scroll + 1
+ )
+
+ if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
+ scroll_right()
+
+ elif mouse_event.event_type == MouseEventType.SCROLL_UP:
+ scroll_left()
+
+ elif mouse_event.event_type == MouseEventType.MOUSE_UP:
+ x = mouse_event.position.x
+ y = mouse_event.position.y
+
+ # Mouse click on left arrow.
+ if x == 0:
+ if self._render_left_arrow:
+ scroll_left()
+
+ # Mouse click on right arrow.
+ elif x == self._render_width - 1:
+ if self._render_right_arrow:
+ scroll_right()
+
+ # Mouse click on completion.
+ else:
+ completion = self._render_pos_to_completion.get((x, y))
+ if completion:
+ b.apply_completion(completion)
+
+ return None
+
+ def get_key_bindings(self) -> KeyBindings:
+ """
+ Expose key bindings that handle the left/right arrow keys when the menu
+ is displayed.
+ """
+ from prompt_toolkit.key_binding.key_bindings import KeyBindings
+
+ kb = KeyBindings()
+
+ @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 = extra_filter & has_completions & ~is_done
+
+ @Condition
+ def any_completion_has_meta() -> bool:
+ complete_state = get_app().current_buffer.complete_state
+ return complete_state is not None and any(
+ c.display_meta for c in complete_state.completions
+ )
+
+ # Create child windows.
+ # NOTE: We don't set style='class:completion-menu' to the
+ # `MultiColumnCompletionMenuControl`, because this is used in a
+ # Float that is made transparent, and the size of the control
+ # doesn't always correspond exactly with the size of the
+ # generated content.
+ completions_window = ConditionalContainer(
+ content=Window(
+ content=MultiColumnCompletionMenuControl(
+ min_rows=min_rows,
+ suggested_max_column_width=suggested_max_column_width,
+ ),
+ width=Dimension(min=8),
+ height=Dimension(min=1),
+ ),
+ filter=full_filter,
+ )
+
+ meta_window = ConditionalContainer(
+ content=Window(content=_SelectedCompletionMetaControl()),
+ filter=full_filter & show_meta & any_completion_has_meta,
+ )
+
+ # Initialize split.
+ super().__init__([completions_window, meta_window], z_index=z_index)
+
+
+class _SelectedCompletionMetaControl(UIControl):
+ """
+ Control that shows the meta information of the selected completion.
+ """
+
+ def preferred_width(self, max_available_width: int) -> int | None:
+ """
+ Report the width of the longest meta text as the preferred width of this control.
+
+ It could be that we use less width, but this way, we're sure that the
+ layout doesn't change when we select another completion (E.g. that
+ completions are suddenly shown in more or fewer columns.)
+ """
+ app = get_app()
+ if app.current_buffer.complete_state:
+ state = app.current_buffer.complete_state
+
+ if len(state.completions) >= 30:
+ # When there are many completions, calling `get_cwidth` for
+ # every `display_meta_text` is too expensive. In this case,
+ # just return the max available width. There will be enough
+ # columns anyway so that the whole screen is filled with
+ # completions and `create_content` will then take up as much
+ # space as needed.
+ return max_available_width
+
+ return 2 + max(
+ get_cwidth(c.display_meta_text) for c in state.completions[:100]
+ )
+ else:
+ return 0
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ return 1
+
+ def create_content(self, width: int, height: int) -> UIContent:
+ fragments = self._get_text_fragments()
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ return fragments
+
+ return UIContent(get_line=get_line, line_count=1 if fragments else 0)
+
+ def _get_text_fragments(self) -> StyleAndTextTuples:
+ style = "class:completion-menu.multi-column-meta"
+ state = get_app().current_buffer.complete_state
+
+ if (
+ state
+ and state.current_completion
+ and state.current_completion.display_meta_text
+ ):
+ return to_formatted_text(
+ cast(StyleAndTextTuples, [("", " ")])
+ + state.current_completion.display_meta
+ + [("", " ")],
+ style=style,
+ )
+
+ return []
diff --git a/src/prompt_toolkit/layout/mouse_handlers.py b/src/prompt_toolkit/layout/mouse_handlers.py
new file mode 100644
index 0000000..56a4edd
--- /dev/null
+++ b/src/prompt_toolkit/layout/mouse_handlers.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import TYPE_CHECKING, Callable
+
+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/src/prompt_toolkit/layout/processors.py b/src/prompt_toolkit/layout/processors.py
new file mode 100644
index 0000000..b737611
--- /dev/null
+++ b/src/prompt_toolkit/layout/processors.py
@@ -0,0 +1,1013 @@
+"""
+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.
+"""
+from __future__ import annotations
+
+import re
+from abc import ABCMeta, abstractmethod
+from typing import TYPE_CHECKING, Callable, Hashable, 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: SourceToDisplay | None = None,
+ display_to_source: DisplayToSource | None = 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 = f" class:{self._classname} "
+ searchmatch_current_fragment = f" class:{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: int | None
+ 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
+ # visualize 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: int | None
+
+ # 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: SourceToDisplay | None
+ display_to_source: DisplayToSource | None
+
+ 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 f"BeforeInput({self.text!r}, {self.style!r})"
+
+
+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 f"{self.__class__.__name__}({self.text!r}, style={self.style!r})"
+
+
+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: Callable[[], str] | None = 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: Callable[[], str] | None = 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: int | Callable[[], int] = 4,
+ char1: str | Callable[[], str] = "|",
+ char2: 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) -> BufferControl | None:
+ 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) -> Processor | None:
+ """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: SourceToDisplay | None
+ display_to_source: DisplayToSource | None
+
+ 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 "{}(processor={!r}, filter={!r})".format(
+ 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[[], Processor | None]) -> 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/src/prompt_toolkit/layout/screen.py b/src/prompt_toolkit/layout/screen.py
new file mode 100644
index 0000000..49aebbd
--- /dev/null
+++ b/src/prompt_toolkit/layout/screen.py
@@ -0,0 +1,329 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import TYPE_CHECKING, Callable
+
+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": "^]",
+ "\x1e": "^^",
+ "\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 f"{self.__class__.__name__}({self.char!r}, {self.style!r})"
+
+
+_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: Char | None = 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():
+ row[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 "{}(x={!r}, y={!r}, width={!r}, height={!r})".format(
+ self.__class__.__name__,
+ self.xpos,
+ self.ypos,
+ self.width,
+ self.height,
+ )
diff --git a/src/prompt_toolkit/layout/scrollable_pane.py b/src/prompt_toolkit/layout/scrollable_pane.py
new file mode 100644
index 0000000..e38fd76
--- /dev/null
+++ b/src/prompt_toolkit/layout/scrollable_pane.py
@@ -0,0 +1,494 @@
+from __future__ import annotations
+
+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 the
+ pane so that the focused window is visible, or as much visible as
+ possible if it doesn'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: ScrollOffsets | None = 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: int | None,
+ ) -> 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_screen.show_cursor = screen.show_cursor
+ 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) -> KeyBindingsBase | None:
+ 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: Point | None,
+ ) -> 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/src/prompt_toolkit/layout/utils.py b/src/prompt_toolkit/layout/utils.py
new file mode 100644
index 0000000..0f78f37
--- /dev/null
+++ b/src/prompt_toolkit/layout/utils.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Iterable, List, TypeVar, 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: SupportsIndex | slice, value: _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(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:
+ for c in string:
+ result.append((style, c, *rest)) # type: ignore
+
+ return _ExplodedList(result)