Uploaded Test files

This commit is contained in:
Batuhan Berk Başoğlu 2020-11-12 11:05:57 -05:00
parent f584ad9d97
commit 2e81cb7d99
16627 changed files with 2065359 additions and 102444 deletions

View file

@ -0,0 +1,142 @@
"""
Command line layout definitions
-------------------------------
The layout of a command line interface is defined by a Container instance.
There are two main groups of classes here. Containers and controls:
- A container can contain other containers or controls, it can have multiple
children and it decides about the dimensions.
- A control is responsible for rendering the actual content to a screen.
A control can propose some dimensions, but it's the container who decides
about the dimensions -- or when the control consumes more space -- which part
of the control will be visible.
Container classes::
- Container (Abstract base class)
|- HSplit (Horizontal split)
|- VSplit (Vertical split)
|- FloatContainer (Container which can also contain menus and other floats)
`- Window (Container which contains one actual control
Control classes::
- UIControl (Abstract base class)
|- FormattedTextControl (Renders formatted text, or a simple list of text fragments)
`- BufferControl (Renders an input buffer.)
Usually, you end up wrapping every control inside a `Window` object, because
that's the only way to render it in a layout.
There are some prepared toolbars which are ready to use::
- SystemToolbar (Shows the 'system' input buffer, for entering system commands.)
- ArgToolbar (Shows the input 'arg', for repetition of input commands.)
- SearchToolbar (Shows the 'search' input buffer, for incremental search.)
- CompletionsToolbar (Shows the completions of the current buffer.)
- ValidationToolbar (Shows validation errors of the current buffer.)
And one prepared menu:
- CompletionsMenu
"""
from .containers import (
AnyContainer,
ColorColumn,
ConditionalContainer,
Container,
DynamicContainer,
Float,
FloatContainer,
HorizontalAlign,
HSplit,
ScrollOffsets,
VerticalAlign,
VSplit,
Window,
WindowAlign,
WindowRenderInfo,
is_container,
to_container,
to_window,
)
from .controls import (
BufferControl,
DummyControl,
FormattedTextControl,
SearchBufferControl,
UIContent,
UIControl,
)
from .dimension import (
AnyDimension,
D,
Dimension,
is_dimension,
max_layout_dimensions,
sum_layout_dimensions,
to_dimension,
)
from .layout import InvalidLayoutError, Layout, walk
from .margins import (
ConditionalMargin,
Margin,
NumberedMargin,
PromptMargin,
ScrollbarMargin,
)
from .menus import CompletionsMenu, MultiColumnCompletionsMenu
__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",
# Controls.
"BufferControl",
"SearchBufferControl",
"DummyControl",
"FormattedTextControl",
"UIControl",
"UIContent",
# Margins.
"Margin",
"NumberedMargin",
"ScrollbarMargin",
"ConditionalMargin",
"PromptMargin",
# Menus.
"CompletionsMenu",
"MultiColumnCompletionsMenu",
]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,947 @@
"""
User interface Controls for the layout.
"""
import time
from abc import ABCMeta, abstractmethod
from typing import (
TYPE_CHECKING,
Callable,
Dict,
Hashable,
Iterable,
List,
NamedTuple,
Optional,
Union,
)
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.cache import SimpleCache
from prompt_toolkit.data_structures import Point
from prompt_toolkit.document import Document
from prompt_toolkit.filters import FilterOrBool, to_filter
from prompt_toolkit.formatted_text import (
AnyFormattedText,
StyleAndTextTuples,
to_formatted_text,
)
from prompt_toolkit.formatted_text.utils import (
fragment_list_to_text,
fragment_list_width,
split_lines,
)
from prompt_toolkit.lexers import Lexer, SimpleLexer
from prompt_toolkit.mouse_events import 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
from prompt_toolkit.utils import Event
# The only two return values for a mouse hander are `None` and
# `NotImplemented`. For the type checker it's best to annotate this as
# `object`. (The consumer never expects a more specific instance: checking
# for NotImplemented can be done using `is NotImplemented`.)
NotImplementedOrNone = object
# Other non-working options are:
# * Optional[Literal[NotImplemented]]
# --> Doesn't work, Literal can't take an Any.
# * None
# --> Doesn't work. We can't assign the result of a function that
# returns `None` to a variable.
# * Any
# --> Works, but too broad.
__all__ = [
"BufferControl",
"SearchBufferControl",
"DummyControl",
"FormattedTextControl",
"UIControl",
"UIContent",
]
GetLinePrefixCallable = Callable[[int, int], AnyFormattedText]
class UIControl(metaclass=ABCMeta):
"""
Base class for all user interface controls.
"""
def reset(self) -> None:
# Default reset. (Doesn't have to be implemented.)
pass
def preferred_width(self, max_available_width: int) -> Optional[int]:
return None
def preferred_height(
self,
width: int,
max_available_height: int,
wrap_lines: bool,
get_line_prefix: Optional[GetLinePrefixCallable],
) -> Optional[int]:
return None
def is_focusable(self) -> bool:
"""
Tell whether this user control is focusable.
"""
return False
@abstractmethod
def create_content(self, width: int, height: int) -> "UIContent":
"""
Generate the content for this user control.
Returns a :class:`.UIContent` instance.
"""
def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone":
"""
Handle mouse events.
When `NotImplemented` is returned, it means that the given event is not
handled by the `UIControl` itself. The `Window` or key bindings can
decide to handle this event as scrolling or changing focus.
:param mouse_event: `MouseEvent` instance.
"""
return NotImplemented
def move_cursor_down(self) -> None:
"""
Request to move the cursor down.
This happens when scrolling down and the cursor is completely at the
top.
"""
def move_cursor_up(self) -> None:
"""
Request to move the cursor up.
"""
def get_key_bindings(self) -> Optional["KeyBindingsBase"]:
"""
The key bindings that are specific for this user control.
Return a :class:`.KeyBindings` object if some key bindings are
specified, or `None` otherwise.
"""
def get_invalidate_events(self) -> Iterable["Event[object]"]:
"""
Return a list of `Event` objects. This can be a generator.
(The application collects all these events, in order to bind redraw
handlers to these events.)
"""
return []
class UIContent:
"""
Content generated by a user control. This content consists of a list of
lines.
:param get_line: Callable that takes a line number and returns the current
line. This is a list of (style_str, text) tuples.
:param line_count: The number of lines.
:param cursor_position: a :class:`.Point` for the cursor position.
:param menu_position: a :class:`.Point` for the menu position.
:param show_cursor: Make the cursor visible.
"""
def __init__(
self,
get_line: Callable[[int], StyleAndTextTuples] = (lambda i: []),
line_count: int = 0,
cursor_position: Optional[Point] = None,
menu_position: Optional[Point] = None,
show_cursor: bool = True,
):
self.get_line = get_line
self.line_count = line_count
self.cursor_position = cursor_position or Point(x=0, y=0)
self.menu_position = menu_position
self.show_cursor = show_cursor
# Cache for line heights. Maps cache key -> height
self._line_heights_cache: Dict[Hashable, int] = {}
def __getitem__(self, lineno: int) -> StyleAndTextTuples:
" Make it iterable (iterate line by line). "
if lineno < self.line_count:
return self.get_line(lineno)
else:
raise IndexError
def get_height_for_line(
self,
lineno: int,
width: int,
get_line_prefix: Optional[GetLinePrefixCallable],
slice_stop: Optional[int] = None,
) -> int:
"""
Return the height that a given line would need if it is rendered in a
space with the given width (using line wrapping).
:param get_line_prefix: None or a `Window.get_line_prefix` callable
that returns the prefix to be inserted before this line.
:param slice_stop: Wrap only "line[:slice_stop]" and return that
partial result. This is needed for scrolling the window correctly
when line wrapping.
:returns: The computed height.
"""
# Instead of using `get_line_prefix` as key, we use render_counter
# instead. This is more reliable, because this function could still be
# the same, while the content would change over time.
key = get_app().render_counter, lineno, width, slice_stop
try:
return self._line_heights_cache[key]
except KeyError:
if width == 0:
height = 10 ** 8
else:
# Calculate line width first.
line = fragment_list_to_text(self.get_line(lineno))[:slice_stop]
text_width = get_cwidth(line)
if get_line_prefix:
# Add prefix width.
text_width += fragment_list_width(
to_formatted_text(get_line_prefix(lineno, 0))
)
# Slower path: compute path when there's a line prefix.
height = 1
# Keep wrapping as long as the line doesn't fit.
# Keep adding new prefixes for every wrapped line.
while text_width > width:
height += 1
text_width -= width
fragments2 = to_formatted_text(
get_line_prefix(lineno, height - 1)
)
prefix_width = get_cwidth(fragment_list_to_text(fragments2))
if prefix_width >= width: # Prefix doesn't fit.
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: Optional["KeyBindingsBase"] = None,
show_cursor: bool = True,
modal: bool = False,
get_cursor_position: Optional[Callable[[], Optional[Point]]] = None,
) -> None:
self.text = text # No type check on 'text'. This is done dynamically.
self.style = style
self.focusable = to_filter(focusable)
# Key bindings.
self.key_bindings = key_bindings
self.show_cursor = show_cursor
self.modal = modal
self.get_cursor_position = get_cursor_position
#: Cache for the content.
self._content_cache: SimpleCache[Hashable, UIContent] = SimpleCache(maxsize=18)
self._fragment_cache: SimpleCache[int, StyleAndTextTuples] = SimpleCache(
maxsize=1
)
# Only cache one fragment list. We don't need the previous item.
# Render info for the mouse support.
self._fragments: Optional[StyleAndTextTuples] = None
def reset(self) -> None:
self._fragments = None
def is_focusable(self) -> bool:
return self.focusable()
def __repr__(self) -> str:
return "%s(%r)" % (self.__class__.__name__, self.text)
def _get_formatted_text_cached(self) -> StyleAndTextTuples:
"""
Get fragments, but only retrieve fragments once during one render run.
(This function is called several times during one rendering, because
we also need those for calculating the dimensions.)
"""
return self._fragment_cache.get(
get_app().render_counter, lambda: to_formatted_text(self.text, self.style)
)
def preferred_width(self, max_available_width: int) -> int:
"""
Return the preferred width for this control.
That is the width of the longest line.
"""
text = fragment_list_to_text(self._get_formatted_text_cached())
line_lengths = [get_cwidth(l) for l in text.split("\n")]
return max(line_lengths)
def preferred_height(
self,
width: int,
max_available_height: int,
wrap_lines: bool,
get_line_prefix: Optional[GetLinePrefixCallable],
) -> Optional[int]:
content = self.create_content(width, None)
if wrap_lines:
height = 0
for i in range(content.line_count):
height += content.get_height_for_line(i, width, get_line_prefix)
if height >= max_available_height:
return max_available_height
return height
else:
return content.line_count
def create_content(self, width: int, height: Optional[int]) -> UIContent:
# Get fragments
fragments_with_mouse_handlers = self._get_formatted_text_cached()
fragment_lines_with_mouse_handlers = list(
split_lines(fragments_with_mouse_handlers)
)
# Strip mouse handlers from fragments.
fragment_lines: List[StyleAndTextTuples] = [
[(item[0], item[1]) for item in line]
for line in fragment_lines_with_mouse_handlers
]
# Keep track of the fragments with mouse handler, for later use in
# `mouse_handler`.
self._fragments = fragments_with_mouse_handlers
# If there is a `[SetCursorPosition]` in the fragment list, set the
# cursor position here.
def get_cursor_position(
fragment: str = "[SetCursorPosition]",
) -> Optional[Point]:
for y, line in enumerate(fragment_lines):
x = 0
for style_str, text, *_ in line:
if fragment in style_str:
return Point(x=x, y=y)
x += len(text)
return None
# If there is a `[SetMenuPosition]`, set the menu over here.
def get_menu_position() -> Optional[Point]:
return get_cursor_position("[SetMenuPosition]")
cursor_position = (self.get_cursor_position or get_cursor_position)()
# Create content, or take it from the cache.
key = (tuple(fragments_with_mouse_handlers), width, cursor_position)
def get_content() -> UIContent:
return UIContent(
get_line=lambda i: fragment_lines[i],
line_count=len(fragment_lines),
show_cursor=self.show_cursor,
cursor_position=cursor_position,
menu_position=get_menu_position(),
)
return self._content_cache.get(key, get_content)
def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone":
"""
Handle mouse events.
(When the fragment list contained mouse handlers and the user clicked on
on any of these, the matching handler is called. This handler can still
return `NotImplemented` in case we want the
:class:`~prompt_toolkit.layout.Window` to handle this particular
event.)
"""
if self._fragments:
# Read the generator.
fragments_for_line = list(split_lines(self._fragments))
try:
fragments = fragments_for_line[mouse_event.position.y]
except IndexError:
return NotImplemented
else:
# Find position in the fragment list.
xpos = mouse_event.position.x
# Find mouse handler for this character.
count = 0
for item in fragments:
count += len(item[1])
if count >= xpos:
if len(item) >= 3:
# Handler found. Call it.
# (Handler can return NotImplemented, so return
# that result.)
handler = item[2] # type: ignore
return handler(mouse_event)
else:
break
# Otherwise, don't handle here.
return NotImplemented
def is_modal(self) -> bool:
return self.modal
def get_key_bindings(self) -> Optional["KeyBindingsBase"]:
return self.key_bindings
class DummyControl(UIControl):
"""
A dummy control object that doesn't paint any content.
Useful for filling a :class:`~prompt_toolkit.layout.Window`. (The
`fragment` and `char` attributes of the `Window` class can be used to
define the filling.)
"""
def create_content(self, width: int, height: int) -> UIContent:
def get_line(i: int) -> StyleAndTextTuples:
return []
return UIContent(
get_line=get_line, line_count=100 ** 100
) # Something very big.
def is_focusable(self) -> bool:
return False
_ProcessedLine = NamedTuple(
"_ProcessedLine",
[
("fragments", StyleAndTextTuples),
("source_to_display", Callable[[int], int]),
("display_to_source", Callable[[int], int]),
],
)
class BufferControl(UIControl):
"""
Control for visualising the content of a :class:`.Buffer`.
:param buffer: The :class:`.Buffer` object to be displayed.
:param input_processors: A list of
:class:`~prompt_toolkit.layout.processors.Processor` objects.
:param include_default_input_processors: When True, include the default
processors for highlighting of selection, search and displaying of
multiple cursors.
:param lexer: :class:`.Lexer` instance for syntax highlighting.
:param preview_search: `bool` or :class:`.Filter`: Show search while
typing. When this is `True`, probably you want to add a
``HighlightIncrementalSearchProcessor`` as well. Otherwise only the
cursor position will move, but the text won't be highlighted.
:param focusable: `bool` or :class:`.Filter`: Tell whether this control is focusable.
:param focus_on_click: Focus this buffer when it's click, but not yet focused.
:param key_bindings: a :class:`.KeyBindings` object.
"""
def __init__(
self,
buffer: Optional[Buffer] = None,
input_processors: Optional[List[Processor]] = None,
include_default_input_processors: bool = True,
lexer: Optional[Lexer] = None,
preview_search: FilterOrBool = False,
focusable: FilterOrBool = True,
search_buffer_control: Union[
None, "SearchBufferControl", Callable[[], "SearchBufferControl"]
] = None,
menu_position: Optional[Callable] = None,
focus_on_click: FilterOrBool = False,
key_bindings: Optional["KeyBindingsBase"] = None,
):
self.input_processors = input_processors
self.include_default_input_processors = include_default_input_processors
self.default_input_processors = [
HighlightSearchProcessor(),
HighlightIncrementalSearchProcessor(),
HighlightSelectionProcessor(),
DisplayMultipleCursors(),
]
self.preview_search = to_filter(preview_search)
self.focusable = to_filter(focusable)
self.focus_on_click = to_filter(focus_on_click)
self.buffer = buffer or Buffer()
self.menu_position = menu_position
self.lexer = lexer or SimpleLexer()
self.key_bindings = key_bindings
self._search_buffer_control = search_buffer_control
#: Cache for the lexer.
#: Often, due to cursor movement, undo/redo and window resizing
#: operations, it happens that a short time, the same document has to be
#: lexed. This is a fairly easy way to cache such an expensive operation.
self._fragment_cache: SimpleCache[
Hashable, Callable[[int], StyleAndTextTuples]
] = SimpleCache(maxsize=8)
self._last_click_timestamp: Optional[float] = None
self._last_get_processed_line: Optional[Callable[[int], _ProcessedLine]] = None
def __repr__(self) -> str:
return "<%s buffer=%r at %r>" % (self.__class__.__name__, self.buffer, id(self))
@property
def search_buffer_control(self) -> Optional["SearchBufferControl"]:
result: Optional[SearchBufferControl]
if callable(self._search_buffer_control):
result = self._search_buffer_control()
else:
result = self._search_buffer_control
assert result is None or isinstance(result, SearchBufferControl)
return result
@property
def search_buffer(self) -> Optional[Buffer]:
control = self.search_buffer_control
if control is not None:
return control.buffer
return None
@property
def search_state(self) -> SearchState:
"""
Return the `SearchState` for searching this `BufferControl`. This is
always associated with the search control. If one search bar is used
for searching multiple `BufferControls`, then they share the same
`SearchState`.
"""
search_buffer_control = self.search_buffer_control
if search_buffer_control:
return search_buffer_control.searcher_search_state
else:
return SearchState()
def is_focusable(self) -> bool:
return self.focusable()
def preferred_width(self, max_available_width: int) -> Optional[int]:
"""
This should return the preferred width.
Note: We don't specify a preferred width according to the content,
because it would be too expensive. Calculating the preferred
width can be done by calculating the longest line, but this would
require applying all the processors to each line. This is
unfeasible for a larger document, and doing it for small
documents only would result in inconsistent behaviour.
"""
return None
def preferred_height(
self,
width: int,
max_available_height: int,
wrap_lines: bool,
get_line_prefix: Optional[GetLinePrefixCallable],
) -> Optional[int]:
# Calculate the content height, if it was drawn on a screen with the
# given width.
height = 0
content = self.create_content(width, height=1) # Pass a dummy '1' as height.
# When line wrapping is off, the height should be equal to the amount
# of lines.
if not wrap_lines:
return content.line_count
# When the number of lines exceeds the max_available_height, just
# return max_available_height. No need to calculate anything.
if content.line_count >= max_available_height:
return max_available_height
for i in range(content.line_count):
height += content.get_height_for_line(i, width, get_line_prefix)
if height >= max_available_height:
return max_available_height
return height
def _get_formatted_text_for_line_func(
self, document: Document
) -> Callable[[int], StyleAndTextTuples]:
"""
Create a function that returns the fragments for a given line.
"""
# Cache using `document.text`.
def get_formatted_text_for_line() -> Callable[[int], StyleAndTextTuples]:
return self.lexer.lex_document(document)
key = (document.text, self.lexer.invalidation_hash())
return self._fragment_cache.get(key, get_formatted_text_for_line)
def _create_get_processed_line_func(
self, document: Document, width: int, height: int
) -> Callable[[int], _ProcessedLine]:
"""
Create a function that takes a line number of the current document and
returns a _ProcessedLine(processed_fragments, source_to_display, display_to_source)
tuple.
"""
# Merge all input processors together.
input_processors = self.input_processors or []
if self.include_default_input_processors:
input_processors = self.default_input_processors + input_processors
merged_processor = merge_processors(input_processors)
def transform(lineno: int, fragments: StyleAndTextTuples) -> _ProcessedLine:
" Transform the fragments for a given line number. "
# Get cursor position at this line.
def source_to_display(i: int) -> int:
"""X position from the buffer to the x position in the
processed fragment list. By default, we start from the 'identity'
operation."""
return i
transformation = merged_processor.apply_transformation(
TransformationInput(
self, document, lineno, source_to_display, fragments, width, height
)
)
return _ProcessedLine(
transformation.fragments,
transformation.source_to_display,
transformation.display_to_source,
)
def create_func() -> Callable[[int], _ProcessedLine]:
get_line = self._get_formatted_text_for_line_func(document)
cache: Dict[int, _ProcessedLine] = {}
def get_processed_line(i: int) -> _ProcessedLine:
try:
return cache[i]
except KeyError:
processed_line = transform(i, get_line(i))
cache[i] = processed_line
return processed_line
return get_processed_line
return create_func()
def create_content(
self, width: int, height: int, preview_search: bool = False
) -> UIContent:
"""
Create a UIContent.
"""
buffer = self.buffer
# 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_UP:
# When the cursor was moved to another place, select the text.
# (The >1 is actually a small but acceptable workaround for
# selecting text in Vi navigation mode. In navigation mode,
# the cursor can never be after the text, so the cursor
# will be repositioned automatically.)
if abs(buffer.cursor_position - index) > 1:
buffer.start_selection(selection_type=SelectionType.CHARACTERS)
buffer.cursor_position = index
# Select word around cursor on double click.
# Two MOUSE_UP events in a short timespan are considered a double click.
double_click = (
self._last_click_timestamp
and time.time() - self._last_click_timestamp < 0.3
)
self._last_click_timestamp = time.time()
if double_click:
start, end = buffer.document.find_boundaries_of_current_word()
buffer.cursor_position += start
buffer.start_selection(selection_type=SelectionType.CHARACTERS)
buffer.cursor_position += end - start
else:
# Don't handle scroll events here.
return NotImplemented
# Not focused, but focusing on click events.
else:
if (
self.focus_on_click()
and mouse_event.event_type == MouseEventType.MOUSE_UP
):
# Focus happens on mouseup. (If we did this on mousedown, the
# up event will be received at the point where this widget is
# focused and be handled anyway.)
get_app().layout.current_control = self
else:
return NotImplemented
return None
def move_cursor_down(self) -> None:
b = self.buffer
b.cursor_position += b.document.get_cursor_down_position()
def move_cursor_up(self) -> None:
b = self.buffer
b.cursor_position += b.document.get_cursor_up_position()
def get_key_bindings(self) -> Optional["KeyBindingsBase"]:
"""
When additional key bindings are given. Return these.
"""
return self.key_bindings
def get_invalidate_events(self) -> Iterable["Event[object]"]:
"""
Return the Window invalidate events.
"""
# Whenever the buffer changes, the UI has to be updated.
yield self.buffer.on_text_changed
yield self.buffer.on_cursor_position_changed
yield self.buffer.on_completions_changed
yield self.buffer.on_suggestion_set
class SearchBufferControl(BufferControl):
"""
:class:`.BufferControl` which is used for searching another
:class:`.BufferControl`.
:param ignore_case: Search case insensitive.
"""
def __init__(
self,
buffer: Optional[Buffer] = None,
input_processors: Optional[List[Processor]] = None,
lexer: Optional[Lexer] = None,
focus_on_click: FilterOrBool = False,
key_bindings: Optional["KeyBindingsBase"] = None,
ignore_case: FilterOrBool = False,
):
super().__init__(
buffer=buffer,
input_processors=input_processors,
lexer=lexer,
focus_on_click=focus_on_click,
key_bindings=key_bindings,
)
# If this BufferControl is used as a search field for one or more other
# BufferControls, then represents the search state.
self.searcher_search_state = SearchState(ignore_case=ignore_case)

