Spaces:
Sleeping
Sleeping
first-space
/
first-space-venv
/lib
/python3.12
/site-packages
/prompt_toolkit
/layout
/containers.py
""" | |
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. | |
""" | |
def reset(self) -> None: | |
""" | |
Reset the state of this container and all the children. | |
(E.g. reset scroll offsets, etc...) | |
""" | |
def preferred_width(self, max_available_width: int) -> Dimension: | |
""" | |
Return a :class:`~prompt_toolkit.layout.Dimension` that represents the | |
desired width for this container. | |
""" | |
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. | |
""" | |
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 | |
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() | |
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() | |
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 f"Float(content={self.content!r})" | |
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 | |
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() | |
} | |
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) | |
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, | |
) | |
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()) | |
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 | |
) | |
def content_height(self) -> int: | |
""" | |
The full height of the user control. | |
""" | |
return self.ui_content.line_count | |
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 | |
) | |
def top_visible(self) -> bool: | |
""" | |
True when the top of the buffer is visible. | |
""" | |
return self.vertical_scroll == 0 | |
def bottom_visible(self) -> bool: | |
""" | |
True when the bottom of the buffer is visible. | |
""" | |
return self.last_visible_line() == self.content_height - 1 | |
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 | |
def top(self) -> int: | |
return to_int(self._top) | |
def bottom(self) -> int: | |
return to_int(self._bottom) | |
def left(self) -> int: | |
return to_int(self._left) | |
def right(self) -> int: | |
return to_int(self._right) | |
def __repr__(self) -> str: | |
return f"ScrollOffsets(top={self._top!r}, bottom={self._bottom!r}, left={self._left!r}, right={self._right!r})" | |
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 f"Window(content={self.content!r})" | |
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(), | |
) | |
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 | |