View file

@ -0,0 +1,214 @@
"""
Layout dimensions are used to give the minimum, maximum and preferred
dimensions for containers and controls.
"""
from typing import Any, Callable, List, Optional, Union
__all__ = [
"Dimension",
"D",
"sum_layout_dimensions",
"max_layout_dimensions",
"AnyDimension",
"to_dimension",
"is_dimension",
]
class Dimension:
"""
Specified dimension (width/height) of a user control or window.
The layout engine tries to honor the preferred size. If that is not
possible, because the terminal is larger or smaller, it tries to keep in
between min and max.
:param min: Minimum size.
:param max: Maximum size.
:param weight: For a VSplit/HSplit, the actual size will be determined
by taking the proportion of weights from all the children.
E.g. When there are two children, one with a weight of 1,
and the other with a weight of 2, the second will always be
twice as big as the first, if the min/max values allow it.
:param preferred: Preferred size.
"""
def __init__(
self,
min: Optional[int] = None,
max: Optional[int] = None,
weight: Optional[int] = None,
preferred: Optional[int] = None,
) -> None:
if weight is not None:
assert weight >= 0 # Also cannot be a float.
assert min is None or min >= 0
assert max is None or max >= 0
assert preferred is None or preferred >= 0
self.min_specified = min is not None
self.max_specified = max is not None
self.preferred_specified = preferred is not None
self.weight_specified = weight is not None
if min is None:
min = 0 # Smallest possible value.
if max is None: # 0-values are allowed, so use "is None"
max = 1000 ** 10 # Something huge.
if preferred is None:
preferred = min
if weight is None:
weight = 1
self.min = min
self.max = max
self.preferred = preferred
self.weight = weight
# Don't allow situations where max < min. (This would be a bug.)
if max < min:
raise ValueError("Invalid Dimension: max < min.")
# Make sure that the 'preferred' size is always in the min..max range.
if self.preferred < self.min:
self.preferred = self.min
if self.preferred > self.max:
self.preferred = self.max
@classmethod
def exact(cls, amount: int) -> "Dimension":
"""
Return a :class:`.Dimension` with an exact size. (min, max and
preferred set to ``amount``).
"""
return cls(min=amount, max=amount, preferred=amount)
@classmethod
def zero(cls) -> "Dimension":
"""
Create a dimension that represents a zero size. (Used for 'invisible'
controls.)
"""
return cls.exact(amount=0)
def is_zero(self) -> bool:
" True if this `Dimension` represents a zero size. "
return self.preferred == 0 or self.max == 0
def __repr__(self) -> str:
fields = []
if self.min_specified:
fields.append("min=%r" % self.min)
if self.max_specified:
fields.append("max=%r" % self.max)
if self.preferred_specified:
fields.append("preferred=%r" % self.preferred)
if self.weight_specified:
fields.append("weight=%r" % self.weight)
return "Dimension(%s)" % ", ".join(fields)
def sum_layout_dimensions(dimensions: List[Dimension]) -> Dimension:
"""
Sum a list of :class:`.Dimension` instances.
"""
min = sum(d.min for d in dimensions)
max = sum(d.max for d in dimensions)
preferred = sum(d.preferred for d in dimensions)
return Dimension(min=min, max=max, preferred=preferred)
def max_layout_dimensions(dimensions: List[Dimension]) -> Dimension:
"""
Take the maximum of a list of :class:`.Dimension` instances.
Used when we have a HSplit/VSplit, and we want to get the best width/height.)
"""
if not len(dimensions):
return Dimension.zero()
# If all dimensions are size zero. Return zero.
# (This is important for HSplit/VSplit, to report the right values to their
# parent when all children are invisible.)
if all(d.is_zero() for d in dimensions):
return dimensions[0]
# Ignore empty dimensions. (They should not reduce the size of others.)
dimensions = [d for d in dimensions if not d.is_zero()]
if dimensions:
# Take the highest minimum dimension.
min_ = max(d.min for d in dimensions)
# For the maximum, we would prefer not to go larger than then smallest
# 'max' value, unless other dimensions have a bigger preferred value.
# This seems to work best:
# - We don't want that a widget with a small height in a VSplit would
# shrink other widgets in the split.
# If it doesn't work well enough, then it's up to the UI designer to
# explicitly pass dimensions.
max_ = min(d.max for d in dimensions)
max_ = max(max_, max(d.preferred for d in dimensions))
# Make sure that min>=max. In some scenarios, when certain min..max
# ranges don't have any overlap, we can end up in such an impossible
# situation. In that case, give priority to the max value.
# E.g. taking (1..5) and (8..9) would return (8..5). Instead take (8..8).
if min_ > max_:
max_ = min_
preferred = max(d.preferred for d in dimensions)
return Dimension(min=min_, max=max_, preferred=preferred)
else:
return Dimension()
# Anything that can be converted to a dimension.
AnyDimension = Union[
None, # None is a valid dimension that will fit anything.
int,
Dimension,
# Callable[[], 'AnyDimension'] # Recursive definition not supported by mypy.
Callable[[], Any],
]
def to_dimension(value: AnyDimension) -> Dimension:
"""
Turn the given object into a `Dimension` object.
"""
if value is None:
return Dimension()
if isinstance(value, int):
return Dimension.exact(value)
if isinstance(value, Dimension):
return value
if callable(value):
return to_dimension(value())
raise ValueError("Not an integer or Dimension object.")
def is_dimension(value: object) -> bool:
"""
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

View file

@ -0,0 +1,37 @@
"""
Dummy layout. Used when somebody creates an `Application` without specifying a
`Layout`.
"""
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from .containers import Window
from .controls import FormattedTextControl
from .dimension import D
from .layout import Layout
__all__ = [
"create_dummy_layout",
]
E = KeyPressEvent
def create_dummy_layout() -> Layout:
"""
Create a dummy layout for use in an 'Application' that doesn't have a
layout specified. When ENTER is pressed, the application quits.
"""
kb = KeyBindings()
@kb.add("enter")
def enter(event: E) -> None:
event.app.exit()
control = FormattedTextControl(
HTML("No layout specified. Press <reverse>ENTER</reverse> to quit."),
key_bindings=kb,
)
window = Window(content=control, height=D(min=1))
return Layout(container=window, focused_element=window)

View file

@ -0,0 +1,417 @@
"""
Wrapper for the layout.
"""
from typing import Dict, Generator, Iterable, List, Optional, Union
from prompt_toolkit.buffer import Buffer
from .containers import (
AnyContainer,
ConditionalContainer,
Container,
Window,
to_container,
)
from .controls import BufferControl, SearchBufferControl, UIControl
__all__ = [
"Layout",
"InvalidLayoutError",
"walk",
]
FocusableElement = Union[str, Buffer, UIControl, AnyContainer]
class Layout:
"""
The layout for a prompt_toolkit
:class:`~prompt_toolkit.application.Application`.
This also keeps track of which user control is focused.
:param container: The "root" container for the layout.
:param focused_element: element to be focused initially. (Can be anything
the `focus` function accepts.)
"""
def __init__(
self,
container: AnyContainer,
focused_element: Optional[FocusableElement] = None,
) -> None:
self.container = to_container(container)
self._stack: List[Window] = []
# Map search BufferControl back to the original BufferControl.
# This is used to keep track of when exactly we are searching, and for
# applying the search.
# When a link exists in this dictionary, that means the search is
# currently active.
# Map: search_buffer_control -> original buffer control.
self.search_links: Dict[SearchBufferControl, BufferControl] = {}
# Mapping that maps the children in the layout to their parent.
# This relationship is calculated dynamically, each time when the UI
# is rendered. (UI elements have only references to their children.)
self._child_to_parent: Dict[Container, Container] = {}
if focused_element is None:
try:
self._stack.append(next(self.find_all_windows()))
except StopIteration as e:
raise InvalidLayoutError(
"Invalid layout. The layout does not contain any Window object."
) from e
else:
self.focus(focused_element)
# List of visible windows.
self.visible_windows: List[Window] = [] # List of `Window` objects.
def __repr__(self) -> str:
return "Layout(%r, current_window=%r)" % (self.container, self.current_window)
def find_all_windows(self) -> Generator[Window, None, None]:
"""
Find all the :class:`.UIControl` objects in this layout.
"""
for item in self.walk():
if isinstance(item, Window):
yield item
def find_all_controls(self) -> Iterable[UIControl]:
for container in self.find_all_windows():
yield container.content
def focus(self, value: FocusableElement) -> None:
"""
Focus the given UI element.
`value` can be either:
- a :class:`.UIControl`
- a :class:`.Buffer` instance or the name of a :class:`.Buffer`
- a :class:`.Window`
- Any container object. In this case we will focus the :class:`.Window`
from this container that was focused most recent, or the very first
focusable :class:`.Window` of the container.
"""
# BufferControl by buffer name.
if isinstance(value, str):
for control in self.find_all_controls():
if isinstance(control, BufferControl) and control.buffer.name == value:
self.focus(control)
return
raise ValueError(
"Couldn't find Buffer in the current layout: %r." % (value,)
)
# BufferControl by buffer object.
elif isinstance(value, Buffer):
for control in self.find_all_controls():
if isinstance(control, BufferControl) and control.buffer == value:
self.focus(control)
return
raise ValueError(
"Couldn't find Buffer in the current layout: %r." % (value,)
)
# Focus UIControl.
elif isinstance(value, UIControl):
if value not in self.find_all_controls():
raise ValueError(
"Invalid value. Container does not appear in the layout."
)
if not value.is_focusable():
raise ValueError("Invalid value. UIControl is not focusable.")
self.current_control = value
# Otherwise, expecting any Container object.
else:
value = to_container(value)
if isinstance(value, Window):
# This is a `Window`: focus that.
if value not in self.find_all_windows():
raise ValueError(
"Invalid value. Window does not appear in the layout: %r"
% (value,)
)
self.current_window = value
else:
# Focus a window in this container.
# If we have many windows as part of this container, and some
# of them have been focused before, take the last focused
# item. (This is very useful when the UI is composed of more
# complex sub components.)
windows = []
for c in walk(value, skip_hidden=True):
if isinstance(c, Window) and c.content.is_focusable():
windows.append(c)
# Take the first one that was focused before.
for w in reversed(self._stack):
if w in windows:
self.current_window = w
return
# None was focused before: take the very first focusable window.
if windows:
self.current_window = windows[0]
return
raise ValueError(
"Invalid value. Container cannot be focused: %r" % (value,)
)
def has_focus(self, value: FocusableElement) -> bool:
"""
Check whether the given control has the focus.
:param value: :class:`.UIControl` or :class:`.Window` instance.
"""
if isinstance(value, str):
if self.current_buffer is None:
return False
return self.current_buffer.name == value
if isinstance(value, Buffer):
return self.current_buffer == value
if isinstance(value, UIControl):
return self.current_control == value
else:
value = to_container(value)
if isinstance(value, Window):
return self.current_window == value
else:
# Check whether this "container" is focused. This is true if
# one of the elements inside is focused.
for element in walk(value):
if element == self.current_window:
return True
return False
@property
def current_control(self) -> UIControl:
"""
Get the :class:`.UIControl` to currently has the focus.
"""
return self._stack[-1].content
@current_control.setter
def current_control(self, control: UIControl) -> None:
"""
Set the :class:`.UIControl` to receive the focus.
"""
for window in self.find_all_windows():
if window.content == control:
self.current_window = window
return
raise ValueError("Control not found in the user interface.")
@property
def current_window(self) -> Window:
" Return the :class:`.Window` object that is currently focused. "
return self._stack[-1]
@current_window.setter
def current_window(self, value: Window):
" Set the :class:`.Window` object to be currently focused. "
self._stack.append(value)
@property
def is_searching(self) -> bool:
" True if we are searching right now. "
return self.current_control in self.search_links
@property
def search_target_buffer_control(self) -> Optional[BufferControl]:
"""
Return the :class:`.BufferControl` in which we are searching or `None`.
"""
# Not every `UIControl` is a `BufferControl`. This only applies to
# `BufferControl`.
control = self.current_control
if isinstance(control, SearchBufferControl):
return self.search_links.get(control)
else:
return None
def get_focusable_windows(self) -> Iterable[Window]:
"""
Return all the :class:`.Window` objects which are focusable (in the
'modal' area).
"""
for w in self.walk_through_modal_area():
if isinstance(w, Window) and w.content.is_focusable():
yield w
def get_visible_focusable_windows(self) -> List[Window]:
"""
Return a list of :class:`.Window` objects that are focusable.
"""
# focusable windows are windows that are visible, but also part of the
# modal container. Make sure to keep the ordering.
visible_windows = self.visible_windows
return [w for w in self.get_focusable_windows() if w in visible_windows]
@property
def current_buffer(self) -> Optional[Buffer]:
"""
The currently focused :class:`~.Buffer` or `None`.
"""
ui_control = self.current_control
if isinstance(ui_control, BufferControl):
return ui_control.buffer
return None
def get_buffer_by_name(self, buffer_name: str) -> Optional[Buffer]:
"""
Look in the layout for a buffer with the given name.
Return `None` when nothing was found.
"""
for w in self.walk():
if isinstance(w, Window) and isinstance(w.content, BufferControl):
if w.content.buffer.name == buffer_name:
return w.content.buffer
return None
@property
def buffer_has_focus(self) -> bool:
"""
Return `True` if the currently focused control is a
:class:`.BufferControl`. (For instance, used to determine whether the
default key bindings should be active or not.)
"""
ui_control = self.current_control
return isinstance(ui_control, BufferControl)
@property
def previous_control(self) -> UIControl:
"""
Get the :class:`.UIControl` to previously had the focus.
"""
try:
return self._stack[-2].content
except IndexError:
return self._stack[-1].content
def focus_last(self) -> None:
"""
Give the focus to the last focused control.
"""
if len(self._stack) > 1:
self._stack = self._stack[:-1]
def focus_next(self) -> None:
"""
Focus the next visible/focusable Window.
"""
windows = self.get_visible_focusable_windows()
if len(windows) > 0:
try:
index = windows.index(self.current_window)
except ValueError:
index = 0
else:
index = (index + 1) % len(windows)
self.focus(windows[index])
def focus_previous(self) -> None:
"""
Focus the previous visible/focusable Window.
"""
windows = self.get_visible_focusable_windows()
if len(windows) > 0:
try:
index = windows.index(self.current_window)
except ValueError:
index = 0
else:
index = (index - 1) % len(windows)
self.focus(windows[index])
def walk(self) -> Iterable[Container]:
"""
Walk through all the layout nodes (and their children) and yield them.
"""
for i in walk(self.container):
yield i
def walk_through_modal_area(self) -> Iterable[Container]:
"""
Walk through all the containers which are in the current 'modal' part
of the layout.
"""
# Go up in the tree, and find the root. (it will be a part of the
# layout, if the focus is in a modal part.)
root: Container = self.current_window
while not root.is_modal() and root in self._child_to_parent:
root = self._child_to_parent[root]
for container in walk(root):
yield container
def update_parents_relations(self) -> None:
"""
Update child->parent relationships mapping.
"""
parents = {}
def walk(e: Container) -> None:
for c in e.get_children():
parents[c] = e
walk(c)
walk(self.container)
self._child_to_parent = parents
def reset(self) -> None:
# Remove all search links when the UI starts.
# (Important, for instance when control-c is been pressed while
# searching. The prompt cancels, but next `run()` call the search
# links are still there.)
self.search_links.clear()
self.container.reset()
def get_parent(self, container: Container) -> Optional[Container]:
"""
Return the parent container for the given container, or ``None``, if it
wasn't found.
"""
try:
return self._child_to_parent[container]
except KeyError:
return None
class InvalidLayoutError(Exception):
pass
def walk(container: Container, skip_hidden: bool = False) -> Iterable[Container]:
"""
Walk through layout, starting at this container.
"""
# When `skip_hidden` is set, don't go into disabled ConditionalContainer containers.
if (
skip_hidden
and isinstance(container, ConditionalContainer)
and not container.filter()
):
return
yield container
for c in container.get_children():
# yield from walk(c)
yield from walk(c, skip_hidden=skip_hidden)

View file

@ -0,0 +1,305 @@
"""
Margin implementations for a :class:`~prompt_toolkit.layout.containers.Window`.
"""
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING, Callable, Optional
from prompt_toolkit.filters import FilterOrBool, to_filter
from prompt_toolkit.formatted_text import (
StyleAndTextTuples,
fragment_list_to_text,
to_formatted_text,
)
from prompt_toolkit.utils import get_cwidth
from .controls import UIContent
if TYPE_CHECKING:
from .containers import WindowRenderInfo
__all__ = [
"Margin",
"NumberedMargin",
"ScrollbarMargin",
"ConditionalMargin",
"PromptMargin",
]
class Margin(metaclass=ABCMeta):
"""
Base interface for a margin.
"""
@abstractmethod
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
"""
Return the width that this margin is going to consume.
:param get_ui_content: Callable that asks the user control to create
a :class:`.UIContent` instance. This can be used for instance to
obtain the number of lines.
"""
return 0
@abstractmethod
def create_margin(
self, window_render_info: "WindowRenderInfo", width: int, height: int
) -> StyleAndTextTuples:
"""
Creates a margin.
This should return a list of (style_str, text) tuples.
:param window_render_info:
:class:`~prompt_toolkit.layout.containers.WindowRenderInfo`
instance, generated after rendering and copying the visible part of
the :class:`~prompt_toolkit.layout.controls.UIControl` into the
:class:`~prompt_toolkit.layout.containers.Window`.
:param width: The width that's available for this margin. (As reported
by :meth:`.get_width`.)
:param height: The height that's available for this margin. (The height
of the :class:`~prompt_toolkit.layout.containers.Window`.)
"""
return []
class NumberedMargin(Margin):
"""
Margin that displays the line numbers.
:param relative: Number relative to the cursor position. Similar to the Vi
'relativenumber' option.
:param display_tildes: Display tildes after the end of the document, just
like Vi does.
"""
def __init__(
self, relative: FilterOrBool = False, display_tildes: FilterOrBool = False
) -> None:
self.relative = to_filter(relative)
self.display_tildes = to_filter(display_tildes)
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
line_count = get_ui_content().line_count
return max(3, len("%s" % line_count) + 1)
def create_margin(
self, window_render_info: "WindowRenderInfo", width: int, height: int
) -> StyleAndTextTuples:
relative = self.relative()
style = "class:line-number"
style_current = "class:line-number.current"
# Get current line number.
current_lineno = window_render_info.ui_content.cursor_position.y
# Construct margin.
result: StyleAndTextTuples = []
last_lineno = None
for y, lineno in enumerate(window_render_info.displayed_lines):
# Only display line number if this line is not a continuation of the previous line.
if lineno != last_lineno:
if lineno is None:
pass
elif lineno == current_lineno:
# Current line.
if relative:
# Left align current number in relative mode.
result.append((style_current, "%i" % (lineno + 1)))
else:
result.append(
(style_current, ("%i " % (lineno + 1)).rjust(width))
)
else:
# Other lines.
if relative:
lineno = abs(lineno - current_lineno) - 1
result.append((style, ("%i " % (lineno + 1)).rjust(width)))
last_lineno = lineno
result.append(("", "\n"))
# Fill with tildes.
if self.display_tildes():
while y < window_render_info.window_height:
result.append(("class:tilde", "~\n"))
y += 1
return result
class ConditionalMargin(Margin):
"""
Wrapper around other :class:`.Margin` classes to show/hide them.
"""
def __init__(self, margin: Margin, filter: FilterOrBool) -> None:
self.margin = margin
self.filter = to_filter(filter)
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
if self.filter():
return self.margin.get_width(get_ui_content)
else:
return 0
def create_margin(
self, window_render_info: "WindowRenderInfo", width: int, height: int
) -> StyleAndTextTuples:
if width and self.filter():
return self.margin.create_margin(window_render_info, width, height)
else:
return []
class ScrollbarMargin(Margin):
"""
Margin displaying a scrollbar.
:param display_arrows: Display scroll up/down arrows.
"""
def __init__(
self,
display_arrows: FilterOrBool = False,
up_arrow_symbol: str = "^",
down_arrow_symbol: str = "v",
) -> None:
self.display_arrows = to_filter(display_arrows)
self.up_arrow_symbol = up_arrow_symbol
self.down_arrow_symbol = down_arrow_symbol
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
return 1
def create_margin(
self, window_render_info: "WindowRenderInfo", width: int, height: int
) -> StyleAndTextTuples:
content_height = window_render_info.content_height
window_height = window_render_info.window_height
display_arrows = self.display_arrows()
if display_arrows:
window_height -= 2
try:
fraction_visible = len(window_render_info.displayed_lines) / float(
content_height
)
fraction_above = window_render_info.vertical_scroll / float(content_height)
scrollbar_height = int(
min(window_height, max(1, window_height * fraction_visible))
)
scrollbar_top = int(window_height * fraction_above)
except ZeroDivisionError:
return []
else:
def is_scroll_button(row: int) -> bool:
" True if we should display a button on this row. "
return scrollbar_top <= row <= scrollbar_top + scrollbar_height
# Up arrow.
result: StyleAndTextTuples = []
if display_arrows:
result.extend(
[
("class:scrollbar.arrow", self.up_arrow_symbol),
("class:scrollbar", "\n"),
]
)
# Scrollbar body.
scrollbar_background = "class:scrollbar.background"
scrollbar_background_start = "class:scrollbar.background,scrollbar.start"
scrollbar_button = "class:scrollbar.button"
scrollbar_button_end = "class:scrollbar.button,scrollbar.end"
for i in range(window_height):
if is_scroll_button(i):
if not is_scroll_button(i + 1):
# Give the last cell a different style, because we
# want to underline this.
result.append((scrollbar_button_end, " "))
else:
result.append((scrollbar_button, " "))
else:
if is_scroll_button(i + 1):
result.append((scrollbar_background_start, " "))
else:
result.append((scrollbar_background, " "))
result.append(("", "\n"))
# Down arrow
if display_arrows:
result.append(("class:scrollbar.arrow", self.down_arrow_symbol))
return result
class PromptMargin(Margin):
"""
[Deprecated]
Create margin that displays a prompt.
This can display one prompt at the first line, and a continuation prompt
(e.g, just dots) on all the following lines.
This `PromptMargin` implementation has been largely superseded in favor of
the `get_line_prefix` attribute of `Window`. The reason is that a margin is
always a fixed width, while `get_line_prefix` can return a variable width
prefix in front of every line, making it more powerful, especially for line
continuations.
:param get_prompt: Callable returns formatted text or a list of
`(style_str, type)` tuples to be shown as the prompt at the first line.
:param get_continuation: Callable that takes three inputs. The width (int),
line_number (int), and is_soft_wrap (bool). It should return formatted
text or a list of `(style_str, type)` tuples for the next lines of the
input.
"""
def __init__(
self,
get_prompt: Callable[[], StyleAndTextTuples],
get_continuation: Optional[
Callable[[int, int, bool], StyleAndTextTuples]
] = None,
) -> None:
self.get_prompt = get_prompt
self.get_continuation = get_continuation
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
" Width to report to the `Window`. "
# Take the width from the first line.
text = fragment_list_to_text(self.get_prompt())
return get_cwidth(text)
def create_margin(
self, window_render_info: "WindowRenderInfo", width: int, height: int
) -> StyleAndTextTuples:
get_continuation = self.get_continuation
result: StyleAndTextTuples = []
# First line.
result.extend(to_formatted_text(self.get_prompt()))
# Next lines.
if get_continuation:
last_y = None
for y in window_render_info.displayed_lines[1:]:
result.append(("", "\n"))
result.extend(
to_formatted_text(get_continuation(width, y, y == last_y))
)
last_y = y
return result

View file

@ -0,0 +1,720 @@
import math
from itertools import zip_longest
from typing import (
TYPE_CHECKING,
Callable,
Dict,
Iterable,
List,
Optional,
Tuple,
TypeVar,
Union,
cast,
)
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import CompletionState
from prompt_toolkit.completion import Completion
from prompt_toolkit.data_structures import Point
from prompt_toolkit.filters import (
Condition,
FilterOrBool,
has_completions,
is_done,
to_filter,
)
from prompt_toolkit.formatted_text import (
StyleAndTextTuples,
fragment_list_width,
to_formatted_text,
)
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.layout.utils import explode_text_fragments
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from prompt_toolkit.utils import get_cwidth
from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window
from .controls import GetLinePrefixCallable, UIContent, UIControl
from .dimension import Dimension
from .margins import ScrollbarMargin
if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_bindings import KeyBindings
NotImplementedOrNone = object
__all__ = [
"CompletionsMenu",
"MultiColumnCompletionsMenu",
]
E = KeyPressEvent
class CompletionsMenuControl(UIControl):
"""
Helper for drawing the complete menu to the screen.
:param scroll_offset: Number (integer) representing the preferred amount of
completions to be displayed before and after the current one. When this
is a very high number, the current completion will be shown in the
middle most of the time.
"""
# Preferred minimum size of the menu control.
# The CompletionsMenu class defines a width of 8, and there is a scrollbar
# of 1.)
MIN_WIDTH = 7
def has_focus(self) -> bool:
return False
def preferred_width(self, max_available_width: int) -> Optional[int]:
complete_state = get_app().current_buffer.complete_state
if complete_state:
menu_width = self._get_menu_width(500, complete_state)
menu_meta_width = self._get_menu_meta_width(500, complete_state)
return menu_width + menu_meta_width
else:
return 0
def preferred_height(
self,
width: int,
max_available_height: int,
wrap_lines: bool,
get_line_prefix: Optional[GetLinePrefixCallable],
) -> Optional[int]:
complete_state = get_app().current_buffer.complete_state
if complete_state:
return len(complete_state.completions)
else:
return 0
def create_content(self, width: int, height: int) -> UIContent:
"""
Create a UIContent object for this control.
"""
complete_state = get_app().current_buffer.complete_state
if complete_state:
completions = complete_state.completions
index = complete_state.complete_index # Can be None!
# Calculate width of completions menu.
menu_width = self._get_menu_width(width, complete_state)
menu_meta_width = self._get_menu_meta_width(
width - menu_width, complete_state
)
show_meta = self._show_meta(complete_state)
def get_line(i: int) -> StyleAndTextTuples:
c = completions[i]
is_current_completion = i == index
result = _get_menu_item_fragments(
c, is_current_completion, menu_width, space_after=True
)
if show_meta:
result += self._get_menu_item_meta_fragments(
c, is_current_completion, menu_meta_width
)
return result
return UIContent(
get_line=get_line,
cursor_position=Point(x=0, y=index or 0),
line_count=len(completions),
)
return UIContent()
def _show_meta(self, complete_state: CompletionState) -> bool:
"""
Return ``True`` if we need to show a column with meta information.
"""
return any(c.display_meta_text for c in complete_state.completions)
def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int:
"""
Return the width of the main column.
"""
return min(
max_width,
max(
self.MIN_WIDTH,
max(get_cwidth(c.display_text) for c in complete_state.completions) + 2,
),
)
def _get_menu_meta_width(
self, max_width: int, complete_state: CompletionState
) -> int:
"""
Return the width of the meta column.
"""
def meta_width(completion: Completion) -> int:
return get_cwidth(completion.display_meta_text)
if self._show_meta(complete_state):
return min(
max_width, max(meta_width(c) for c in complete_state.completions) + 2
)
else:
return 0
def _get_menu_item_meta_fragments(
self, completion: Completion, is_current_completion: bool, width: int
) -> StyleAndTextTuples:
if is_current_completion:
style_str = "class:completion-menu.meta.completion.current"
else:
style_str = "class:completion-menu.meta.completion"
text, tw = _trim_formatted_text(completion.display_meta, width - 2)
padding = " " * (width - 1 - tw)
return to_formatted_text(
cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
style=style_str,
)
def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone":
"""
Handle mouse events: clicking and scrolling.
"""
b = get_app().current_buffer
if mouse_event.event_type == MouseEventType.MOUSE_UP:
# Select completion.
b.go_to_completion(mouse_event.position.y)
b.complete_state = None
elif mouse_event.event_type == MouseEventType.SCROLL_DOWN:
# Scroll up.
b.complete_next(count=3, disable_wrap_around=True)
elif mouse_event.event_type == MouseEventType.SCROLL_UP:
# Scroll down.
b.complete_previous(count=3, disable_wrap_around=True)
return None
def _get_menu_item_fragments(
completion: Completion,
is_current_completion: bool,
width: int,
space_after: bool = False,
) -> StyleAndTextTuples:
"""
Get the style/text tuples for a menu item, styled and trimmed to the given
width.
"""
if is_current_completion:
style_str = "class:completion-menu.completion.current %s %s" % (
completion.style,
completion.selected_style,
)
else:
style_str = "class:completion-menu.completion " + completion.style
text, tw = _trim_formatted_text(
completion.display, (width - 2 if space_after else width - 1)
)
padding = " " * (width - 1 - tw)
return to_formatted_text(
cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
style=style_str,
)
def _trim_formatted_text(
formatted_text: StyleAndTextTuples, max_width: int
) -> Tuple[StyleAndTextTuples, int]:
"""
Trim the text to `max_width`, append dots when the text is too long.
Returns (text, width) tuple.
"""
width = fragment_list_width(formatted_text)
# When the text is too wide, trim it.
if width > max_width:
result = [] # Text fragments.
remaining_width = max_width - 3
for style_and_ch in explode_text_fragments(formatted_text):
ch_width = get_cwidth(style_and_ch[1])
if ch_width <= remaining_width:
result.append(style_and_ch)
remaining_width -= ch_width
else:
break
result.append(("", "..."))
return result, max_width - remaining_width
else:
return formatted_text, width
class CompletionsMenu(ConditionalContainer):
# NOTE: We use a pretty big z_index by default. Menus are supposed to be
# above anything else. We also want to make sure that the content is
# visible at the point where we draw this menu.
def __init__(
self,
max_height: Optional[int] = None,
scroll_offset: Union[int, Callable[[], int]] = 0,
extra_filter: FilterOrBool = True,
display_arrows: FilterOrBool = False,
z_index: int = 10 ** 8,
) -> None:
extra_filter = to_filter(extra_filter)
display_arrows = to_filter(display_arrows)
super().__init__(
content=Window(
content=CompletionsMenuControl(),
width=Dimension(min=8),
height=Dimension(min=1, max=max_height),
scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset),
right_margins=[ScrollbarMargin(display_arrows=display_arrows)],
dont_extend_width=True,
style="class:completion-menu",
z_index=z_index,
),
# Show when there are completions but not at the point we are
# returning the input.
filter=has_completions & ~is_done & extra_filter,
)
class MultiColumnCompletionMenuControl(UIControl):
"""
Completion menu that displays all the completions in several columns.
When there are more completions than space for them to be displayed, an
arrow is shown on the left or right side.
`min_rows` indicates how many rows will be available in any possible case.
When this is larger than one, it will try to use less columns and more
rows until this value is reached.
Be careful passing in a too big value, if less than the given amount of
rows are available, more columns would have been required, but
`preferred_width` doesn't know about that and reports a too small value.
This results in less completions displayed and additional scrolling.
(It's a limitation of how the layout engine currently works: first the
widths are calculated, then the heights.)
:param suggested_max_column_width: The suggested max width of a column.
The column can still be bigger than this, but if there is place for two
columns of this width, we will display two columns. This to avoid that
if there is one very wide completion, that it doesn't significantly
reduce the amount of columns.
"""
_required_margin = 3 # One extra padding on the right + space for arrows.
def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None:
assert min_rows >= 1
self.min_rows = min_rows
self.suggested_max_column_width = suggested_max_column_width
self.scroll = 0
# Info of last rendering.
self._rendered_rows = 0
self._rendered_columns = 0
self._total_columns = 0
self._render_pos_to_completion: Dict[Tuple[int, int], Completion] = {}
self._render_left_arrow = False
self._render_right_arrow = False
self._render_width = 0
def reset(self) -> None:
self.scroll = 0
def has_focus(self) -> bool:
return False
def preferred_width(self, max_available_width: int) -> Optional[int]:
"""
Preferred width: prefer to use at least min_rows, but otherwise as much
as possible horizontally.
"""
complete_state = get_app().current_buffer.complete_state
if complete_state is None:
return 0
column_width = self._get_column_width(complete_state)
result = int(
column_width
* math.ceil(len(complete_state.completions) / float(self.min_rows))
)
# When the desired width is still more than the maximum available,
# reduce by removing columns until we are less than the available
# width.
while (
result > column_width
and result > max_available_width - self._required_margin
):
result -= column_width
return result + self._required_margin
def preferred_height(
self,
width: int,
max_available_height: int,
wrap_lines: bool,
get_line_prefix: Optional[GetLinePrefixCallable],
) -> Optional[int]:
"""
Preferred height: as much as needed in order to display all the completions.
"""
complete_state = get_app().current_buffer.complete_state
if complete_state is None:
return 0
column_width = self._get_column_width(complete_state)
column_count = max(1, (width - self._required_margin) // column_width)
return int(math.ceil(len(complete_state.completions) / float(column_count)))
def create_content(self, width: int, height: int) -> UIContent:
"""
Create a UIContent object for this menu.
"""
complete_state = get_app().current_buffer.complete_state
if complete_state is None:
return UIContent()
column_width = self._get_column_width(complete_state)
self._render_pos_to_completion = {}
_T = TypeVar("_T")
def grouper(
n: int, iterable: Iterable[_T], fillvalue: Optional[_T] = None
) -> Iterable[List[_T]]:
" grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx "
args = [iter(iterable)] * n
return zip_longest(fillvalue=fillvalue, *args)
def is_current_completion(completion: Completion) -> bool:
" Returns True when this completion is the currently selected one. "
return (
complete_state is not None
and complete_state.complete_index is not None
and c == complete_state.current_completion
)
# Space required outside of the regular columns, for displaying the
# left and right arrow.
HORIZONTAL_MARGIN_REQUIRED = 3
# There should be at least one column, but it cannot be wider than
# the available width.
column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width)
# However, when the columns tend to be very wide, because there are
# some very wide entries, shrink it anyway.
if column_width > self.suggested_max_column_width:
# `column_width` can still be bigger that `suggested_max_column_width`,
# but if there is place for two columns, we divide by two.
column_width //= column_width // self.suggested_max_column_width
visible_columns = max(1, (width - self._required_margin) // column_width)
columns_ = list(grouper(height, complete_state.completions))
rows_ = list(zip(*columns_))
# Make sure the current completion is always visible: update scroll offset.
selected_column = (complete_state.complete_index or 0) // height
self.scroll = min(
selected_column, max(self.scroll, selected_column - visible_columns + 1)
)
render_left_arrow = self.scroll > 0
render_right_arrow = self.scroll < len(rows_[0]) - visible_columns
# Write completions to screen.
fragments_for_line = []
for row_index, row in enumerate(rows_):
fragments: StyleAndTextTuples = []
middle_row = row_index == len(rows_) // 2
# Draw left arrow if we have hidden completions on the left.
if render_left_arrow:
fragments.append(("class:scrollbar", "<" if middle_row else " "))
elif render_right_arrow:
# Reserve one column empty space. (If there is a right
# arrow right now, there can be a left arrow as well.)
fragments.append(("", " "))
# Draw row content.
for column_index, c in enumerate(row[self.scroll :][:visible_columns]):
if c is not None:
fragments += _get_menu_item_fragments(
c, is_current_completion(c), column_width, space_after=False
)
# Remember render position for mouse click handler.
for x in range(column_width):
self._render_pos_to_completion[
(column_index * column_width + x, row_index)
] = c
else:
fragments.append(("class:completion", " " * column_width))
# Draw trailing padding for this row.
# (_get_menu_item_fragments only returns padding on the left.)
if render_left_arrow or render_right_arrow:
fragments.append(("class:completion", " "))
# Draw right arrow if we have hidden completions on the right.
if render_right_arrow:
fragments.append(("class:scrollbar", ">" if middle_row else " "))
elif render_left_arrow:
fragments.append(("class:completion", " "))
# Add line.
fragments_for_line.append(
to_formatted_text(fragments, style="class:completion-menu")
)
self._rendered_rows = height
self._rendered_columns = visible_columns
self._total_columns = len(columns_)
self._render_left_arrow = render_left_arrow
self._render_right_arrow = render_right_arrow
self._render_width = (
column_width * visible_columns + render_left_arrow + render_right_arrow + 1
)
def get_line(i: int) -> StyleAndTextTuples:
return fragments_for_line[i]
return UIContent(get_line=get_line, line_count=len(rows_))
def _get_column_width(self, complete_state: CompletionState) -> int:
"""
Return the width of each column.
"""
return max(get_cwidth(c.display_text) for c in complete_state.completions) + 1
def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone":
"""
Handle scroll and click events.
"""
b = get_app().current_buffer
def scroll_left() -> None:
b.complete_previous(count=self._rendered_rows, disable_wrap_around=True)
self.scroll = max(0, self.scroll - 1)
def scroll_right() -> None:
b.complete_next(count=self._rendered_rows, disable_wrap_around=True)
self.scroll = min(
self._total_columns - self._rendered_columns, self.scroll + 1
)
if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
scroll_right()
elif mouse_event.event_type == MouseEventType.SCROLL_UP:
scroll_left()
elif mouse_event.event_type == MouseEventType.MOUSE_UP:
x = mouse_event.position.x
y = mouse_event.position.y
# Mouse click on left arrow.
if x == 0:
if self._render_left_arrow:
scroll_left()
# Mouse click on right arrow.
elif x == self._render_width - 1:
if self._render_right_arrow:
scroll_right()
# Mouse click on completion.
else:
completion = self._render_pos_to_completion.get((x, y))
if completion:
b.apply_completion(completion)
return None
def get_key_bindings(self) -> "KeyBindings":
"""
Expose key bindings that handle the left/right arrow keys when the menu
is displayed.
"""
from prompt_toolkit.key_binding.key_bindings import KeyBindings
kb = KeyBindings()
@Condition
def filter() -> bool:
" Only handle key bindings if this menu is visible. "
app = get_app()
complete_state = app.current_buffer.complete_state
# There need to be completions, and one needs to be selected.
if complete_state is None or complete_state.complete_index is None:
return False
# This menu needs to be visible.
return any(window.content == self for window in app.layout.visible_windows)
def move(right: bool = False) -> None:
buff = get_app().current_buffer
complete_state = buff.complete_state
if complete_state is not None and complete_state.complete_index is not None:
# Calculate new complete index.
new_index = complete_state.complete_index
if right:
new_index += self._rendered_rows
else:
new_index -= self._rendered_rows
if 0 <= new_index < len(complete_state.completions):
buff.go_to_completion(new_index)
# NOTE: the is_global is required because the completion menu will
# never be focussed.
@kb.add("left", is_global=True, filter=filter)
def _left(event: E) -> None:
move()
@kb.add("right", is_global=True, filter=filter)
def _right(event: E) -> None:
move(True)
return kb
class MultiColumnCompletionsMenu(HSplit):
"""
Container that displays the completions in several columns.
When `show_meta` (a :class:`~prompt_toolkit.filters.Filter`) evaluates
to True, it shows the meta information at the bottom.
"""
def __init__(
self,
min_rows: int = 3,
suggested_max_column_width: int = 30,
show_meta: FilterOrBool = True,
extra_filter: FilterOrBool = True,
z_index: int = 10 ** 8,
) -> None:
show_meta = to_filter(show_meta)
extra_filter = to_filter(extra_filter)
# Display filter: show when there are completions but not at the point
# we are returning the input.
full_filter = has_completions & ~is_done & extra_filter
@Condition
def any_completion_has_meta() -> bool:
complete_state = get_app().current_buffer.complete_state
return complete_state is not None and any(
c.display_meta for c in complete_state.completions
)
# Create child windows.
# NOTE: We don't set style='class:completion-menu' to the
# `MultiColumnCompletionMenuControl`, because this is used in a
# Float that is made transparent, and the size of the control
# doesn't always correspond exactly with the size of the
# generated content.
completions_window = ConditionalContainer(
content=Window(
content=MultiColumnCompletionMenuControl(
min_rows=min_rows,
suggested_max_column_width=suggested_max_column_width,
),
width=Dimension(min=8),
height=Dimension(min=1),
),
filter=full_filter,
)
meta_window = ConditionalContainer(
content=Window(content=_SelectedCompletionMetaControl()),
filter=show_meta & full_filter & any_completion_has_meta,
)
# Initialise split.
super().__init__([completions_window, meta_window], z_index=z_index)
class _SelectedCompletionMetaControl(UIControl):
"""
Control that shows the meta information of the selected completion.
"""
def preferred_width(self, max_available_width: int) -> Optional[int]:
"""
Report the width of the longest meta text as the preferred width of this control.
It could be that we use less width, but this way, we're sure that the
layout doesn't change when we select another completion (E.g. that
completions are suddenly shown in more or fewer columns.)
"""
app = get_app()
if app.current_buffer.complete_state:
state = app.current_buffer.complete_state
return 2 + max(get_cwidth(c.display_meta_text) for c in state.completions)
else:
return 0
def preferred_height(
self,
width: int,
max_available_height: int,
wrap_lines: bool,
get_line_prefix: Optional[GetLinePrefixCallable],
) -> Optional[int]:
return 1
def create_content(self, width: int, height: int) -> UIContent:
fragments = self._get_text_fragments()
def get_line(i: int) -> StyleAndTextTuples:
return fragments
return UIContent(get_line=get_line, line_count=1 if fragments else 0)
def _get_text_fragments(self) -> StyleAndTextTuples:
style = "class:completion-menu.multi-column-meta"
state = get_app().current_buffer.complete_state
if (
state
and state.current_completion
and state.current_completion.display_meta_text
):
return to_formatted_text(
cast(StyleAndTextTuples, [("", " ")])
+ state.current_completion.display_meta
+ [("", " ")],
style=style,
)
return []

View file

@ -0,0 +1,40 @@
from collections import defaultdict
from itertools import product
from typing import Callable, DefaultDict, Tuple
from prompt_toolkit.mouse_events import MouseEvent
__all__ = [
"MouseHandlers",
]
class MouseHandlers:
"""
Two dimensional raster of callbacks for mouse events.
"""
def __init__(self) -> None:
def dummy_callback(mouse_event: MouseEvent) -> None:
"""
:param mouse_event: `MouseEvent` instance.
"""
# Map (x,y) tuples to handlers.
self.mouse_handlers: DefaultDict[
Tuple[int, int], Callable[[MouseEvent], None]
] = 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], None],
) -> None:
"""
Set mouse handler for a region.
"""
for x, y in product(range(x_min, x_max), range(y_min, y_max)):
self.mouse_handlers[x, y] = handler

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,316 @@
from collections import defaultdict
from typing import TYPE_CHECKING, Callable, DefaultDict, Dict, List, Optional, Tuple
from prompt_toolkit.cache import FastDictCache
from prompt_toolkit.data_structures import Point
from prompt_toolkit.utils import get_cwidth
if TYPE_CHECKING:
from .containers import Window
__all__ = [
"Screen",
"Char",
]
class Char:
"""
Represent a single character in a :class:`.Screen`.
This should be considered immutable.
:param char: A single character (can be a double-width character).
:param style: A style string. (Can contain classnames.)
"""
__slots__ = ("char", "style", "width")
# If we end up having one of these special control sequences in the input string,
# we should display them as follows:
# Usually this happens after a "quoted insert".
display_mappings: Dict[str, str] = {
"\x00": "^@", # Control space
"\x01": "^A",
"\x02": "^B",
"\x03": "^C",
"\x04": "^D",
"\x05": "^E",
"\x06": "^F",
"\x07": "^G",
"\x08": "^H",
"\x09": "^I",
"\x0a": "^J",
"\x0b": "^K",
"\x0c": "^L",
"\x0d": "^M",
"\x0e": "^N",
"\x0f": "^O",
"\x10": "^P",
"\x11": "^Q",
"\x12": "^R",
"\x13": "^S",
"\x14": "^T",
"\x15": "^U",
"\x16": "^V",
"\x17": "^W",
"\x18": "^X",
"\x19": "^Y",
"\x1a": "^Z",
"\x1b": "^[", # Escape
"\x1c": "^\\",
"\x1d": "^]",
"\x1f": "^_",
"\x7f": "^?", # ASCII Delete (backspace).
# Special characters. All visualized like Vim does.
"\x80": "<80>",
"\x81": "<81>",
"\x82": "<82>",
"\x83": "<83>",
"\x84": "<84>",
"\x85": "<85>",
"\x86": "<86>",
"\x87": "<87>",
"\x88": "<88>",
"\x89": "<89>",
"\x8a": "<8a>",
"\x8b": "<8b>",
"\x8c": "<8c>",
"\x8d": "<8d>",
"\x8e": "<8e>",
"\x8f": "<8f>",
"\x90": "<90>",
"\x91": "<91>",
"\x92": "<92>",
"\x93": "<93>",
"\x94": "<94>",
"\x95": "<95>",
"\x96": "<96>",
"\x97": "<97>",
"\x98": "<98>",
"\x99": "<99>",
"\x9a": "<9a>",
"\x9b": "<9b>",
"\x9c": "<9c>",
"\x9d": "<9d>",
"\x9e": "<9e>",
"\x9f": "<9f>",
# For the non-breaking space: visualize like Emacs does by default.
# (Print a space, but attach the 'nbsp' class that applies the
# underline style.)
"\xa0": " ",
}
def __init__(self, char: str = " ", style: str = ""):
# 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)
def __eq__(self, other) -> bool:
return self.char == other.char and self.style == other.style
def __ne__(self, other) -> 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
def __repr__(self) -> str:
return "%s(%r, %r)" % (self.__class__.__name__, self.char, self.style)
_CHAR_CACHE: FastDictCache[Tuple[str, str], Char] = FastDictCache(
Char, size=1000 * 1000
)
Transparent = "[transparent]"
class Screen:
"""
Two dimensional buffer of :class:`.Char` instances.
"""
def __init__(
self,
default_char: Optional[Char] = None,
initial_width: int = 0,
initial_height: int = 0,
) -> None:
if default_char is None:
default_char2 = _CHAR_CACHE[" ", Transparent]
else:
default_char2 = default_char
self.data_buffer: DefaultDict[int, DefaultDict[int, Char]] = defaultdict(
lambda: defaultdict(lambda: default_char2)
)
#: Escape sequences to be injected.
self.zero_width_escapes: DefaultDict[int, DefaultDict[int, str]] = defaultdict(
lambda: defaultdict(lambda: "")
)
#: Position of the cursor.
self.cursor_positions: Dict[
"Window", Point
] = {} # Map `Window` objects to `Point` objects.
#: Visibility of the cursor.
self.show_cursor = True
#: (Optional) Where to position the menu. E.g. at the start of a completion.
#: (We can't use the cursor position, because we don't want the
#: completion menu to change its position when we browse through all the
#: completions.)
self.menu_positions: Dict[
"Window", Point
] = {} # Map `Window` objects to `Point` objects.
#: Currently used width/height of the screen. This will increase when
#: data is written to the screen.
self.width = initial_width or 0
self.height = initial_height or 0
# Windows that have been drawn. (Each `Window` class will add itself to
# this list.)
self.visible_windows: List["Window"] = []
# List of (z_index, draw_func)
self._draw_float_functions: List[Tuple[int, Callable[[], None]]] = []
def set_cursor_position(self, window: "Window", position: Point) -> None:
"""
Set the cursor position for a given window.
"""
self.cursor_positions[window] = position
def set_menu_position(self, window: "Window", position: Point) -> None:
"""
Set the cursor position for a given window.
"""
self.menu_positions[window] = position
def get_cursor_position(self, window: "Window") -> Point:
"""
Get the cursor position for a given window.
Returns a `Point`.
"""
try:
return self.cursor_positions[window]
except KeyError:
return Point(x=0, y=0)
def get_menu_position(self, window: "Window") -> Point:
"""
Get the menu position for a given window.
(This falls back to the cursor position if no menu position was set.)
"""
try:
return self.menu_positions[window]
except KeyError:
try:
return self.cursor_positions[window]
except KeyError:
return Point(x=0, y=0)
def draw_with_z_index(self, z_index: int, draw_func: Callable[[], None]) -> None:
"""
Add a draw-function for a `Window` which has a >= 0 z_index.
This will be postponed until `draw_all_floats` is called.
"""
self._draw_float_functions.append((z_index, draw_func))
def draw_all_floats(self) -> None:
"""
Draw all float functions in order of z-index.
"""
# We keep looping because some draw functions could add new functions
# to this list. See `FloatContainer`.
while self._draw_float_functions:
# Sort the floats that we have so far by z_index.
functions = sorted(self._draw_float_functions, key=lambda item: item[0])
# Draw only one at a time, then sort everything again. Now floats
# might have been added.
self._draw_float_functions = functions[1:]
functions[0][1]()
def append_style_to_content(self, style_str: str) -> None:
"""
For all the characters in the screen.
Set the style string to the given `style_str`.
"""
b = self.data_buffer
char_cache = _CHAR_CACHE
append_style = " " + style_str
for y, row in b.items():
for x, char in row.items():
b[y][x] = char_cache[char.char, char.style + append_style]
def fill_area(
self, write_position: "WritePosition", style: str = "", after: bool = False
) -> None:
"""
Fill the content of this area, using the given `style`.
The style is prepended before whatever was here before.
"""
if not style.strip():
return
xmin = write_position.xpos
xmax = write_position.xpos + write_position.width
char_cache = _CHAR_CACHE
data_buffer = self.data_buffer
if after:
append_style = " " + style
prepend_style = ""
else:
append_style = ""
prepend_style = style + " "
for y in range(
write_position.ypos, write_position.ypos + write_position.height
):
row = data_buffer[y]
for x in range(xmin, xmax):
cell = row[x]
row[x] = char_cache[
cell.char, prepend_style + cell.style + append_style
]
class WritePosition:
def __init__(self, xpos: int, ypos: int, width: int, height: int) -> None:
assert height >= 0
assert width >= 0
# xpos and ypos can be negative. (A float can be partially visible.)
self.xpos = xpos
self.ypos = ypos
self.width = width
self.height = height
def __repr__(self) -> str:
return "%s(x=%r, y=%r, width=%r, height=%r)" % (
self.__class__.__name__,
self.xpos,
self.ypos,
self.width,
self.height,
)

View file

@ -0,0 +1,76 @@
from typing import Iterable, List, TypeVar, Union, cast, overload
from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple
__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: int, item: _T) -> None:
raise NotImplementedError # TODO
# TODO: When creating a copy() or [:], return also an _ExplodedList.
@overload
def __setitem__(self, index: int, value: _T) -> None:
...
@overload
def __setitem__(self, index: slice, value: Iterable[_T]) -> None:
...
def __setitem__(
self, index: Union[int, slice], value: Union[_T, Iterable[_T]]
) -> None:
"""
Ensure that when `(style_str, 'long string')` is set, the string will be
exploded.
"""
if not isinstance(index, slice):
index = slice(index, index + 1)
if isinstance(value, tuple): # In case of `OneStyleAndTextTuple`.
value = cast("List[_T]", [value])
super().__setitem__(index, explode_text_fragments(cast("Iterable[_T]", value)))
def explode_text_fragments(fragments: Iterable[_T]) -> _ExplodedList[_T]:
"""
Turn a list of (style_str, text) tuples into another list where each string is
exactly one character.
It should be fine to call this function several times. Calling this on a
list that is already exploded, is a null operation.
:param fragments: List of (style, text) tuples.
"""
# When the fragments is already exploded, don't explode again.
if isinstance(fragments, _ExplodedList):
return fragments
result: List[_T] = []
for style, string, *rest in fragments: # type: ignore
for c in string: # type: ignore
result.append((style, c, *rest)) # type: ignore
return _ExplodedList(result)