first commit

This commit is contained in:
Ayxan
2022-05-23 00:16:32 +04:00
commit d660f2a4ca
24786 changed files with 4428337 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
"""
prompt_toolkit
==============
Author: Jonathan Slenders
Description: prompt_toolkit is a Library for building powerful interactive
command lines in Python. It can be a replacement for GNU
Readline, but it can be much more than that.
See the examples directory to learn about the usage.
Probably, to get started, you might also want to have a look at
`prompt_toolkit.shortcuts.prompt`.
"""
from .application import Application
from .formatted_text import ANSI, HTML
from .shortcuts import PromptSession, print_formatted_text, prompt
# Don't forget to update in `docs/conf.py`!
__version__ = "3.0.29"
# Version tuple.
VERSION = tuple(__version__.split("."))
__all__ = [
# Application.
"Application",
# Shortcuts.
"prompt",
"PromptSession",
"print_formatted_text",
# Formatted text.
"HTML",
"ANSI",
# Version info.
"__version__",
"VERSION",
]

View File

@@ -0,0 +1,30 @@
from .application import Application
from .current import (
AppSession,
create_app_session,
create_app_session_from_tty,
get_app,
get_app_or_none,
get_app_session,
set_app,
)
from .dummy import DummyApplication
from .run_in_terminal import in_terminal, run_in_terminal
__all__ = [
# Application.
"Application",
# Current.
"AppSession",
"get_app_session",
"create_app_session",
"create_app_session_from_tty",
"get_app",
"get_app_or_none",
"set_app",
# Dummy.
"DummyApplication",
# Run_in_terminal
"in_terminal",
"run_in_terminal",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,197 @@
import sys
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Generator, Optional
try:
from contextvars import ContextVar
except ImportError:
from prompt_toolkit.eventloop.dummy_contextvars import ContextVar # type: ignore
if TYPE_CHECKING:
from prompt_toolkit.input.base import Input
from prompt_toolkit.output.base import Output
from .application import Application
__all__ = [
"AppSession",
"get_app_session",
"get_app",
"get_app_or_none",
"set_app",
"create_app_session",
"create_app_session_from_tty",
]
class AppSession:
"""
An AppSession is an interactive session, usually connected to one terminal.
Within one such session, interaction with many applications can happen, one
after the other.
The input/output device is not supposed to change during one session.
Warning: Always use the `create_app_session` function to create an
instance, so that it gets activated correctly.
:param input: Use this as a default input for all applications
running in this session, unless an input is passed to the `Application`
explicitely.
:param output: Use this as a default output.
"""
def __init__(
self, input: Optional["Input"] = None, output: Optional["Output"] = None
) -> None:
self._input = input
self._output = output
# The application will be set dynamically by the `set_app` context
# manager. This is called in the application itself.
self.app: Optional["Application[Any]"] = None
def __repr__(self) -> str:
return f"AppSession(app={self.app!r})"
@property
def input(self) -> "Input":
if self._input is None:
from prompt_toolkit.input.defaults import create_input
self._input = create_input()
return self._input
@property
def output(self) -> "Output":
if self._output is None:
from prompt_toolkit.output.defaults import create_output
self._output = create_output()
return self._output
_current_app_session: ContextVar["AppSession"] = ContextVar(
"_current_app_session", default=AppSession()
)
def get_app_session() -> AppSession:
return _current_app_session.get()
def get_app() -> "Application[Any]":
"""
Get the current active (running) Application.
An :class:`.Application` is active during the
:meth:`.Application.run_async` call.
We assume that there can only be one :class:`.Application` active at the
same time. There is only one terminal window, with only one stdin and
stdout. This makes the code significantly easier than passing around the
:class:`.Application` everywhere.
If no :class:`.Application` is running, then return by default a
:class:`.DummyApplication`. For practical reasons, we prefer to not raise
an exception. This way, we don't have to check all over the place whether
an actual `Application` was returned.
(For applications like pymux where we can have more than one `Application`,
we'll use a work-around to handle that.)
"""
session = _current_app_session.get()
if session.app is not None:
return session.app
from .dummy import DummyApplication
return DummyApplication()
def get_app_or_none() -> Optional["Application[Any]"]:
"""
Get the current active (running) Application, or return `None` if no
application is running.
"""
session = _current_app_session.get()
return session.app
@contextmanager
def set_app(app: "Application[Any]") -> Generator[None, None, None]:
"""
Context manager that sets the given :class:`.Application` active in an
`AppSession`.
This should only be called by the `Application` itself.
The application will automatically be active while its running. If you want
the application to be active in other threads/coroutines, where that's not
the case, use `contextvars.copy_context()`, or use `Application.context` to
run it in the appropriate context.
"""
session = _current_app_session.get()
previous_app = session.app
session.app = app
try:
yield
finally:
session.app = previous_app
@contextmanager
def create_app_session(
input: Optional["Input"] = None, output: Optional["Output"] = None
) -> Generator[AppSession, None, None]:
"""
Create a separate AppSession.
This is useful if there can be multiple individual `AppSession`s going on.
Like in the case of an Telnet/SSH server. This functionality uses
contextvars and requires at least Python 3.7.
"""
if sys.version_info <= (3, 6):
raise RuntimeError("Application sessions require Python 3.7.")
# If no input/output is specified, fall back to the current input/output,
# whatever that is.
if input is None:
input = get_app_session().input
if output is None:
output = get_app_session().output
# Create new `AppSession` and activate.
session = AppSession(input=input, output=output)
token = _current_app_session.set(session)
try:
yield session
finally:
_current_app_session.reset(token)
@contextmanager
def create_app_session_from_tty() -> Generator[AppSession, None, None]:
"""
Create `AppSession` that always prefers the TTY input/output.
Even if `sys.stdin` and `sys.stdout` are connected to input/output pipes,
this will still use the terminal for interaction (because `sys.stderr` is
still connected to the terminal).
Usage::
from prompt_toolkit.shortcuts import prompt
with create_app_session_from_tty():
prompt('>')
"""
from prompt_toolkit.input.defaults import create_input
from prompt_toolkit.output.defaults import create_output
input = create_input(always_prefer_tty=True)
output = create_output(always_prefer_tty=True)
with create_app_session(input=input, output=output) as app_session:
yield app_session

View File

@@ -0,0 +1,51 @@
from typing import Callable, Optional
from prompt_toolkit.formatted_text import AnyFormattedText
from prompt_toolkit.input import DummyInput
from prompt_toolkit.output import DummyOutput
from .application import Application
__all__ = [
"DummyApplication",
]
class DummyApplication(Application[None]):
"""
When no :class:`.Application` is running,
:func:`.get_app` will run an instance of this :class:`.DummyApplication` instead.
"""
def __init__(self) -> None:
super().__init__(output=DummyOutput(), input=DummyInput())
def run(
self,
pre_run: Optional[Callable[[], None]] = None,
set_exception_handler: bool = True,
handle_sigint: bool = True,
in_thread: bool = False,
) -> None:
raise NotImplementedError("A DummyApplication is not supposed to run.")
async def run_async(
self,
pre_run: Optional[Callable[[], None]] = None,
set_exception_handler: bool = True,
handle_sigint: bool = True,
slow_callback_duration: float = 0.5,
) -> None:
raise NotImplementedError("A DummyApplication is not supposed to run.")
async def run_system_command(
self,
command: str,
wait_for_enter: bool = True,
display_before_text: AnyFormattedText = "",
wait_text: str = "",
) -> None:
raise NotImplementedError
def suspend_to_background(self, suspend_group: bool = True) -> None:
raise NotImplementedError

View File

@@ -0,0 +1,117 @@
"""
Tools for running functions on the terminal above the current application or prompt.
"""
import sys
from asyncio import Future, ensure_future
from typing import AsyncGenerator, Awaitable, Callable, TypeVar
from prompt_toolkit.eventloop import run_in_executor_with_context
from .current import get_app_or_none
if sys.version_info >= (3, 7):
from contextlib import asynccontextmanager
else:
from prompt_toolkit.eventloop.async_context_manager import asynccontextmanager
__all__ = [
"run_in_terminal",
"in_terminal",
]
_T = TypeVar("_T")
def run_in_terminal(
func: Callable[[], _T], render_cli_done: bool = False, in_executor: bool = False
) -> Awaitable[_T]:
"""
Run function on the terminal above the current application or prompt.
What this does is first hiding the prompt, then running this callable
(which can safely output to the terminal), and then again rendering the
prompt which causes the output of this function to scroll above the
prompt.
``func`` is supposed to be a synchronous function. If you need an
asynchronous version of this function, use the ``in_terminal`` context
manager directly.
:param func: The callable to execute.
:param render_cli_done: When True, render the interface in the
'Done' state first, then execute the function. If False,
erase the interface first.
:param in_executor: When True, run in executor. (Use this for long
blocking functions, when you don't want to block the event loop.)
:returns: A `Future`.
"""
async def run() -> _T:
async with in_terminal(render_cli_done=render_cli_done):
if in_executor:
return await run_in_executor_with_context(func)
else:
return func()
return ensure_future(run())
@asynccontextmanager
async def in_terminal(render_cli_done: bool = False) -> AsyncGenerator[None, None]:
"""
Asynchronous context manager that suspends the current application and runs
the body in the terminal.
.. code::
async def f():
async with in_terminal():
call_some_function()
await call_some_async_function()
"""
app = get_app_or_none()
if app is None or not app._is_running:
yield
return
# When a previous `run_in_terminal` call was in progress. Wait for that
# to finish, before starting this one. Chain to previous call.
previous_run_in_terminal_f = app._running_in_terminal_f
new_run_in_terminal_f: Future[None] = Future()
app._running_in_terminal_f = new_run_in_terminal_f
# Wait for the previous `run_in_terminal` to finish.
if previous_run_in_terminal_f is not None:
await previous_run_in_terminal_f
# Wait for all CPRs to arrive. We don't want to detach the input until
# all cursor position responses have been arrived. Otherwise, the tty
# will echo its input and can show stuff like ^[[39;1R.
if app.output.responds_to_cpr:
await app.renderer.wait_for_cpr_responses()
# Draw interface in 'done' state, or erase.
if render_cli_done:
app._redraw(render_as_done=True)
else:
app.renderer.erase()
# Disable rendering.
app._running_in_terminal = True
# Detach input.
try:
with app.input.detach():
with app.input.cooked_mode():
yield
finally:
# Redraw interface again.
try:
app._running_in_terminal = False
app.renderer.reset()
app._request_absolute_cursor_position()
app._redraw()
finally:
new_run_in_terminal_f.set_result(None)

View File

@@ -0,0 +1,187 @@
"""
`Fish-style <http://fishshell.com/>`_ like auto-suggestion.
While a user types input in a certain buffer, suggestions are generated
(asynchronously.) Usually, they are displayed after the input. When the cursor
presses the right arrow and the cursor is at the end of the input, the
suggestion will be inserted.
If you want the auto suggestions to be asynchronous (in a background thread),
because they take too much time, and could potentially block the event loop,
then wrap the :class:`.AutoSuggest` instance into a
:class:`.ThreadedAutoSuggest`.
"""
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING, Callable, Optional, Union
from prompt_toolkit.eventloop import run_in_executor_with_context
from .document import Document
from .filters import Filter, to_filter
if TYPE_CHECKING:
from .buffer import Buffer
__all__ = [
"Suggestion",
"AutoSuggest",
"ThreadedAutoSuggest",
"DummyAutoSuggest",
"AutoSuggestFromHistory",
"ConditionalAutoSuggest",
"DynamicAutoSuggest",
]
class Suggestion:
"""
Suggestion returned by an auto-suggest algorithm.
:param text: The suggestion text.
"""
def __init__(self, text: str) -> None:
self.text = text
def __repr__(self) -> str:
return "Suggestion(%s)" % self.text
class AutoSuggest(metaclass=ABCMeta):
"""
Base class for auto suggestion implementations.
"""
@abstractmethod
def get_suggestion(
self, buffer: "Buffer", document: Document
) -> Optional[Suggestion]:
"""
Return `None` or a :class:`.Suggestion` instance.
We receive both :class:`~prompt_toolkit.buffer.Buffer` and
:class:`~prompt_toolkit.document.Document`. The reason is that auto
suggestions are retrieved asynchronously. (Like completions.) The
buffer text could be changed in the meantime, but ``document`` contains
the buffer document like it was at the start of the auto suggestion
call. So, from here, don't access ``buffer.text``, but use
``document.text`` instead.
:param buffer: The :class:`~prompt_toolkit.buffer.Buffer` instance.
:param document: The :class:`~prompt_toolkit.document.Document` instance.
"""
async def get_suggestion_async(
self, buff: "Buffer", document: Document
) -> Optional[Suggestion]:
"""
Return a :class:`.Future` which is set when the suggestions are ready.
This function can be overloaded in order to provide an asynchronous
implementation.
"""
return self.get_suggestion(buff, document)
class ThreadedAutoSuggest(AutoSuggest):
"""
Wrapper that runs auto suggestions in a thread.
(Use this to prevent the user interface from becoming unresponsive if the
generation of suggestions takes too much time.)
"""
def __init__(self, auto_suggest: AutoSuggest) -> None:
self.auto_suggest = auto_suggest
def get_suggestion(
self, buff: "Buffer", document: Document
) -> Optional[Suggestion]:
return self.auto_suggest.get_suggestion(buff, document)
async def get_suggestion_async(
self, buff: "Buffer", document: Document
) -> Optional[Suggestion]:
"""
Run the `get_suggestion` function in a thread.
"""
def run_get_suggestion_thread() -> Optional[Suggestion]:
return self.get_suggestion(buff, document)
return await run_in_executor_with_context(run_get_suggestion_thread)
class DummyAutoSuggest(AutoSuggest):
"""
AutoSuggest class that doesn't return any suggestion.
"""
def get_suggestion(
self, buffer: "Buffer", document: Document
) -> Optional[Suggestion]:
return None # No suggestion
class AutoSuggestFromHistory(AutoSuggest):
"""
Give suggestions based on the lines in the history.
"""
def get_suggestion(
self, buffer: "Buffer", document: Document
) -> Optional[Suggestion]:
history = buffer.history
# Consider only the last line for the suggestion.
text = document.text.rsplit("\n", 1)[-1]
# Only create a suggestion when this is not an empty line.
if text.strip():
# Find first matching line in history.
for string in reversed(list(history.get_strings())):
for line in reversed(string.splitlines()):
if line.startswith(text):
return Suggestion(line[len(text) :])
return None
class ConditionalAutoSuggest(AutoSuggest):
"""
Auto suggest that can be turned on and of according to a certain condition.
"""
def __init__(self, auto_suggest: AutoSuggest, filter: Union[bool, Filter]) -> None:
self.auto_suggest = auto_suggest
self.filter = to_filter(filter)
def get_suggestion(
self, buffer: "Buffer", document: Document
) -> Optional[Suggestion]:
if self.filter():
return self.auto_suggest.get_suggestion(buffer, document)
return None
class DynamicAutoSuggest(AutoSuggest):
"""
Validator class that can dynamically returns any Validator.
:param get_validator: Callable that returns a :class:`.Validator` instance.
"""
def __init__(self, get_auto_suggest: Callable[[], Optional[AutoSuggest]]) -> None:
self.get_auto_suggest = get_auto_suggest
def get_suggestion(
self, buff: "Buffer", document: Document
) -> Optional[Suggestion]:
auto_suggest = self.get_auto_suggest() or DummyAutoSuggest()
return auto_suggest.get_suggestion(buff, document)
async def get_suggestion_async(
self, buff: "Buffer", document: Document
) -> Optional[Suggestion]:
auto_suggest = self.get_auto_suggest() or DummyAutoSuggest()
return await auto_suggest.get_suggestion_async(buff, document)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
from collections import deque
from functools import wraps
from typing import Any, Callable, Deque, Dict, Generic, Hashable, Tuple, TypeVar, cast
__all__ = [
"SimpleCache",
"FastDictCache",
"memoized",
]
_T = TypeVar("_T", bound=Hashable)
_U = TypeVar("_U")
class SimpleCache(Generic[_T, _U]):
"""
Very simple cache that discards the oldest item when the cache size is
exceeded.
:param maxsize: Maximum size of the cache. (Don't make it too big.)
"""
def __init__(self, maxsize: int = 8) -> None:
assert maxsize > 0
self._data: Dict[_T, _U] = {}
self._keys: Deque[_T] = deque()
self.maxsize: int = maxsize
def get(self, key: _T, getter_func: Callable[[], _U]) -> _U:
"""
Get object from the cache.
If not found, call `getter_func` to resolve it, and put that on the top
of the cache instead.
"""
# Look in cache first.
try:
return self._data[key]
except KeyError:
# Not found? Get it.
value = getter_func()
self._data[key] = value
self._keys.append(key)
# Remove the oldest key when the size is exceeded.
if len(self._data) > self.maxsize:
key_to_remove = self._keys.popleft()
if key_to_remove in self._data:
del self._data[key_to_remove]
return value
def clear(self) -> None:
"Clear cache."
self._data = {}
self._keys = deque()
_K = TypeVar("_K", bound=Tuple[Hashable, ...])
_V = TypeVar("_V")
class FastDictCache(Dict[_K, _V]):
"""
Fast, lightweight cache which keeps at most `size` items.
It will discard the oldest items in the cache first.
The cache is a dictionary, which doesn't keep track of access counts.
It is perfect to cache little immutable objects which are not expensive to
create, but where a dictionary lookup is still much faster than an object
instantiation.
:param get_value: Callable that's called in case of a missing key.
"""
# NOTE: This cache is used to cache `prompt_toolkit.layout.screen.Char` and
# `prompt_toolkit.Document`. Make sure to keep this really lightweight.
# Accessing the cache should stay faster than instantiating new
# objects.
# (Dictionary lookups are really fast.)
# SimpleCache is still required for cases where the cache key is not
# the same as the arguments given to the function that creates the
# value.)
def __init__(self, get_value: Callable[..., _V], size: int = 1000000) -> None:
assert size > 0
self._keys: Deque[_K] = deque()
self.get_value = get_value
self.size = size
def __missing__(self, key: _K) -> _V:
# Remove the oldest key when the size is exceeded.
if len(self) > self.size:
key_to_remove = self._keys.popleft()
if key_to_remove in self:
del self[key_to_remove]
result = self.get_value(*key)
self[key] = result
self._keys.append(key)
return result
_F = TypeVar("_F", bound=Callable[..., object])
def memoized(maxsize: int = 1024) -> Callable[[_F], _F]:
"""
Memoization decorator for immutable classes and pure functions.
"""
def decorator(obj: _F) -> _F:
cache: SimpleCache[Hashable, Any] = SimpleCache(maxsize=maxsize)
@wraps(obj)
def new_callable(*a: Any, **kw: Any) -> Any:
def create_new() -> Any:
return obj(*a, **kw)
key = (a, tuple(sorted(kw.items())))
return cache.get(key, create_new)
return cast(_F, new_callable)
return decorator

View File

@@ -0,0 +1,15 @@
from .base import Clipboard, ClipboardData, DummyClipboard, DynamicClipboard
from .in_memory import InMemoryClipboard
# We are not importing `PyperclipClipboard` here, because it would require the
# `pyperclip` module to be present.
# from .pyperclip import PyperclipClipboard
__all__ = [
"Clipboard",
"ClipboardData",
"DummyClipboard",
"DynamicClipboard",
"InMemoryClipboard",
]

View File

@@ -0,0 +1,107 @@
"""
Clipboard for command line interface.
"""
from abc import ABCMeta, abstractmethod
from typing import Callable, Optional
from prompt_toolkit.selection import SelectionType
__all__ = [
"Clipboard",
"ClipboardData",
"DummyClipboard",
"DynamicClipboard",
]
class ClipboardData:
"""
Text on the clipboard.
:param text: string
:param type: :class:`~prompt_toolkit.selection.SelectionType`
"""
def __init__(
self, text: str = "", type: SelectionType = SelectionType.CHARACTERS
) -> None:
self.text = text
self.type = type
class Clipboard(metaclass=ABCMeta):
"""
Abstract baseclass for clipboards.
(An implementation can be in memory, it can share the X11 or Windows
keyboard, or can be persistent.)
"""
@abstractmethod
def set_data(self, data: ClipboardData) -> None:
"""
Set data to the clipboard.
:param data: :class:`~.ClipboardData` instance.
"""
def set_text(self, text: str) -> None: # Not abstract.
"""
Shortcut for setting plain text on clipboard.
"""
self.set_data(ClipboardData(text))
def rotate(self) -> None:
"""
For Emacs mode, rotate the kill ring.
"""
@abstractmethod
def get_data(self) -> ClipboardData:
"""
Return clipboard data.
"""
class DummyClipboard(Clipboard):
"""
Clipboard implementation that doesn't remember anything.
"""
def set_data(self, data: ClipboardData) -> None:
pass
def set_text(self, text: str) -> None:
pass
def rotate(self) -> None:
pass
def get_data(self) -> ClipboardData:
return ClipboardData()
class DynamicClipboard(Clipboard):
"""
Clipboard class that can dynamically returns any Clipboard.
:param get_clipboard: Callable that returns a :class:`.Clipboard` instance.
"""
def __init__(self, get_clipboard: Callable[[], Optional[Clipboard]]) -> None:
self.get_clipboard = get_clipboard
def _clipboard(self) -> Clipboard:
return self.get_clipboard() or DummyClipboard()
def set_data(self, data: ClipboardData) -> None:
self._clipboard().set_data(data)
def set_text(self, text: str) -> None:
self._clipboard().set_text(text)
def rotate(self) -> None:
self._clipboard().rotate()
def get_data(self) -> ClipboardData:
return self._clipboard().get_data()

View File

@@ -0,0 +1,46 @@
from collections import deque
from typing import Deque, Optional
from .base import Clipboard, ClipboardData
__all__ = [
"InMemoryClipboard",
]
class InMemoryClipboard(Clipboard):
"""
Default clipboard implementation.
Just keep the data in memory.
This implements a kill-ring, for Emacs mode.
"""
def __init__(
self, data: Optional[ClipboardData] = None, max_size: int = 60
) -> None:
assert max_size >= 1
self.max_size = max_size
self._ring: Deque[ClipboardData] = deque()
if data is not None:
self.set_data(data)
def set_data(self, data: ClipboardData) -> None:
self._ring.appendleft(data)
while len(self._ring) > self.max_size:
self._ring.pop()
def get_data(self) -> ClipboardData:
if self._ring:
return self._ring[0]
else:
return ClipboardData()
def rotate(self) -> None:
if self._ring:
# Add the very first item at the end.
self._ring.append(self._ring.popleft())

View File

@@ -0,0 +1,42 @@
from typing import Optional
import pyperclip
from prompt_toolkit.selection import SelectionType
from .base import Clipboard, ClipboardData
__all__ = [
"PyperclipClipboard",
]
class PyperclipClipboard(Clipboard):
"""
Clipboard that synchronizes with the Windows/Mac/Linux system clipboard,
using the pyperclip module.
"""
def __init__(self) -> None:
self._data: Optional[ClipboardData] = None
def set_data(self, data: ClipboardData) -> None:
self._data = data
pyperclip.copy(data.text)
def get_data(self) -> ClipboardData:
text = pyperclip.paste()
# When the clipboard data is equal to what we copied last time, reuse
# the `ClipboardData` instance. That way we're sure to keep the same
# `SelectionType`.
if self._data and self._data.text == text:
return self._data
# Pyperclip returned something else. Create a new `ClipboardData`
# instance.
else:
return ClipboardData(
text=text,
type=SelectionType.LINES if "\n" in text else SelectionType.CHARACTERS,
)

View File

@@ -0,0 +1,41 @@
from .base import (
CompleteEvent,
Completer,
Completion,
ConditionalCompleter,
DummyCompleter,
DynamicCompleter,
ThreadedCompleter,
get_common_complete_suffix,
merge_completers,
)
from .deduplicate import DeduplicateCompleter
from .filesystem import ExecutableCompleter, PathCompleter
from .fuzzy_completer import FuzzyCompleter, FuzzyWordCompleter
from .nested import NestedCompleter
from .word_completer import WordCompleter
__all__ = [
# Base.
"Completion",
"Completer",
"ThreadedCompleter",
"DummyCompleter",
"DynamicCompleter",
"CompleteEvent",
"ConditionalCompleter",
"merge_completers",
"get_common_complete_suffix",
# Filesystem.
"PathCompleter",
"ExecutableCompleter",
# Fuzzy
"FuzzyCompleter",
"FuzzyWordCompleter",
# Nested.
"NestedCompleter",
# Word completer.
"WordCompleter",
# Deduplicate
"DeduplicateCompleter",
]

View File

@@ -0,0 +1,396 @@
"""
"""
from abc import ABCMeta, abstractmethod
from typing import AsyncGenerator, Callable, Iterable, Optional, Sequence
from prompt_toolkit.document import Document
from prompt_toolkit.eventloop import generator_to_async_generator
from prompt_toolkit.filters import FilterOrBool, to_filter
from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples
__all__ = [
"Completion",
"Completer",
"ThreadedCompleter",
"DummyCompleter",
"DynamicCompleter",
"CompleteEvent",
"ConditionalCompleter",
"merge_completers",
"get_common_complete_suffix",
]
class Completion:
"""
:param text: The new string that will be inserted into the document.
:param start_position: Position relative to the cursor_position where the
new text will start. The text will be inserted between the
start_position and the original cursor position.
:param display: (optional string or formatted text) If the completion has
to be displayed differently in the completion menu.
:param display_meta: (Optional string or formatted text) Meta information
about the completion, e.g. the path or source where it's coming from.
This can also be a callable that returns a string.
:param style: Style string.
:param selected_style: Style string, used for a selected completion.
This can override the `style` parameter.
"""
def __init__(
self,
text: str,
start_position: int = 0,
display: Optional[AnyFormattedText] = None,
display_meta: Optional[AnyFormattedText] = None,
style: str = "",
selected_style: str = "",
) -> None:
from prompt_toolkit.formatted_text import to_formatted_text
self.text = text
self.start_position = start_position
self._display_meta = display_meta
if display is None:
display = text
self.display = to_formatted_text(display)
self.style = style
self.selected_style = selected_style
assert self.start_position <= 0
def __repr__(self) -> str:
if isinstance(self.display, str) and self.display == self.text:
return "{}(text={!r}, start_position={!r})".format(
self.__class__.__name__,
self.text,
self.start_position,
)
else:
return "{}(text={!r}, start_position={!r}, display={!r})".format(
self.__class__.__name__,
self.text,
self.start_position,
self.display,
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Completion):
return False
return (
self.text == other.text
and self.start_position == other.start_position
and self.display == other.display
and self._display_meta == other._display_meta
)
def __hash__(self) -> int:
return hash((self.text, self.start_position, self.display, self._display_meta))
@property
def display_text(self) -> str:
"The 'display' field as plain text."
from prompt_toolkit.formatted_text import fragment_list_to_text
return fragment_list_to_text(self.display)
@property
def display_meta(self) -> StyleAndTextTuples:
"Return meta-text. (This is lazy when using a callable)."
from prompt_toolkit.formatted_text import to_formatted_text
return to_formatted_text(self._display_meta or "")
@property
def display_meta_text(self) -> str:
"The 'meta' field as plain text."
from prompt_toolkit.formatted_text import fragment_list_to_text
return fragment_list_to_text(self.display_meta)
def new_completion_from_position(self, position: int) -> "Completion":
"""
(Only for internal use!)
Get a new completion by splitting this one. Used by `Application` when
it needs to have a list of new completions after inserting the common
prefix.
"""
assert position - self.start_position >= 0
return Completion(
text=self.text[position - self.start_position :],
display=self.display,
display_meta=self._display_meta,
)
class CompleteEvent:
"""
Event that called the completer.
:param text_inserted: When True, it means that completions are requested
because of a text insert. (`Buffer.complete_while_typing`.)
:param completion_requested: When True, it means that the user explicitly
pressed the `Tab` key in order to view the completions.
These two flags can be used for instance to implement a completer that
shows some completions when ``Tab`` has been pressed, but not
automatically when the user presses a space. (Because of
`complete_while_typing`.)
"""
def __init__(
self, text_inserted: bool = False, completion_requested: bool = False
) -> None:
assert not (text_inserted and completion_requested)
#: Automatic completion while typing.
self.text_inserted = text_inserted
#: Used explicitly requested completion by pressing 'tab'.
self.completion_requested = completion_requested
def __repr__(self) -> str:
return "{}(text_inserted={!r}, completion_requested={!r})".format(
self.__class__.__name__,
self.text_inserted,
self.completion_requested,
)
class Completer(metaclass=ABCMeta):
"""
Base class for completer implementations.
"""
@abstractmethod
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
"""
This should be a generator that yields :class:`.Completion` instances.
If the generation of completions is something expensive (that takes a
lot of time), consider wrapping this `Completer` class in a
`ThreadedCompleter`. In that case, the completer algorithm runs in a
background thread and completions will be displayed as soon as they
arrive.
:param document: :class:`~prompt_toolkit.document.Document` instance.
:param complete_event: :class:`.CompleteEvent` instance.
"""
while False:
yield
async def get_completions_async(
self, document: Document, complete_event: CompleteEvent
) -> AsyncGenerator[Completion, None]:
"""
Asynchronous generator for completions. (Probably, you won't have to
override this.)
Asynchronous generator of :class:`.Completion` objects.
"""
for item in self.get_completions(document, complete_event):
yield item
class ThreadedCompleter(Completer):
"""
Wrapper that runs the `get_completions` generator in a thread.
(Use this to prevent the user interface from becoming unresponsive if the
generation of completions takes too much time.)
The completions will be displayed as soon as they are produced. The user
can already select a completion, even if not all completions are displayed.
"""
def __init__(self, completer: Completer) -> None:
self.completer = completer
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
return self.completer.get_completions(document, complete_event)
async def get_completions_async(
self, document: Document, complete_event: CompleteEvent
) -> AsyncGenerator[Completion, None]:
"""
Asynchronous generator of completions.
"""
async for completion in generator_to_async_generator(
lambda: self.completer.get_completions(document, complete_event)
):
yield completion
def __repr__(self) -> str:
return f"ThreadedCompleter({self.completer!r})"
class DummyCompleter(Completer):
"""
A completer that doesn't return any completion.
"""
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
return []
def __repr__(self) -> str:
return "DummyCompleter()"
class DynamicCompleter(Completer):
"""
Completer class that can dynamically returns any Completer.
:param get_completer: Callable that returns a :class:`.Completer` instance.
"""
def __init__(self, get_completer: Callable[[], Optional[Completer]]) -> None:
self.get_completer = get_completer
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
completer = self.get_completer() or DummyCompleter()
return completer.get_completions(document, complete_event)
async def get_completions_async(
self, document: Document, complete_event: CompleteEvent
) -> AsyncGenerator[Completion, None]:
completer = self.get_completer() or DummyCompleter()
async for completion in completer.get_completions_async(
document, complete_event
):
yield completion
def __repr__(self) -> str:
return f"DynamicCompleter({self.get_completer!r} -> {self.get_completer()!r})"
class ConditionalCompleter(Completer):
"""
Wrapper around any other completer that will enable/disable the completions
depending on whether the received condition is satisfied.
:param completer: :class:`.Completer` instance.
:param filter: :class:`.Filter` instance.
"""
def __init__(self, completer: Completer, filter: FilterOrBool) -> None:
self.completer = completer
self.filter = to_filter(filter)
def __repr__(self) -> str:
return f"ConditionalCompleter({self.completer!r}, filter={self.filter!r})"
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
# Get all completions in a blocking way.
if self.filter():
yield from self.completer.get_completions(document, complete_event)
async def get_completions_async(
self, document: Document, complete_event: CompleteEvent
) -> AsyncGenerator[Completion, None]:
# Get all completions in a non-blocking way.
if self.filter():
async for item in self.completer.get_completions_async(
document, complete_event
):
yield item
class _MergedCompleter(Completer):
"""
Combine several completers into one.
"""
def __init__(self, completers: Sequence[Completer]) -> None:
self.completers = completers
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
# Get all completions from the other completers in a blocking way.
for completer in self.completers:
yield from completer.get_completions(document, complete_event)
async def get_completions_async(
self, document: Document, complete_event: CompleteEvent
) -> AsyncGenerator[Completion, None]:
# Get all completions from the other completers in a non-blocking way.
for completer in self.completers:
async for item in completer.get_completions_async(document, complete_event):
yield item
def merge_completers(
completers: Sequence[Completer], deduplicate: bool = False
) -> Completer:
"""
Combine several completers into one.
:param deduplicate: If `True`, wrap the result in a `DeduplicateCompleter`
so that completions that would result in the same text will be
deduplicated.
"""
if deduplicate:
from .deduplicate import DeduplicateCompleter
return DeduplicateCompleter(_MergedCompleter(completers))
return _MergedCompleter(completers)
def get_common_complete_suffix(
document: Document, completions: Sequence[Completion]
) -> str:
"""
Return the common prefix for all completions.
"""
# Take only completions that don't change the text before the cursor.
def doesnt_change_before_cursor(completion: Completion) -> bool:
end = completion.text[: -completion.start_position]
return document.text_before_cursor.endswith(end)
completions2 = [c for c in completions if doesnt_change_before_cursor(c)]
# When there is at least one completion that changes the text before the
# cursor, don't return any common part.
if len(completions2) != len(completions):
return ""
# Return the common prefix.
def get_suffix(completion: Completion) -> str:
return completion.text[-completion.start_position :]
return _commonprefix([get_suffix(c) for c in completions2])
def _commonprefix(strings: Iterable[str]) -> str:
# Similar to os.path.commonprefix
if not strings:
return ""
else:
s1 = min(strings)
s2 = max(strings)
for i, c in enumerate(s1):
if c != s2[i]:
return s1[:i]
return s1

View File

@@ -0,0 +1,43 @@
from typing import Iterable, Set
from prompt_toolkit.document import Document
from .base import CompleteEvent, Completer, Completion
__all__ = ["DeduplicateCompleter"]
class DeduplicateCompleter(Completer):
"""
Wrapper around a completer that removes duplicates. Only the first unique
completions are kept.
Completions are considered to be a duplicate if they result in the same
document text when they would be applied.
"""
def __init__(self, completer: Completer) -> None:
self.completer = completer
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
# Keep track of the document strings we'd get after applying any completion.
found_so_far: Set[str] = set()
for completion in self.completer.get_completions(document, complete_event):
text_if_applied = (
document.text[: document.cursor_position + completion.start_position]
+ completion.text
+ document.text[document.cursor_position :]
)
if text_if_applied == document.text:
# Don't include completions that don't have any effect at all.
continue
if text_if_applied in found_so_far:
continue
found_so_far.add(text_if_applied)
yield completion

View File

@@ -0,0 +1,117 @@
import os
from typing import Callable, Iterable, List, Optional
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
__all__ = [
"PathCompleter",
"ExecutableCompleter",
]
class PathCompleter(Completer):
"""
Complete for Path variables.
:param get_paths: Callable which returns a list of directories to look into
when the user enters a relative path.
:param file_filter: Callable which takes a filename and returns whether
this file should show up in the completion. ``None``
when no filtering has to be done.
:param min_input_len: Don't do autocompletion when the input string is shorter.
"""
def __init__(
self,
only_directories: bool = False,
get_paths: Optional[Callable[[], List[str]]] = None,
file_filter: Optional[Callable[[str], bool]] = None,
min_input_len: int = 0,
expanduser: bool = False,
) -> None:
self.only_directories = only_directories
self.get_paths = get_paths or (lambda: ["."])
self.file_filter = file_filter or (lambda _: True)
self.min_input_len = min_input_len
self.expanduser = expanduser
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
text = document.text_before_cursor
# Complete only when we have at least the minimal input length,
# otherwise, we can too many results and autocompletion will become too
# heavy.
if len(text) < self.min_input_len:
return
try:
# Do tilde expansion.
if self.expanduser:
text = os.path.expanduser(text)
# Directories where to look.
dirname = os.path.dirname(text)
if dirname:
directories = [
os.path.dirname(os.path.join(p, text)) for p in self.get_paths()
]
else:
directories = self.get_paths()
# Start of current file.
prefix = os.path.basename(text)
# Get all filenames.
filenames = []
for directory in directories:
# Look for matches in this directory.
if os.path.isdir(directory):
for filename in os.listdir(directory):
if filename.startswith(prefix):
filenames.append((directory, filename))
# Sort
filenames = sorted(filenames, key=lambda k: k[1])
# Yield them.
for directory, filename in filenames:
completion = filename[len(prefix) :]
full_name = os.path.join(directory, filename)
if os.path.isdir(full_name):
# For directories, add a slash to the filename.
# (We don't add them to the `completion`. Users can type it
# to trigger the autocompletion themselves.)
filename += "/"
elif self.only_directories:
continue
if not self.file_filter(full_name):
continue
yield Completion(
text=completion,
start_position=0,
display=filename,
)
except OSError:
pass
class ExecutableCompleter(PathCompleter):
"""
Complete only executable files in the current path.
"""
def __init__(self) -> None:
super().__init__(
only_directories=False,
min_input_len=1,
get_paths=lambda: os.environ.get("PATH", "").split(os.pathsep),
file_filter=lambda name: os.access(name, os.X_OK),
expanduser=True,
),

View File

@@ -0,0 +1,201 @@
import re
from typing import Callable, Dict, Iterable, List, NamedTuple, Optional, Tuple, Union
from prompt_toolkit.document import Document
from prompt_toolkit.filters import FilterOrBool, to_filter
from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples
from .base import CompleteEvent, Completer, Completion
from .word_completer import WordCompleter
__all__ = [
"FuzzyCompleter",
"FuzzyWordCompleter",
]
class FuzzyCompleter(Completer):
"""
Fuzzy completion.
This wraps any other completer and turns it into a fuzzy completer.
If the list of words is: ["leopard" , "gorilla", "dinosaur", "cat", "bee"]
Then trying to complete "oar" would yield "leopard" and "dinosaur", but not
the others, because they match the regular expression 'o.*a.*r'.
Similar, in another application "djm" could expand to "django_migrations".
The results are sorted by relevance, which is defined as the start position
and the length of the match.
Notice that this is not really a tool to work around spelling mistakes,
like what would be possible with difflib. The purpose is rather to have a
quicker or more intuitive way to filter the given completions, especially
when many completions have a common prefix.
Fuzzy algorithm is based on this post:
https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python
:param completer: A :class:`~.Completer` instance.
:param WORD: When True, use WORD characters.
:param pattern: Regex pattern which selects the characters before the
cursor that are considered for the fuzzy matching.
:param enable_fuzzy: (bool or `Filter`) Enabled the fuzzy behavior. For
easily turning fuzzyness on or off according to a certain condition.
"""
def __init__(
self,
completer: Completer,
WORD: bool = False,
pattern: Optional[str] = None,
enable_fuzzy: FilterOrBool = True,
):
assert pattern is None or pattern.startswith("^")
self.completer = completer
self.pattern = pattern
self.WORD = WORD
self.pattern = pattern
self.enable_fuzzy = to_filter(enable_fuzzy)
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
if self.enable_fuzzy():
return self._get_fuzzy_completions(document, complete_event)
else:
return self.completer.get_completions(document, complete_event)
def _get_pattern(self) -> str:
if self.pattern:
return self.pattern
if self.WORD:
return r"[^\s]+"
return "^[a-zA-Z0-9_]*"
def _get_fuzzy_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
word_before_cursor = document.get_word_before_cursor(
pattern=re.compile(self._get_pattern())
)
# Get completions
document2 = Document(
text=document.text[: document.cursor_position - len(word_before_cursor)],
cursor_position=document.cursor_position - len(word_before_cursor),
)
completions = list(self.completer.get_completions(document2, complete_event))
fuzzy_matches: List[_FuzzyMatch] = []
pat = ".*?".join(map(re.escape, word_before_cursor))
pat = f"(?=({pat}))" # lookahead regex to manage overlapping matches
regex = re.compile(pat, re.IGNORECASE)
for compl in completions:
matches = list(regex.finditer(compl.text))
if matches:
# Prefer the match, closest to the left, then shortest.
best = min(matches, key=lambda m: (m.start(), len(m.group(1))))
fuzzy_matches.append(
_FuzzyMatch(len(best.group(1)), best.start(), compl)
)
def sort_key(fuzzy_match: "_FuzzyMatch") -> Tuple[int, int]:
"Sort by start position, then by the length of the match."
return fuzzy_match.start_pos, fuzzy_match.match_length
fuzzy_matches = sorted(fuzzy_matches, key=sort_key)
for match in fuzzy_matches:
# Include these completions, but set the correct `display`
# attribute and `start_position`.
yield Completion(
text=match.completion.text,
start_position=match.completion.start_position
- len(word_before_cursor),
display_meta=match.completion.display_meta,
display=self._get_display(match, word_before_cursor),
style=match.completion.style,
)
def _get_display(
self, fuzzy_match: "_FuzzyMatch", word_before_cursor: str
) -> AnyFormattedText:
"""
Generate formatted text for the display label.
"""
m = fuzzy_match
word = m.completion.text
if m.match_length == 0:
# No highlighting when we have zero length matches (no input text).
# In this case, use the original display text (which can include
# additional styling or characters).
return m.completion.display
result: StyleAndTextTuples = []
# Text before match.
result.append(("class:fuzzymatch.outside", word[: m.start_pos]))
# The match itself.
characters = list(word_before_cursor)
for c in word[m.start_pos : m.start_pos + m.match_length]:
classname = "class:fuzzymatch.inside"
if characters and c.lower() == characters[0].lower():
classname += ".character"
del characters[0]
result.append((classname, c))
# Text after match.
result.append(
("class:fuzzymatch.outside", word[m.start_pos + m.match_length :])
)
return result
class FuzzyWordCompleter(Completer):
"""
Fuzzy completion on a list of words.
(This is basically a `WordCompleter` wrapped in a `FuzzyCompleter`.)
:param words: List of words or callable that returns a list of words.
:param meta_dict: Optional dict mapping words to their meta-information.
:param WORD: When True, use WORD characters.
"""
def __init__(
self,
words: Union[List[str], Callable[[], List[str]]],
meta_dict: Optional[Dict[str, str]] = None,
WORD: bool = False,
) -> None:
self.words = words
self.meta_dict = meta_dict or {}
self.WORD = WORD
self.word_completer = WordCompleter(
words=self.words, WORD=self.WORD, meta_dict=self.meta_dict
)
self.fuzzy_completer = FuzzyCompleter(self.word_completer, WORD=self.WORD)
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
return self.fuzzy_completer.get_completions(document, complete_event)
class _FuzzyMatch(NamedTuple):
match_length: int
start_pos: int
completion: Completion

View File

@@ -0,0 +1,107 @@
"""
Nestedcompleter for completion of hierarchical data structures.
"""
from typing import Any, Dict, Iterable, Mapping, Optional, Set, Union
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.completion.word_completer import WordCompleter
from prompt_toolkit.document import Document
__all__ = ["NestedCompleter"]
# NestedDict = Mapping[str, Union['NestedDict', Set[str], None, Completer]]
NestedDict = Mapping[str, Union[Any, Set[str], None, Completer]]
class NestedCompleter(Completer):
"""
Completer which wraps around several other completers, and calls any the
one that corresponds with the first word of the input.
By combining multiple `NestedCompleter` instances, we can achieve multiple
hierarchical levels of autocompletion. This is useful when `WordCompleter`
is not sufficient.
If you need multiple levels, check out the `from_nested_dict` classmethod.
"""
def __init__(
self, options: Dict[str, Optional[Completer]], ignore_case: bool = True
) -> None:
self.options = options
self.ignore_case = ignore_case
def __repr__(self) -> str:
return f"NestedCompleter({self.options!r}, ignore_case={self.ignore_case!r})"
@classmethod
def from_nested_dict(cls, data: NestedDict) -> "NestedCompleter":
"""
Create a `NestedCompleter`, starting from a nested dictionary data
structure, like this:
.. code::
data = {
'show': {
'version': None,
'interfaces': None,
'clock': None,
'ip': {'interface': {'brief'}}
},
'exit': None
'enable': None
}
The value should be `None` if there is no further completion at some
point. If all values in the dictionary are None, it is also possible to
use a set instead.
Values in this data structure can be a completers as well.
"""
options: Dict[str, Optional[Completer]] = {}
for key, value in data.items():
if isinstance(value, Completer):
options[key] = value
elif isinstance(value, dict):
options[key] = cls.from_nested_dict(value)
elif isinstance(value, set):
options[key] = cls.from_nested_dict({item: None for item in value})
else:
assert value is None
options[key] = None
return cls(options)
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
# Split document.
text = document.text_before_cursor.lstrip()
stripped_len = len(document.text_before_cursor) - len(text)
# If there is a space, check for the first term, and use a
# subcompleter.
if " " in text:
first_term = text.split()[0]
completer = self.options.get(first_term)
# If we have a sub completer, use this for the completions.
if completer is not None:
remaining_text = text[len(first_term) :].lstrip()
move_cursor = len(text) - len(remaining_text) + stripped_len
new_document = Document(
remaining_text,
cursor_position=document.cursor_position - move_cursor,
)
yield from completer.get_completions(new_document, complete_event)
# No space in the input: behave exactly like `WordCompleter`.
else:
completer = WordCompleter(
list(self.options.keys()), ignore_case=self.ignore_case
)
yield from completer.get_completions(document, complete_event)

View File

@@ -0,0 +1,93 @@
from typing import Callable, Iterable, List, Mapping, Optional, Pattern, Union
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import AnyFormattedText
__all__ = [
"WordCompleter",
]
class WordCompleter(Completer):
"""
Simple autocompletion on a list of words.
:param words: List of words or callable that returns a list of words.
:param ignore_case: If True, case-insensitive completion.
:param meta_dict: Optional dict mapping words to their meta-text. (This
should map strings to strings or formatted text.)
:param WORD: When True, use WORD characters.
:param sentence: When True, don't complete by comparing the word before the
cursor, but by comparing all the text before the cursor. In this case,
the list of words is just a list of strings, where each string can
contain spaces. (Can not be used together with the WORD option.)
:param match_middle: When True, match not only the start, but also in the
middle of the word.
:param pattern: Optional compiled regex for finding the word before
the cursor to complete. When given, use this regex pattern instead of
default one (see document._FIND_WORD_RE)
"""
def __init__(
self,
words: Union[List[str], Callable[[], List[str]]],
ignore_case: bool = False,
display_dict: Optional[Mapping[str, AnyFormattedText]] = None,
meta_dict: Optional[Mapping[str, AnyFormattedText]] = None,
WORD: bool = False,
sentence: bool = False,
match_middle: bool = False,
pattern: Optional[Pattern[str]] = None,
) -> None:
assert not (WORD and sentence)
self.words = words
self.ignore_case = ignore_case
self.display_dict = display_dict or {}
self.meta_dict = meta_dict or {}
self.WORD = WORD
self.sentence = sentence
self.match_middle = match_middle
self.pattern = pattern
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
# Get list of words.
words = self.words
if callable(words):
words = words()
# Get word/text before cursor.
if self.sentence:
word_before_cursor = document.text_before_cursor
else:
word_before_cursor = document.get_word_before_cursor(
WORD=self.WORD, pattern=self.pattern
)
if self.ignore_case:
word_before_cursor = word_before_cursor.lower()
def word_matches(word: str) -> bool:
"""True when the word before the cursor matches."""
if self.ignore_case:
word = word.lower()
if self.match_middle:
return word_before_cursor in word
else:
return word.startswith(word_before_cursor)
for a in words:
if word_matches(a):
display = self.display_dict.get(a, a)
display_meta = self.meta_dict.get(a, "")
yield Completion(
text=a,
start_position=-len(word_before_cursor),
display=display,
display_meta=display_meta,
)

View File

@@ -0,0 +1,3 @@
from .system import SystemCompleter
__all__ = ["SystemCompleter"]

View File

@@ -0,0 +1,62 @@
from prompt_toolkit.completion.filesystem import ExecutableCompleter, PathCompleter
from prompt_toolkit.contrib.regular_languages.compiler import compile
from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
__all__ = [
"SystemCompleter",
]
class SystemCompleter(GrammarCompleter):
"""
Completer for system commands.
"""
def __init__(self) -> None:
# Compile grammar.
g = compile(
r"""
# First we have an executable.
(?P<executable>[^\s]+)
# Ignore literals in between.
(
\s+
("[^"]*" | '[^']*' | [^'"]+ )
)*
\s+
# Filename as parameters.
(
(?P<filename>[^\s]+) |
"(?P<double_quoted_filename>[^\s]+)" |
'(?P<single_quoted_filename>[^\s]+)'
)
""",
escape_funcs={
"double_quoted_filename": (lambda string: string.replace('"', '\\"')),
"single_quoted_filename": (lambda string: string.replace("'", "\\'")),
},
unescape_funcs={
"double_quoted_filename": (
lambda string: string.replace('\\"', '"')
), # XXX: not entirely correct.
"single_quoted_filename": (lambda string: string.replace("\\'", "'")),
},
)
# Create GrammarCompleter
super().__init__(
g,
{
"executable": ExecutableCompleter(),
"filename": PathCompleter(only_directories=False, expanduser=True),
"double_quoted_filename": PathCompleter(
only_directories=False, expanduser=True
),
"single_quoted_filename": PathCompleter(
only_directories=False, expanduser=True
),
},
)

View File

@@ -0,0 +1,77 @@
r"""
Tool for expressing the grammar of an input as a regular language.
==================================================================
The grammar for the input of many simple command line interfaces can be
expressed by a regular language. Examples are PDB (the Python debugger); a
simple (bash-like) shell with "pwd", "cd", "cat" and "ls" commands; arguments
that you can pass to an executable; etc. It is possible to use regular
expressions for validation and parsing of such a grammar. (More about regular
languages: http://en.wikipedia.org/wiki/Regular_language)
Example
-------
Let's take the pwd/cd/cat/ls example. We want to have a shell that accepts
these three commands. "cd" is followed by a quoted directory name and "cat" is
followed by a quoted file name. (We allow quotes inside the filename when
they're escaped with a backslash.) We could define the grammar using the
following regular expression::
grammar = \s* (
pwd |
ls |
(cd \s+ " ([^"]|\.)+ ") |
(cat \s+ " ([^"]|\.)+ ")
) \s*
What can we do with this grammar?
---------------------------------
- Syntax highlighting: We could use this for instance to give file names
different colour.
- Parse the result: .. We can extract the file names and commands by using a
regular expression with named groups.
- Input validation: .. Don't accept anything that does not match this grammar.
When combined with a parser, we can also recursively do
filename validation (and accept only existing files.)
- Autocompletion: .... Each part of the grammar can have its own autocompleter.
"cat" has to be completed using file names, while "cd"
has to be completed using directory names.
How does it work?
-----------------
As a user of this library, you have to define the grammar of the input as a
regular expression. The parts of this grammar where autocompletion, validation
or any other processing is required need to be marked using a regex named
group. Like ``(?P<varname>...)`` for instance.
When the input is processed for validation (for instance), the regex will
execute, the named group is captured, and the validator associated with this
named group will test the captured string.
There is one tricky bit:
Often we operate on incomplete input (this is by definition the case for
autocompletion) and we have to decide for the cursor position in which
possible state the grammar it could be and in which way variables could be
matched up to that point.
To solve this problem, the compiler takes the original regular expression and
translates it into a set of other regular expressions which each match certain
prefixes of the original regular expression. We generate one prefix regular
expression for every named variable (with this variable being the end of that
expression).
TODO: some examples of:
- How to create a highlighter from this grammar.
- How to create a validator from this grammar.
- How to create an autocompleter from this grammar.
- How to create a parser from this grammar.
"""
from .compiler import compile
__all__ = ["compile"]

View File

@@ -0,0 +1,574 @@
r"""
Compiler for a regular grammar.
Example usage::
# Create and compile grammar.
p = compile('add \s+ (?P<var1>[^\s]+) \s+ (?P<var2>[^\s]+)')
# Match input string.
m = p.match('add 23 432')
# Get variables.
m.variables().get('var1') # Returns "23"
m.variables().get('var2') # Returns "432"
Partial matches are possible::
# Create and compile grammar.
p = compile('''
# Operators with two arguments.
((?P<operator1>[^\s]+) \s+ (?P<var1>[^\s]+) \s+ (?P<var2>[^\s]+)) |
# Operators with only one arguments.
((?P<operator2>[^\s]+) \s+ (?P<var1>[^\s]+))
''')
# Match partial input string.
m = p.match_prefix('add 23')
# Get variables. (Notice that both operator1 and operator2 contain the
# value "add".) This is because our input is incomplete, and we don't know
# yet in which rule of the regex we we'll end up. It could also be that
# `operator1` and `operator2` have a different autocompleter and we want to
# call all possible autocompleters that would result in valid input.)
m.variables().get('var1') # Returns "23"
m.variables().get('operator1') # Returns "add"
m.variables().get('operator2') # Returns "add"
"""
import re
from typing import Callable, Dict, Iterable, Iterator, List
from typing import Match as RegexMatch
from typing import Optional, Pattern, Tuple
from .regex_parser import (
AnyNode,
Lookahead,
Node,
NodeSequence,
Regex,
Repeat,
Variable,
parse_regex,
tokenize_regex,
)
__all__ = [
"compile",
]
# Name of the named group in the regex, matching trailing input.
# (Trailing input is when the input contains characters after the end of the
# expression has been matched.)
_INVALID_TRAILING_INPUT = "invalid_trailing"
EscapeFuncDict = Dict[str, Callable[[str], str]]
class _CompiledGrammar:
"""
Compiles a grammar. This will take the parse tree of a regular expression
and compile the grammar.
:param root_node: :class~`.regex_parser.Node` instance.
:param escape_funcs: `dict` mapping variable names to escape callables.
:param unescape_funcs: `dict` mapping variable names to unescape callables.
"""
def __init__(
self,
root_node: Node,
escape_funcs: Optional[EscapeFuncDict] = None,
unescape_funcs: Optional[EscapeFuncDict] = None,
) -> None:
self.root_node = root_node
self.escape_funcs = escape_funcs or {}
self.unescape_funcs = unescape_funcs or {}
#: Dictionary that will map the regex names to Node instances.
self._group_names_to_nodes: Dict[
str, str
] = {} # Maps regex group names to varnames.
counter = [0]
def create_group_func(node: Variable) -> str:
name = "n%s" % counter[0]
self._group_names_to_nodes[name] = node.varname
counter[0] += 1
return name
# Compile regex strings.
self._re_pattern = "^%s$" % self._transform(root_node, create_group_func)
self._re_prefix_patterns = list(
self._transform_prefix(root_node, create_group_func)
)
# Compile the regex itself.
flags = re.DOTALL # Note that we don't need re.MULTILINE! (^ and $
# still represent the start and end of input text.)
self._re = re.compile(self._re_pattern, flags)
self._re_prefix = [re.compile(t, flags) for t in self._re_prefix_patterns]
# We compile one more set of regexes, similar to `_re_prefix`, but accept any trailing
# input. This will ensure that we can still highlight the input correctly, even when the
# input contains some additional characters at the end that don't match the grammar.)
self._re_prefix_with_trailing_input = [
re.compile(
r"(?:{})(?P<{}>.*?)$".format(t.rstrip("$"), _INVALID_TRAILING_INPUT),
flags,
)
for t in self._re_prefix_patterns
]
def escape(self, varname: str, value: str) -> str:
"""
Escape `value` to fit in the place of this variable into the grammar.
"""
f = self.escape_funcs.get(varname)
return f(value) if f else value
def unescape(self, varname: str, value: str) -> str:
"""
Unescape `value`.
"""
f = self.unescape_funcs.get(varname)
return f(value) if f else value
@classmethod
def _transform(
cls, root_node: Node, create_group_func: Callable[[Variable], str]
) -> str:
"""
Turn a :class:`Node` object into a regular expression.
:param root_node: The :class:`Node` instance for which we generate the grammar.
:param create_group_func: A callable which takes a `Node` and returns the next
free name for this node.
"""
def transform(node: Node) -> str:
# Turn `AnyNode` into an OR.
if isinstance(node, AnyNode):
return "(?:%s)" % "|".join(transform(c) for c in node.children)
# Concatenate a `NodeSequence`
elif isinstance(node, NodeSequence):
return "".join(transform(c) for c in node.children)
# For Regex and Lookahead nodes, just insert them literally.
elif isinstance(node, Regex):
return node.regex
elif isinstance(node, Lookahead):
before = "(?!" if node.negative else "(="
return before + transform(node.childnode) + ")"
# A `Variable` wraps the children into a named group.
elif isinstance(node, Variable):
return "(?P<{}>{})".format(
create_group_func(node),
transform(node.childnode),
)
# `Repeat`.
elif isinstance(node, Repeat):
if node.max_repeat is None:
if node.min_repeat == 0:
repeat_sign = "*"
elif node.min_repeat == 1:
repeat_sign = "+"
else:
repeat_sign = "{%i,%s}" % (
node.min_repeat,
("" if node.max_repeat is None else str(node.max_repeat)),
)
return "(?:{}){}{}".format(
transform(node.childnode),
repeat_sign,
("" if node.greedy else "?"),
)
else:
raise TypeError(f"Got {node!r}")
return transform(root_node)
@classmethod
def _transform_prefix(
cls, root_node: Node, create_group_func: Callable[[Variable], str]
) -> Iterable[str]:
"""
Yield all the regular expressions matching a prefix of the grammar
defined by the `Node` instance.
For each `Variable`, one regex pattern will be generated, with this
named group at the end. This is required because a regex engine will
terminate once a match is found. For autocompletion however, we need
the matches for all possible paths, so that we can provide completions
for each `Variable`.
- So, in the case of an `Any` (`A|B|C)', we generate a pattern for each
clause. This is one for `A`, one for `B` and one for `C`. Unless some
groups don't contain a `Variable`, then these can be merged together.
- In the case of a `NodeSequence` (`ABC`), we generate a pattern for
each prefix that ends with a variable, and one pattern for the whole
sequence. So, that's one for `A`, one for `AB` and one for `ABC`.
:param root_node: The :class:`Node` instance for which we generate the grammar.
:param create_group_func: A callable which takes a `Node` and returns the next
free name for this node.
"""
def contains_variable(node: Node) -> bool:
if isinstance(node, Regex):
return False
elif isinstance(node, Variable):
return True
elif isinstance(node, (Lookahead, Repeat)):
return contains_variable(node.childnode)
elif isinstance(node, (NodeSequence, AnyNode)):
return any(contains_variable(child) for child in node.children)
return False
def transform(node: Node) -> Iterable[str]:
# Generate separate pattern for all terms that contain variables
# within this OR. Terms that don't contain a variable can be merged
# together in one pattern.
if isinstance(node, AnyNode):
# If we have a definition like:
# (?P<name> .*) | (?P<city> .*)
# Then we want to be able to generate completions for both the
# name as well as the city. We do this by yielding two
# different regular expressions, because the engine won't
# follow multiple paths, if multiple are possible.
children_with_variable = []
children_without_variable = []
for c in node.children:
if contains_variable(c):
children_with_variable.append(c)
else:
children_without_variable.append(c)
for c in children_with_variable:
yield from transform(c)
# Merge options without variable together.
if children_without_variable:
yield "|".join(
r for c in children_without_variable for r in transform(c)
)
# For a sequence, generate a pattern for each prefix that ends with
# a variable + one pattern of the complete sequence.
# (This is because, for autocompletion, we match the text before
# the cursor, and completions are given for the variable that we
# match right before the cursor.)
elif isinstance(node, NodeSequence):
# For all components in the sequence, compute prefix patterns,
# as well as full patterns.
complete = [cls._transform(c, create_group_func) for c in node.children]
prefixes = [list(transform(c)) for c in node.children]
variable_nodes = [contains_variable(c) for c in node.children]
# If any child is contains a variable, we should yield a
# pattern up to that point, so that we are sure this will be
# matched.
for i in range(len(node.children)):
if variable_nodes[i]:
for c_str in prefixes[i]:
yield "".join(complete[:i]) + c_str
# If there are non-variable nodes, merge all the prefixes into
# one pattern. If the input is: "[part1] [part2] [part3]", then
# this gets compiled into:
# (complete1 + (complete2 + (complete3 | partial3) | partial2) | partial1 )
# For nodes that contain a variable, we skip the "|partial"
# part here, because thees are matched with the previous
# patterns.
if not all(variable_nodes):
result = []
# Start with complete patterns.
for i in range(len(node.children)):
result.append("(?:")
result.append(complete[i])
# Add prefix patterns.
for i in range(len(node.children) - 1, -1, -1):
if variable_nodes[i]:
# No need to yield a prefix for this one, we did
# the variable prefixes earlier.
result.append(")")
else:
result.append("|(?:")
# If this yields multiple, we should yield all combinations.
assert len(prefixes[i]) == 1
result.append(prefixes[i][0])
result.append("))")
yield "".join(result)
elif isinstance(node, Regex):
yield "(?:%s)?" % node.regex
elif isinstance(node, Lookahead):
if node.negative:
yield "(?!%s)" % cls._transform(node.childnode, create_group_func)
else:
# Not sure what the correct semantics are in this case.
# (Probably it's not worth implementing this.)
raise Exception("Positive lookahead not yet supported.")
elif isinstance(node, Variable):
# (Note that we should not append a '?' here. the 'transform'
# method will already recursively do that.)
for c_str in transform(node.childnode):
yield f"(?P<{create_group_func(node)}>{c_str})"
elif isinstance(node, Repeat):
# If we have a repetition of 8 times. That would mean that the
# current input could have for instance 7 times a complete
# match, followed by a partial match.
prefix = cls._transform(node.childnode, create_group_func)
if node.max_repeat == 1:
yield from transform(node.childnode)
else:
for c_str in transform(node.childnode):
if node.max_repeat:
repeat_sign = "{,%i}" % (node.max_repeat - 1)
else:
repeat_sign = "*"
yield "(?:{}){}{}{}".format(
prefix,
repeat_sign,
("" if node.greedy else "?"),
c_str,
)
else:
raise TypeError("Got %r" % node)
for r in transform(root_node):
yield "^(?:%s)$" % r
def match(self, string: str) -> Optional["Match"]:
"""
Match the string with the grammar.
Returns a :class:`Match` instance or `None` when the input doesn't match the grammar.
:param string: The input string.
"""
m = self._re.match(string)
if m:
return Match(
string, [(self._re, m)], self._group_names_to_nodes, self.unescape_funcs
)
return None
def match_prefix(self, string: str) -> Optional["Match"]:
"""
Do a partial match of the string with the grammar. The returned
:class:`Match` instance can contain multiple representations of the
match. This will never return `None`. If it doesn't match at all, the "trailing input"
part will capture all of the input.
:param string: The input string.
"""
# First try to match using `_re_prefix`. If nothing is found, use the patterns that
# also accept trailing characters.
for patterns in [self._re_prefix, self._re_prefix_with_trailing_input]:
matches = [(r, r.match(string)) for r in patterns]
matches2 = [(r, m) for r, m in matches if m]
if matches2 != []:
return Match(
string, matches2, self._group_names_to_nodes, self.unescape_funcs
)
return None
class Match:
"""
:param string: The input string.
:param re_matches: List of (compiled_re_pattern, re_match) tuples.
:param group_names_to_nodes: Dictionary mapping all the re group names to the matching Node instances.
"""
def __init__(
self,
string: str,
re_matches: List[Tuple[Pattern[str], RegexMatch[str]]],
group_names_to_nodes: Dict[str, str],
unescape_funcs: Dict[str, Callable[[str], str]],
):
self.string = string
self._re_matches = re_matches
self._group_names_to_nodes = group_names_to_nodes
self._unescape_funcs = unescape_funcs
def _nodes_to_regs(self) -> List[Tuple[str, Tuple[int, int]]]:
"""
Return a list of (varname, reg) tuples.
"""
def get_tuples() -> Iterable[Tuple[str, Tuple[int, int]]]:
for r, re_match in self._re_matches:
for group_name, group_index in r.groupindex.items():
if group_name != _INVALID_TRAILING_INPUT:
regs = re_match.regs
reg = regs[group_index]
node = self._group_names_to_nodes[group_name]
yield (node, reg)
return list(get_tuples())
def _nodes_to_values(self) -> List[Tuple[str, str, Tuple[int, int]]]:
"""
Returns list of (Node, string_value) tuples.
"""
def is_none(sl: Tuple[int, int]) -> bool:
return sl[0] == -1 and sl[1] == -1
def get(sl: Tuple[int, int]) -> str:
return self.string[sl[0] : sl[1]]
return [
(varname, get(slice), slice)
for varname, slice in self._nodes_to_regs()
if not is_none(slice)
]
def _unescape(self, varname: str, value: str) -> str:
unwrapper = self._unescape_funcs.get(varname)
return unwrapper(value) if unwrapper else value
def variables(self) -> "Variables":
"""
Returns :class:`Variables` instance.
"""
return Variables(
[(k, self._unescape(k, v), sl) for k, v, sl in self._nodes_to_values()]
)
def trailing_input(self) -> Optional["MatchVariable"]:
"""
Get the `MatchVariable` instance, representing trailing input, if there is any.
"Trailing input" is input at the end that does not match the grammar anymore, but
when this is removed from the end of the input, the input would be a valid string.
"""
slices: List[Tuple[int, int]] = []
# Find all regex group for the name _INVALID_TRAILING_INPUT.
for r, re_match in self._re_matches:
for group_name, group_index in r.groupindex.items():
if group_name == _INVALID_TRAILING_INPUT:
slices.append(re_match.regs[group_index])
# Take the smallest part. (Smaller trailing text means that a larger input has
# been matched, so that is better.)
if slices:
slice = (max(i[0] for i in slices), max(i[1] for i in slices))
value = self.string[slice[0] : slice[1]]
return MatchVariable("<trailing_input>", value, slice)
return None
def end_nodes(self) -> Iterable["MatchVariable"]:
"""
Yields `MatchVariable` instances for all the nodes having their end
position at the end of the input string.
"""
for varname, reg in self._nodes_to_regs():
# If this part goes until the end of the input string.
if reg[1] == len(self.string):
value = self._unescape(varname, self.string[reg[0] : reg[1]])
yield MatchVariable(varname, value, (reg[0], reg[1]))
class Variables:
def __init__(self, tuples: List[Tuple[str, str, Tuple[int, int]]]) -> None:
#: List of (varname, value, slice) tuples.
self._tuples = tuples
def __repr__(self) -> str:
return "{}({})".format(
self.__class__.__name__,
", ".join(f"{k}={v!r}" for k, v, _ in self._tuples),
)
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
items = self.getall(key)
return items[0] if items else default
def getall(self, key: str) -> List[str]:
return [v for k, v, _ in self._tuples if k == key]
def __getitem__(self, key: str) -> Optional[str]:
return self.get(key)
def __iter__(self) -> Iterator["MatchVariable"]:
"""
Yield `MatchVariable` instances.
"""
for varname, value, slice in self._tuples:
yield MatchVariable(varname, value, slice)
class MatchVariable:
"""
Represents a match of a variable in the grammar.
:param varname: (string) Name of the variable.
:param value: (string) Value of this variable.
:param slice: (start, stop) tuple, indicating the position of this variable
in the input string.
"""
def __init__(self, varname: str, value: str, slice: Tuple[int, int]) -> None:
self.varname = varname
self.value = value
self.slice = slice
self.start = self.slice[0]
self.stop = self.slice[1]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.varname!r}, {self.value!r})"
def compile(
expression: str,
escape_funcs: Optional[EscapeFuncDict] = None,
unescape_funcs: Optional[EscapeFuncDict] = None,
) -> _CompiledGrammar:
"""
Compile grammar (given as regex string), returning a `CompiledGrammar`
instance.
"""
return _compile_from_parse_tree(
parse_regex(tokenize_regex(expression)),
escape_funcs=escape_funcs,
unescape_funcs=unescape_funcs,
)
def _compile_from_parse_tree(
root_node: Node,
escape_funcs: Optional[EscapeFuncDict] = None,
unescape_funcs: Optional[EscapeFuncDict] = None,
) -> _CompiledGrammar:
"""
Compile grammar (given as parse tree), returning a `CompiledGrammar`
instance.
"""
return _CompiledGrammar(
root_node, escape_funcs=escape_funcs, unescape_funcs=unescape_funcs
)

View File

@@ -0,0 +1,93 @@
"""
Completer for a regular grammar.
"""
from typing import Dict, Iterable, List
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from .compiler import Match, _CompiledGrammar
__all__ = [
"GrammarCompleter",
]
class GrammarCompleter(Completer):
"""
Completer which can be used for autocompletion according to variables in
the grammar. Each variable can have a different autocompleter.
:param compiled_grammar: `GrammarCompleter` instance.
:param completers: `dict` mapping variable names of the grammar to the
`Completer` instances to be used for each variable.
"""
def __init__(
self, compiled_grammar: _CompiledGrammar, completers: Dict[str, Completer]
) -> None:
self.compiled_grammar = compiled_grammar
self.completers = completers
def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
m = self.compiled_grammar.match_prefix(document.text_before_cursor)
if m:
completions = self._remove_duplicates(
self._get_completions_for_match(m, complete_event)
)
yield from completions
def _get_completions_for_match(
self, match: Match, complete_event: CompleteEvent
) -> Iterable[Completion]:
"""
Yield all the possible completions for this input string.
(The completer assumes that the cursor position was at the end of the
input string.)
"""
for match_variable in match.end_nodes():
varname = match_variable.varname
start = match_variable.start
completer = self.completers.get(varname)
if completer:
text = match_variable.value
# Unwrap text.
unwrapped_text = self.compiled_grammar.unescape(varname, text)
# Create a document, for the completions API (text/cursor_position)
document = Document(unwrapped_text, len(unwrapped_text))
# Call completer
for completion in completer.get_completions(document, complete_event):
new_text = (
unwrapped_text[: len(text) + completion.start_position]
+ completion.text
)
# Wrap again.
yield Completion(
text=self.compiled_grammar.escape(varname, new_text),
start_position=start - len(match.string),
display=completion.display,
display_meta=completion.display_meta,
)
def _remove_duplicates(self, items: Iterable[Completion]) -> List[Completion]:
"""
Remove duplicates, while keeping the order.
(Sometimes we have duplicates, because the there several matches of the
same grammar, each yielding similar completions.)
"""
result: List[Completion] = []
for i in items:
if i not in result:
result.append(i)
return result

View File

@@ -0,0 +1,92 @@
"""
`GrammarLexer` is compatible with other lexers and can be used to highlight
the input using a regular grammar with annotations.
"""
from typing import Callable, Dict, Optional
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text.base import StyleAndTextTuples
from prompt_toolkit.formatted_text.utils import split_lines
from prompt_toolkit.lexers import Lexer
from .compiler import _CompiledGrammar
__all__ = [
"GrammarLexer",
]
class GrammarLexer(Lexer):
"""
Lexer which can be used for highlighting of fragments according to variables in the grammar.
(It does not actual lexing of the string, but it exposes an API, compatible
with the Pygments lexer class.)
:param compiled_grammar: Grammar as returned by the `compile()` function.
:param lexers: Dictionary mapping variable names of the regular grammar to
the lexers that should be used for this part. (This can
call other lexers recursively.) If you wish a part of the
grammar to just get one fragment, use a
`prompt_toolkit.lexers.SimpleLexer`.
"""
def __init__(
self,
compiled_grammar: _CompiledGrammar,
default_style: str = "",
lexers: Optional[Dict[str, Lexer]] = None,
) -> None:
self.compiled_grammar = compiled_grammar
self.default_style = default_style
self.lexers = lexers or {}
def _get_text_fragments(self, text: str) -> StyleAndTextTuples:
m = self.compiled_grammar.match_prefix(text)
if m:
characters: StyleAndTextTuples = [(self.default_style, c) for c in text]
for v in m.variables():
# If we have a `Lexer` instance for this part of the input.
# Tokenize recursively and apply tokens.
lexer = self.lexers.get(v.varname)
if lexer:
document = Document(text[v.start : v.stop])
lexer_tokens_for_line = lexer.lex_document(document)
text_fragments: StyleAndTextTuples = []
for i in range(len(document.lines)):
text_fragments.extend(lexer_tokens_for_line(i))
text_fragments.append(("", "\n"))
if text_fragments:
text_fragments.pop()
i = v.start
for t, s, *_ in text_fragments:
for c in s:
if characters[i][0] == self.default_style:
characters[i] = (t, characters[i][1])
i += 1
# Highlight trailing input.
trailing_input = m.trailing_input()
if trailing_input:
for i in range(trailing_input.start, trailing_input.stop):
characters[i] = ("class:trailing-input", characters[i][1])
return characters
else:
return [("", text)]
def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
lines = list(split_lines(self._get_text_fragments(document.text)))
def get_line(lineno: int) -> StyleAndTextTuples:
try:
return lines[lineno]
except IndexError:
return []
return get_line

View File

@@ -0,0 +1,281 @@
"""
Parser for parsing a regular expression.
Take a string representing a regular expression and return the root node of its
parse tree.
usage::
root_node = parse_regex('(hello|world)')
Remarks:
- The regex parser processes multiline, it ignores all whitespace and supports
multiple named groups with the same name and #-style comments.
Limitations:
- Lookahead is not supported.
"""
import re
from typing import List, Optional
__all__ = [
"Repeat",
"Variable",
"Regex",
"Lookahead",
"tokenize_regex",
"parse_regex",
]
class Node:
"""
Base class for all the grammar nodes.
(You don't initialize this one.)
"""
def __add__(self, other_node: "Node") -> "NodeSequence":
return NodeSequence([self, other_node])
def __or__(self, other_node: "Node") -> "AnyNode":
return AnyNode([self, other_node])
class AnyNode(Node):
"""
Union operation (OR operation) between several grammars. You don't
initialize this yourself, but it's a result of a "Grammar1 | Grammar2"
operation.
"""
def __init__(self, children: List[Node]) -> None:
self.children = children
def __or__(self, other_node: Node) -> "AnyNode":
return AnyNode(self.children + [other_node])
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.children!r})"
class NodeSequence(Node):
"""
Concatenation operation of several grammars. You don't initialize this
yourself, but it's a result of a "Grammar1 + Grammar2" operation.
"""
def __init__(self, children: List[Node]) -> None:
self.children = children
def __add__(self, other_node: Node) -> "NodeSequence":
return NodeSequence(self.children + [other_node])
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.children!r})"
class Regex(Node):
"""
Regular expression.
"""
def __init__(self, regex: str) -> None:
re.compile(regex) # Validate
self.regex = regex
def __repr__(self) -> str:
return f"{self.__class__.__name__}(/{self.regex}/)"
class Lookahead(Node):
"""
Lookahead expression.
"""
def __init__(self, childnode: Node, negative: bool = False) -> None:
self.childnode = childnode
self.negative = negative
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.childnode!r})"
class Variable(Node):
"""
Mark a variable in the regular grammar. This will be translated into a
named group. Each variable can have his own completer, validator, etc..
:param childnode: The grammar which is wrapped inside this variable.
:param varname: String.
"""
def __init__(self, childnode: Node, varname: str = "") -> None:
self.childnode = childnode
self.varname = varname
def __repr__(self) -> str:
return "{}(childnode={!r}, varname={!r})".format(
self.__class__.__name__,
self.childnode,
self.varname,
)
class Repeat(Node):
def __init__(
self,
childnode: Node,
min_repeat: int = 0,
max_repeat: Optional[int] = None,
greedy: bool = True,
) -> None:
self.childnode = childnode
self.min_repeat = min_repeat
self.max_repeat = max_repeat
self.greedy = greedy
def __repr__(self) -> str:
return f"{self.__class__.__name__}(childnode={self.childnode!r})"
def tokenize_regex(input: str) -> List[str]:
"""
Takes a string, representing a regular expression as input, and tokenizes
it.
:param input: string, representing a regular expression.
:returns: List of tokens.
"""
# Regular expression for tokenizing other regular expressions.
p = re.compile(
r"""^(
\(\?P\<[a-zA-Z0-9_-]+\> | # Start of named group.
\(\?#[^)]*\) | # Comment
\(\?= | # Start of lookahead assertion
\(\?! | # Start of negative lookahead assertion
\(\?<= | # If preceded by.
\(\?< | # If not preceded by.
\(?: | # Start of group. (non capturing.)
\( | # Start of group.
\(?[iLmsux] | # Flags.
\(?P=[a-zA-Z]+\) | # Back reference to named group
\) | # End of group.
\{[^{}]*\} | # Repetition
\*\? | \+\? | \?\?\ | # Non greedy repetition.
\* | \+ | \? | # Repetition
\#.*\n | # Comment
\\. |
# Character group.
\[
( [^\]\\] | \\.)*
\] |
[^(){}] |
.
)""",
re.VERBOSE,
)
tokens = []
while input:
m = p.match(input)
if m:
token, input = input[: m.end()], input[m.end() :]
if not token.isspace():
tokens.append(token)
else:
raise Exception("Could not tokenize input regex.")
return tokens
def parse_regex(regex_tokens: List[str]) -> Node:
"""
Takes a list of tokens from the tokenizer, and returns a parse tree.
"""
# We add a closing brace because that represents the final pop of the stack.
tokens: List[str] = [")"] + regex_tokens[::-1]
def wrap(lst: List[Node]) -> Node:
"""Turn list into sequence when it contains several items."""
if len(lst) == 1:
return lst[0]
else:
return NodeSequence(lst)
def _parse() -> Node:
or_list: List[List[Node]] = []
result: List[Node] = []
def wrapped_result() -> Node:
if or_list == []:
return wrap(result)
else:
or_list.append(result)
return AnyNode([wrap(i) for i in or_list])
while tokens:
t = tokens.pop()
if t.startswith("(?P<"):
variable = Variable(_parse(), varname=t[4:-1])
result.append(variable)
elif t in ("*", "*?"):
greedy = t == "*"
result[-1] = Repeat(result[-1], greedy=greedy)
elif t in ("+", "+?"):
greedy = t == "+"
result[-1] = Repeat(result[-1], min_repeat=1, greedy=greedy)
elif t in ("?", "??"):
if result == []:
raise Exception("Nothing to repeat." + repr(tokens))
else:
greedy = t == "?"
result[-1] = Repeat(
result[-1], min_repeat=0, max_repeat=1, greedy=greedy
)
elif t == "|":
or_list.append(result)
result = []
elif t in ("(", "(?:"):
result.append(_parse())
elif t == "(?!":
result.append(Lookahead(_parse(), negative=True))
elif t == "(?=":
result.append(Lookahead(_parse(), negative=False))
elif t == ")":
return wrapped_result()
elif t.startswith("#"):
pass
elif t.startswith("{"):
# TODO: implement!
raise Exception(f"{t}-style repetition not yet supported")
elif t.startswith("(?"):
raise Exception("%r not supported" % t)
elif t.isspace():
pass
else:
result.append(Regex(t))
raise Exception("Expecting ')' token")
result = _parse()
if len(tokens) != 0:
raise Exception("Unmatched parentheses.")
else:
return result

View File

@@ -0,0 +1,60 @@
"""
Validator for a regular language.
"""
from typing import Dict
from prompt_toolkit.document import Document
from prompt_toolkit.validation import ValidationError, Validator
from .compiler import _CompiledGrammar
__all__ = [
"GrammarValidator",
]
class GrammarValidator(Validator):
"""
Validator which can be used for validation according to variables in
the grammar. Each variable can have its own validator.
:param compiled_grammar: `GrammarCompleter` instance.
:param validators: `dict` mapping variable names of the grammar to the
`Validator` instances to be used for each variable.
"""
def __init__(
self, compiled_grammar: _CompiledGrammar, validators: Dict[str, Validator]
) -> None:
self.compiled_grammar = compiled_grammar
self.validators = validators
def validate(self, document: Document) -> None:
# Parse input document.
# We use `match`, not `match_prefix`, because for validation, we want
# the actual, unambiguous interpretation of the input.
m = self.compiled_grammar.match(document.text)
if m:
for v in m.variables():
validator = self.validators.get(v.varname)
if validator:
# Unescape text.
unwrapped_text = self.compiled_grammar.unescape(v.varname, v.value)
# Create a document, for the completions API (text/cursor_position)
inner_document = Document(unwrapped_text, len(unwrapped_text))
try:
validator.validate(inner_document)
except ValidationError as e:
raise ValidationError(
cursor_position=v.start + e.cursor_position,
message=e.message,
) from e
else:
raise ValidationError(
cursor_position=len(document.text), message="Invalid command"
)

View File

@@ -0,0 +1,6 @@
from .server import PromptToolkitSSHServer, PromptToolkitSSHSession
__all__ = [
"PromptToolkitSSHSession",
"PromptToolkitSSHServer",
]

View File

@@ -0,0 +1,161 @@
"""
Utility for running a prompt_toolkit application in an asyncssh server.
"""
import asyncio
import traceback
from typing import Any, Awaitable, Callable, Optional, TextIO, cast
import asyncssh
from prompt_toolkit.application.current import AppSession, create_app_session
from prompt_toolkit.data_structures import Size
from prompt_toolkit.eventloop import get_event_loop
from prompt_toolkit.input import PipeInput, create_pipe_input
from prompt_toolkit.output.vt100 import Vt100_Output
__all__ = ["PromptToolkitSSHSession", "PromptToolkitSSHServer"]
class PromptToolkitSSHSession(asyncssh.SSHServerSession): # type: ignore
def __init__(
self, interact: Callable[["PromptToolkitSSHSession"], Awaitable[None]]
) -> None:
self.interact = interact
self.interact_task: Optional[asyncio.Task[None]] = None
self._chan: Optional[Any] = None
self.app_session: Optional[AppSession] = None
# PipInput object, for sending input in the CLI.
# (This is something that we can use in the prompt_toolkit event loop,
# but still write date in manually.)
self._input: Optional[PipeInput] = None
self._output: Optional[Vt100_Output] = None
# Output object. Don't render to the real stdout, but write everything
# in the SSH channel.
class Stdout:
def write(s, data: str) -> None:
try:
if self._chan is not None:
self._chan.write(data.replace("\n", "\r\n"))
except BrokenPipeError:
pass # Channel not open for sending.
def isatty(s) -> bool:
return True
def flush(s) -> None:
pass
@property
def encoding(s) -> str:
assert self._chan is not None
return str(self._chan._orig_chan.get_encoding()[0])
self.stdout = cast(TextIO, Stdout())
def _get_size(self) -> Size:
"""
Callable that returns the current `Size`, required by Vt100_Output.
"""
if self._chan is None:
return Size(rows=20, columns=79)
else:
width, height, pixwidth, pixheight = self._chan.get_terminal_size()
return Size(rows=height, columns=width)
def connection_made(self, chan: Any) -> None:
self._chan = chan
def shell_requested(self) -> bool:
return True
def session_started(self) -> None:
self.interact_task = get_event_loop().create_task(self._interact())
async def _interact(self) -> None:
if self._chan is None:
# Should not happen.
raise Exception("`_interact` called before `connection_made`.")
if hasattr(self._chan, "set_line_mode") and self._chan._editor is not None:
# Disable the line editing provided by asyncssh. Prompt_toolkit
# provides the line editing.
self._chan.set_line_mode(False)
term = self._chan.get_terminal_type()
self._output = Vt100_Output(
self.stdout, self._get_size, term=term, write_binary=False
)
with create_pipe_input() as self._input:
with create_app_session(input=self._input, output=self._output) as session:
self.app_session = session
try:
await self.interact(self)
except BaseException:
traceback.print_exc()
finally:
# Close the connection.
self._chan.close()
self._input.close()
def terminal_size_changed(
self, width: int, height: int, pixwidth: object, pixheight: object
) -> None:
# Send resize event to the current application.
if self.app_session and self.app_session.app:
self.app_session.app._on_resize()
def data_received(self, data: str, datatype: object) -> None:
if self._input is None:
# Should not happen.
return
self._input.send_text(data)
class PromptToolkitSSHServer(asyncssh.SSHServer): # type: ignore
"""
Run a prompt_toolkit application over an asyncssh server.
This takes one argument, an `interact` function, which is called for each
connection. This should be an asynchronous function that runs the
prompt_toolkit applications. This function runs in an `AppSession`, which
means that we can have multiple UI interactions concurrently.
Example usage:
.. code:: python
async def interact(ssh_session: PromptToolkitSSHSession) -> None:
await yes_no_dialog("my title", "my text").run_async()
prompt_session = PromptSession()
text = await prompt_session.prompt_async("Type something: ")
print_formatted_text('You said: ', text)
server = PromptToolkitSSHServer(interact=interact)
loop = get_event_loop()
loop.run_until_complete(
asyncssh.create_server(
lambda: MySSHServer(interact),
"",
port,
server_host_keys=["/etc/ssh/..."],
)
)
loop.run_forever()
"""
def __init__(
self, interact: Callable[[PromptToolkitSSHSession], Awaitable[None]]
) -> None:
self.interact = interact
def begin_auth(self, username: str) -> bool:
# No authentication.
return False
def session_requested(self) -> PromptToolkitSSHSession:
return PromptToolkitSSHSession(self.interact)

View File

@@ -0,0 +1,5 @@
from .server import TelnetServer
__all__ = [
"TelnetServer",
]

View File

@@ -0,0 +1,10 @@
"""
Python logger for the telnet server.
"""
import logging
logger = logging.getLogger(__package__)
__all__ = [
"logger",
]

View File

@@ -0,0 +1,207 @@
"""
Parser for the Telnet protocol. (Not a complete implementation of the telnet
specification, but sufficient for a command line interface.)
Inspired by `Twisted.conch.telnet`.
"""
import struct
from typing import Callable, Generator
from .log import logger
__all__ = [
"TelnetProtocolParser",
]
def int2byte(number: int) -> bytes:
return bytes((number,))
# Telnet constants.
NOP = int2byte(0)
SGA = int2byte(3)
IAC = int2byte(255)
DO = int2byte(253)
DONT = int2byte(254)
LINEMODE = int2byte(34)
SB = int2byte(250)
WILL = int2byte(251)
WONT = int2byte(252)
MODE = int2byte(1)
SE = int2byte(240)
ECHO = int2byte(1)
NAWS = int2byte(31)
LINEMODE = int2byte(34)
SUPPRESS_GO_AHEAD = int2byte(3)
TTYPE = int2byte(24)
SEND = int2byte(1)
IS = int2byte(0)
DM = int2byte(242)
BRK = int2byte(243)
IP = int2byte(244)
AO = int2byte(245)
AYT = int2byte(246)
EC = int2byte(247)
EL = int2byte(248)
GA = int2byte(249)
class TelnetProtocolParser:
"""
Parser for the Telnet protocol.
Usage::
def data_received(data):
print(data)
def size_received(rows, columns):
print(rows, columns)
p = TelnetProtocolParser(data_received, size_received)
p.feed(binary_data)
"""
def __init__(
self,
data_received_callback: Callable[[bytes], None],
size_received_callback: Callable[[int, int], None],
ttype_received_callback: Callable[[str], None],
) -> None:
self.data_received_callback = data_received_callback
self.size_received_callback = size_received_callback
self.ttype_received_callback = ttype_received_callback
self._parser = self._parse_coroutine()
self._parser.send(None) # type: ignore
def received_data(self, data: bytes) -> None:
self.data_received_callback(data)
def do_received(self, data: bytes) -> None:
"""Received telnet DO command."""
logger.info("DO %r", data)
def dont_received(self, data: bytes) -> None:
"""Received telnet DONT command."""
logger.info("DONT %r", data)
def will_received(self, data: bytes) -> None:
"""Received telnet WILL command."""
logger.info("WILL %r", data)
def wont_received(self, data: bytes) -> None:
"""Received telnet WONT command."""
logger.info("WONT %r", data)
def command_received(self, command: bytes, data: bytes) -> None:
if command == DO:
self.do_received(data)
elif command == DONT:
self.dont_received(data)
elif command == WILL:
self.will_received(data)
elif command == WONT:
self.wont_received(data)
else:
logger.info("command received %r %r", command, data)
def naws(self, data: bytes) -> None:
"""
Received NAWS. (Window dimensions.)
"""
if len(data) == 4:
# NOTE: the first parameter of struct.unpack should be
# a 'str' object. Both on Py2/py3. This crashes on OSX
# otherwise.
columns, rows = struct.unpack("!HH", data)
self.size_received_callback(rows, columns)
else:
logger.warning("Wrong number of NAWS bytes")
def ttype(self, data: bytes) -> None:
"""
Received terminal type.
"""
subcmd, data = data[0:1], data[1:]
if subcmd == IS:
ttype = data.decode("ascii")
self.ttype_received_callback(ttype)
else:
logger.warning("Received a non-IS terminal type Subnegotiation")
def negotiate(self, data: bytes) -> None:
"""
Got negotiate data.
"""
command, payload = data[0:1], data[1:]
if command == NAWS:
self.naws(payload)
elif command == TTYPE:
self.ttype(payload)
else:
logger.info("Negotiate (%r got bytes)", len(data))
def _parse_coroutine(self) -> Generator[None, bytes, None]:
"""
Parser state machine.
Every 'yield' expression returns the next byte.
"""
while True:
d = yield
if d == int2byte(0):
pass # NOP
# Go to state escaped.
elif d == IAC:
d2 = yield
if d2 == IAC:
self.received_data(d2)
# Handle simple commands.
elif d2 in (NOP, DM, BRK, IP, AO, AYT, EC, EL, GA):
self.command_received(d2, b"")
# Handle IAC-[DO/DONT/WILL/WONT] commands.
elif d2 in (DO, DONT, WILL, WONT):
d3 = yield
self.command_received(d2, d3)
# Subnegotiation
elif d2 == SB:
# Consume everything until next IAC-SE
data = []
while True:
d3 = yield
if d3 == IAC:
d4 = yield
if d4 == SE:
break
else:
data.append(d4)
else:
data.append(d3)
self.negotiate(b"".join(data))
else:
self.received_data(d)
def feed(self, data: bytes) -> None:
"""
Feed data to the parser.
"""
for b in data:
self._parser.send(int2byte(b))

View File

@@ -0,0 +1,377 @@
"""
Telnet server.
"""
import asyncio
import socket
import sys
from typing import Any, Awaitable, Callable, List, Optional, Set, TextIO, Tuple, cast
from prompt_toolkit.application.current import create_app_session, get_app
from prompt_toolkit.application.run_in_terminal import run_in_terminal
from prompt_toolkit.data_structures import Size
from prompt_toolkit.eventloop import get_event_loop
from prompt_toolkit.formatted_text import AnyFormattedText, to_formatted_text
from prompt_toolkit.input import PipeInput, create_pipe_input
from prompt_toolkit.output.vt100 import Vt100_Output
from prompt_toolkit.renderer import print_formatted_text as print_formatted_text
from prompt_toolkit.styles import BaseStyle, DummyStyle
from .log import logger
from .protocol import (
DO,
ECHO,
IAC,
LINEMODE,
MODE,
NAWS,
SB,
SE,
SEND,
SUPPRESS_GO_AHEAD,
TTYPE,
WILL,
TelnetProtocolParser,
)
if sys.version_info >= (3, 7):
import contextvars # Requires Python3.7!
else:
contextvars: Any = None
__all__ = [
"TelnetServer",
]
def int2byte(number: int) -> bytes:
return bytes((number,))
def _initialize_telnet(connection: socket.socket) -> None:
logger.info("Initializing telnet connection")
# Iac Do Linemode
connection.send(IAC + DO + LINEMODE)
# Suppress Go Ahead. (This seems important for Putty to do correct echoing.)
# This will allow bi-directional operation.
connection.send(IAC + WILL + SUPPRESS_GO_AHEAD)
# Iac sb
connection.send(IAC + SB + LINEMODE + MODE + int2byte(0) + IAC + SE)
# IAC Will Echo
connection.send(IAC + WILL + ECHO)
# Negotiate window size
connection.send(IAC + DO + NAWS)
# Negotiate terminal type
# Assume the client will accept the negociation with `IAC + WILL + TTYPE`
connection.send(IAC + DO + TTYPE)
# We can then select the first terminal type supported by the client,
# which is generally the best type the client supports
# The client should reply with a `IAC + SB + TTYPE + IS + ttype + IAC + SE`
connection.send(IAC + SB + TTYPE + SEND + IAC + SE)
class _ConnectionStdout:
"""
Wrapper around socket which provides `write` and `flush` methods for the
Vt100_Output output.
"""
def __init__(self, connection: socket.socket, encoding: str) -> None:
self._encoding = encoding
self._connection = connection
self._errors = "strict"
self._buffer: List[bytes] = []
self._closed = False
def write(self, data: str) -> None:
data = data.replace("\n", "\r\n")
self._buffer.append(data.encode(self._encoding, errors=self._errors))
self.flush()
def isatty(self) -> bool:
return True
def flush(self) -> None:
try:
if not self._closed:
self._connection.send(b"".join(self._buffer))
except OSError as e:
logger.warning("Couldn't send data over socket: %s" % e)
self._buffer = []
def close(self) -> None:
self._closed = True
@property
def encoding(self) -> str:
return self._encoding
@property
def errors(self) -> str:
return self._errors
class TelnetConnection:
"""
Class that represents one Telnet connection.
"""
def __init__(
self,
conn: socket.socket,
addr: Tuple[str, int],
interact: Callable[["TelnetConnection"], Awaitable[None]],
server: "TelnetServer",
encoding: str,
style: Optional[BaseStyle],
vt100_input: PipeInput,
) -> None:
self.conn = conn
self.addr = addr
self.interact = interact
self.server = server
self.encoding = encoding
self.style = style
self._closed = False
self._ready = asyncio.Event()
self.vt100_input = vt100_input
self.vt100_output = None
# Create "Output" object.
self.size = Size(rows=40, columns=79)
# Initialize.
_initialize_telnet(conn)
# Create output.
def get_size() -> Size:
return self.size
self.stdout = cast(TextIO, _ConnectionStdout(conn, encoding=encoding))
def data_received(data: bytes) -> None:
"""TelnetProtocolParser 'data_received' callback"""
self.vt100_input.send_bytes(data)
def size_received(rows: int, columns: int) -> None:
"""TelnetProtocolParser 'size_received' callback"""
self.size = Size(rows=rows, columns=columns)
if self.vt100_output is not None and self.context:
self.context.run(lambda: get_app()._on_resize())
def ttype_received(ttype: str) -> None:
"""TelnetProtocolParser 'ttype_received' callback"""
self.vt100_output = Vt100_Output(
self.stdout, get_size, term=ttype, write_binary=False
)
self._ready.set()
self.parser = TelnetProtocolParser(data_received, size_received, ttype_received)
self.context: Optional[contextvars.Context] = None
async def run_application(self) -> None:
"""
Run application.
"""
def handle_incoming_data() -> None:
data = self.conn.recv(1024)
if data:
self.feed(data)
else:
# Connection closed by client.
logger.info("Connection closed by client. %r %r" % self.addr)
self.close()
# Add reader.
loop = get_event_loop()
loop.add_reader(self.conn, handle_incoming_data)
try:
# Wait for v100_output to be properly instantiated
await self._ready.wait()
with create_app_session(input=self.vt100_input, output=self.vt100_output):
self.context = contextvars.copy_context()
await self.interact(self)
finally:
self.close()
def feed(self, data: bytes) -> None:
"""
Handler for incoming data. (Called by TelnetServer.)
"""
self.parser.feed(data)
def close(self) -> None:
"""
Closed by client.
"""
if not self._closed:
self._closed = True
self.vt100_input.close()
get_event_loop().remove_reader(self.conn)
self.conn.close()
self.stdout.close()
def send(self, formatted_text: AnyFormattedText) -> None:
"""
Send text to the client.
"""
if self.vt100_output is None:
return
formatted_text = to_formatted_text(formatted_text)
print_formatted_text(
self.vt100_output, formatted_text, self.style or DummyStyle()
)
def send_above_prompt(self, formatted_text: AnyFormattedText) -> None:
"""
Send text to the client.
This is asynchronous, returns a `Future`.
"""
formatted_text = to_formatted_text(formatted_text)
return self._run_in_terminal(lambda: self.send(formatted_text))
def _run_in_terminal(self, func: Callable[[], None]) -> None:
# Make sure that when an application was active for this connection,
# that we print the text above the application.
if self.context:
self.context.run(run_in_terminal, func)
else:
raise RuntimeError("Called _run_in_terminal outside `run_application`.")
def erase_screen(self) -> None:
"""
Erase the screen and move the cursor to the top.
"""
if self.vt100_output is None:
return
self.vt100_output.erase_screen()
self.vt100_output.cursor_goto(0, 0)
self.vt100_output.flush()
async def _dummy_interact(connection: TelnetConnection) -> None:
pass
class TelnetServer:
"""
Telnet server implementation.
"""
def __init__(
self,
host: str = "127.0.0.1",
port: int = 23,
interact: Callable[[TelnetConnection], Awaitable[None]] = _dummy_interact,
encoding: str = "utf-8",
style: Optional[BaseStyle] = None,
) -> None:
self.host = host
self.port = port
self.interact = interact
self.encoding = encoding
self.style = style
self._application_tasks: List[asyncio.Task[None]] = []
self.connections: Set[TelnetConnection] = set()
self._listen_socket: Optional[socket.socket] = None
@classmethod
def _create_socket(cls, host: str, port: int) -> socket.socket:
# Create and bind socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
s.listen(4)
return s
def start(self) -> None:
"""
Start the telnet server.
Don't forget to call `loop.run_forever()` after doing this.
"""
self._listen_socket = self._create_socket(self.host, self.port)
logger.info(
"Listening for telnet connections on %s port %r", self.host, self.port
)
get_event_loop().add_reader(self._listen_socket, self._accept)
async def stop(self) -> None:
if self._listen_socket:
get_event_loop().remove_reader(self._listen_socket)
self._listen_socket.close()
# Wait for all applications to finish.
for t in self._application_tasks:
t.cancel()
for t in self._application_tasks:
try:
await t
except asyncio.CancelledError:
logger.debug("Task %s cancelled", str(t))
def _accept(self) -> None:
"""
Accept new incoming connection.
"""
if self._listen_socket is None:
return # Should not happen. `_accept` is called after `start`.
conn, addr = self._listen_socket.accept()
logger.info("New connection %r %r", *addr)
# Run application for this connection.
async def run() -> None:
try:
with create_pipe_input() as vt100_input:
connection = TelnetConnection(
conn,
addr,
self.interact,
self,
encoding=self.encoding,
style=self.style,
vt100_input=vt100_input,
)
self.connections.add(connection)
logger.info("Starting interaction %r %r", *addr)
try:
await connection.run_application()
finally:
self.connections.remove(connection)
logger.info("Stopping interaction %r %r", *addr)
except EOFError:
# Happens either when the connection is closed by the client
# (e.g., when the user types 'control-]', then 'quit' in the
# telnet client) or when the user types control-d in a prompt
# and this is not handled by the interact function.
logger.info("Unhandled EOFError in telnet application.")
except KeyboardInterrupt:
# Unhandled control-c propagated by a prompt.
logger.info("Unhandled KeyboardInterrupt in telnet application.")
except BaseException as e:
print("Got %s" % type(e).__name__, e)
import traceback
traceback.print_exc()
finally:
self._application_tasks.remove(task)
task = get_event_loop().create_task(run())
self._application_tasks.append(task)

View File

@@ -0,0 +1,102 @@
from abc import ABC, abstractmethod
from enum import Enum
from typing import TYPE_CHECKING, Any, Callable, Union
from prompt_toolkit.enums import EditingMode
from prompt_toolkit.key_binding.vi_state import InputMode
if TYPE_CHECKING:
from .application import Application
__all__ = [
"CursorShape",
"CursorShapeConfig",
"SimpleCursorShapeConfig",
"ModalCursorShapeConfig",
"DynamicCursorShapeConfig",
"to_cursor_shape_config",
]
class CursorShape(Enum):
# Default value that should tell the output implementation to never send
# cursor shape escape sequences. This is the default right now, because
# before this `CursorShape` functionality was introduced into
# prompt_toolkit itself, people had workarounds to send cursor shapes
# escapes into the terminal, by monkey patching some of prompt_toolkit's
# internals. We don't want the default prompt_toolkit implemetation to
# interefere with that. E.g., IPython patches the `ViState.input_mode`
# property. See: https://github.com/ipython/ipython/pull/13501/files
_NEVER_CHANGE = "_NEVER_CHANGE"
BLOCK = "BLOCK"
BEAM = "BEAM"
UNDERLINE = "UNDERLINE"
BLINKING_BLOCK = "BLINKING_BLOCK"
BLINKING_BEAM = "BLINKING_BEAM"
BLINKING_UNDERLINE = "BLINKING_UNDERLINE"
class CursorShapeConfig(ABC):
@abstractmethod
def get_cursor_shape(self, application: "Application[Any]") -> CursorShape:
"""
Return the cursor shape to be used in the current state.
"""
AnyCursorShapeConfig = Union[CursorShape, CursorShapeConfig, None]
class SimpleCursorShapeConfig(CursorShapeConfig):
"""
Always show the given cursor shape.
"""
def __init__(self, cursor_shape: CursorShape = CursorShape._NEVER_CHANGE) -> None:
self.cursor_shape = cursor_shape
def get_cursor_shape(self, application: "Application[Any]") -> CursorShape:
return self.cursor_shape
class ModalCursorShapeConfig(CursorShapeConfig):
"""
Show cursor shape according to the current input mode.
"""
def get_cursor_shape(self, application: "Application[Any]") -> CursorShape:
if application.editing_mode == EditingMode.VI:
if application.vi_state.input_mode == InputMode.INSERT:
return CursorShape.BEAM
if application.vi_state.input_mode == InputMode.REPLACE:
return CursorShape.UNDERLINE
# Default
return CursorShape.BLOCK
class DynamicCursorShapeConfig(CursorShapeConfig):
def __init__(
self, get_cursor_shape_config: Callable[[], AnyCursorShapeConfig]
) -> None:
self.get_cursor_shape_config = get_cursor_shape_config
def get_cursor_shape(self, application: "Application[Any]") -> CursorShape:
return to_cursor_shape_config(self.get_cursor_shape_config()).get_cursor_shape(
application
)
def to_cursor_shape_config(value: AnyCursorShapeConfig) -> CursorShapeConfig:
"""
Take a `CursorShape` instance or `CursorShapeConfig` and turn it into a
`CursorShapeConfig`.
"""
if value is None:
return SimpleCursorShapeConfig()
if isinstance(value, CursorShape):
return SimpleCursorShapeConfig(value)
return value

View File

@@ -0,0 +1,16 @@
from typing import NamedTuple
__all__ = [
"Point",
"Size",
]
class Point(NamedTuple):
x: int
y: int
class Size(NamedTuple):
rows: int
columns: int

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
from enum import Enum
class EditingMode(Enum):
# The set of key bindings that is active.
VI = "VI"
EMACS = "EMACS"
#: Name of the search buffer.
SEARCH_BUFFER = "SEARCH_BUFFER"
#: Name of the default buffer.
DEFAULT_BUFFER = "DEFAULT_BUFFER"
#: Name of the system buffer.
SYSTEM_BUFFER = "SYSTEM_BUFFER"

View File

@@ -0,0 +1,28 @@
from .async_generator import generator_to_async_generator
from .inputhook import (
InputHookContext,
InputHookSelector,
new_eventloop_with_inputhook,
set_eventloop_with_inputhook,
)
from .utils import (
call_soon_threadsafe,
get_event_loop,
get_traceback_from_context,
run_in_executor_with_context,
)
__all__ = [
# Async generator
"generator_to_async_generator",
# Utils.
"run_in_executor_with_context",
"call_soon_threadsafe",
"get_traceback_from_context",
"get_event_loop",
# Inputhooks.
"new_eventloop_with_inputhook",
"set_eventloop_with_inputhook",
"InputHookSelector",
"InputHookContext",
]

View File

@@ -0,0 +1,132 @@
"""
@asynccontextmanager code, copied from Python 3.7's contextlib.
For usage in Python 3.6.
Types have been added to this file, just enough to make Mypy happy.
"""
# mypy: allow-untyped-defs
import abc
from functools import wraps
from typing import AsyncContextManager, AsyncIterator, Callable, TypeVar
import _collections_abc
__all__ = ["asynccontextmanager"]
class AbstractAsyncContextManager(abc.ABC):
"""An abstract base class for asynchronous context managers."""
async def __aenter__(self):
"""Return `self` upon entering the runtime context."""
return self
@abc.abstractmethod
async def __aexit__(self, exc_type, exc_value, traceback):
"""Raise any exception triggered within the runtime context."""
return None
@classmethod
def __subclasshook__(cls, C):
if cls is AbstractAsyncContextManager:
return _collections_abc._check_methods(C, "__aenter__", "__aexit__") # type: ignore
return NotImplemented
class _GeneratorContextManagerBase:
"""Shared functionality for @contextmanager and @asynccontextmanager."""
def __init__(self, func, args, kwds):
self.gen = func(*args, **kwds)
self.func, self.args, self.kwds = func, args, kwds
# Issue 19330: ensure context manager instances have good docstrings
doc = getattr(func, "__doc__", None)
if doc is None:
doc = type(self).__doc__
self.__doc__ = doc
# Unfortunately, this still doesn't provide good help output when
# inspecting the created context manager instances, since pydoc
# currently bypasses the instance docstring and shows the docstring
# for the class instead.
# See http://bugs.python.org/issue19404 for more details.
class _AsyncGeneratorContextManager(
_GeneratorContextManagerBase, AbstractAsyncContextManager
):
"""Helper for @asynccontextmanager."""
async def __aenter__(self):
try:
return await self.gen.__anext__()
except StopAsyncIteration:
raise RuntimeError("generator didn't yield") from None
async def __aexit__(self, typ, value, traceback):
if typ is None:
try:
await self.gen.__anext__()
except StopAsyncIteration:
return
else:
raise RuntimeError("generator didn't stop")
else:
if value is None:
value = typ()
# See _GeneratorContextManager.__exit__ for comments on subtleties
# in this implementation
try:
await self.gen.athrow(typ, value, traceback)
raise RuntimeError("generator didn't stop after athrow()")
except StopAsyncIteration as exc:
return exc is not value
except RuntimeError as exc:
if exc is value:
return False
# Avoid suppressing if a StopIteration exception
# was passed to throw() and later wrapped into a RuntimeError
# (see PEP 479 for sync generators; async generators also
# have this behavior). But do this only if the exception wrapped
# by the RuntimeError is actully Stop(Async)Iteration (see
# issue29692).
if isinstance(value, (StopIteration, StopAsyncIteration)):
if exc.__cause__ is value:
return False
raise
except BaseException as exc:
if exc is not value:
raise
_T = TypeVar("_T")
def asynccontextmanager(
func: Callable[..., AsyncIterator[_T]]
) -> Callable[..., AsyncContextManager[_T]]:
"""@asynccontextmanager decorator.
Typical usage:
@asynccontextmanager
async def some_async_generator(<arguments>):
<setup>
try:
yield <value>
finally:
<cleanup>
This makes this:
async with some_async_generator(<arguments>) as <variable>:
<body>
equivalent to this:
<setup>
try:
<variable> = <value>
<body>
finally:
<cleanup>
"""
@wraps(func)
def helper(*args, **kwds):
return _AsyncGeneratorContextManager(func, args, kwds) # type: ignore
return helper

View File

@@ -0,0 +1,74 @@
"""
Implementation for async generators.
"""
from asyncio import Queue
from typing import AsyncGenerator, Callable, Iterable, TypeVar, Union
from .utils import get_event_loop, run_in_executor_with_context
__all__ = [
"generator_to_async_generator",
]
_T = TypeVar("_T")
class _Done:
pass
async def generator_to_async_generator(
get_iterable: Callable[[], Iterable[_T]]
) -> AsyncGenerator[_T, None]:
"""
Turn a generator or iterable into an async generator.
This works by running the generator in a background thread.
:param get_iterable: Function that returns a generator or iterable when
called.
"""
quitting = False
_done = _Done()
q: Queue[Union[_T, _Done]] = Queue()
loop = get_event_loop()
def runner() -> None:
"""
Consume the generator in background thread.
When items are received, they'll be pushed to the queue.
"""
try:
for item in get_iterable():
# When this async generator was cancelled (closed), stop this
# thread.
if quitting:
break
loop.call_soon_threadsafe(q.put_nowait, item)
finally:
loop.call_soon_threadsafe(q.put_nowait, _done)
# Start background thread.
runner_f = run_in_executor_with_context(runner)
try:
while True:
item = await q.get()
if isinstance(item, _Done):
break
else:
yield item
finally:
# When this async generator is closed (GeneratorExit exception, stop
# the background thread as well. - we don't need that anymore.)
quitting = True
# Wait for the background thread to finish. (should happen right after
# the next item is yielded). If we don't do this, and the event loop
# gets closed before the runner is done, then we'll get a
# `RuntimeError: Event loop is closed` exception printed to stdout that
# we can't handle.
await runner_f

View File

@@ -0,0 +1,49 @@
"""
Dummy contextvars implementation, to make prompt_toolkit work on Python 3.6.
As long as there is only one application running at a time, we don't need the
real contextvars. So, stuff like the telnet-server and so on requires 3.7.
"""
from typing import Any, Callable, Generic, Optional, TypeVar
def copy_context() -> "Context":
return Context()
_T = TypeVar("_T")
class Context:
def run(self, callable: Callable[..., _T], *args: Any, **kwargs: Any) -> _T:
return callable(*args, **kwargs)
def copy(self) -> "Context":
return self
class Token(Generic[_T]):
pass
class ContextVar(Generic[_T]):
def __init__(self, name: str, *, default: Optional[_T] = None) -> None:
self._name = name
self._value = default
@property
def name(self) -> str:
return self._name
def get(self, default: Optional[_T] = None) -> _T:
result = self._value or default
if result is None:
raise LookupError
return result
def set(self, value: _T) -> Token[_T]:
self._value = value
return Token()
def reset(self, token: Token[_T]) -> None:
pass

View File

@@ -0,0 +1,183 @@
"""
Similar to `PyOS_InputHook` of the Python API, we can plug in an input hook in
the asyncio event loop.
The way this works is by using a custom 'selector' that runs the other event
loop until the real selector is ready.
It's the responsibility of this event hook to return when there is input ready.
There are two ways to detect when input is ready:
The inputhook itself is a callable that receives an `InputHookContext`. This
callable should run the other event loop, and return when the main loop has
stuff to do. There are two ways to detect when to return:
- Call the `input_is_ready` method periodically. Quit when this returns `True`.
- Add the `fileno` as a watch to the external eventloop. Quit when file descriptor
becomes readable. (But don't read from it.)
Note that this is not the same as checking for `sys.stdin.fileno()`. The
eventloop of prompt-toolkit allows thread-based executors, for example for
asynchronous autocompletion. When the completion for instance is ready, we
also want prompt-toolkit to gain control again in order to display that.
"""
import asyncio
import os
import select
import selectors
import sys
import threading
from asyncio import AbstractEventLoop
from selectors import BaseSelector, SelectorKey
from typing import TYPE_CHECKING, Any, Callable, List, Mapping, Optional, Tuple
from .utils import get_event_loop
__all__ = [
"new_eventloop_with_inputhook",
"set_eventloop_with_inputhook",
"InputHookSelector",
"InputHookContext",
]
if TYPE_CHECKING:
from _typeshed import FileDescriptorLike
_EventMask = int
def new_eventloop_with_inputhook(
inputhook: Callable[["InputHookContext"], None]
) -> AbstractEventLoop:
"""
Create a new event loop with the given inputhook.
"""
selector = InputHookSelector(selectors.DefaultSelector(), inputhook)
loop = asyncio.SelectorEventLoop(selector)
return loop
def set_eventloop_with_inputhook(
inputhook: Callable[["InputHookContext"], None]
) -> AbstractEventLoop:
"""
Create a new event loop with the given inputhook, and activate it.
"""
loop = new_eventloop_with_inputhook(inputhook)
asyncio.set_event_loop(loop)
return loop
class InputHookSelector(BaseSelector):
"""
Usage:
selector = selectors.SelectSelector()
loop = asyncio.SelectorEventLoop(InputHookSelector(selector, inputhook))
asyncio.set_event_loop(loop)
"""
def __init__(
self, selector: BaseSelector, inputhook: Callable[["InputHookContext"], None]
) -> None:
self.selector = selector
self.inputhook = inputhook
self._r, self._w = os.pipe()
def register(
self, fileobj: "FileDescriptorLike", events: "_EventMask", data: Any = None
) -> "SelectorKey":
return self.selector.register(fileobj, events, data=data)
def unregister(self, fileobj: "FileDescriptorLike") -> "SelectorKey":
return self.selector.unregister(fileobj)
def modify(
self, fileobj: "FileDescriptorLike", events: "_EventMask", data: Any = None
) -> "SelectorKey":
return self.selector.modify(fileobj, events, data=None)
def select(
self, timeout: Optional[float] = None
) -> List[Tuple["SelectorKey", "_EventMask"]]:
# If there are tasks in the current event loop,
# don't run the input hook.
if len(getattr(get_event_loop(), "_ready", [])) > 0:
return self.selector.select(timeout=timeout)
ready = False
result = None
# Run selector in other thread.
def run_selector() -> None:
nonlocal ready, result
result = self.selector.select(timeout=timeout)
os.write(self._w, b"x")
ready = True
th = threading.Thread(target=run_selector)
th.start()
def input_is_ready() -> bool:
return ready
# Call inputhook.
# The inputhook function is supposed to return when our selector
# becomes ready. The inputhook can do that by registering the fd in its
# own loop, or by checking the `input_is_ready` function regularly.
self.inputhook(InputHookContext(self._r, input_is_ready))
# Flush the read end of the pipe.
try:
# Before calling 'os.read', call select.select. This is required
# when the gevent monkey patch has been applied. 'os.read' is never
# monkey patched and won't be cooperative, so that would block all
# other select() calls otherwise.
# See: http://www.gevent.org/gevent.os.html
# Note: On Windows, this is apparently not an issue.
# However, if we would ever want to add a select call, it
# should use `windll.kernel32.WaitForMultipleObjects`,
# because `select.select` can't wait for a pipe on Windows.
if sys.platform != "win32":
select.select([self._r], [], [], None)
os.read(self._r, 1024)
except OSError:
# This happens when the window resizes and a SIGWINCH was received.
# We get 'Error: [Errno 4] Interrupted system call'
# Just ignore.
pass
# Wait for the real selector to be done.
th.join()
assert result is not None
return result
def close(self) -> None:
"""
Clean up resources.
"""
if self._r:
os.close(self._r)
os.close(self._w)
self._r = self._w = -1
self.selector.close()
def get_map(self) -> Mapping["FileDescriptorLike", "SelectorKey"]:
return self.selector.get_map()
class InputHookContext:
"""
Given as a parameter to the inputhook.
"""
def __init__(self, fileno: int, input_is_ready: Callable[[], bool]) -> None:
self._fileno = fileno
self.input_is_ready = input_is_ready
def fileno(self) -> int:
return self._fileno

View File

@@ -0,0 +1,118 @@
import asyncio
import sys
import time
from types import TracebackType
from typing import Any, Awaitable, Callable, Dict, Optional, TypeVar, cast
try:
import contextvars
except ImportError:
from . import dummy_contextvars as contextvars # type: ignore
__all__ = [
"run_in_executor_with_context",
"call_soon_threadsafe",
"get_traceback_from_context",
"get_event_loop",
]
_T = TypeVar("_T")
def run_in_executor_with_context(
func: Callable[..., _T],
*args: Any,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> Awaitable[_T]:
"""
Run a function in an executor, but make sure it uses the same contextvars.
This is required so that the function will see the right application.
See also: https://bugs.python.org/issue34014
"""
loop = loop or get_event_loop()
ctx: contextvars.Context = contextvars.copy_context()
return loop.run_in_executor(None, ctx.run, func, *args)
def call_soon_threadsafe(
func: Callable[[], None],
max_postpone_time: Optional[float] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> None:
"""
Wrapper around asyncio's `call_soon_threadsafe`.
This takes a `max_postpone_time` which can be used to tune the urgency of
the method.
Asyncio runs tasks in first-in-first-out. However, this is not what we
want for the render function of the prompt_toolkit UI. Rendering is
expensive, but since the UI is invalidated very often, in some situations
we render the UI too often, so much that the rendering CPU usage slows down
the rest of the processing of the application. (Pymux is an example where
we have to balance the CPU time spend on rendering the UI, and parsing
process output.)
However, we want to set a deadline value, for when the rendering should
happen. (The UI should stay responsive).
"""
loop2 = loop or get_event_loop()
# If no `max_postpone_time` has been given, schedule right now.
if max_postpone_time is None:
loop2.call_soon_threadsafe(func)
return
max_postpone_until = time.time() + max_postpone_time
def schedule() -> None:
# When there are no other tasks scheduled in the event loop. Run it
# now.
# Notice: uvloop doesn't have this _ready attribute. In that case,
# always call immediately.
if not getattr(loop2, "_ready", []):
func()
return
# If the timeout expired, run this now.
if time.time() > max_postpone_until:
func()
return
# Schedule again for later.
loop2.call_soon_threadsafe(schedule)
loop2.call_soon_threadsafe(schedule)
def get_traceback_from_context(context: Dict[str, Any]) -> Optional[TracebackType]:
"""
Get the traceback object from the context.
"""
exception = context.get("exception")
if exception:
if hasattr(exception, "__traceback__"):
return cast(TracebackType, exception.__traceback__)
else:
# call_exception_handler() is usually called indirectly
# from an except block. If it's not the case, the traceback
# is undefined...
return sys.exc_info()[2]
return None
def get_event_loop() -> asyncio.AbstractEventLoop:
"""Backward compatible way to get the event loop"""
# Python 3.6 doesn't have get_running_loop
# Python 3.10 deprecated get_event_loop
if sys.version_info >= (3, 7):
getloop = asyncio.get_running_loop
else:
getloop = asyncio.get_event_loop
try:
return getloop()
except RuntimeError:
return asyncio.get_event_loop_policy().get_event_loop()

View File

@@ -0,0 +1,73 @@
import sys
assert sys.platform == "win32"
from ctypes import pointer
from ..utils import SPHINX_AUTODOC_RUNNING
# Do not import win32-specific stuff when generating documentation.
# Otherwise RTD would be unable to generate docs for this module.
if not SPHINX_AUTODOC_RUNNING:
from ctypes import windll
from ctypes.wintypes import BOOL, DWORD, HANDLE
from typing import List, Optional
from prompt_toolkit.win32_types import SECURITY_ATTRIBUTES
__all__ = ["wait_for_handles", "create_win32_event"]
WAIT_TIMEOUT = 0x00000102
INFINITE = -1
def wait_for_handles(
handles: List[HANDLE], timeout: int = INFINITE
) -> Optional[HANDLE]:
"""
Waits for multiple handles. (Similar to 'select') Returns the handle which is ready.
Returns `None` on timeout.
http://msdn.microsoft.com/en-us/library/windows/desktop/ms687025(v=vs.85).aspx
Note that handles should be a list of `HANDLE` objects, not integers. See
this comment in the patch by @quark-zju for the reason why:
''' Make sure HANDLE on Windows has a correct size
Previously, the type of various HANDLEs are native Python integer
types. The ctypes library will treat them as 4-byte integer when used
in function arguments. On 64-bit Windows, HANDLE is 8-byte and usually
a small integer. Depending on whether the extra 4 bytes are zero-ed out
or not, things can happen to work, or break. '''
This function returns either `None` or one of the given `HANDLE` objects.
(The return value can be tested with the `is` operator.)
"""
arrtype = HANDLE * len(handles)
handle_array = arrtype(*handles)
ret: int = windll.kernel32.WaitForMultipleObjects(
len(handle_array), handle_array, BOOL(False), DWORD(timeout)
)
if ret == WAIT_TIMEOUT:
return None
else:
return handles[ret]
def create_win32_event() -> HANDLE:
"""
Creates a Win32 unnamed Event .
http://msdn.microsoft.com/en-us/library/windows/desktop/ms682396(v=vs.85).aspx
"""
return HANDLE(
windll.kernel32.CreateEventA(
pointer(SECURITY_ATTRIBUTES()),
BOOL(True), # Manual reset event.
BOOL(False), # Initial state.
None, # Unnamed event object.
)
)

View File

@@ -0,0 +1,68 @@
"""
Filters decide whether something is active or not (they decide about a boolean
state). This is used to enable/disable features, like key bindings, parts of
the layout and other stuff. For instance, we could have a `HasSearch` filter
attached to some part of the layout, in order to show that part of the user
interface only while the user is searching.
Filters are made to avoid having to attach callbacks to all event in order to
propagate state. However, they are lazy, they don't automatically propagate the
state of what they are observing. Only when a filter is called (it's actually a
callable), it will calculate its value. So, its not really reactive
programming, but it's made to fit for this framework.
Filters can be chained using ``&`` and ``|`` operations, and inverted using the
``~`` operator, for instance::
filter = has_focus('default') & ~ has_selection
"""
from .app import *
from .base import Always, Condition, Filter, FilterOrBool, Never
from .cli import *
from .utils import is_true, to_filter
__all__ = [
# app
"has_arg",
"has_completions",
"completion_is_selected",
"has_focus",
"buffer_has_focus",
"has_selection",
"has_validation_error",
"is_done",
"is_read_only",
"is_multiline",
"renderer_height_is_known",
"in_editing_mode",
"in_paste_mode",
"vi_mode",
"vi_navigation_mode",
"vi_insert_mode",
"vi_insert_multiple_mode",
"vi_replace_mode",
"vi_selection_mode",
"vi_waiting_for_text_object_mode",
"vi_digraph_mode",
"vi_recording_macro",
"emacs_mode",
"emacs_insert_mode",
"emacs_selection_mode",
"shift_selection_mode",
"is_searching",
"control_is_searchable",
"vi_search_direction_reversed",
# base.
"Filter",
"Never",
"Always",
"Condition",
"FilterOrBool",
# utils.
"is_true",
"to_filter",
]
from .cli import __all__ as cli_all
__all__.extend(cli_all)

View File

@@ -0,0 +1,402 @@
"""
Filters that accept a `Application` as argument.
"""
from typing import TYPE_CHECKING, cast
from prompt_toolkit.application.current import get_app
from prompt_toolkit.cache import memoized
from prompt_toolkit.enums import EditingMode
from .base import Condition
if TYPE_CHECKING:
from prompt_toolkit.layout.layout import FocusableElement
__all__ = [
"has_arg",
"has_completions",
"completion_is_selected",
"has_focus",
"buffer_has_focus",
"has_selection",
"has_validation_error",
"is_done",
"is_read_only",
"is_multiline",
"renderer_height_is_known",
"in_editing_mode",
"in_paste_mode",
"vi_mode",
"vi_navigation_mode",
"vi_insert_mode",
"vi_insert_multiple_mode",
"vi_replace_mode",
"vi_selection_mode",
"vi_waiting_for_text_object_mode",
"vi_digraph_mode",
"vi_recording_macro",
"emacs_mode",
"emacs_insert_mode",
"emacs_selection_mode",
"shift_selection_mode",
"is_searching",
"control_is_searchable",
"vi_search_direction_reversed",
]
@memoized()
def has_focus(value: "FocusableElement") -> Condition:
"""
Enable when this buffer has the focus.
"""
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.layout import walk
from prompt_toolkit.layout.containers import Container, Window, to_container
from prompt_toolkit.layout.controls import UIControl
if isinstance(value, str):
def test() -> bool:
return get_app().current_buffer.name == value
elif isinstance(value, Buffer):
def test() -> bool:
return get_app().current_buffer == value
elif isinstance(value, UIControl):
def test() -> bool:
return get_app().layout.current_control == value
else:
value = to_container(value)
if isinstance(value, Window):
def test() -> bool:
return get_app().layout.current_window == value
else:
def test() -> bool:
# Consider focused when any window inside this container is
# focused.
current_window = get_app().layout.current_window
for c in walk(cast(Container, value)):
if isinstance(c, Window) and c == current_window:
return True
return False
@Condition
def has_focus_filter() -> bool:
return test()
return has_focus_filter
@Condition
def buffer_has_focus() -> bool:
"""
Enabled when the currently focused control is a `BufferControl`.
"""
return get_app().layout.buffer_has_focus
@Condition
def has_selection() -> bool:
"""
Enable when the current buffer has a selection.
"""
return bool(get_app().current_buffer.selection_state)
@Condition
def has_completions() -> bool:
"""
Enable when the current buffer has completions.
"""
state = get_app().current_buffer.complete_state
return state is not None and len(state.completions) > 0
@Condition
def completion_is_selected() -> bool:
"""
True when the user selected a completion.
"""
complete_state = get_app().current_buffer.complete_state
return complete_state is not None and complete_state.current_completion is not None
@Condition
def is_read_only() -> bool:
"""
True when the current buffer is read only.
"""
return get_app().current_buffer.read_only()
@Condition
def is_multiline() -> bool:
"""
True when the current buffer has been marked as multiline.
"""
return get_app().current_buffer.multiline()
@Condition
def has_validation_error() -> bool:
"Current buffer has validation error."
return get_app().current_buffer.validation_error is not None
@Condition
def has_arg() -> bool:
"Enable when the input processor has an 'arg'."
return get_app().key_processor.arg is not None
@Condition
def is_done() -> bool:
"""
True when the CLI is returning, aborting or exiting.
"""
return get_app().is_done
@Condition
def renderer_height_is_known() -> bool:
"""
Only True when the renderer knows it's real height.
(On VT100 terminals, we have to wait for a CPR response, before we can be
sure of the available height between the cursor position and the bottom of
the terminal. And usually it's nicer to wait with drawing bottom toolbars
until we receive the height, in order to avoid flickering -- first drawing
somewhere in the middle, and then again at the bottom.)
"""
return get_app().renderer.height_is_known
@memoized()
def in_editing_mode(editing_mode: EditingMode) -> Condition:
"""
Check whether a given editing mode is active. (Vi or Emacs.)
"""
@Condition
def in_editing_mode_filter() -> bool:
return get_app().editing_mode == editing_mode
return in_editing_mode_filter
@Condition
def in_paste_mode() -> bool:
return get_app().paste_mode()
@Condition
def vi_mode() -> bool:
return get_app().editing_mode == EditingMode.VI
@Condition
def vi_navigation_mode() -> bool:
"""
Active when the set for Vi navigation key bindings are active.
"""
from prompt_toolkit.key_binding.vi_state import InputMode
app = get_app()
if (
app.editing_mode != EditingMode.VI
or app.vi_state.operator_func
or app.vi_state.waiting_for_digraph
or app.current_buffer.selection_state
):
return False
return (
app.vi_state.input_mode == InputMode.NAVIGATION
or app.vi_state.temporary_navigation_mode
or app.current_buffer.read_only()
)
@Condition
def vi_insert_mode() -> bool:
from prompt_toolkit.key_binding.vi_state import InputMode
app = get_app()
if (
app.editing_mode != EditingMode.VI
or app.vi_state.operator_func
or app.vi_state.waiting_for_digraph
or app.current_buffer.selection_state
or app.vi_state.temporary_navigation_mode
or app.current_buffer.read_only()
):
return False
return app.vi_state.input_mode == InputMode.INSERT
@Condition
def vi_insert_multiple_mode() -> bool:
from prompt_toolkit.key_binding.vi_state import InputMode
app = get_app()
if (
app.editing_mode != EditingMode.VI
or app.vi_state.operator_func
or app.vi_state.waiting_for_digraph
or app.current_buffer.selection_state
or app.vi_state.temporary_navigation_mode
or app.current_buffer.read_only()
):
return False
return app.vi_state.input_mode == InputMode.INSERT_MULTIPLE
@Condition
def vi_replace_mode() -> bool:
from prompt_toolkit.key_binding.vi_state import InputMode
app = get_app()
if (
app.editing_mode != EditingMode.VI
or app.vi_state.operator_func
or app.vi_state.waiting_for_digraph
or app.current_buffer.selection_state
or app.vi_state.temporary_navigation_mode
or app.current_buffer.read_only()
):
return False
return app.vi_state.input_mode == InputMode.REPLACE
@Condition
def vi_replace_single_mode() -> bool:
from prompt_toolkit.key_binding.vi_state import InputMode
app = get_app()
if (
app.editing_mode != EditingMode.VI
or app.vi_state.operator_func
or app.vi_state.waiting_for_digraph
or app.current_buffer.selection_state
or app.vi_state.temporary_navigation_mode
or app.current_buffer.read_only()
):
return False
return app.vi_state.input_mode == InputMode.REPLACE_SINGLE
@Condition
def vi_selection_mode() -> bool:
app = get_app()
if app.editing_mode != EditingMode.VI:
return False
return bool(app.current_buffer.selection_state)
@Condition
def vi_waiting_for_text_object_mode() -> bool:
app = get_app()
if app.editing_mode != EditingMode.VI:
return False
return app.vi_state.operator_func is not None
@Condition
def vi_digraph_mode() -> bool:
app = get_app()
if app.editing_mode != EditingMode.VI:
return False
return app.vi_state.waiting_for_digraph
@Condition
def vi_recording_macro() -> bool:
"When recording a Vi macro."
app = get_app()
if app.editing_mode != EditingMode.VI:
return False
return app.vi_state.recording_register is not None
@Condition
def emacs_mode() -> bool:
"When the Emacs bindings are active."
return get_app().editing_mode == EditingMode.EMACS
@Condition
def emacs_insert_mode() -> bool:
app = get_app()
if (
app.editing_mode != EditingMode.EMACS
or app.current_buffer.selection_state
or app.current_buffer.read_only()
):
return False
return True
@Condition
def emacs_selection_mode() -> bool:
app = get_app()
return bool(
app.editing_mode == EditingMode.EMACS and app.current_buffer.selection_state
)
@Condition
def shift_selection_mode() -> bool:
app = get_app()
return bool(
app.current_buffer.selection_state
and app.current_buffer.selection_state.shift_mode
)
@Condition
def is_searching() -> bool:
"When we are searching."
app = get_app()
return app.layout.is_searching
@Condition
def control_is_searchable() -> bool:
"When the current UIControl is searchable."
from prompt_toolkit.layout.controls import BufferControl
control = get_app().layout.current_control
return (
isinstance(control, BufferControl) and control.search_buffer_control is not None
)
@Condition
def vi_search_direction_reversed() -> bool:
"When the '/' and '?' key bindings for Vi-style searching have been reversed."
return get_app().reverse_vi_search_direction()

View File

@@ -0,0 +1,217 @@
from abc import ABCMeta, abstractmethod
from typing import Callable, Dict, Iterable, List, Tuple, Union
__all__ = ["Filter", "Never", "Always", "Condition", "FilterOrBool"]
class Filter(metaclass=ABCMeta):
"""
Base class for any filter to activate/deactivate a feature, depending on a
condition.
The return value of ``__call__`` will tell if the feature should be active.
"""
@abstractmethod
def __call__(self) -> bool:
"""
The actual call to evaluate the filter.
"""
return True
def __and__(self, other: "Filter") -> "Filter":
"""
Chaining of filters using the & operator.
"""
return _and_cache[self, other]
def __or__(self, other: "Filter") -> "Filter":
"""
Chaining of filters using the | operator.
"""
return _or_cache[self, other]
def __invert__(self) -> "Filter":
"""
Inverting of filters using the ~ operator.
"""
return _invert_cache[self]
def __bool__(self) -> None:
"""
By purpose, we don't allow bool(...) operations directly on a filter,
because the meaning is ambiguous.
Executing a filter has to be done always by calling it. Providing
defaults for `None` values should be done through an `is None` check
instead of for instance ``filter1 or Always()``.
"""
raise ValueError(
"The truth value of a Filter is ambiguous. "
"Instead, call it as a function."
)
class _AndCache(Dict[Tuple[Filter, Filter], "_AndList"]):
"""
Cache for And operation between filters.
(Filter classes are stateless, so we can reuse them.)
Note: This could be a memory leak if we keep creating filters at runtime.
If that is True, the filters should be weakreffed (not the tuple of
filters), and tuples should be removed when one of these filters is
removed. In practise however, there is a finite amount of filters.
"""
def __missing__(self, filters: Tuple[Filter, Filter]) -> Filter:
a, b = filters
assert isinstance(b, Filter), "Expecting filter, got %r" % b
if isinstance(b, Always) or isinstance(a, Never):
return a
elif isinstance(b, Never) or isinstance(a, Always):
return b
result = _AndList(filters)
self[filters] = result
return result
class _OrCache(Dict[Tuple[Filter, Filter], "_OrList"]):
"""Cache for Or operation between filters."""
def __missing__(self, filters: Tuple[Filter, Filter]) -> Filter:
a, b = filters
assert isinstance(b, Filter), "Expecting filter, got %r" % b
if isinstance(b, Always) or isinstance(a, Never):
return b
elif isinstance(b, Never) or isinstance(a, Always):
return a
result = _OrList(filters)
self[filters] = result
return result
class _InvertCache(Dict[Filter, "_Invert"]):
"""Cache for inversion operator."""
def __missing__(self, filter: Filter) -> Filter:
result = _Invert(filter)
self[filter] = result
return result
_and_cache = _AndCache()
_or_cache = _OrCache()
_invert_cache = _InvertCache()
class _AndList(Filter):
"""
Result of &-operation between several filters.
"""
def __init__(self, filters: Iterable[Filter]) -> None:
self.filters: List[Filter] = []
for f in filters:
if isinstance(f, _AndList): # Turn nested _AndLists into one.
self.filters.extend(f.filters)
else:
self.filters.append(f)
def __call__(self) -> bool:
return all(f() for f in self.filters)
def __repr__(self) -> str:
return "&".join(repr(f) for f in self.filters)
class _OrList(Filter):
"""
Result of |-operation between several filters.
"""
def __init__(self, filters: Iterable[Filter]) -> None:
self.filters: List[Filter] = []
for f in filters:
if isinstance(f, _OrList): # Turn nested _OrLists into one.
self.filters.extend(f.filters)
else:
self.filters.append(f)
def __call__(self) -> bool:
return any(f() for f in self.filters)
def __repr__(self) -> str:
return "|".join(repr(f) for f in self.filters)
class _Invert(Filter):
"""
Negation of another filter.
"""
def __init__(self, filter: Filter) -> None:
self.filter = filter
def __call__(self) -> bool:
return not self.filter()
def __repr__(self) -> str:
return "~%r" % self.filter
class Always(Filter):
"""
Always enable feature.
"""
def __call__(self) -> bool:
return True
def __invert__(self) -> "Never":
return Never()
class Never(Filter):
"""
Never enable feature.
"""
def __call__(self) -> bool:
return False
def __invert__(self) -> Always:
return Always()
class Condition(Filter):
"""
Turn any callable into a Filter. The callable is supposed to not take any
arguments.
This can be used as a decorator::
@Condition
def feature_is_active(): # `feature_is_active` becomes a Filter.
return True
:param func: Callable which takes no inputs and returns a boolean.
"""
def __init__(self, func: Callable[[], bool]) -> None:
self.func = func
def __call__(self) -> bool:
return self.func()
def __repr__(self) -> str:
return "Condition(%r)" % self.func
# Often used as type annotation.
FilterOrBool = Union[Filter, bool]

View File

@@ -0,0 +1,62 @@
"""
For backwards-compatibility. keep this file.
(Many people are going to have key bindings that rely on this file.)
"""
from .app import *
__all__ = [
# Old names.
"HasArg",
"HasCompletions",
"HasFocus",
"HasSelection",
"HasValidationError",
"IsDone",
"IsReadOnly",
"IsMultiline",
"RendererHeightIsKnown",
"InEditingMode",
"InPasteMode",
"ViMode",
"ViNavigationMode",
"ViInsertMode",
"ViInsertMultipleMode",
"ViReplaceMode",
"ViSelectionMode",
"ViWaitingForTextObjectMode",
"ViDigraphMode",
"EmacsMode",
"EmacsInsertMode",
"EmacsSelectionMode",
"IsSearching",
"HasSearch",
"ControlIsSearchable",
]
# Keep the original classnames for backwards compatibility.
HasValidationError = lambda: has_validation_error
HasArg = lambda: has_arg
IsDone = lambda: is_done
RendererHeightIsKnown = lambda: renderer_height_is_known
ViNavigationMode = lambda: vi_navigation_mode
InPasteMode = lambda: in_paste_mode
EmacsMode = lambda: emacs_mode
EmacsInsertMode = lambda: emacs_insert_mode
ViMode = lambda: vi_mode
IsSearching = lambda: is_searching
HasSearch = lambda: is_searching
ControlIsSearchable = lambda: control_is_searchable
EmacsSelectionMode = lambda: emacs_selection_mode
ViDigraphMode = lambda: vi_digraph_mode
ViWaitingForTextObjectMode = lambda: vi_waiting_for_text_object_mode
ViSelectionMode = lambda: vi_selection_mode
ViReplaceMode = lambda: vi_replace_mode
ViInsertMultipleMode = lambda: vi_insert_multiple_mode
ViInsertMode = lambda: vi_insert_mode
HasSelection = lambda: has_selection
HasCompletions = lambda: has_completions
IsReadOnly = lambda: is_read_only
IsMultiline = lambda: is_multiline
HasFocus = has_focus # No lambda here! (Has_focus is callable that returns a callable.)
InEditingMode = in_editing_mode

View File

@@ -0,0 +1,41 @@
from typing import Dict
from .base import Always, Filter, FilterOrBool, Never
__all__ = [
"to_filter",
"is_true",
]
_always = Always()
_never = Never()
_bool_to_filter: Dict[bool, Filter] = {
True: _always,
False: _never,
}
def to_filter(bool_or_filter: FilterOrBool) -> Filter:
"""
Accept both booleans and Filters as input and
turn it into a Filter.
"""
if isinstance(bool_or_filter, bool):
return _bool_to_filter[bool_or_filter]
if isinstance(bool_or_filter, Filter):
return bool_or_filter
raise TypeError("Expecting a bool or a Filter instance. Got %r" % bool_or_filter)
def is_true(value: FilterOrBool) -> bool:
"""
Test whether `value` is True. In case of a Filter, call it.
:param value: Boolean or `Filter` instance.
"""
return to_filter(value)()

View File

@@ -0,0 +1,54 @@
"""
Many places in prompt_toolkit can take either plain text, or formatted text.
For instance the :func:`~prompt_toolkit.shortcuts.prompt` function takes either
plain text or formatted text for the prompt. The
:class:`~prompt_toolkit.layout.FormattedTextControl` can also take either plain
text or formatted text.
In any case, there is an input that can either be just plain text (a string),
an :class:`.HTML` object, an :class:`.ANSI` object or a sequence of
`(style_string, text)` tuples. The :func:`.to_formatted_text` conversion
function takes any of these and turns all of them into such a tuple sequence.
"""
from .ansi import ANSI
from .base import (
AnyFormattedText,
FormattedText,
StyleAndTextTuples,
Template,
is_formatted_text,
merge_formatted_text,
to_formatted_text,
)
from .html import HTML
from .pygments import PygmentsTokens
from .utils import (
fragment_list_len,
fragment_list_to_text,
fragment_list_width,
split_lines,
to_plain_text,
)
__all__ = [
# Base.
"AnyFormattedText",
"to_formatted_text",
"is_formatted_text",
"Template",
"merge_formatted_text",
"FormattedText",
"StyleAndTextTuples",
# HTML.
"HTML",
# ANSI.
"ANSI",
# Pygments.
"PygmentsTokens",
# Utils.
"fragment_list_len",
"fragment_list_width",
"fragment_list_to_text",
"split_lines",
"to_plain_text",
]

View File

@@ -0,0 +1,297 @@
from string import Formatter
from typing import Generator, List, Optional, Tuple, Union
from prompt_toolkit.output.vt100 import BG_ANSI_COLORS, FG_ANSI_COLORS
from prompt_toolkit.output.vt100 import _256_colors as _256_colors_table
from .base import StyleAndTextTuples
__all__ = [
"ANSI",
"ansi_escape",
]
class ANSI:
"""
ANSI formatted text.
Take something ANSI escaped text, for use as a formatted string. E.g.
::
ANSI('\\x1b[31mhello \\x1b[32mworld')
Characters between ``\\001`` and ``\\002`` are supposed to have a zero width
when printed, but these are literally sent to the terminal output. This can
be used for instance, for inserting Final Term prompt commands. They will
be translated into a prompt_toolkit '[ZeroWidthEscape]' fragment.
"""
def __init__(self, value: str) -> None:
self.value = value
self._formatted_text: StyleAndTextTuples = []
# Default style attributes.
self._color: Optional[str] = None
self._bgcolor: Optional[str] = None
self._bold = False
self._underline = False
self._strike = False
self._italic = False
self._blink = False
self._reverse = False
self._hidden = False
# Process received text.
parser = self._parse_corot()
parser.send(None) # type: ignore
for c in value:
parser.send(c)
def _parse_corot(self) -> Generator[None, str, None]:
"""
Coroutine that parses the ANSI escape sequences.
"""
style = ""
formatted_text = self._formatted_text
while True:
# NOTE: CSI is a special token within a stream of characters that
# introduces an ANSI control sequence used to set the
# style attributes of the following characters.
csi = False
c = yield
# Everything between \001 and \002 should become a ZeroWidthEscape.
if c == "\001":
escaped_text = ""
while c != "\002":
c = yield
if c == "\002":
formatted_text.append(("[ZeroWidthEscape]", escaped_text))
c = yield
break
else:
escaped_text += c
# Check for CSI
if c == "\x1b":
# Start of color escape sequence.
square_bracket = yield
if square_bracket == "[":
csi = True
else:
continue
elif c == "\x9b":
csi = True
if csi:
# Got a CSI sequence. Color codes are following.
current = ""
params = []
while True:
char = yield
# Construct number
if char.isdigit():
current += char
# Eval number
else:
# Limit and save number value
params.append(min(int(current or 0), 9999))
# Get delimiter token if present
if char == ";":
current = ""
# Check and evaluate color codes
elif char == "m":
# Set attributes and token.
self._select_graphic_rendition(params)
style = self._create_style_string()
break
# Check and evaluate cursor forward
elif char == "C":
for i in range(params[0]):
# add <SPACE> using current style
formatted_text.append((style, " "))
break
else:
# Ignore unsupported sequence.
break
else:
# Add current character.
# NOTE: At this point, we could merge the current character
# into the previous tuple if the style did not change,
# however, it's not worth the effort given that it will
# be "Exploded" once again when it's rendered to the
# output.
formatted_text.append((style, c))
def _select_graphic_rendition(self, attrs: List[int]) -> None:
"""
Taken a list of graphics attributes and apply changes.
"""
if not attrs:
attrs = [0]
else:
attrs = list(attrs[::-1])
while attrs:
attr = attrs.pop()
if attr in _fg_colors:
self._color = _fg_colors[attr]
elif attr in _bg_colors:
self._bgcolor = _bg_colors[attr]
elif attr == 1:
self._bold = True
# elif attr == 2:
# self._faint = True
elif attr == 3:
self._italic = True
elif attr == 4:
self._underline = True
elif attr == 5:
self._blink = True # Slow blink
elif attr == 6:
self._blink = True # Fast blink
elif attr == 7:
self._reverse = True
elif attr == 8:
self._hidden = True
elif attr == 9:
self._strike = True
elif attr == 22:
self._bold = False # Normal intensity
elif attr == 23:
self._italic = False
elif attr == 24:
self._underline = False
elif attr == 25:
self._blink = False
elif attr == 27:
self._reverse = False
elif attr == 28:
self._hidden = False
elif attr == 29:
self._strike = False
elif not attr:
# Reset all style attributes
self._color = None
self._bgcolor = None
self._bold = False
self._underline = False
self._strike = False
self._italic = False
self._blink = False
self._reverse = False
self._hidden = False
elif attr in (38, 48) and len(attrs) > 1:
n = attrs.pop()
# 256 colors.
if n == 5 and len(attrs) >= 1:
if attr == 38:
m = attrs.pop()
self._color = _256_colors.get(m)
elif attr == 48:
m = attrs.pop()
self._bgcolor = _256_colors.get(m)
# True colors.
if n == 2 and len(attrs) >= 3:
try:
color_str = "#{:02x}{:02x}{:02x}".format(
attrs.pop(),
attrs.pop(),
attrs.pop(),
)
except IndexError:
pass
else:
if attr == 38:
self._color = color_str
elif attr == 48:
self._bgcolor = color_str
def _create_style_string(self) -> str:
"""
Turn current style flags into a string for usage in a formatted text.
"""
result = []
if self._color:
result.append(self._color)
if self._bgcolor:
result.append("bg:" + self._bgcolor)
if self._bold:
result.append("bold")
if self._underline:
result.append("underline")
if self._strike:
result.append("strike")
if self._italic:
result.append("italic")
if self._blink:
result.append("blink")
if self._reverse:
result.append("reverse")
if self._hidden:
result.append("hidden")
return " ".join(result)
def __repr__(self) -> str:
return f"ANSI({self.value!r})"
def __pt_formatted_text__(self) -> StyleAndTextTuples:
return self._formatted_text
def format(self, *args: str, **kwargs: str) -> "ANSI":
"""
Like `str.format`, but make sure that the arguments are properly
escaped. (No ANSI escapes can be injected.)
"""
return ANSI(FORMATTER.vformat(self.value, args, kwargs))
def __mod__(self, value: object) -> "ANSI":
"""
ANSI('<b>%s</b>') % value
"""
if not isinstance(value, tuple):
value = (value,)
value = tuple(ansi_escape(i) for i in value)
return ANSI(self.value % value)
# Mapping of the ANSI color codes to their names.
_fg_colors = {v: k for k, v in FG_ANSI_COLORS.items()}
_bg_colors = {v: k for k, v in BG_ANSI_COLORS.items()}
# Mapping of the escape codes for 256colors to their 'ffffff' value.
_256_colors = {}
for i, (r, g, b) in enumerate(_256_colors_table.colors):
_256_colors[i] = f"#{r:02x}{g:02x}{b:02x}"
def ansi_escape(text: object) -> str:
"""
Replace characters with a special meaning.
"""
return str(text).replace("\x1b", "?").replace("\b", "?")
class ANSIFormatter(Formatter):
def format_field(self, value: object, format_spec: str) -> str:
return ansi_escape(format(value, format_spec))
FORMATTER = ANSIFormatter()

View File

@@ -0,0 +1,176 @@
from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Tuple, Union, cast
from prompt_toolkit.mouse_events import MouseEvent
if TYPE_CHECKING:
from typing_extensions import Protocol
__all__ = [
"OneStyleAndTextTuple",
"StyleAndTextTuples",
"MagicFormattedText",
"AnyFormattedText",
"to_formatted_text",
"is_formatted_text",
"Template",
"merge_formatted_text",
"FormattedText",
]
OneStyleAndTextTuple = Union[
Tuple[str, str], Tuple[str, str, Callable[[MouseEvent], None]]
]
# List of (style, text) tuples.
StyleAndTextTuples = List[OneStyleAndTextTuple]
if TYPE_CHECKING:
from typing_extensions import TypeGuard
class MagicFormattedText(Protocol):
"""
Any object that implements ``__pt_formatted_text__`` represents formatted
text.
"""
def __pt_formatted_text__(self) -> StyleAndTextTuples:
...
AnyFormattedText = Union[
str,
"MagicFormattedText",
StyleAndTextTuples,
# Callable[[], 'AnyFormattedText'] # Recursive definition not supported by mypy.
Callable[[], Any],
None,
]
def to_formatted_text(
value: AnyFormattedText, style: str = "", auto_convert: bool = False
) -> "FormattedText":
"""
Convert the given value (which can be formatted text) into a list of text
fragments. (Which is the canonical form of formatted text.) The outcome is
always a `FormattedText` instance, which is a list of (style, text) tuples.
It can take a plain text string, an `HTML` or `ANSI` object, anything that
implements `__pt_formatted_text__` or a callable that takes no arguments and
returns one of those.
:param style: An additional style string which is applied to all text
fragments.
:param auto_convert: If `True`, also accept other types, and convert them
to a string first.
"""
result: Union[FormattedText, StyleAndTextTuples]
if value is None:
result = []
elif isinstance(value, str):
result = [("", value)]
elif isinstance(value, list):
result = value # StyleAndTextTuples
elif hasattr(value, "__pt_formatted_text__"):
result = cast("MagicFormattedText", value).__pt_formatted_text__()
elif callable(value):
return to_formatted_text(value(), style=style)
elif auto_convert:
result = [("", f"{value}")]
else:
raise ValueError(
"No formatted text. Expecting a unicode object, "
"HTML, ANSI or a FormattedText instance. Got %r" % (value,)
)
# Apply extra style.
if style:
result = cast(
StyleAndTextTuples,
[(style + " " + item_style, *rest) for item_style, *rest in result],
)
# Make sure the result is wrapped in a `FormattedText`. Among other
# reasons, this is important for `print_formatted_text` to work correctly
# and distinguish between lists and formatted text.
if isinstance(result, FormattedText):
return result
else:
return FormattedText(result)
def is_formatted_text(value: object) -> "TypeGuard[AnyFormattedText]":
"""
Check whether the input is valid formatted text (for use in assert
statements).
In case of a callable, it doesn't check the return type.
"""
if callable(value):
return True
if isinstance(value, (str, list)):
return True
if hasattr(value, "__pt_formatted_text__"):
return True
return False
class FormattedText(StyleAndTextTuples):
"""
A list of ``(style, text)`` tuples.
(In some situations, this can also be ``(style, text, mouse_handler)``
tuples.)
"""
def __pt_formatted_text__(self) -> StyleAndTextTuples:
return self
def __repr__(self) -> str:
return "FormattedText(%s)" % super().__repr__()
class Template:
"""
Template for string interpolation with formatted text.
Example::
Template(' ... {} ... ').format(HTML(...))
:param text: Plain text.
"""
def __init__(self, text: str) -> None:
assert "{0}" not in text
self.text = text
def format(self, *values: AnyFormattedText) -> AnyFormattedText:
def get_result() -> AnyFormattedText:
# Split the template in parts.
parts = self.text.split("{}")
assert len(parts) - 1 == len(values)
result = FormattedText()
for part, val in zip(parts, values):
result.append(("", part))
result.extend(to_formatted_text(val))
result.append(("", parts[-1]))
return result
return get_result
def merge_formatted_text(items: Iterable[AnyFormattedText]) -> AnyFormattedText:
"""
Merge (Concatenate) several pieces of formatted text together.
"""
def _merge_formatted_text() -> AnyFormattedText:
result = FormattedText()
for i in items:
result.extend(to_formatted_text(i))
return result
return _merge_formatted_text

View File

@@ -0,0 +1,143 @@
import xml.dom.minidom as minidom
from string import Formatter
from typing import Any, List, Tuple, Union
from .base import FormattedText, StyleAndTextTuples
__all__ = ["HTML"]
class HTML:
"""
HTML formatted text.
Take something HTML-like, for use as a formatted string.
::
# Turn something into red.
HTML('<style fg="ansired" bg="#00ff44">...</style>')
# Italic, bold, underline and strike.
HTML('<i>...</i>')
HTML('<b>...</b>')
HTML('<u>...</u>')
HTML('<s>...</s>')
All HTML elements become available as a "class" in the style sheet.
E.g. ``<username>...</username>`` can be styled, by setting a style for
``username``.
"""
def __init__(self, value: str) -> None:
self.value = value
document = minidom.parseString(f"<html-root>{value}</html-root>")
result: StyleAndTextTuples = []
name_stack: List[str] = []
fg_stack: List[str] = []
bg_stack: List[str] = []
def get_current_style() -> str:
"Build style string for current node."
parts = []
if name_stack:
parts.append("class:" + ",".join(name_stack))
if fg_stack:
parts.append("fg:" + fg_stack[-1])
if bg_stack:
parts.append("bg:" + bg_stack[-1])
return " ".join(parts)
def process_node(node: Any) -> None:
"Process node recursively."
for child in node.childNodes:
if child.nodeType == child.TEXT_NODE:
result.append((get_current_style(), child.data))
else:
add_to_name_stack = child.nodeName not in (
"#document",
"html-root",
"style",
)
fg = bg = ""
for k, v in child.attributes.items():
if k == "fg":
fg = v
if k == "bg":
bg = v
if k == "color":
fg = v # Alias for 'fg'.
# Check for spaces in attributes. This would result in
# invalid style strings otherwise.
if " " in fg:
raise ValueError('"fg" attribute contains a space.')
if " " in bg:
raise ValueError('"bg" attribute contains a space.')
if add_to_name_stack:
name_stack.append(child.nodeName)
if fg:
fg_stack.append(fg)
if bg:
bg_stack.append(bg)
process_node(child)
if add_to_name_stack:
name_stack.pop()
if fg:
fg_stack.pop()
if bg:
bg_stack.pop()
process_node(document)
self.formatted_text = FormattedText(result)
def __repr__(self) -> str:
return f"HTML({self.value!r})"
def __pt_formatted_text__(self) -> StyleAndTextTuples:
return self.formatted_text
def format(self, *args: object, **kwargs: object) -> "HTML":
"""
Like `str.format`, but make sure that the arguments are properly
escaped.
"""
return HTML(FORMATTER.vformat(self.value, args, kwargs))
def __mod__(self, value: object) -> "HTML":
"""
HTML('<b>%s</b>') % value
"""
if not isinstance(value, tuple):
value = (value,)
value = tuple(html_escape(i) for i in value)
return HTML(self.value % value)
class HTMLFormatter(Formatter):
def format_field(self, value: object, format_spec: str) -> str:
return html_escape(format(value, format_spec))
def html_escape(text: object) -> str:
# The string interpolation functions also take integers and other types.
# Convert to string first.
if not isinstance(text, str):
text = f"{text}"
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
FORMATTER = HTMLFormatter()

View File

@@ -0,0 +1,30 @@
from typing import TYPE_CHECKING, List, Tuple
from prompt_toolkit.styles.pygments import pygments_token_to_classname
from .base import StyleAndTextTuples
if TYPE_CHECKING:
from pygments.token import Token
__all__ = [
"PygmentsTokens",
]
class PygmentsTokens:
"""
Turn a pygments token list into a list of prompt_toolkit text fragments
(``(style_str, text)`` tuples).
"""
def __init__(self, token_list: List[Tuple["Token", str]]) -> None:
self.token_list = token_list
def __pt_formatted_text__(self) -> StyleAndTextTuples:
result: StyleAndTextTuples = []
for token, text in self.token_list:
result.append(("class:" + pygments_token_to_classname(token), text))
return result

View File

@@ -0,0 +1,98 @@
"""
Utilities for manipulating formatted text.
When ``to_formatted_text`` has been called, we get a list of ``(style, text)``
tuples. This file contains functions for manipulating such a list.
"""
from typing import Iterable, cast
from prompt_toolkit.utils import get_cwidth
from .base import (
AnyFormattedText,
OneStyleAndTextTuple,
StyleAndTextTuples,
to_formatted_text,
)
__all__ = [
"to_plain_text",
"fragment_list_len",
"fragment_list_width",
"fragment_list_to_text",
"split_lines",
]
def to_plain_text(value: AnyFormattedText) -> str:
"""
Turn any kind of formatted text back into plain text.
"""
return fragment_list_to_text(to_formatted_text(value))
def fragment_list_len(fragments: StyleAndTextTuples) -> int:
"""
Return the amount of characters in this text fragment list.
:param fragments: List of ``(style_str, text)`` or
``(style_str, text, mouse_handler)`` tuples.
"""
ZeroWidthEscape = "[ZeroWidthEscape]"
return sum(len(item[1]) for item in fragments if ZeroWidthEscape not in item[0])
def fragment_list_width(fragments: StyleAndTextTuples) -> int:
"""
Return the character width of this text fragment list.
(Take double width characters into account.)
:param fragments: List of ``(style_str, text)`` or
``(style_str, text, mouse_handler)`` tuples.
"""
ZeroWidthEscape = "[ZeroWidthEscape]"
return sum(
get_cwidth(c)
for item in fragments
for c in item[1]
if ZeroWidthEscape not in item[0]
)
def fragment_list_to_text(fragments: StyleAndTextTuples) -> str:
"""
Concatenate all the text parts again.
:param fragments: List of ``(style_str, text)`` or
``(style_str, text, mouse_handler)`` tuples.
"""
ZeroWidthEscape = "[ZeroWidthEscape]"
return "".join(item[1] for item in fragments if ZeroWidthEscape not in item[0])
def split_lines(fragments: StyleAndTextTuples) -> Iterable[StyleAndTextTuples]:
"""
Take a single list of (style_str, text) tuples and yield one such list for each
line. Just like str.split, this will yield at least one item.
:param fragments: List of (style_str, text) or (style_str, text, mouse_handler)
tuples.
"""
line: StyleAndTextTuples = []
for style, string, *mouse_handler in fragments:
parts = string.split("\n")
for part in parts[:-1]:
if part:
line.append(cast(OneStyleAndTextTuple, (style, part, *mouse_handler)))
yield line
line = []
line.append(cast(OneStyleAndTextTuple, (style, parts[-1], *mouse_handler)))
# Always yield the last line, even when this is an empty line. This ensures
# that when `fragments` ends with a newline character, an additional empty
# line is yielded. (Otherwise, there's no way to differentiate between the
# cases where `fragments` does and doesn't end with a newline.)
yield line

View File

@@ -0,0 +1,301 @@
"""
Implementations for the history of a `Buffer`.
NOTE: There is no `DynamicHistory`:
This doesn't work well, because the `Buffer` needs to be able to attach
an event handler to the event when a history entry is loaded. This
loading can be done asynchronously and making the history swappable would
probably break this.
"""
import datetime
import os
import threading
from abc import ABCMeta, abstractmethod
from typing import AsyncGenerator, Iterable, List, Optional, Sequence, Tuple
from prompt_toolkit.eventloop import get_event_loop
__all__ = [
"History",
"ThreadedHistory",
"DummyHistory",
"FileHistory",
"InMemoryHistory",
]
class History(metaclass=ABCMeta):
"""
Base ``History`` class.
This also includes abstract methods for loading/storing history.
"""
def __init__(self) -> None:
# In memory storage for strings.
self._loaded = False
# History that's loaded already, in reverse order. Latest, most recent
# item first.
self._loaded_strings: List[str] = []
#
# Methods expected by `Buffer`.
#
async def load(self) -> AsyncGenerator[str, None]:
"""
Load the history and yield all the entries in reverse order (latest,
most recent history entry first).
This method can be called multiple times from the `Buffer` to
repopulate the history when prompting for a new input. So we are
responsible here for both caching, and making sure that strings that
were were appended to the history will be incorporated next time this
method is called.
"""
if not self._loaded:
self._loaded_strings = list(self.load_history_strings())
self._loaded = True
for item in self._loaded_strings:
yield item
def get_strings(self) -> List[str]:
"""
Get the strings from the history that are loaded so far.
(In order. Oldest item first.)
"""
return self._loaded_strings[::-1]
def append_string(self, string: str) -> None:
"Add string to the history."
self._loaded_strings.insert(0, string)
self.store_string(string)
#
# Implementation for specific backends.
#
@abstractmethod
def load_history_strings(self) -> Iterable[str]:
"""
This should be a generator that yields `str` instances.
It should yield the most recent items first, because they are the most
important. (The history can already be used, even when it's only
partially loaded.)
"""
while False:
yield
@abstractmethod
def store_string(self, string: str) -> None:
"""
Store the string in persistent storage.
"""
class ThreadedHistory(History):
"""
Wrapper around `History` implementations that run the `load()` generator in
a thread.
Use this to increase the start-up time of prompt_toolkit applications.
History entries are available as soon as they are loaded. We don't have to
wait for everything to be loaded.
"""
def __init__(self, history: History) -> None:
super().__init__()
self.history = history
self._load_thread: Optional[threading.Thread] = None
# Lock for accessing/manipulating `_loaded_strings` and `_loaded`
# together in a consistent state.
self._lock = threading.Lock()
# Events created by each `load()` call. Used to wait for new history
# entries from the loader thread.
self._string_load_events: List[threading.Event] = []
async def load(self) -> AsyncGenerator[str, None]:
"""
Like `History.load(), but call `self.load_history_strings()` in a
background thread.
"""
# Start the load thread, if this is called for the first time.
if not self._load_thread:
self._load_thread = threading.Thread(
target=self._in_load_thread,
daemon=True,
)
self._load_thread.start()
# Consume the `_loaded_strings` list, using asyncio.
loop = get_event_loop()
# Create threading Event so that we can wait for new items.
event = threading.Event()
event.set()
self._string_load_events.append(event)
items_yielded = 0
try:
while True:
# Wait for new items to be available.
# (Use a timeout, because the executor thread is not a daemon
# thread. The "slow-history.py" example would otherwise hang if
# Control-C is pressed before the history is fully loaded,
# because there's still this non-daemon executor thread waiting
# for this event.)
got_timeout = await loop.run_in_executor(
None, lambda: event.wait(timeout=0.5)
)
if not got_timeout:
continue
# Read new items (in lock).
def in_executor() -> Tuple[List[str], bool]:
with self._lock:
new_items = self._loaded_strings[items_yielded:]
done = self._loaded
event.clear()
return new_items, done
new_items, done = await loop.run_in_executor(None, in_executor)
items_yielded += len(new_items)
for item in new_items:
yield item
if done:
break
finally:
self._string_load_events.remove(event)
def _in_load_thread(self) -> None:
try:
# Start with an empty list. In case `append_string()` was called
# before `load()` happened. Then `.store_string()` will have
# written these entries back to disk and we will reload it.
self._loaded_strings = []
for item in self.history.load_history_strings():
with self._lock:
self._loaded_strings.append(item)
for event in self._string_load_events:
event.set()
finally:
with self._lock:
self._loaded = True
for event in self._string_load_events:
event.set()
def append_string(self, string: str) -> None:
with self._lock:
self._loaded_strings.insert(0, string)
self.store_string(string)
# All of the following are proxied to `self.history`.
def load_history_strings(self) -> Iterable[str]:
return self.history.load_history_strings()
def store_string(self, string: str) -> None:
self.history.store_string(string)
def __repr__(self) -> str:
return f"ThreadedHistory({self.history!r})"
class InMemoryHistory(History):
"""
:class:`.History` class that keeps a list of all strings in memory.
In order to prepopulate the history, it's possible to call either
`append_string` for all items or pass a list of strings to `__init__` here.
"""
def __init__(self, history_strings: Optional[Sequence[str]] = None) -> None:
super().__init__()
# Emulating disk storage.
if history_strings is None:
self._storage = []
else:
self._storage = list(history_strings)
def load_history_strings(self) -> Iterable[str]:
yield from self._storage[::-1]
def store_string(self, string: str) -> None:
self._storage.append(string)
class DummyHistory(History):
"""
:class:`.History` object that doesn't remember anything.
"""
def load_history_strings(self) -> Iterable[str]:
return []
def store_string(self, string: str) -> None:
pass
def append_string(self, string: str) -> None:
# Don't remember this.
pass
class FileHistory(History):
"""
:class:`.History` class that stores all strings in a file.
"""
def __init__(self, filename: str) -> None:
self.filename = filename
super().__init__()
def load_history_strings(self) -> Iterable[str]:
strings: List[str] = []
lines: List[str] = []
def add() -> None:
if lines:
# Join and drop trailing newline.
string = "".join(lines)[:-1]
strings.append(string)
if os.path.exists(self.filename):
with open(self.filename, "rb") as f:
for line_bytes in f:
line = line_bytes.decode("utf-8", errors="replace")
if line.startswith("+"):
lines.append(line[1:])
else:
add()
lines = []
add()
# Reverse the order, because newest items have to go first.
return reversed(strings)
def store_string(self, string: str) -> None:
# Save to file.
with open(self.filename, "ab") as f:
def write(t: str) -> None:
f.write(t.encode("utf-8"))
write("\n# %s\n" % datetime.datetime.now())
for line in string.split("\n"):
write("+%s\n" % line)

View File

@@ -0,0 +1,12 @@
from .base import DummyInput, Input, PipeInput
from .defaults import create_input, create_pipe_input
__all__ = [
# Base.
"Input",
"PipeInput",
"DummyInput",
# Defaults.
"create_input",
"create_pipe_input",
]

View File

@@ -0,0 +1,343 @@
"""
Mappings from VT100 (ANSI) escape sequences to the corresponding prompt_toolkit
keys.
We are not using the terminfo/termcap databases to detect the ANSI escape
sequences for the input. Instead, we recognize 99% of the most common
sequences. This works well, because in practice, every modern terminal is
mostly Xterm compatible.
Some useful docs:
- Mintty: https://github.com/mintty/mintty/blob/master/wiki/Keycodes.md
"""
from typing import Dict, Tuple, Union
from ..keys import Keys
__all__ = [
"ANSI_SEQUENCES",
"REVERSE_ANSI_SEQUENCES",
]
# Mapping of vt100 escape codes to Keys.
ANSI_SEQUENCES: Dict[str, Union[Keys, Tuple[Keys, ...]]] = {
# Control keys.
"\x00": Keys.ControlAt, # Control-At (Also for Ctrl-Space)
"\x01": Keys.ControlA, # Control-A (home)
"\x02": Keys.ControlB, # Control-B (emacs cursor left)
"\x03": Keys.ControlC, # Control-C (interrupt)
"\x04": Keys.ControlD, # Control-D (exit)
"\x05": Keys.ControlE, # Control-E (end)
"\x06": Keys.ControlF, # Control-F (cursor forward)
"\x07": Keys.ControlG, # Control-G
"\x08": Keys.ControlH, # Control-H (8) (Identical to '\b')
"\x09": Keys.ControlI, # Control-I (9) (Identical to '\t')
"\x0a": Keys.ControlJ, # Control-J (10) (Identical to '\n')
"\x0b": Keys.ControlK, # Control-K (delete until end of line; vertical tab)
"\x0c": Keys.ControlL, # Control-L (clear; form feed)
"\x0d": Keys.ControlM, # Control-M (13) (Identical to '\r')
"\x0e": Keys.ControlN, # Control-N (14) (history forward)
"\x0f": Keys.ControlO, # Control-O (15)
"\x10": Keys.ControlP, # Control-P (16) (history back)
"\x11": Keys.ControlQ, # Control-Q
"\x12": Keys.ControlR, # Control-R (18) (reverse search)
"\x13": Keys.ControlS, # Control-S (19) (forward search)
"\x14": Keys.ControlT, # Control-T
"\x15": Keys.ControlU, # Control-U
"\x16": Keys.ControlV, # Control-V
"\x17": Keys.ControlW, # Control-W
"\x18": Keys.ControlX, # Control-X
"\x19": Keys.ControlY, # Control-Y (25)
"\x1a": Keys.ControlZ, # Control-Z
"\x1b": Keys.Escape, # Also Control-[
"\x9b": Keys.ShiftEscape,
"\x1c": Keys.ControlBackslash, # Both Control-\ (also Ctrl-| )
"\x1d": Keys.ControlSquareClose, # Control-]
"\x1e": Keys.ControlCircumflex, # Control-^
"\x1f": Keys.ControlUnderscore, # Control-underscore (Also for Ctrl-hyphen.)
# ASCII Delete (0x7f)
# Vt220 (and Linux terminal) send this when pressing backspace. We map this
# to ControlH, because that will make it easier to create key bindings that
# work everywhere, with the trade-off that it's no longer possible to
# handle backspace and control-h individually for the few terminals that
# support it. (Most terminals send ControlH when backspace is pressed.)
# See: http://www.ibb.net/~anne/keyboard.html
"\x7f": Keys.ControlH,
# --
# Various
"\x1b[1~": Keys.Home, # tmux
"\x1b[2~": Keys.Insert,
"\x1b[3~": Keys.Delete,
"\x1b[4~": Keys.End, # tmux
"\x1b[5~": Keys.PageUp,
"\x1b[6~": Keys.PageDown,
"\x1b[7~": Keys.Home, # xrvt
"\x1b[8~": Keys.End, # xrvt
"\x1b[Z": Keys.BackTab, # shift + tab
"\x1b\x09": Keys.BackTab, # Linux console
"\x1b[~": Keys.BackTab, # Windows console
# --
# Function keys.
"\x1bOP": Keys.F1,
"\x1bOQ": Keys.F2,
"\x1bOR": Keys.F3,
"\x1bOS": Keys.F4,
"\x1b[[A": Keys.F1, # Linux console.
"\x1b[[B": Keys.F2, # Linux console.
"\x1b[[C": Keys.F3, # Linux console.
"\x1b[[D": Keys.F4, # Linux console.
"\x1b[[E": Keys.F5, # Linux console.
"\x1b[11~": Keys.F1, # rxvt-unicode
"\x1b[12~": Keys.F2, # rxvt-unicode
"\x1b[13~": Keys.F3, # rxvt-unicode
"\x1b[14~": Keys.F4, # rxvt-unicode
"\x1b[15~": Keys.F5,
"\x1b[17~": Keys.F6,
"\x1b[18~": Keys.F7,
"\x1b[19~": Keys.F8,
"\x1b[20~": Keys.F9,
"\x1b[21~": Keys.F10,
"\x1b[23~": Keys.F11,
"\x1b[24~": Keys.F12,
"\x1b[25~": Keys.F13,
"\x1b[26~": Keys.F14,
"\x1b[28~": Keys.F15,
"\x1b[29~": Keys.F16,
"\x1b[31~": Keys.F17,
"\x1b[32~": Keys.F18,
"\x1b[33~": Keys.F19,
"\x1b[34~": Keys.F20,
# Xterm
"\x1b[1;2P": Keys.F13,
"\x1b[1;2Q": Keys.F14,
# '\x1b[1;2R': Keys.F15, # Conflicts with CPR response.
"\x1b[1;2S": Keys.F16,
"\x1b[15;2~": Keys.F17,
"\x1b[17;2~": Keys.F18,
"\x1b[18;2~": Keys.F19,
"\x1b[19;2~": Keys.F20,
"\x1b[20;2~": Keys.F21,
"\x1b[21;2~": Keys.F22,
"\x1b[23;2~": Keys.F23,
"\x1b[24;2~": Keys.F24,
# --
# CSI 27 disambiguated modified "other" keys (xterm)
# Ref: https://invisible-island.net/xterm/modified-keys.html
# These are currently unsupported, so just re-map some common ones to the
# unmodified versions
"\x1b[27;2;13~": Keys.ControlM, # Shift + Enter
"\x1b[27;5;13~": Keys.ControlM, # Ctrl + Enter
"\x1b[27;6;13~": Keys.ControlM, # Ctrl + Shift + Enter
# --
# Control + function keys.
"\x1b[1;5P": Keys.ControlF1,
"\x1b[1;5Q": Keys.ControlF2,
# "\x1b[1;5R": Keys.ControlF3, # Conflicts with CPR response.
"\x1b[1;5S": Keys.ControlF4,
"\x1b[15;5~": Keys.ControlF5,
"\x1b[17;5~": Keys.ControlF6,
"\x1b[18;5~": Keys.ControlF7,
"\x1b[19;5~": Keys.ControlF8,
"\x1b[20;5~": Keys.ControlF9,
"\x1b[21;5~": Keys.ControlF10,
"\x1b[23;5~": Keys.ControlF11,
"\x1b[24;5~": Keys.ControlF12,
"\x1b[1;6P": Keys.ControlF13,
"\x1b[1;6Q": Keys.ControlF14,
# "\x1b[1;6R": Keys.ControlF15, # Conflicts with CPR response.
"\x1b[1;6S": Keys.ControlF16,
"\x1b[15;6~": Keys.ControlF17,
"\x1b[17;6~": Keys.ControlF18,
"\x1b[18;6~": Keys.ControlF19,
"\x1b[19;6~": Keys.ControlF20,
"\x1b[20;6~": Keys.ControlF21,
"\x1b[21;6~": Keys.ControlF22,
"\x1b[23;6~": Keys.ControlF23,
"\x1b[24;6~": Keys.ControlF24,
# --
# Tmux (Win32 subsystem) sends the following scroll events.
"\x1b[62~": Keys.ScrollUp,
"\x1b[63~": Keys.ScrollDown,
"\x1b[200~": Keys.BracketedPaste, # Start of bracketed paste.
# --
# Sequences generated by numpad 5. Not sure what it means. (It doesn't
# appear in 'infocmp'. Just ignore.
"\x1b[E": Keys.Ignore, # Xterm.
"\x1b[G": Keys.Ignore, # Linux console.
# --
# Meta/control/escape + pageup/pagedown/insert/delete.
"\x1b[3;2~": Keys.ShiftDelete, # xterm, gnome-terminal.
"\x1b[5;2~": Keys.ShiftPageUp,
"\x1b[6;2~": Keys.ShiftPageDown,
"\x1b[2;3~": (Keys.Escape, Keys.Insert),
"\x1b[3;3~": (Keys.Escape, Keys.Delete),
"\x1b[5;3~": (Keys.Escape, Keys.PageUp),
"\x1b[6;3~": (Keys.Escape, Keys.PageDown),
"\x1b[2;4~": (Keys.Escape, Keys.ShiftInsert),
"\x1b[3;4~": (Keys.Escape, Keys.ShiftDelete),
"\x1b[5;4~": (Keys.Escape, Keys.ShiftPageUp),
"\x1b[6;4~": (Keys.Escape, Keys.ShiftPageDown),
"\x1b[3;5~": Keys.ControlDelete, # xterm, gnome-terminal.
"\x1b[5;5~": Keys.ControlPageUp,
"\x1b[6;5~": Keys.ControlPageDown,
"\x1b[3;6~": Keys.ControlShiftDelete,
"\x1b[5;6~": Keys.ControlShiftPageUp,
"\x1b[6;6~": Keys.ControlShiftPageDown,
"\x1b[2;7~": (Keys.Escape, Keys.ControlInsert),
"\x1b[5;7~": (Keys.Escape, Keys.ControlPageDown),
"\x1b[6;7~": (Keys.Escape, Keys.ControlPageDown),
"\x1b[2;8~": (Keys.Escape, Keys.ControlShiftInsert),
"\x1b[5;8~": (Keys.Escape, Keys.ControlShiftPageDown),
"\x1b[6;8~": (Keys.Escape, Keys.ControlShiftPageDown),
# --
# Arrows.
# (Normal cursor mode).
"\x1b[A": Keys.Up,
"\x1b[B": Keys.Down,
"\x1b[C": Keys.Right,
"\x1b[D": Keys.Left,
"\x1b[H": Keys.Home,
"\x1b[F": Keys.End,
# Tmux sends following keystrokes when control+arrow is pressed, but for
# Emacs ansi-term sends the same sequences for normal arrow keys. Consider
# it a normal arrow press, because that's more important.
# (Application cursor mode).
"\x1bOA": Keys.Up,
"\x1bOB": Keys.Down,
"\x1bOC": Keys.Right,
"\x1bOD": Keys.Left,
"\x1bOF": Keys.End,
"\x1bOH": Keys.Home,
# Shift + arrows.
"\x1b[1;2A": Keys.ShiftUp,
"\x1b[1;2B": Keys.ShiftDown,
"\x1b[1;2C": Keys.ShiftRight,
"\x1b[1;2D": Keys.ShiftLeft,
"\x1b[1;2F": Keys.ShiftEnd,
"\x1b[1;2H": Keys.ShiftHome,
# Meta + arrow keys. Several terminals handle this differently.
# The following sequences are for xterm and gnome-terminal.
# (Iterm sends ESC followed by the normal arrow_up/down/left/right
# sequences, and the OSX Terminal sends ESCb and ESCf for "alt
# arrow_left" and "alt arrow_right." We don't handle these
# explicitly, in here, because would could not distinguish between
# pressing ESC (to go to Vi navigation mode), followed by just the
# 'b' or 'f' key. These combinations are handled in
# the input processor.)
"\x1b[1;3A": (Keys.Escape, Keys.Up),
"\x1b[1;3B": (Keys.Escape, Keys.Down),
"\x1b[1;3C": (Keys.Escape, Keys.Right),
"\x1b[1;3D": (Keys.Escape, Keys.Left),
"\x1b[1;3F": (Keys.Escape, Keys.End),
"\x1b[1;3H": (Keys.Escape, Keys.Home),
# Alt+shift+number.
"\x1b[1;4A": (Keys.Escape, Keys.ShiftDown),
"\x1b[1;4B": (Keys.Escape, Keys.ShiftUp),
"\x1b[1;4C": (Keys.Escape, Keys.ShiftRight),
"\x1b[1;4D": (Keys.Escape, Keys.ShiftLeft),
"\x1b[1;4F": (Keys.Escape, Keys.ShiftEnd),
"\x1b[1;4H": (Keys.Escape, Keys.ShiftHome),
# Control + arrows.
"\x1b[1;5A": Keys.ControlUp, # Cursor Mode
"\x1b[1;5B": Keys.ControlDown, # Cursor Mode
"\x1b[1;5C": Keys.ControlRight, # Cursor Mode
"\x1b[1;5D": Keys.ControlLeft, # Cursor Mode
"\x1b[1;5F": Keys.ControlEnd,
"\x1b[1;5H": Keys.ControlHome,
# Tmux sends following keystrokes when control+arrow is pressed, but for
# Emacs ansi-term sends the same sequences for normal arrow keys. Consider
# it a normal arrow press, because that's more important.
"\x1b[5A": Keys.ControlUp,
"\x1b[5B": Keys.ControlDown,
"\x1b[5C": Keys.ControlRight,
"\x1b[5D": Keys.ControlLeft,
"\x1bOc": Keys.ControlRight, # rxvt
"\x1bOd": Keys.ControlLeft, # rxvt
# Control + shift + arrows.
"\x1b[1;6A": Keys.ControlShiftDown,
"\x1b[1;6B": Keys.ControlShiftUp,
"\x1b[1;6C": Keys.ControlShiftRight,
"\x1b[1;6D": Keys.ControlShiftLeft,
"\x1b[1;6F": Keys.ControlShiftEnd,
"\x1b[1;6H": Keys.ControlShiftHome,
# Control + Meta + arrows.
"\x1b[1;7A": (Keys.Escape, Keys.ControlDown),
"\x1b[1;7B": (Keys.Escape, Keys.ControlUp),
"\x1b[1;7C": (Keys.Escape, Keys.ControlRight),
"\x1b[1;7D": (Keys.Escape, Keys.ControlLeft),
"\x1b[1;7F": (Keys.Escape, Keys.ControlEnd),
"\x1b[1;7H": (Keys.Escape, Keys.ControlHome),
# Meta + Shift + arrows.
"\x1b[1;8A": (Keys.Escape, Keys.ControlShiftDown),
"\x1b[1;8B": (Keys.Escape, Keys.ControlShiftUp),
"\x1b[1;8C": (Keys.Escape, Keys.ControlShiftRight),
"\x1b[1;8D": (Keys.Escape, Keys.ControlShiftLeft),
"\x1b[1;8F": (Keys.Escape, Keys.ControlShiftEnd),
"\x1b[1;8H": (Keys.Escape, Keys.ControlShiftHome),
# Meta + arrow on (some?) Macs when using iTerm defaults (see issue #483).
"\x1b[1;9A": (Keys.Escape, Keys.Up),
"\x1b[1;9B": (Keys.Escape, Keys.Down),
"\x1b[1;9C": (Keys.Escape, Keys.Right),
"\x1b[1;9D": (Keys.Escape, Keys.Left),
# --
# Control/shift/meta + number in mintty.
# (c-2 will actually send c-@ and c-6 will send c-^.)
"\x1b[1;5p": Keys.Control0,
"\x1b[1;5q": Keys.Control1,
"\x1b[1;5r": Keys.Control2,
"\x1b[1;5s": Keys.Control3,
"\x1b[1;5t": Keys.Control4,
"\x1b[1;5u": Keys.Control5,
"\x1b[1;5v": Keys.Control6,
"\x1b[1;5w": Keys.Control7,
"\x1b[1;5x": Keys.Control8,
"\x1b[1;5y": Keys.Control9,
"\x1b[1;6p": Keys.ControlShift0,
"\x1b[1;6q": Keys.ControlShift1,
"\x1b[1;6r": Keys.ControlShift2,
"\x1b[1;6s": Keys.ControlShift3,
"\x1b[1;6t": Keys.ControlShift4,
"\x1b[1;6u": Keys.ControlShift5,
"\x1b[1;6v": Keys.ControlShift6,
"\x1b[1;6w": Keys.ControlShift7,
"\x1b[1;6x": Keys.ControlShift8,
"\x1b[1;6y": Keys.ControlShift9,
"\x1b[1;7p": (Keys.Escape, Keys.Control0),
"\x1b[1;7q": (Keys.Escape, Keys.Control1),
"\x1b[1;7r": (Keys.Escape, Keys.Control2),
"\x1b[1;7s": (Keys.Escape, Keys.Control3),
"\x1b[1;7t": (Keys.Escape, Keys.Control4),
"\x1b[1;7u": (Keys.Escape, Keys.Control5),
"\x1b[1;7v": (Keys.Escape, Keys.Control6),
"\x1b[1;7w": (Keys.Escape, Keys.Control7),
"\x1b[1;7x": (Keys.Escape, Keys.Control8),
"\x1b[1;7y": (Keys.Escape, Keys.Control9),
"\x1b[1;8p": (Keys.Escape, Keys.ControlShift0),
"\x1b[1;8q": (Keys.Escape, Keys.ControlShift1),
"\x1b[1;8r": (Keys.Escape, Keys.ControlShift2),
"\x1b[1;8s": (Keys.Escape, Keys.ControlShift3),
"\x1b[1;8t": (Keys.Escape, Keys.ControlShift4),
"\x1b[1;8u": (Keys.Escape, Keys.ControlShift5),
"\x1b[1;8v": (Keys.Escape, Keys.ControlShift6),
"\x1b[1;8w": (Keys.Escape, Keys.ControlShift7),
"\x1b[1;8x": (Keys.Escape, Keys.ControlShift8),
"\x1b[1;8y": (Keys.Escape, Keys.ControlShift9),
}
def _get_reverse_ansi_sequences() -> Dict[Keys, str]:
"""
Create a dictionary that maps prompt_toolkit keys back to the VT100 escape
sequences.
"""
result: Dict[Keys, str] = {}
for sequence, key in ANSI_SEQUENCES.items():
if not isinstance(key, tuple):
if key not in result:
result[key] = sequence
return result
REVERSE_ANSI_SEQUENCES = _get_reverse_ansi_sequences()

View File

@@ -0,0 +1,150 @@
"""
Abstraction of CLI Input.
"""
from abc import ABCMeta, abstractmethod, abstractproperty
from contextlib import contextmanager
from typing import Callable, ContextManager, Generator, List
from prompt_toolkit.key_binding import KeyPress
__all__ = [
"Input",
"PipeInput",
"DummyInput",
]
class Input(metaclass=ABCMeta):
"""
Abstraction for any input.
An instance of this class can be given to the constructor of a
:class:`~prompt_toolkit.application.Application` and will also be
passed to the :class:`~prompt_toolkit.eventloop.base.EventLoop`.
"""
@abstractmethod
def fileno(self) -> int:
"""
Fileno for putting this in an event loop.
"""
@abstractmethod
def typeahead_hash(self) -> str:
"""
Identifier for storing type ahead key presses.
"""
@abstractmethod
def read_keys(self) -> List[KeyPress]:
"""
Return a list of Key objects which are read/parsed from the input.
"""
def flush_keys(self) -> List[KeyPress]:
"""
Flush the underlying parser. and return the pending keys.
(Used for vt100 input.)
"""
return []
def flush(self) -> None:
"The event loop can call this when the input has to be flushed."
pass
@abstractproperty
def closed(self) -> bool:
"Should be true when the input stream is closed."
return False
@abstractmethod
def raw_mode(self) -> ContextManager[None]:
"""
Context manager that turns the input into raw mode.
"""
@abstractmethod
def cooked_mode(self) -> ContextManager[None]:
"""
Context manager that turns the input into cooked mode.
"""
@abstractmethod
def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
"""
Return a context manager that makes this input active in the current
event loop.
"""
@abstractmethod
def detach(self) -> ContextManager[None]:
"""
Return a context manager that makes sure that this input is not active
in the current event loop.
"""
def close(self) -> None:
"Close input."
pass
class PipeInput(Input):
"""
Abstraction for pipe input.
"""
@abstractmethod
def send_bytes(self, data: bytes) -> None:
"""Feed byte string into the pipe"""
@abstractmethod
def send_text(self, data: str) -> None:
"""Feed a text string into the pipe"""
class DummyInput(Input):
"""
Input for use in a `DummyApplication`
If used in an actual application, it will make the application render
itself once and exit immediately, due to an `EOFError`.
"""
def fileno(self) -> int:
raise NotImplementedError
def typeahead_hash(self) -> str:
return "dummy-%s" % id(self)
def read_keys(self) -> List[KeyPress]:
return []
@property
def closed(self) -> bool:
# This needs to be true, so that the dummy input will trigger an
# `EOFError` immediately in the application.
return True
def raw_mode(self) -> ContextManager[None]:
return _dummy_context_manager()
def cooked_mode(self) -> ContextManager[None]:
return _dummy_context_manager()
def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
# Call the callback immediately once after attaching.
# This tells the callback to call `read_keys` and check the
# `input.closed` flag, after which it won't receive any keys, but knows
# that `EOFError` should be raised. This unblocks `read_from_input` in
# `application.py`.
input_ready_callback()
return _dummy_context_manager()
def detach(self) -> ContextManager[None]:
return _dummy_context_manager()
@contextmanager
def _dummy_context_manager() -> Generator[None, None, None]:
yield

View File

@@ -0,0 +1,69 @@
import sys
from typing import ContextManager, Optional, TextIO
from .base import DummyInput, Input, PipeInput
__all__ = [
"create_input",
"create_pipe_input",
]
def create_input(
stdin: Optional[TextIO] = None, always_prefer_tty: bool = False
) -> Input:
"""
Create the appropriate `Input` object for the current os/environment.
:param always_prefer_tty: When set, if `sys.stdin` is connected to a Unix
`pipe`, check whether `sys.stdout` or `sys.stderr` are connected to a
pseudo terminal. If so, open the tty for reading instead of reading for
`sys.stdin`. (We can open `stdout` or `stderr` for reading, this is how
a `$PAGER` works.)
"""
if sys.platform == "win32":
from .win32 import Win32Input
# If `stdin` was assigned `None` (which happens with pythonw.exe), use
# a `DummyInput`. This triggers `EOFError` in the application code.
if stdin is None and sys.stdin is None:
return DummyInput()
return Win32Input(stdin or sys.stdin)
else:
from .vt100 import Vt100Input
# If no input TextIO is given, use stdin/stdout.
if stdin is None:
stdin = sys.stdin
if always_prefer_tty:
for io in [sys.stdin, sys.stdout, sys.stderr]:
if io.isatty():
stdin = io
break
return Vt100Input(stdin)
def create_pipe_input() -> ContextManager[PipeInput]:
"""
Create an input pipe.
This is mostly useful for unit testing.
Usage::
with create_pipe_input() as input:
input.send_text('inputdata')
Breaking change: In prompt_toolkit 3.0.28 and earlier, this was returning
the `PipeInput` directly, rather than through a context manager.
"""
if sys.platform == "win32":
from .win32_pipe import Win32PipeInput
return Win32PipeInput.create()
else:
from .posix_pipe import PosixPipeInput
return PosixPipeInput.create()

View File

@@ -0,0 +1,116 @@
import sys
assert sys.platform != "win32"
import os
from contextlib import contextmanager
from typing import ContextManager, Iterator, TextIO, cast
from ..utils import DummyContext
from .base import PipeInput
from .vt100 import Vt100Input
__all__ = [
"PosixPipeInput",
]
class _Pipe:
"Wrapper around os.pipe, that ensures we don't double close any end."
def __init__(self) -> None:
self.read_fd, self.write_fd = os.pipe()
self._read_closed = False
self._write_closed = False
def close_read(self) -> None:
"Close read-end if not yet closed."
if self._read_closed:
return
os.close(self.read_fd)
self._read_closed = True
def close_write(self) -> None:
"Close write-end if not yet closed."
if self._write_closed:
return
os.close(self.write_fd)
self._write_closed = True
def close(self) -> None:
"Close both read and write ends."
self.close_read()
self.close_write()
class PosixPipeInput(Vt100Input, PipeInput):
"""
Input that is send through a pipe.
This is useful if we want to send the input programmatically into the
application. Mostly useful for unit testing.
Usage::
with PosixPipeInput.create() as input:
input.send_text('inputdata')
"""
_id = 0
def __init__(self, _pipe: _Pipe, _text: str = "") -> None:
# Private constructor. Users should use the public `.create()` method.
self.pipe = _pipe
class Stdin:
encoding = "utf-8"
def isatty(stdin) -> bool:
return True
def fileno(stdin) -> int:
return self.pipe.read_fd
super().__init__(cast(TextIO, Stdin()))
self.send_text(_text)
# Identifier for every PipeInput for the hash.
self.__class__._id += 1
self._id = self.__class__._id
@classmethod
@contextmanager
def create(cls, text: str = "") -> Iterator["PosixPipeInput"]:
pipe = _Pipe()
try:
yield PosixPipeInput(_pipe=pipe, _text=text)
finally:
pipe.close()
def send_bytes(self, data: bytes) -> None:
os.write(self.pipe.write_fd, data)
def send_text(self, data: str) -> None:
"Send text to the input."
os.write(self.pipe.write_fd, data.encode("utf-8"))
def raw_mode(self) -> ContextManager[None]:
return DummyContext()
def cooked_mode(self) -> ContextManager[None]:
return DummyContext()
def close(self) -> None:
"Close pipe fds."
# Only close the write-end of the pipe. This will unblock the reader
# callback (in vt100.py > _attached_input), which eventually will raise
# `EOFError`. If we'd also close the read-end, then the event loop
# won't wake up the corresponding callback because of this.
self.pipe.close_write()
def typeahead_hash(self) -> str:
"""
This needs to be unique for every `PipeInput`.
"""
return f"pipe-input-{self._id}"

View File

@@ -0,0 +1,95 @@
import os
import select
from codecs import getincrementaldecoder
__all__ = [
"PosixStdinReader",
]
class PosixStdinReader:
"""
Wrapper around stdin which reads (nonblocking) the next available 1024
bytes and decodes it.
Note that you can't be sure that the input file is closed if the ``read``
function returns an empty string. When ``errors=ignore`` is passed,
``read`` can return an empty string if all malformed input was replaced by
an empty string. (We can't block here and wait for more input.) So, because
of that, check the ``closed`` attribute, to be sure that the file has been
closed.
:param stdin_fd: File descriptor from which we read.
:param errors: Can be 'ignore', 'strict' or 'replace'.
On Python3, this can be 'surrogateescape', which is the default.
'surrogateescape' is preferred, because this allows us to transfer
unrecognised bytes to the key bindings. Some terminals, like lxterminal
and Guake, use the 'Mxx' notation to send mouse events, where each 'x'
can be any possible byte.
"""
# By default, we want to 'ignore' errors here. The input stream can be full
# of junk. One occurrence of this that I had was when using iTerm2 on OS X,
# with "Option as Meta" checked (You should choose "Option as +Esc".)
def __init__(
self, stdin_fd: int, errors: str = "surrogateescape", encoding: str = "utf-8"
) -> None:
self.stdin_fd = stdin_fd
self.errors = errors
# Create incremental decoder for decoding stdin.
# We can not just do `os.read(stdin.fileno(), 1024).decode('utf-8')`, because
# it could be that we are in the middle of a utf-8 byte sequence.
self._stdin_decoder_cls = getincrementaldecoder(encoding)
self._stdin_decoder = self._stdin_decoder_cls(errors=errors)
#: True when there is nothing anymore to read.
self.closed = False
def read(self, count: int = 1024) -> str:
# By default we choose a rather small chunk size, because reading
# big amounts of input at once, causes the event loop to process
# all these key bindings also at once without going back to the
# loop. This will make the application feel unresponsive.
"""
Read the input and return it as a string.
Return the text. Note that this can return an empty string, even when
the input stream was not yet closed. This means that something went
wrong during the decoding.
"""
if self.closed:
return ""
# Check whether there is some input to read. `os.read` would block
# otherwise.
# (Actually, the event loop is responsible to make sure that this
# function is only called when there is something to read, but for some
# reason this happens in certain situations.)
try:
if not select.select([self.stdin_fd], [], [], 0)[0]:
return ""
except OSError:
# Happens for instance when the file descriptor was closed.
# (We had this in ptterm, where the FD became ready, a callback was
# scheduled, but in the meantime another callback closed it already.)
self.closed = True
# Note: the following works better than wrapping `self.stdin` like
# `codecs.getreader('utf-8')(stdin)` and doing `read(1)`.
# Somehow that causes some latency when the escape
# character is pressed. (Especially on combination with the `select`.)
try:
data = os.read(self.stdin_fd, count)
# Nothing more to read, stream is closed.
if data == b"":
self.closed = True
return ""
except OSError:
# In case of SIGWINCH
data = b""
return self._stdin_decoder.decode(data)

View File

@@ -0,0 +1,76 @@
r"""
Store input key strokes if we did read more than was required.
The input classes `Vt100Input` and `Win32Input` read the input text in chunks
of a few kilobytes. This means that if we read input from stdin, it could be
that we read a couple of lines (with newlines in between) at once.
This creates a problem: potentially, we read too much from stdin. Sometimes
people paste several lines at once because they paste input in a REPL and
expect each input() call to process one line. Or they rely on type ahead
because the application can't keep up with the processing.
However, we need to read input in bigger chunks. We need this mostly to support
pasting of larger chunks of text. We don't want everything to become
unresponsive because we:
- read one character;
- parse one character;
- call the key binding, which does a string operation with one character;
- and render the user interface.
Doing text operations on single characters is very inefficient in Python, so we
prefer to work on bigger chunks of text. This is why we have to read the input
in bigger chunks.
Further, line buffering is also not an option, because it doesn't work well in
the architecture. We use lower level Posix APIs, that work better with the
event loop and so on. In fact, there is also nothing that defines that only \n
can accept the input, you could create a key binding for any key to accept the
input.
To support type ahead, this module will store all the key strokes that were
read too early, so that they can be feed into to the next `prompt()` call or to
the next prompt_toolkit `Application`.
"""
from collections import defaultdict
from typing import Dict, List
from ..key_binding import KeyPress
from .base import Input
__all__ = [
"store_typeahead",
"get_typeahead",
"clear_typeahead",
]
_buffer: Dict[str, List[KeyPress]] = defaultdict(list)
def store_typeahead(input_obj: Input, key_presses: List[KeyPress]) -> None:
"""
Insert typeahead key presses for the given input.
"""
global _buffer
key = input_obj.typeahead_hash()
_buffer[key].extend(key_presses)
def get_typeahead(input_obj: Input) -> List[KeyPress]:
"""
Retrieve typeahead and reset the buffer for this input.
"""
global _buffer
key = input_obj.typeahead_hash()
result = _buffer[key]
_buffer[key] = []
return result
def clear_typeahead(input_obj: Input) -> None:
"""
Clear typeahead buffer.
"""
global _buffer
key = input_obj.typeahead_hash()
_buffer[key] = []

View File

@@ -0,0 +1,320 @@
import sys
assert sys.platform != "win32"
import contextlib
import io
import termios
import tty
from asyncio import AbstractEventLoop
from typing import (
Callable,
ContextManager,
Dict,
Generator,
List,
Optional,
Set,
TextIO,
Tuple,
Union,
)
from prompt_toolkit.eventloop import get_event_loop
from ..key_binding import KeyPress
from .base import Input
from .posix_utils import PosixStdinReader
from .vt100_parser import Vt100Parser
__all__ = [
"Vt100Input",
"raw_mode",
"cooked_mode",
]
class Vt100Input(Input):
"""
Vt100 input for Posix systems.
(This uses a posix file descriptor that can be registered in the event loop.)
"""
# For the error messages. Only display "Input is not a terminal" once per
# file descriptor.
_fds_not_a_terminal: Set[int] = set()
def __init__(self, stdin: TextIO) -> None:
# Test whether the given input object has a file descriptor.
# (Idle reports stdin to be a TTY, but fileno() is not implemented.)
try:
# This should not raise, but can return 0.
stdin.fileno()
except io.UnsupportedOperation as e:
if "idlelib.run" in sys.modules:
raise io.UnsupportedOperation(
"Stdin is not a terminal. Running from Idle is not supported."
) from e
else:
raise io.UnsupportedOperation("Stdin is not a terminal.") from e
# Even when we have a file descriptor, it doesn't mean it's a TTY.
# Normally, this requires a real TTY device, but people instantiate
# this class often during unit tests as well. They use for instance
# pexpect to pipe data into an application. For convenience, we print
# an error message and go on.
isatty = stdin.isatty()
fd = stdin.fileno()
if not isatty and fd not in Vt100Input._fds_not_a_terminal:
msg = "Warning: Input is not a terminal (fd=%r).\n"
sys.stderr.write(msg % fd)
sys.stderr.flush()
Vt100Input._fds_not_a_terminal.add(fd)
#
self.stdin = stdin
# Create a backup of the fileno(). We want this to work even if the
# underlying file is closed, so that `typeahead_hash()` keeps working.
self._fileno = stdin.fileno()
self._buffer: List[KeyPress] = [] # Buffer to collect the Key objects.
self.stdin_reader = PosixStdinReader(self._fileno, encoding=stdin.encoding)
self.vt100_parser = Vt100Parser(
lambda key_press: self._buffer.append(key_press)
)
def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
"""
Return a context manager that makes this input active in the current
event loop.
"""
return _attached_input(self, input_ready_callback)
def detach(self) -> ContextManager[None]:
"""
Return a context manager that makes sure that this input is not active
in the current event loop.
"""
return _detached_input(self)
def read_keys(self) -> List[KeyPress]:
"Read list of KeyPress."
# Read text from stdin.
data = self.stdin_reader.read()
# Pass it through our vt100 parser.
self.vt100_parser.feed(data)
# Return result.
result = self._buffer
self._buffer = []
return result
def flush_keys(self) -> List[KeyPress]:
"""
Flush pending keys and return them.
(Used for flushing the 'escape' key.)
"""
# Flush all pending keys. (This is most important to flush the vt100
# 'Escape' key early when nothing else follows.)
self.vt100_parser.flush()
# Return result.
result = self._buffer
self._buffer = []
return result
@property
def closed(self) -> bool:
return self.stdin_reader.closed
def raw_mode(self) -> ContextManager[None]:
return raw_mode(self.stdin.fileno())
def cooked_mode(self) -> ContextManager[None]:
return cooked_mode(self.stdin.fileno())
def fileno(self) -> int:
return self.stdin.fileno()
def typeahead_hash(self) -> str:
return f"fd-{self._fileno}"
_current_callbacks: Dict[
Tuple[AbstractEventLoop, int], Optional[Callable[[], None]]
] = {} # (loop, fd) -> current callback
@contextlib.contextmanager
def _attached_input(
input: Vt100Input, callback: Callable[[], None]
) -> Generator[None, None, None]:
"""
Context manager that makes this input active in the current event loop.
:param input: :class:`~prompt_toolkit.input.Input` object.
:param callback: Called when the input is ready to read.
"""
loop = get_event_loop()
fd = input.fileno()
previous = _current_callbacks.get((loop, fd))
def callback_wrapper() -> None:
"""Wrapper around the callback that already removes the reader when
the input is closed. Otherwise, we keep continuously calling this
callback, until we leave the context manager (which can happen a bit
later). This fixes issues when piping /dev/null into a prompt_toolkit
application."""
if input.closed:
loop.remove_reader(fd)
callback()
try:
loop.add_reader(fd, callback_wrapper)
except PermissionError:
# For `EPollSelector`, adding /dev/null to the event loop will raise
# `PermisisonError` (that doesn't happen for `SelectSelector`
# apparently). Whenever we get a `PermissionError`, we can raise
# `EOFError`, because there's not more to be read anyway. `EOFError` is
# an exception that people expect in
# `prompt_toolkit.application.Application.run()`.
# To reproduce, do: `ptpython 0< /dev/null 1< /dev/null`
raise EOFError
_current_callbacks[loop, fd] = callback
try:
yield
finally:
loop.remove_reader(fd)
if previous:
loop.add_reader(fd, previous)
_current_callbacks[loop, fd] = previous
else:
del _current_callbacks[loop, fd]
@contextlib.contextmanager
def _detached_input(input: Vt100Input) -> Generator[None, None, None]:
loop = get_event_loop()
fd = input.fileno()
previous = _current_callbacks.get((loop, fd))
if previous:
loop.remove_reader(fd)
_current_callbacks[loop, fd] = None
try:
yield
finally:
if previous:
loop.add_reader(fd, previous)
_current_callbacks[loop, fd] = previous
class raw_mode:
"""
::
with raw_mode(stdin):
''' the pseudo-terminal stdin is now used in raw mode '''
We ignore errors when executing `tcgetattr` fails.
"""
# There are several reasons for ignoring errors:
# 1. To avoid the "Inappropriate ioctl for device" crash if somebody would
# execute this code (In a Python REPL, for instance):
#
# import os; f = open(os.devnull); os.dup2(f.fileno(), 0)
#
# The result is that the eventloop will stop correctly, because it has
# to logic to quit when stdin is closed. However, we should not fail at
# this point. See:
# https://github.com/jonathanslenders/python-prompt-toolkit/pull/393
# https://github.com/jonathanslenders/python-prompt-toolkit/issues/392
# 2. Related, when stdin is an SSH pipe, and no full terminal was allocated.
# See: https://github.com/jonathanslenders/python-prompt-toolkit/pull/165
def __init__(self, fileno: int) -> None:
self.fileno = fileno
self.attrs_before: Optional[List[Union[int, List[Union[bytes, int]]]]]
try:
self.attrs_before = termios.tcgetattr(fileno)
except termios.error:
# Ignore attribute errors.
self.attrs_before = None
def __enter__(self) -> None:
# NOTE: On os X systems, using pty.setraw() fails. Therefor we are using this:
try:
newattr = termios.tcgetattr(self.fileno)
except termios.error:
pass
else:
newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG])
newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG])
# VMIN defines the number of characters read at a time in
# non-canonical mode. It seems to default to 1 on Linux, but on
# Solaris and derived operating systems it defaults to 4. (This is
# because the VMIN slot is the same as the VEOF slot, which
# defaults to ASCII EOT = Ctrl-D = 4.)
newattr[tty.CC][termios.VMIN] = 1
termios.tcsetattr(self.fileno, termios.TCSANOW, newattr)
@classmethod
def _patch_lflag(cls, attrs: int) -> int:
return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG)
@classmethod
def _patch_iflag(cls, attrs: int) -> int:
return attrs & ~(
# Disable XON/XOFF flow control on output and input.
# (Don't capture Ctrl-S and Ctrl-Q.)
# Like executing: "stty -ixon."
termios.IXON
| termios.IXOFF
|
# Don't translate carriage return into newline on input.
termios.ICRNL
| termios.INLCR
| termios.IGNCR
)
def __exit__(self, *a: object) -> None:
if self.attrs_before is not None:
try:
termios.tcsetattr(self.fileno, termios.TCSANOW, self.attrs_before)
except termios.error:
pass
# # Put the terminal in application mode.
# self._stdout.write('\x1b[?1h')
class cooked_mode(raw_mode):
"""
The opposite of ``raw_mode``, used when we need cooked mode inside a
`raw_mode` block. Used in `Application.run_in_terminal`.::
with cooked_mode(stdin):
''' the pseudo-terminal stdin is now used in cooked mode. '''
"""
@classmethod
def _patch_lflag(cls, attrs: int) -> int:
return attrs | (termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG)
@classmethod
def _patch_iflag(cls, attrs: int) -> int:
# Turn the ICRNL flag back on. (Without this, calling `input()` in
# run_in_terminal doesn't work and displays ^M instead. Ptpython
# evaluates commands using `run_in_terminal`, so it's important that
# they translate ^M back into ^J.)
return attrs | termios.ICRNL

View File

@@ -0,0 +1,247 @@
"""
Parser for VT100 input stream.
"""
import re
from typing import Callable, Dict, Generator, Tuple, Union
from ..key_binding.key_processor import KeyPress
from ..keys import Keys
from .ansi_escape_sequences import ANSI_SEQUENCES
__all__ = [
"Vt100Parser",
]
# Regex matching any CPR response
# (Note that we use '\Z' instead of '$', because '$' could include a trailing
# newline.)
_cpr_response_re = re.compile("^" + re.escape("\x1b[") + r"\d+;\d+R\Z")
# Mouse events:
# Typical: "Esc[MaB*" Urxvt: "Esc[96;14;13M" and for Xterm SGR: "Esc[<64;85;12M"
_mouse_event_re = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")
# Regex matching any valid prefix of a CPR response.
# (Note that it doesn't contain the last character, the 'R'. The prefix has to
# be shorter.)
_cpr_response_prefix_re = re.compile("^" + re.escape("\x1b[") + r"[\d;]*\Z")
_mouse_event_prefix_re = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]*|M.{0,2})\Z")
class _Flush:
"""Helper object to indicate flush operation to the parser."""
pass
class _IsPrefixOfLongerMatchCache(Dict[str, bool]):
"""
Dictionary that maps input sequences to a boolean indicating whether there is
any key that start with this characters.
"""
def __missing__(self, prefix: str) -> bool:
# (hard coded) If this could be a prefix of a CPR response, return
# True.
if _cpr_response_prefix_re.match(prefix) or _mouse_event_prefix_re.match(
prefix
):
result = True
else:
# If this could be a prefix of anything else, also return True.
result = any(
v
for k, v in ANSI_SEQUENCES.items()
if k.startswith(prefix) and k != prefix
)
self[prefix] = result
return result
_IS_PREFIX_OF_LONGER_MATCH_CACHE = _IsPrefixOfLongerMatchCache()
class Vt100Parser:
"""
Parser for VT100 input stream.
Data can be fed through the `feed` method and the given callback will be
called with KeyPress objects.
::
def callback(key):
pass
i = Vt100Parser(callback)
i.feed('data\x01...')
:attr feed_key_callback: Function that will be called when a key is parsed.
"""
# Lookup table of ANSI escape sequences for a VT100 terminal
# Hint: in order to know what sequences your terminal writes to stdin, run
# "od -c" and start typing.
def __init__(self, feed_key_callback: Callable[[KeyPress], None]) -> None:
self.feed_key_callback = feed_key_callback
self.reset()
def reset(self, request: bool = False) -> None:
self._in_bracketed_paste = False
self._start_parser()
def _start_parser(self) -> None:
"""
Start the parser coroutine.
"""
self._input_parser = self._input_parser_generator()
self._input_parser.send(None) # type: ignore
def _get_match(self, prefix: str) -> Union[None, Keys, Tuple[Keys, ...]]:
"""
Return the key (or keys) that maps to this prefix.
"""
# (hard coded) If we match a CPR response, return Keys.CPRResponse.
# (This one doesn't fit in the ANSI_SEQUENCES, because it contains
# integer variables.)
if _cpr_response_re.match(prefix):
return Keys.CPRResponse
elif _mouse_event_re.match(prefix):
return Keys.Vt100MouseEvent
# Otherwise, use the mappings.
try:
return ANSI_SEQUENCES[prefix]
except KeyError:
return None
def _input_parser_generator(self) -> Generator[None, Union[str, _Flush], None]:
"""
Coroutine (state machine) for the input parser.
"""
prefix = ""
retry = False
flush = False
while True:
flush = False
if retry:
retry = False
else:
# Get next character.
c = yield
if isinstance(c, _Flush):
flush = True
else:
prefix += c
# If we have some data, check for matches.
if prefix:
is_prefix_of_longer_match = _IS_PREFIX_OF_LONGER_MATCH_CACHE[prefix]
match = self._get_match(prefix)
# Exact matches found, call handlers..
if (flush or not is_prefix_of_longer_match) and match:
self._call_handler(match, prefix)
prefix = ""
# No exact match found.
elif (flush or not is_prefix_of_longer_match) and not match:
found = False
retry = True
# Loop over the input, try the longest match first and
# shift.
for i in range(len(prefix), 0, -1):
match = self._get_match(prefix[:i])
if match:
self._call_handler(match, prefix[:i])
prefix = prefix[i:]
found = True
if not found:
self._call_handler(prefix[0], prefix[0])
prefix = prefix[1:]
def _call_handler(
self, key: Union[str, Keys, Tuple[Keys, ...]], insert_text: str
) -> None:
"""
Callback to handler.
"""
if isinstance(key, tuple):
# Received ANSI sequence that corresponds with multiple keys
# (probably alt+something). Handle keys individually, but only pass
# data payload to first KeyPress (so that we won't insert it
# multiple times).
for i, k in enumerate(key):
self._call_handler(k, insert_text if i == 0 else "")
else:
if key == Keys.BracketedPaste:
self._in_bracketed_paste = True
self._paste_buffer = ""
else:
self.feed_key_callback(KeyPress(key, insert_text))
def feed(self, data: str) -> None:
"""
Feed the input stream.
:param data: Input string (unicode).
"""
# Handle bracketed paste. (We bypass the parser that matches all other
# key presses and keep reading input until we see the end mark.)
# This is much faster then parsing character by character.
if self._in_bracketed_paste:
self._paste_buffer += data
end_mark = "\x1b[201~"
if end_mark in self._paste_buffer:
end_index = self._paste_buffer.index(end_mark)
# Feed content to key bindings.
paste_content = self._paste_buffer[:end_index]
self.feed_key_callback(KeyPress(Keys.BracketedPaste, paste_content))
# Quit bracketed paste mode and handle remaining input.
self._in_bracketed_paste = False
remaining = self._paste_buffer[end_index + len(end_mark) :]
self._paste_buffer = ""
self.feed(remaining)
# Handle normal input character by character.
else:
for i, c in enumerate(data):
if self._in_bracketed_paste:
# Quit loop and process from this position when the parser
# entered bracketed paste.
self.feed(data[i:])
break
else:
self._input_parser.send(c)
def flush(self) -> None:
"""
Flush the buffer of the input stream.
This will allow us to handle the escape key (or maybe meta) sooner.
The input received by the escape key is actually the same as the first
characters of e.g. Arrow-Up, so without knowing what follows the escape
sequence, we don't know whether escape has been pressed, or whether
it's something else. This flush function should be called after a
timeout, and processes everything that's still in the buffer as-is, so
without assuming any characters will follow.
"""
self._input_parser.send(_Flush())
def feed_and_flush(self, data: str) -> None:
"""
Wrapper around ``feed`` and ``flush``.
"""
self.feed(data)
self.flush()

View File

@@ -0,0 +1,757 @@
import os
import sys
from abc import abstractmethod
from contextlib import contextmanager
from prompt_toolkit.eventloop import get_event_loop
from ..utils import SPHINX_AUTODOC_RUNNING
assert sys.platform == "win32"
# Do not import win32-specific stuff when generating documentation.
# Otherwise RTD would be unable to generate docs for this module.
if not SPHINX_AUTODOC_RUNNING:
import msvcrt
from ctypes import windll
from ctypes import Array, pointer
from ctypes.wintypes import DWORD, HANDLE
from typing import (
Callable,
ContextManager,
Dict,
Iterable,
Iterator,
List,
Optional,
TextIO,
)
from prompt_toolkit.eventloop import run_in_executor_with_context
from prompt_toolkit.eventloop.win32 import create_win32_event, wait_for_handles
from prompt_toolkit.key_binding.key_processor import KeyPress
from prompt_toolkit.keys import Keys
from prompt_toolkit.mouse_events import MouseButton, MouseEventType
from prompt_toolkit.win32_types import (
INPUT_RECORD,
KEY_EVENT_RECORD,
MOUSE_EVENT_RECORD,
STD_INPUT_HANDLE,
EventTypes,
)
from .ansi_escape_sequences import REVERSE_ANSI_SEQUENCES
from .base import Input
__all__ = [
"Win32Input",
"ConsoleInputReader",
"raw_mode",
"cooked_mode",
"attach_win32_input",
"detach_win32_input",
]
# Win32 Constants for MOUSE_EVENT_RECORD.
# See: https://docs.microsoft.com/en-us/windows/console/mouse-event-record-str
FROM_LEFT_1ST_BUTTON_PRESSED = 0x1
RIGHTMOST_BUTTON_PRESSED = 0x2
MOUSE_MOVED = 0x0001
MOUSE_WHEELED = 0x0004
class _Win32InputBase(Input):
"""
Base class for `Win32Input` and `Win32PipeInput`.
"""
def __init__(self) -> None:
self.win32_handles = _Win32Handles()
@property
@abstractmethod
def handle(self) -> HANDLE:
pass
class Win32Input(_Win32InputBase):
"""
`Input` class that reads from the Windows console.
"""
def __init__(self, stdin: Optional[TextIO] = None) -> None:
super().__init__()
self.console_input_reader = ConsoleInputReader()
def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
"""
Return a context manager that makes this input active in the current
event loop.
"""
return attach_win32_input(self, input_ready_callback)
def detach(self) -> ContextManager[None]:
"""
Return a context manager that makes sure that this input is not active
in the current event loop.
"""
return detach_win32_input(self)
def read_keys(self) -> List[KeyPress]:
return list(self.console_input_reader.read())
def flush(self) -> None:
pass
@property
def closed(self) -> bool:
return False
def raw_mode(self) -> ContextManager[None]:
return raw_mode()
def cooked_mode(self) -> ContextManager[None]:
return cooked_mode()
def fileno(self) -> int:
# The windows console doesn't depend on the file handle, so
# this is not used for the event loop (which uses the
# handle instead). But it's used in `Application.run_system_command`
# which opens a subprocess with a given stdin/stdout.
return sys.stdin.fileno()
def typeahead_hash(self) -> str:
return "win32-input"
def close(self) -> None:
self.console_input_reader.close()
@property
def handle(self) -> HANDLE:
return self.console_input_reader.handle
class ConsoleInputReader:
"""
:param recognize_paste: When True, try to discover paste actions and turn
the event into a BracketedPaste.
"""
# Keys with character data.
mappings = {
b"\x1b": Keys.Escape,
b"\x00": Keys.ControlSpace, # Control-Space (Also for Ctrl-@)
b"\x01": Keys.ControlA, # Control-A (home)
b"\x02": Keys.ControlB, # Control-B (emacs cursor left)
b"\x03": Keys.ControlC, # Control-C (interrupt)
b"\x04": Keys.ControlD, # Control-D (exit)
b"\x05": Keys.ControlE, # Control-E (end)
b"\x06": Keys.ControlF, # Control-F (cursor forward)
b"\x07": Keys.ControlG, # Control-G
b"\x08": Keys.ControlH, # Control-H (8) (Identical to '\b')
b"\x09": Keys.ControlI, # Control-I (9) (Identical to '\t')
b"\x0a": Keys.ControlJ, # Control-J (10) (Identical to '\n')
b"\x0b": Keys.ControlK, # Control-K (delete until end of line; vertical tab)
b"\x0c": Keys.ControlL, # Control-L (clear; form feed)
b"\x0d": Keys.ControlM, # Control-M (enter)
b"\x0e": Keys.ControlN, # Control-N (14) (history forward)
b"\x0f": Keys.ControlO, # Control-O (15)
b"\x10": Keys.ControlP, # Control-P (16) (history back)
b"\x11": Keys.ControlQ, # Control-Q
b"\x12": Keys.ControlR, # Control-R (18) (reverse search)
b"\x13": Keys.ControlS, # Control-S (19) (forward search)
b"\x14": Keys.ControlT, # Control-T
b"\x15": Keys.ControlU, # Control-U
b"\x16": Keys.ControlV, # Control-V
b"\x17": Keys.ControlW, # Control-W
b"\x18": Keys.ControlX, # Control-X
b"\x19": Keys.ControlY, # Control-Y (25)
b"\x1a": Keys.ControlZ, # Control-Z
b"\x1c": Keys.ControlBackslash, # Both Control-\ and Ctrl-|
b"\x1d": Keys.ControlSquareClose, # Control-]
b"\x1e": Keys.ControlCircumflex, # Control-^
b"\x1f": Keys.ControlUnderscore, # Control-underscore (Also for Ctrl-hyphen.)
b"\x7f": Keys.Backspace, # (127) Backspace (ASCII Delete.)
}
# Keys that don't carry character data.
keycodes = {
# Home/End
33: Keys.PageUp,
34: Keys.PageDown,
35: Keys.End,
36: Keys.Home,
# Arrows
37: Keys.Left,
38: Keys.Up,
39: Keys.Right,
40: Keys.Down,
45: Keys.Insert,
46: Keys.Delete,
# F-keys.
112: Keys.F1,
113: Keys.F2,
114: Keys.F3,
115: Keys.F4,
116: Keys.F5,
117: Keys.F6,
118: Keys.F7,
119: Keys.F8,
120: Keys.F9,
121: Keys.F10,
122: Keys.F11,
123: Keys.F12,
}
LEFT_ALT_PRESSED = 0x0002
RIGHT_ALT_PRESSED = 0x0001
SHIFT_PRESSED = 0x0010
LEFT_CTRL_PRESSED = 0x0008
RIGHT_CTRL_PRESSED = 0x0004
def __init__(self, recognize_paste: bool = True) -> None:
self._fdcon = None
self.recognize_paste = recognize_paste
# When stdin is a tty, use that handle, otherwise, create a handle from
# CONIN$.
self.handle: HANDLE
if sys.stdin.isatty():
self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
else:
self._fdcon = os.open("CONIN$", os.O_RDWR | os.O_BINARY)
self.handle = HANDLE(msvcrt.get_osfhandle(self._fdcon))
def close(self) -> None:
"Close fdcon."
if self._fdcon is not None:
os.close(self._fdcon)
def read(self) -> Iterable[KeyPress]:
"""
Return a list of `KeyPress` instances. It won't return anything when
there was nothing to read. (This function doesn't block.)
http://msdn.microsoft.com/en-us/library/windows/desktop/ms684961(v=vs.85).aspx
"""
max_count = 2048 # Max events to read at the same time.
read = DWORD(0)
arrtype = INPUT_RECORD * max_count
input_records = arrtype()
# Check whether there is some input to read. `ReadConsoleInputW` would
# block otherwise.
# (Actually, the event loop is responsible to make sure that this
# function is only called when there is something to read, but for some
# reason this happened in the asyncio_win32 loop, and it's better to be
# safe anyway.)
if not wait_for_handles([self.handle], timeout=0):
return
# Get next batch of input event.
windll.kernel32.ReadConsoleInputW(
self.handle, pointer(input_records), max_count, pointer(read)
)
# First, get all the keys from the input buffer, in order to determine
# whether we should consider this a paste event or not.
all_keys = list(self._get_keys(read, input_records))
# Fill in 'data' for key presses.
all_keys = [self._insert_key_data(key) for key in all_keys]
# Correct non-bmp characters that are passed as separate surrogate codes
all_keys = list(self._merge_paired_surrogates(all_keys))
if self.recognize_paste and self._is_paste(all_keys):
gen = iter(all_keys)
k: Optional[KeyPress]
for k in gen:
# Pasting: if the current key consists of text or \n, turn it
# into a BracketedPaste.
data = []
while k and (
not isinstance(k.key, Keys)
or k.key in {Keys.ControlJ, Keys.ControlM}
):
data.append(k.data)
try:
k = next(gen)
except StopIteration:
k = None
if data:
yield KeyPress(Keys.BracketedPaste, "".join(data))
if k is not None:
yield k
else:
yield from all_keys
def _insert_key_data(self, key_press: KeyPress) -> KeyPress:
"""
Insert KeyPress data, for vt100 compatibility.
"""
if key_press.data:
return key_press
if isinstance(key_press.key, Keys):
data = REVERSE_ANSI_SEQUENCES.get(key_press.key, "")
else:
data = ""
return KeyPress(key_press.key, data)
def _get_keys(
self, read: DWORD, input_records: "Array[INPUT_RECORD]"
) -> Iterator[KeyPress]:
"""
Generator that yields `KeyPress` objects from the input records.
"""
for i in range(read.value):
ir = input_records[i]
# Get the right EventType from the EVENT_RECORD.
# (For some reason the Windows console application 'cmder'
# [http://gooseberrycreative.com/cmder/] can return '0' for
# ir.EventType. -- Just ignore that.)
if ir.EventType in EventTypes:
ev = getattr(ir.Event, EventTypes[ir.EventType])
# Process if this is a key event. (We also have mouse, menu and
# focus events.)
if type(ev) == KEY_EVENT_RECORD and ev.KeyDown:
yield from self._event_to_key_presses(ev)
elif type(ev) == MOUSE_EVENT_RECORD:
yield from self._handle_mouse(ev)
@staticmethod
def _merge_paired_surrogates(key_presses: List[KeyPress]) -> Iterator[KeyPress]:
"""
Combines consecutive KeyPresses with high and low surrogates into
single characters
"""
buffered_high_surrogate = None
for key in key_presses:
is_text = not isinstance(key.key, Keys)
is_high_surrogate = is_text and "\uD800" <= key.key <= "\uDBFF"
is_low_surrogate = is_text and "\uDC00" <= key.key <= "\uDFFF"
if buffered_high_surrogate:
if is_low_surrogate:
# convert high surrogate + low surrogate to single character
fullchar = (
(buffered_high_surrogate.key + key.key)
.encode("utf-16-le", "surrogatepass")
.decode("utf-16-le")
)
key = KeyPress(fullchar, fullchar)
else:
yield buffered_high_surrogate
buffered_high_surrogate = None
if is_high_surrogate:
buffered_high_surrogate = key
else:
yield key
if buffered_high_surrogate:
yield buffered_high_surrogate
@staticmethod
def _is_paste(keys: List[KeyPress]) -> bool:
"""
Return `True` when we should consider this list of keys as a paste
event. Pasted text on windows will be turned into a
`Keys.BracketedPaste` event. (It's not 100% correct, but it is probably
the best possible way to detect pasting of text and handle that
correctly.)
"""
# Consider paste when it contains at least one newline and at least one
# other character.
text_count = 0
newline_count = 0
for k in keys:
if not isinstance(k.key, Keys):
text_count += 1
if k.key == Keys.ControlM:
newline_count += 1
return newline_count >= 1 and text_count >= 1
def _event_to_key_presses(self, ev: KEY_EVENT_RECORD) -> List[KeyPress]:
"""
For this `KEY_EVENT_RECORD`, return a list of `KeyPress` instances.
"""
assert type(ev) == KEY_EVENT_RECORD and ev.KeyDown
result: Optional[KeyPress] = None
control_key_state = ev.ControlKeyState
u_char = ev.uChar.UnicodeChar
# Use surrogatepass because u_char may be an unmatched surrogate
ascii_char = u_char.encode("utf-8", "surrogatepass")
# NOTE: We don't use `ev.uChar.AsciiChar`. That appears to be the
# unicode code point truncated to 1 byte. See also:
# https://github.com/ipython/ipython/issues/10004
# https://github.com/jonathanslenders/python-prompt-toolkit/issues/389
if u_char == "\x00":
if ev.VirtualKeyCode in self.keycodes:
result = KeyPress(self.keycodes[ev.VirtualKeyCode], "")
else:
if ascii_char in self.mappings:
if self.mappings[ascii_char] == Keys.ControlJ:
u_char = (
"\n" # Windows sends \n, turn into \r for unix compatibility.
)
result = KeyPress(self.mappings[ascii_char], u_char)
else:
result = KeyPress(u_char, u_char)
# First we handle Shift-Control-Arrow/Home/End (need to do this first)
if (
(
control_key_state & self.LEFT_CTRL_PRESSED
or control_key_state & self.RIGHT_CTRL_PRESSED
)
and control_key_state & self.SHIFT_PRESSED
and result
):
mapping: Dict[str, str] = {
Keys.Left: Keys.ControlShiftLeft,
Keys.Right: Keys.ControlShiftRight,
Keys.Up: Keys.ControlShiftUp,
Keys.Down: Keys.ControlShiftDown,
Keys.Home: Keys.ControlShiftHome,
Keys.End: Keys.ControlShiftEnd,
Keys.Insert: Keys.ControlShiftInsert,
Keys.PageUp: Keys.ControlShiftPageUp,
Keys.PageDown: Keys.ControlShiftPageDown,
}
result.key = mapping.get(result.key, result.key)
# Correctly handle Control-Arrow/Home/End and Control-Insert/Delete keys.
if (
control_key_state & self.LEFT_CTRL_PRESSED
or control_key_state & self.RIGHT_CTRL_PRESSED
) and result:
mapping = {
Keys.Left: Keys.ControlLeft,
Keys.Right: Keys.ControlRight,
Keys.Up: Keys.ControlUp,
Keys.Down: Keys.ControlDown,
Keys.Home: Keys.ControlHome,
Keys.End: Keys.ControlEnd,
Keys.Insert: Keys.ControlInsert,
Keys.Delete: Keys.ControlDelete,
Keys.PageUp: Keys.ControlPageUp,
Keys.PageDown: Keys.ControlPageDown,
}
result.key = mapping.get(result.key, result.key)
# Turn 'Tab' into 'BackTab' when shift was pressed.
# Also handle other shift-key combination
if control_key_state & self.SHIFT_PRESSED and result:
mapping = {
Keys.Tab: Keys.BackTab,
Keys.Left: Keys.ShiftLeft,
Keys.Right: Keys.ShiftRight,
Keys.Up: Keys.ShiftUp,
Keys.Down: Keys.ShiftDown,
Keys.Home: Keys.ShiftHome,
Keys.End: Keys.ShiftEnd,
Keys.Insert: Keys.ShiftInsert,
Keys.Delete: Keys.ShiftDelete,
Keys.PageUp: Keys.ShiftPageUp,
Keys.PageDown: Keys.ShiftPageDown,
}
result.key = mapping.get(result.key, result.key)
# Turn 'Space' into 'ControlSpace' when control was pressed.
if (
(
control_key_state & self.LEFT_CTRL_PRESSED
or control_key_state & self.RIGHT_CTRL_PRESSED
)
and result
and result.data == " "
):
result = KeyPress(Keys.ControlSpace, " ")
# Turn Control-Enter into META-Enter. (On a vt100 terminal, we cannot
# detect this combination. But it's really practical on Windows.)
if (
(
control_key_state & self.LEFT_CTRL_PRESSED
or control_key_state & self.RIGHT_CTRL_PRESSED
)
and result
and result.key == Keys.ControlJ
):
return [KeyPress(Keys.Escape, ""), result]
# Return result. If alt was pressed, prefix the result with an
# 'Escape' key, just like unix VT100 terminals do.
# NOTE: Only replace the left alt with escape. The right alt key often
# acts as altgr and is used in many non US keyboard layouts for
# typing some special characters, like a backslash. We don't want
# all backslashes to be prefixed with escape. (Esc-\ has a
# meaning in E-macs, for instance.)
if result:
meta_pressed = control_key_state & self.LEFT_ALT_PRESSED
if meta_pressed:
return [KeyPress(Keys.Escape, ""), result]
else:
return [result]
else:
return []
def _handle_mouse(self, ev: MOUSE_EVENT_RECORD) -> List[KeyPress]:
"""
Handle mouse events. Return a list of KeyPress instances.
"""
event_flags = ev.EventFlags
button_state = ev.ButtonState
event_type: Optional[MouseEventType] = None
button: MouseButton = MouseButton.NONE
# Scroll events.
if event_flags & MOUSE_WHEELED:
if button_state > 0:
event_type = MouseEventType.SCROLL_UP
else:
event_type = MouseEventType.SCROLL_DOWN
else:
# Handle button state for non-scroll events.
if button_state == FROM_LEFT_1ST_BUTTON_PRESSED:
button = MouseButton.LEFT
elif button_state == RIGHTMOST_BUTTON_PRESSED:
button = MouseButton.RIGHT
# Move events.
if event_flags & MOUSE_MOVED:
event_type = MouseEventType.MOUSE_MOVE
# No key pressed anymore: mouse up.
if event_type is None:
if button_state > 0:
# Some button pressed.
event_type = MouseEventType.MOUSE_DOWN
else:
# No button pressed.
event_type = MouseEventType.MOUSE_UP
data = ";".join(
[
button.value,
event_type.value,
str(ev.MousePosition.X),
str(ev.MousePosition.Y),
]
)
return [KeyPress(Keys.WindowsMouseEvent, data)]
class _Win32Handles:
"""
Utility to keep track of which handles are connectod to which callbacks.
`add_win32_handle` starts a tiny event loop in another thread which waits
for the Win32 handle to become ready. When this happens, the callback will
be called in the current asyncio event loop using `call_soon_threadsafe`.
`remove_win32_handle` will stop this tiny event loop.
NOTE: We use this technique, so that we don't have to use the
`ProactorEventLoop` on Windows and we can wait for things like stdin
in a `SelectorEventLoop`. This is important, because our inputhook
mechanism (used by IPython), only works with the `SelectorEventLoop`.
"""
def __init__(self) -> None:
self._handle_callbacks: Dict[int, Callable[[], None]] = {}
# Windows Events that are triggered when we have to stop watching this
# handle.
self._remove_events: Dict[int, HANDLE] = {}
def add_win32_handle(self, handle: HANDLE, callback: Callable[[], None]) -> None:
"""
Add a Win32 handle to the event loop.
"""
handle_value = handle.value
if handle_value is None:
raise ValueError("Invalid handle.")
# Make sure to remove a previous registered handler first.
self.remove_win32_handle(handle)
loop = get_event_loop()
self._handle_callbacks[handle_value] = callback
# Create remove event.
remove_event = create_win32_event()
self._remove_events[handle_value] = remove_event
# Add reader.
def ready() -> None:
# Tell the callback that input's ready.
try:
callback()
finally:
run_in_executor_with_context(wait, loop=loop)
# Wait for the input to become ready.
# (Use an executor for this, the Windows asyncio event loop doesn't
# allow us to wait for handles like stdin.)
def wait() -> None:
# Wait until either the handle becomes ready, or the remove event
# has been set.
result = wait_for_handles([remove_event, handle])
if result is remove_event:
windll.kernel32.CloseHandle(remove_event)
return
else:
loop.call_soon_threadsafe(ready)
run_in_executor_with_context(wait, loop=loop)
def remove_win32_handle(self, handle: HANDLE) -> Optional[Callable[[], None]]:
"""
Remove a Win32 handle from the event loop.
Return either the registered handler or `None`.
"""
if handle.value is None:
return None # Ignore.
# Trigger remove events, so that the reader knows to stop.
try:
event = self._remove_events.pop(handle.value)
except KeyError:
pass
else:
windll.kernel32.SetEvent(event)
try:
return self._handle_callbacks.pop(handle.value)
except KeyError:
return None
@contextmanager
def attach_win32_input(
input: _Win32InputBase, callback: Callable[[], None]
) -> Iterator[None]:
"""
Context manager that makes this input active in the current event loop.
:param input: :class:`~prompt_toolkit.input.Input` object.
:param input_ready_callback: Called when the input is ready to read.
"""
win32_handles = input.win32_handles
handle = input.handle
if handle.value is None:
raise ValueError("Invalid handle.")
# Add reader.
previous_callback = win32_handles.remove_win32_handle(handle)
win32_handles.add_win32_handle(handle, callback)
try:
yield
finally:
win32_handles.remove_win32_handle(handle)
if previous_callback:
win32_handles.add_win32_handle(handle, previous_callback)
@contextmanager
def detach_win32_input(input: _Win32InputBase) -> Iterator[None]:
win32_handles = input.win32_handles
handle = input.handle
if handle.value is None:
raise ValueError("Invalid handle.")
previous_callback = win32_handles.remove_win32_handle(handle)
try:
yield
finally:
if previous_callback:
win32_handles.add_win32_handle(handle, previous_callback)
class raw_mode:
"""
::
with raw_mode(stdin):
''' the windows terminal is now in 'raw' mode. '''
The ``fileno`` attribute is ignored. This is to be compatible with the
`raw_input` method of `.vt100_input`.
"""
def __init__(self, fileno: Optional[int] = None) -> None:
self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
def __enter__(self) -> None:
# Remember original mode.
original_mode = DWORD()
windll.kernel32.GetConsoleMode(self.handle, pointer(original_mode))
self.original_mode = original_mode
self._patch()
def _patch(self) -> None:
# Set raw
ENABLE_ECHO_INPUT = 0x0004
ENABLE_LINE_INPUT = 0x0002
ENABLE_PROCESSED_INPUT = 0x0001
windll.kernel32.SetConsoleMode(
self.handle,
self.original_mode.value
& ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT),
)
def __exit__(self, *a: object) -> None:
# Restore original mode
windll.kernel32.SetConsoleMode(self.handle, self.original_mode)
class cooked_mode(raw_mode):
"""
::
with cooked_mode(stdin):
''' The pseudo-terminal stdin is now used in cooked mode. '''
"""
def _patch(self) -> None:
# Set cooked.
ENABLE_ECHO_INPUT = 0x0004
ENABLE_LINE_INPUT = 0x0002
ENABLE_PROCESSED_INPUT = 0x0001
windll.kernel32.SetConsoleMode(
self.handle,
self.original_mode.value
| (ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT),
)

View File

@@ -0,0 +1,154 @@
import sys
assert sys.platform == "win32"
from contextlib import contextmanager
from ctypes import windll
from ctypes.wintypes import HANDLE
from typing import Callable, ContextManager, Iterator, List
from prompt_toolkit.eventloop.win32 import create_win32_event
from ..key_binding import KeyPress
from ..utils import DummyContext
from .base import PipeInput
from .vt100_parser import Vt100Parser
from .win32 import _Win32InputBase, attach_win32_input, detach_win32_input
__all__ = ["Win32PipeInput"]
class Win32PipeInput(_Win32InputBase, PipeInput):
"""
This is an input pipe that works on Windows.
Text or bytes can be feed into the pipe, and key strokes can be read from
the pipe. This is useful if we want to send the input programmatically into
the application. Mostly useful for unit testing.
Notice that even though it's Windows, we use vt100 escape sequences over
the pipe.
Usage::
input = Win32PipeInput()
input.send_text('inputdata')
"""
_id = 0
def __init__(self, _event: HANDLE) -> None:
super().__init__()
# Event (handle) for registering this input in the event loop.
# This event is set when there is data available to read from the pipe.
# Note: We use this approach instead of using a regular pipe, like
# returned from `os.pipe()`, because making such a regular pipe
# non-blocking is tricky and this works really well.
self._event = create_win32_event()
self._closed = False
# Parser for incoming keys.
self._buffer: List[KeyPress] = [] # Buffer to collect the Key objects.
self.vt100_parser = Vt100Parser(lambda key: self._buffer.append(key))
# Identifier for every PipeInput for the hash.
self.__class__._id += 1
self._id = self.__class__._id
@classmethod
@contextmanager
def create(cls) -> Iterator["Win32PipeInput"]:
event = create_win32_event()
try:
yield Win32PipeInput(_event=event)
finally:
windll.kernel32.CloseHandle(event)
@property
def closed(self) -> bool:
return self._closed
def fileno(self) -> int:
"""
The windows pipe doesn't depend on the file handle.
"""
raise NotImplementedError
@property
def handle(self) -> HANDLE:
"The handle used for registering this pipe in the event loop."
return self._event
def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
"""
Return a context manager that makes this input active in the current
event loop.
"""
return attach_win32_input(self, input_ready_callback)
def detach(self) -> ContextManager[None]:
"""
Return a context manager that makes sure that this input is not active
in the current event loop.
"""
return detach_win32_input(self)
def read_keys(self) -> List[KeyPress]:
"Read list of KeyPress."
# Return result.
result = self._buffer
self._buffer = []
# Reset event.
if not self._closed:
# (If closed, the event should not reset.)
windll.kernel32.ResetEvent(self._event)
return result
def flush_keys(self) -> List[KeyPress]:
"""
Flush pending keys and return them.
(Used for flushing the 'escape' key.)
"""
# Flush all pending keys. (This is most important to flush the vt100
# 'Escape' key early when nothing else follows.)
self.vt100_parser.flush()
# Return result.
result = self._buffer
self._buffer = []
return result
def send_bytes(self, data: bytes) -> None:
"Send bytes to the input."
self.send_text(data.decode("utf-8", "ignore"))
def send_text(self, text: str) -> None:
"Send text to the input."
if self._closed:
raise ValueError("Attempt to write into a closed pipe.")
# Pass it through our vt100 parser.
self.vt100_parser.feed(text)
# Set event.
windll.kernel32.SetEvent(self._event)
def raw_mode(self) -> ContextManager[None]:
return DummyContext()
def cooked_mode(self) -> ContextManager[None]:
return DummyContext()
def close(self) -> None:
"Close write-end of the pipe."
self._closed = True
windll.kernel32.SetEvent(self._event)
def typeahead_hash(self) -> str:
"""
This needs to be unique for every `PipeInput`.
"""
return f"pipe-input-{self._id}"

View File

@@ -0,0 +1,20 @@
from .key_bindings import (
ConditionalKeyBindings,
DynamicKeyBindings,
KeyBindings,
KeyBindingsBase,
merge_key_bindings,
)
from .key_processor import KeyPress, KeyPressEvent
__all__ = [
# key_bindings.
"ConditionalKeyBindings",
"DynamicKeyBindings",
"KeyBindings",
"KeyBindingsBase",
"merge_key_bindings",
# key_processor
"KeyPress",
"KeyPressEvent",
]

View File

@@ -0,0 +1,63 @@
"""
Key bindings for auto suggestion (for fish-style auto suggestion).
"""
import re
from prompt_toolkit.application.current import get_app
from prompt_toolkit.filters import Condition, emacs_mode
from prompt_toolkit.key_binding.key_bindings import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
__all__ = [
"load_auto_suggest_bindings",
]
E = KeyPressEvent
def load_auto_suggest_bindings() -> KeyBindings:
"""
Key bindings for accepting auto suggestion text.
(This has to come after the Vi bindings, because they also have an
implementation for the "right arrow", but we really want the suggestion
binding when a suggestion is available.)
"""
key_bindings = KeyBindings()
handle = key_bindings.add
@Condition
def suggestion_available() -> bool:
app = get_app()
return (
app.current_buffer.suggestion is not None
and len(app.current_buffer.suggestion.text) > 0
and app.current_buffer.document.is_cursor_at_the_end
)
@handle("c-f", filter=suggestion_available)
@handle("c-e", filter=suggestion_available)
@handle("right", filter=suggestion_available)
def _accept(event: E) -> None:
"""
Accept suggestion.
"""
b = event.current_buffer
suggestion = b.suggestion
if suggestion:
b.insert_text(suggestion.text)
@handle("escape", "f", filter=suggestion_available & emacs_mode)
def _fill(event: E) -> None:
"""
Fill partial suggestion.
"""
b = event.current_buffer
suggestion = b.suggestion
if suggestion:
t = re.split(r"(\S+\s+)", suggestion.text)
b.insert_text(next(x for x in t if x))
return key_bindings

View File

@@ -0,0 +1,253 @@
# pylint: disable=function-redefined
from prompt_toolkit.application.current import get_app
from prompt_toolkit.filters import (
Condition,
emacs_insert_mode,
has_selection,
in_paste_mode,
is_multiline,
vi_insert_mode,
)
from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
from prompt_toolkit.keys import Keys
from ..key_bindings import KeyBindings
from .named_commands import get_by_name
__all__ = [
"load_basic_bindings",
]
E = KeyPressEvent
def if_no_repeat(event: E) -> bool:
"""Callable that returns True when the previous event was delivered to
another handler."""
return not event.is_repeat
def load_basic_bindings() -> KeyBindings:
key_bindings = KeyBindings()
insert_mode = vi_insert_mode | emacs_insert_mode
handle = key_bindings.add
@handle("c-a")
@handle("c-b")
@handle("c-c")
@handle("c-d")
@handle("c-e")
@handle("c-f")
@handle("c-g")
@handle("c-h")
@handle("c-i")
@handle("c-j")
@handle("c-k")
@handle("c-l")
@handle("c-m")
@handle("c-n")
@handle("c-o")
@handle("c-p")
@handle("c-q")
@handle("c-r")
@handle("c-s")
@handle("c-t")
@handle("c-u")
@handle("c-v")
@handle("c-w")
@handle("c-x")
@handle("c-y")
@handle("c-z")
@handle("f1")
@handle("f2")
@handle("f3")
@handle("f4")
@handle("f5")
@handle("f6")
@handle("f7")
@handle("f8")
@handle("f9")
@handle("f10")
@handle("f11")
@handle("f12")
@handle("f13")
@handle("f14")
@handle("f15")
@handle("f16")
@handle("f17")
@handle("f18")
@handle("f19")
@handle("f20")
@handle("f21")
@handle("f22")
@handle("f23")
@handle("f24")
@handle("c-@") # Also c-space.
@handle("c-\\")
@handle("c-]")
@handle("c-^")
@handle("c-_")
@handle("backspace")
@handle("up")
@handle("down")
@handle("right")
@handle("left")
@handle("s-up")
@handle("s-down")
@handle("s-right")
@handle("s-left")
@handle("home")
@handle("end")
@handle("s-home")
@handle("s-end")
@handle("delete")
@handle("s-delete")
@handle("c-delete")
@handle("pageup")
@handle("pagedown")
@handle("s-tab")
@handle("tab")
@handle("c-s-left")
@handle("c-s-right")
@handle("c-s-home")
@handle("c-s-end")
@handle("c-left")
@handle("c-right")
@handle("c-up")
@handle("c-down")
@handle("c-home")
@handle("c-end")
@handle("insert")
@handle("s-insert")
@handle("c-insert")
@handle("<sigint>")
@handle(Keys.Ignore)
def _ignore(event: E) -> None:
"""
First, for any of these keys, Don't do anything by default. Also don't
catch them in the 'Any' handler which will insert them as data.
If people want to insert these characters as a literal, they can always
do by doing a quoted insert. (ControlQ in emacs mode, ControlV in Vi
mode.)
"""
pass
# Readline-style bindings.
handle("home")(get_by_name("beginning-of-line"))
handle("end")(get_by_name("end-of-line"))
handle("left")(get_by_name("backward-char"))
handle("right")(get_by_name("forward-char"))
handle("c-up")(get_by_name("previous-history"))
handle("c-down")(get_by_name("next-history"))
handle("c-l")(get_by_name("clear-screen"))
handle("c-k", filter=insert_mode)(get_by_name("kill-line"))
handle("c-u", filter=insert_mode)(get_by_name("unix-line-discard"))
handle("backspace", filter=insert_mode, save_before=if_no_repeat)(
get_by_name("backward-delete-char")
)
handle("delete", filter=insert_mode, save_before=if_no_repeat)(
get_by_name("delete-char")
)
handle("c-delete", filter=insert_mode, save_before=if_no_repeat)(
get_by_name("delete-char")
)
handle(Keys.Any, filter=insert_mode, save_before=if_no_repeat)(
get_by_name("self-insert")
)
handle("c-t", filter=insert_mode)(get_by_name("transpose-chars"))
handle("c-i", filter=insert_mode)(get_by_name("menu-complete"))
handle("s-tab", filter=insert_mode)(get_by_name("menu-complete-backward"))
# Control-W should delete, using whitespace as separator, while M-Del
# should delete using [^a-zA-Z0-9] as a boundary.
handle("c-w", filter=insert_mode)(get_by_name("unix-word-rubout"))
handle("pageup", filter=~has_selection)(get_by_name("previous-history"))
handle("pagedown", filter=~has_selection)(get_by_name("next-history"))
# CTRL keys.
@Condition
def has_text_before_cursor() -> bool:
return bool(get_app().current_buffer.text)
handle("c-d", filter=has_text_before_cursor & insert_mode)(
get_by_name("delete-char")
)
@handle("enter", filter=insert_mode & is_multiline)
def _newline(event: E) -> None:
"""
Newline (in case of multiline input.
"""
event.current_buffer.newline(copy_margin=not in_paste_mode())
@handle("c-j")
def _newline2(event: E) -> None:
r"""
By default, handle \n as if it were a \r (enter).
(It appears that some terminals send \n instead of \r when pressing
enter. - at least the Linux subsystem for Windows.)
"""
event.key_processor.feed(KeyPress(Keys.ControlM, "\r"), first=True)
# Delete the word before the cursor.
@handle("up")
def _go_up(event: E) -> None:
event.current_buffer.auto_up(count=event.arg)
@handle("down")
def _go_down(event: E) -> None:
event.current_buffer.auto_down(count=event.arg)
@handle("delete", filter=has_selection)
def _cut(event: E) -> None:
data = event.current_buffer.cut_selection()
event.app.clipboard.set_data(data)
# Global bindings.
@handle("c-z")
def _insert_ctrl_z(event: E) -> None:
"""
By default, control-Z should literally insert Ctrl-Z.
(Ansi Ctrl-Z, code 26 in MSDOS means End-Of-File.
In a Python REPL for instance, it's possible to type
Control-Z followed by enter to quit.)
When the system bindings are loaded and suspend-to-background is
supported, that will override this binding.
"""
event.current_buffer.insert_text(event.data)
@handle(Keys.BracketedPaste)
def _paste(event: E) -> None:
"""
Pasting from clipboard.
"""
data = event.data
# Be sure to use \n as line ending.
# Some terminals (Like iTerm2) seem to paste \r\n line endings in a
# bracketed paste. See: https://github.com/ipython/ipython/issues/9737
data = data.replace("\r\n", "\n")
data = data.replace("\r", "\n")
event.current_buffer.insert_text(data)
@Condition
def in_quoted_insert() -> bool:
return get_app().quoted_insert
@handle(Keys.Any, filter=in_quoted_insert, eager=True)
def _insert_text(event: E) -> None:
"""
Handle quoted insert.
"""
event.current_buffer.insert_text(event.data, overwrite=False)
event.app.quoted_insert = False
return key_bindings

View File

@@ -0,0 +1,203 @@
"""
Key binding handlers for displaying completions.
"""
import asyncio
import math
from typing import TYPE_CHECKING, List
from prompt_toolkit.application.run_in_terminal import in_terminal
from prompt_toolkit.completion import (
CompleteEvent,
Completion,
get_common_complete_suffix,
)
from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.key_binding.key_bindings import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.keys import Keys
from prompt_toolkit.utils import get_cwidth
if TYPE_CHECKING:
from prompt_toolkit.application import Application
from prompt_toolkit.shortcuts import PromptSession
__all__ = [
"generate_completions",
"display_completions_like_readline",
]
E = KeyPressEvent
def generate_completions(event: E) -> None:
r"""
Tab-completion: where the first tab completes the common suffix and the
second tab lists all the completions.
"""
b = event.current_buffer
# When already navigating through completions, select the next one.
if b.complete_state:
b.complete_next()
else:
b.start_completion(insert_common_part=True)
def display_completions_like_readline(event: E) -> None:
"""
Key binding handler for readline-style tab completion.
This is meant to be as similar as possible to the way how readline displays
completions.
Generate the completions immediately (blocking) and display them above the
prompt in columns.
Usage::
# Call this handler when 'Tab' has been pressed.
key_bindings.add(Keys.ControlI)(display_completions_like_readline)
"""
# Request completions.
b = event.current_buffer
if b.completer is None:
return
complete_event = CompleteEvent(completion_requested=True)
completions = list(b.completer.get_completions(b.document, complete_event))
# Calculate the common suffix.
common_suffix = get_common_complete_suffix(b.document, completions)
# One completion: insert it.
if len(completions) == 1:
b.delete_before_cursor(-completions[0].start_position)
b.insert_text(completions[0].text)
# Multiple completions with common part.
elif common_suffix:
b.insert_text(common_suffix)
# Otherwise: display all completions.
elif completions:
_display_completions_like_readline(event.app, completions)
def _display_completions_like_readline(
app: "Application[object]", completions: List[Completion]
) -> "asyncio.Task[None]":
"""
Display the list of completions in columns above the prompt.
This will ask for a confirmation if there are too many completions to fit
on a single page and provide a paginator to walk through them.
"""
from prompt_toolkit.formatted_text import to_formatted_text
from prompt_toolkit.shortcuts.prompt import create_confirm_session
# Get terminal dimensions.
term_size = app.output.get_size()
term_width = term_size.columns
term_height = term_size.rows
# Calculate amount of required columns/rows for displaying the
# completions. (Keep in mind that completions are displayed
# alphabetically column-wise.)
max_compl_width = min(
term_width, max(get_cwidth(c.display_text) for c in completions) + 1
)
column_count = max(1, term_width // max_compl_width)
completions_per_page = column_count * (term_height - 1)
page_count = int(math.ceil(len(completions) / float(completions_per_page)))
# Note: math.ceil can return float on Python2.
def display(page: int) -> None:
# Display completions.
page_completions = completions[
page * completions_per_page : (page + 1) * completions_per_page
]
page_row_count = int(math.ceil(len(page_completions) / float(column_count)))
page_columns = [
page_completions[i * page_row_count : (i + 1) * page_row_count]
for i in range(column_count)
]
result: StyleAndTextTuples = []
for r in range(page_row_count):
for c in range(column_count):
try:
completion = page_columns[c][r]
style = "class:readline-like-completions.completion " + (
completion.style or ""
)
result.extend(to_formatted_text(completion.display, style=style))
# Add padding.
padding = max_compl_width - get_cwidth(completion.display_text)
result.append((completion.style, " " * padding))
except IndexError:
pass
result.append(("", "\n"))
app.print_text(to_formatted_text(result, "class:readline-like-completions"))
# User interaction through an application generator function.
async def run_compl() -> None:
"Coroutine."
async with in_terminal(render_cli_done=True):
if len(completions) > completions_per_page:
# Ask confirmation if it doesn't fit on the screen.
confirm = await create_confirm_session(
f"Display all {len(completions)} possibilities?",
).prompt_async()
if confirm:
# Display pages.
for page in range(page_count):
display(page)
if page != page_count - 1:
# Display --MORE-- and go to the next page.
show_more = await _create_more_session(
"--MORE--"
).prompt_async()
if not show_more:
return
else:
app.output.flush()
else:
# Display all completions.
display(0)
return app.create_background_task(run_compl())
def _create_more_session(message: str = "--MORE--") -> "PromptSession[bool]":
"""
Create a `PromptSession` object for displaying the "--MORE--".
"""
from prompt_toolkit.shortcuts import PromptSession
bindings = KeyBindings()
@bindings.add(" ")
@bindings.add("y")
@bindings.add("Y")
@bindings.add(Keys.ControlJ)
@bindings.add(Keys.ControlM)
@bindings.add(Keys.ControlI) # Tab.
def _yes(event: E) -> None:
event.app.exit(result=True)
@bindings.add("n")
@bindings.add("N")
@bindings.add("q")
@bindings.add("Q")
@bindings.add(Keys.ControlC)
def _no(event: E) -> None:
event.app.exit(result=False)
@bindings.add(Keys.Any)
def _ignore(event: E) -> None:
"Disable inserting of text."
return PromptSession(message, key_bindings=bindings, erase_when_done=True)

View File

@@ -0,0 +1,28 @@
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.keys import Keys
from ..key_bindings import KeyBindings
__all__ = [
"load_cpr_bindings",
]
E = KeyPressEvent
def load_cpr_bindings() -> KeyBindings:
key_bindings = KeyBindings()
@key_bindings.add(Keys.CPRResponse, save_before=lambda e: False)
def _(event: E) -> None:
"""
Handle incoming Cursor-Position-Request response.
"""
# The incoming data looks like u'\x1b[35;1R'
# Parse row/col information.
row, col = map(int, event.data[2:-1].split(";"))
# Report absolute cursor position to the renderer.
event.app.renderer.report_absolute_cursor_row(row)
return key_bindings

View File

@@ -0,0 +1,557 @@
# pylint: disable=function-redefined
from typing import Dict, Union
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import Buffer, indent, unindent
from prompt_toolkit.completion import CompleteEvent
from prompt_toolkit.filters import (
Condition,
emacs_insert_mode,
emacs_mode,
has_arg,
has_selection,
in_paste_mode,
is_multiline,
is_read_only,
shift_selection_mode,
vi_search_direction_reversed,
)
from prompt_toolkit.key_binding.key_bindings import Binding
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.keys import Keys
from prompt_toolkit.selection import SelectionType
from ..key_bindings import ConditionalKeyBindings, KeyBindings, KeyBindingsBase
from .named_commands import get_by_name
__all__ = [
"load_emacs_bindings",
"load_emacs_search_bindings",
"load_emacs_shift_selection_bindings",
]
E = KeyPressEvent
def load_emacs_bindings() -> KeyBindingsBase:
"""
Some e-macs extensions.
"""
# Overview of Readline emacs commands:
# http://www.catonmat.net/download/readline-emacs-editing-mode-cheat-sheet.pdf
key_bindings = KeyBindings()
handle = key_bindings.add
insert_mode = emacs_insert_mode
@handle("escape")
def _esc(event: E) -> None:
"""
By default, ignore escape key.
(If we don't put this here, and Esc is followed by a key which sequence
is not handled, we'll insert an Escape character in the input stream.
Something we don't want and happens to easily in emacs mode.
Further, people can always use ControlQ to do a quoted insert.)
"""
pass
handle("c-a")(get_by_name("beginning-of-line"))
handle("c-b")(get_by_name("backward-char"))
handle("c-delete", filter=insert_mode)(get_by_name("kill-word"))
handle("c-e")(get_by_name("end-of-line"))
handle("c-f")(get_by_name("forward-char"))
handle("c-left")(get_by_name("backward-word"))
handle("c-right")(get_by_name("forward-word"))
handle("c-x", "r", "y", filter=insert_mode)(get_by_name("yank"))
handle("c-y", filter=insert_mode)(get_by_name("yank"))
handle("escape", "b")(get_by_name("backward-word"))
handle("escape", "c", filter=insert_mode)(get_by_name("capitalize-word"))
handle("escape", "d", filter=insert_mode)(get_by_name("kill-word"))
handle("escape", "f")(get_by_name("forward-word"))
handle("escape", "l", filter=insert_mode)(get_by_name("downcase-word"))
handle("escape", "u", filter=insert_mode)(get_by_name("uppercase-word"))
handle("escape", "y", filter=insert_mode)(get_by_name("yank-pop"))
handle("escape", "backspace", filter=insert_mode)(get_by_name("backward-kill-word"))
handle("escape", "\\", filter=insert_mode)(get_by_name("delete-horizontal-space"))
handle("c-home")(get_by_name("beginning-of-buffer"))
handle("c-end")(get_by_name("end-of-buffer"))
handle("c-_", save_before=(lambda e: False), filter=insert_mode)(
get_by_name("undo")
)
handle("c-x", "c-u", save_before=(lambda e: False), filter=insert_mode)(
get_by_name("undo")
)
handle("escape", "<", filter=~has_selection)(get_by_name("beginning-of-history"))
handle("escape", ">", filter=~has_selection)(get_by_name("end-of-history"))
handle("escape", ".", filter=insert_mode)(get_by_name("yank-last-arg"))
handle("escape", "_", filter=insert_mode)(get_by_name("yank-last-arg"))
handle("escape", "c-y", filter=insert_mode)(get_by_name("yank-nth-arg"))
handle("escape", "#", filter=insert_mode)(get_by_name("insert-comment"))
handle("c-o")(get_by_name("operate-and-get-next"))
# ControlQ does a quoted insert. Not that for vt100 terminals, you have to
# disable flow control by running ``stty -ixon``, otherwise Ctrl-Q and
# Ctrl-S are captured by the terminal.
handle("c-q", filter=~has_selection)(get_by_name("quoted-insert"))
handle("c-x", "(")(get_by_name("start-kbd-macro"))
handle("c-x", ")")(get_by_name("end-kbd-macro"))
handle("c-x", "e")(get_by_name("call-last-kbd-macro"))
@handle("c-n")
def _next(event: E) -> None:
"Next line."
event.current_buffer.auto_down()
@handle("c-p")
def _prev(event: E) -> None:
"Previous line."
event.current_buffer.auto_up(count=event.arg)
def handle_digit(c: str) -> None:
"""
Handle input of arguments.
The first number needs to be preceded by escape.
"""
@handle(c, filter=has_arg)
@handle("escape", c)
def _(event: E) -> None:
event.append_to_arg_count(c)
for c in "0123456789":
handle_digit(c)
@handle("escape", "-", filter=~has_arg)
def _meta_dash(event: E) -> None:
""""""
if event._arg is None:
event.append_to_arg_count("-")
@handle("-", filter=Condition(lambda: get_app().key_processor.arg == "-"))
def _dash(event: E) -> None:
"""
When '-' is typed again, after exactly '-' has been given as an
argument, ignore this.
"""
event.app.key_processor.arg = "-"
@Condition
def is_returnable() -> bool:
return get_app().current_buffer.is_returnable
# Meta + Enter: always accept input.
handle("escape", "enter", filter=insert_mode & is_returnable)(
get_by_name("accept-line")
)
# Enter: accept input in single line mode.
handle("enter", filter=insert_mode & is_returnable & ~is_multiline)(
get_by_name("accept-line")
)
def character_search(buff: Buffer, char: str, count: int) -> None:
if count < 0:
match = buff.document.find_backwards(
char, in_current_line=True, count=-count
)
else:
match = buff.document.find(char, in_current_line=True, count=count)
if match is not None:
buff.cursor_position += match
@handle("c-]", Keys.Any)
def _goto_char(event: E) -> None:
"When Ctl-] + a character is pressed. go to that character."
# Also named 'character-search'
character_search(event.current_buffer, event.data, event.arg)
@handle("escape", "c-]", Keys.Any)
def _goto_char_backwards(event: E) -> None:
"Like Ctl-], but backwards."
# Also named 'character-search-backward'
character_search(event.current_buffer, event.data, -event.arg)
@handle("escape", "a")
def _prev_sentence(event: E) -> None:
"Previous sentence."
# TODO:
@handle("escape", "e")
def _end_of_sentence(event: E) -> None:
"Move to end of sentence."
# TODO:
@handle("escape", "t", filter=insert_mode)
def _swap_characters(event: E) -> None:
"""
Swap the last two words before the cursor.
"""
# TODO
@handle("escape", "*", filter=insert_mode)
def _insert_all_completions(event: E) -> None:
"""
`meta-*`: Insert all possible completions of the preceding text.
"""
buff = event.current_buffer
# List all completions.
complete_event = CompleteEvent(text_inserted=False, completion_requested=True)
completions = list(
buff.completer.get_completions(buff.document, complete_event)
)
# Insert them.
text_to_insert = " ".join(c.text for c in completions)
buff.insert_text(text_to_insert)
@handle("c-x", "c-x")
def _toggle_start_end(event: E) -> None:
"""
Move cursor back and forth between the start and end of the current
line.
"""
buffer = event.current_buffer
if buffer.document.is_cursor_at_the_end_of_line:
buffer.cursor_position += buffer.document.get_start_of_line_position(
after_whitespace=False
)
else:
buffer.cursor_position += buffer.document.get_end_of_line_position()
@handle("c-@") # Control-space or Control-@
def _start_selection(event: E) -> None:
"""
Start of the selection (if the current buffer is not empty).
"""
# Take the current cursor position as the start of this selection.
buff = event.current_buffer
if buff.text:
buff.start_selection(selection_type=SelectionType.CHARACTERS)
@handle("c-g", filter=~has_selection)
def _cancel(event: E) -> None:
"""
Control + G: Cancel completion menu and validation state.
"""
event.current_buffer.complete_state = None
event.current_buffer.validation_error = None
@handle("c-g", filter=has_selection)
def _cancel_selection(event: E) -> None:
"""
Cancel selection.
"""
event.current_buffer.exit_selection()
@handle("c-w", filter=has_selection)
@handle("c-x", "r", "k", filter=has_selection)
def _cut(event: E) -> None:
"""
Cut selected text.
"""
data = event.current_buffer.cut_selection()
event.app.clipboard.set_data(data)
@handle("escape", "w", filter=has_selection)
def _copy(event: E) -> None:
"""
Copy selected text.
"""
data = event.current_buffer.copy_selection()
event.app.clipboard.set_data(data)
@handle("escape", "left")
def _start_of_word(event: E) -> None:
"""
Cursor to start of previous word.
"""
buffer = event.current_buffer
buffer.cursor_position += (
buffer.document.find_previous_word_beginning(count=event.arg) or 0
)
@handle("escape", "right")
def _start_next_word(event: E) -> None:
"""
Cursor to start of next word.
"""
buffer = event.current_buffer
buffer.cursor_position += (
buffer.document.find_next_word_beginning(count=event.arg)
or buffer.document.get_end_of_document_position()
)
@handle("escape", "/", filter=insert_mode)
def _complete(event: E) -> None:
"""
M-/: Complete.
"""
b = event.current_buffer
if b.complete_state:
b.complete_next()
else:
b.start_completion(select_first=True)
@handle("c-c", ">", filter=has_selection)
def _indent(event: E) -> None:
"""
Indent selected text.
"""
buffer = event.current_buffer
buffer.cursor_position += buffer.document.get_start_of_line_position(
after_whitespace=True
)
from_, to = buffer.document.selection_range()
from_, _ = buffer.document.translate_index_to_position(from_)
to, _ = buffer.document.translate_index_to_position(to)
indent(buffer, from_, to + 1, count=event.arg)
@handle("c-c", "<", filter=has_selection)
def _unindent(event: E) -> None:
"""
Unindent selected text.
"""
buffer = event.current_buffer
from_, to = buffer.document.selection_range()
from_, _ = buffer.document.translate_index_to_position(from_)
to, _ = buffer.document.translate_index_to_position(to)
unindent(buffer, from_, to + 1, count=event.arg)
return ConditionalKeyBindings(key_bindings, emacs_mode)
def load_emacs_search_bindings() -> KeyBindingsBase:
key_bindings = KeyBindings()
handle = key_bindings.add
from . import search
# NOTE: We don't bind 'Escape' to 'abort_search'. The reason is that we
# want Alt+Enter to accept input directly in incremental search mode.
# Instead, we have double escape.
handle("c-r")(search.start_reverse_incremental_search)
handle("c-s")(search.start_forward_incremental_search)
handle("c-c")(search.abort_search)
handle("c-g")(search.abort_search)
handle("c-r")(search.reverse_incremental_search)
handle("c-s")(search.forward_incremental_search)
handle("up")(search.reverse_incremental_search)
handle("down")(search.forward_incremental_search)
handle("enter")(search.accept_search)
# Handling of escape.
handle("escape", eager=True)(search.accept_search)
# Like Readline, it's more natural to accept the search when escape has
# been pressed, however instead the following two bindings could be used
# instead.
# #handle('escape', 'escape', eager=True)(search.abort_search)
# #handle('escape', 'enter', eager=True)(search.accept_search_and_accept_input)
# If Read-only: also include the following key bindings:
# '/' and '?' key bindings for searching, just like Vi mode.
handle("?", filter=is_read_only & ~vi_search_direction_reversed)(
search.start_reverse_incremental_search
)
handle("/", filter=is_read_only & ~vi_search_direction_reversed)(
search.start_forward_incremental_search
)
handle("?", filter=is_read_only & vi_search_direction_reversed)(
search.start_forward_incremental_search
)
handle("/", filter=is_read_only & vi_search_direction_reversed)(
search.start_reverse_incremental_search
)
@handle("n", filter=is_read_only)
def _jump_next(event: E) -> None:
"Jump to next match."
event.current_buffer.apply_search(
event.app.current_search_state,
include_current_position=False,
count=event.arg,
)
@handle("N", filter=is_read_only)
def _jump_prev(event: E) -> None:
"Jump to previous match."
event.current_buffer.apply_search(
~event.app.current_search_state,
include_current_position=False,
count=event.arg,
)
return ConditionalKeyBindings(key_bindings, emacs_mode)
def load_emacs_shift_selection_bindings() -> KeyBindingsBase:
"""
Bindings to select text with shift + cursor movements
"""
key_bindings = KeyBindings()
handle = key_bindings.add
def unshift_move(event: E) -> None:
"""
Used for the shift selection mode. When called with
a shift + movement key press event, moves the cursor
as if shift is not pressed.
"""
key = event.key_sequence[0].key
if key == Keys.ShiftUp:
event.current_buffer.auto_up(count=event.arg)
return
if key == Keys.ShiftDown:
event.current_buffer.auto_down(count=event.arg)
return
# the other keys are handled through their readline command
key_to_command: Dict[Union[Keys, str], str] = {
Keys.ShiftLeft: "backward-char",
Keys.ShiftRight: "forward-char",
Keys.ShiftHome: "beginning-of-line",
Keys.ShiftEnd: "end-of-line",
Keys.ControlShiftLeft: "backward-word",
Keys.ControlShiftRight: "forward-word",
Keys.ControlShiftHome: "beginning-of-buffer",
Keys.ControlShiftEnd: "end-of-buffer",
}
try:
# Both the dict lookup and `get_by_name` can raise KeyError.
binding = get_by_name(key_to_command[key])
except KeyError:
pass
else: # (`else` is not really needed here.)
if isinstance(binding, Binding):
# (It should always be a binding here)
binding.call(event)
@handle("s-left", filter=~has_selection)
@handle("s-right", filter=~has_selection)
@handle("s-up", filter=~has_selection)
@handle("s-down", filter=~has_selection)
@handle("s-home", filter=~has_selection)
@handle("s-end", filter=~has_selection)
@handle("c-s-left", filter=~has_selection)
@handle("c-s-right", filter=~has_selection)
@handle("c-s-home", filter=~has_selection)
@handle("c-s-end", filter=~has_selection)
def _start_selection(event: E) -> None:
"""
Start selection with shift + movement.
"""
# Take the current cursor position as the start of this selection.
buff = event.current_buffer
if buff.text:
buff.start_selection(selection_type=SelectionType.CHARACTERS)
if buff.selection_state is not None:
# (`selection_state` should never be `None`, it is created by
# `start_selection`.)
buff.selection_state.enter_shift_mode()
# Then move the cursor
original_position = buff.cursor_position
unshift_move(event)
if buff.cursor_position == original_position:
# Cursor didn't actually move - so cancel selection
# to avoid having an empty selection
buff.exit_selection()
@handle("s-left", filter=shift_selection_mode)
@handle("s-right", filter=shift_selection_mode)
@handle("s-up", filter=shift_selection_mode)
@handle("s-down", filter=shift_selection_mode)
@handle("s-home", filter=shift_selection_mode)
@handle("s-end", filter=shift_selection_mode)
@handle("c-s-left", filter=shift_selection_mode)
@handle("c-s-right", filter=shift_selection_mode)
@handle("c-s-home", filter=shift_selection_mode)
@handle("c-s-end", filter=shift_selection_mode)
def _extend_selection(event: E) -> None:
"""
Extend the selection
"""
# Just move the cursor, like shift was not pressed
unshift_move(event)
buff = event.current_buffer
if buff.selection_state is not None:
if buff.cursor_position == buff.selection_state.original_cursor_position:
# selection is now empty, so cancel selection
buff.exit_selection()
@handle(Keys.Any, filter=shift_selection_mode)
def _replace_selection(event: E) -> None:
"""
Replace selection by what is typed
"""
event.current_buffer.cut_selection()
get_by_name("self-insert").call(event)
@handle("enter", filter=shift_selection_mode & is_multiline)
def _newline(event: E) -> None:
"""
A newline replaces the selection
"""
event.current_buffer.cut_selection()
event.current_buffer.newline(copy_margin=not in_paste_mode())
@handle("backspace", filter=shift_selection_mode)
def _delete(event: E) -> None:
"""
Delete selection.
"""
event.current_buffer.cut_selection()
@handle("c-y", filter=shift_selection_mode)
def _yank(event: E) -> None:
"""
In shift selection mode, yanking (pasting) replace the selection.
"""
buff = event.current_buffer
if buff.selection_state:
buff.cut_selection()
get_by_name("yank").call(event)
# moving the cursor in shift selection mode cancels the selection
@handle("left", filter=shift_selection_mode)
@handle("right", filter=shift_selection_mode)
@handle("up", filter=shift_selection_mode)
@handle("down", filter=shift_selection_mode)
@handle("home", filter=shift_selection_mode)
@handle("end", filter=shift_selection_mode)
@handle("c-left", filter=shift_selection_mode)
@handle("c-right", filter=shift_selection_mode)
@handle("c-home", filter=shift_selection_mode)
@handle("c-end", filter=shift_selection_mode)
def _cancel(event: E) -> None:
"""
Cancel selection.
"""
event.current_buffer.exit_selection()
# we then process the cursor movement
key_press = event.key_sequence[0]
event.key_processor.feed(key_press, first=True)
return ConditionalKeyBindings(key_bindings, emacs_mode)

View File

@@ -0,0 +1,24 @@
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
__all__ = [
"focus_next",
"focus_previous",
]
E = KeyPressEvent
def focus_next(event: E) -> None:
"""
Focus the next visible Window.
(Often bound to the `Tab` key.)
"""
event.app.layout.focus_next()
def focus_previous(event: E) -> None:
"""
Focus the previous visible Window.
(Often bound to the `BackTab` key.)
"""
event.app.layout.focus_previous()

View File

@@ -0,0 +1,347 @@
import sys
from typing import TYPE_CHECKING, FrozenSet
from prompt_toolkit.data_structures import Point
from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
from prompt_toolkit.keys import Keys
from prompt_toolkit.mouse_events import (
MouseButton,
MouseEvent,
MouseEventType,
MouseModifier,
)
from ..key_bindings import KeyBindings
if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
__all__ = [
"load_mouse_bindings",
]
E = KeyPressEvent
# fmt: off
# flake8: noqa E201
SCROLL_UP = MouseEventType.SCROLL_UP
SCROLL_DOWN = MouseEventType.SCROLL_DOWN
MOUSE_DOWN = MouseEventType.MOUSE_DOWN
MOUSE_MOVE = MouseEventType.MOUSE_MOVE
MOUSE_UP = MouseEventType.MOUSE_UP
NO_MODIFIER : FrozenSet[MouseModifier] = frozenset()
SHIFT : FrozenSet[MouseModifier] = frozenset({MouseModifier.SHIFT})
ALT : FrozenSet[MouseModifier] = frozenset({MouseModifier.ALT})
SHIFT_ALT : FrozenSet[MouseModifier] = frozenset({MouseModifier.SHIFT, MouseModifier.ALT})
CONTROL : FrozenSet[MouseModifier] = frozenset({MouseModifier.CONTROL})
SHIFT_CONTROL : FrozenSet[MouseModifier] = frozenset({MouseModifier.SHIFT, MouseModifier.CONTROL})
ALT_CONTROL : FrozenSet[MouseModifier] = frozenset({MouseModifier.ALT, MouseModifier.CONTROL})
SHIFT_ALT_CONTROL: FrozenSet[MouseModifier] = frozenset({MouseModifier.SHIFT, MouseModifier.ALT, MouseModifier.CONTROL})
UNKNOWN_MODIFIER : FrozenSet[MouseModifier] = frozenset()
LEFT = MouseButton.LEFT
MIDDLE = MouseButton.MIDDLE
RIGHT = MouseButton.RIGHT
NO_BUTTON = MouseButton.NONE
UNKNOWN_BUTTON = MouseButton.UNKNOWN
xterm_sgr_mouse_events = {
( 0, 'm') : (LEFT, MOUSE_UP, NO_MODIFIER), # left_up 0+ + + =0
( 4, 'm') : (LEFT, MOUSE_UP, SHIFT), # left_up Shift 0+4+ + =4
( 8, 'm') : (LEFT, MOUSE_UP, ALT), # left_up Alt 0+ +8+ =8
(12, 'm') : (LEFT, MOUSE_UP, SHIFT_ALT), # left_up Shift Alt 0+4+8+ =12
(16, 'm') : (LEFT, MOUSE_UP, CONTROL), # left_up Control 0+ + +16=16
(20, 'm') : (LEFT, MOUSE_UP, SHIFT_CONTROL), # left_up Shift Control 0+4+ +16=20
(24, 'm') : (LEFT, MOUSE_UP, ALT_CONTROL), # left_up Alt Control 0+ +8+16=24
(28, 'm') : (LEFT, MOUSE_UP, SHIFT_ALT_CONTROL), # left_up Shift Alt Control 0+4+8+16=28
( 1, 'm') : (MIDDLE, MOUSE_UP, NO_MODIFIER), # middle_up 1+ + + =1
( 5, 'm') : (MIDDLE, MOUSE_UP, SHIFT), # middle_up Shift 1+4+ + =5
( 9, 'm') : (MIDDLE, MOUSE_UP, ALT), # middle_up Alt 1+ +8+ =9
(13, 'm') : (MIDDLE, MOUSE_UP, SHIFT_ALT), # middle_up Shift Alt 1+4+8+ =13
(17, 'm') : (MIDDLE, MOUSE_UP, CONTROL), # middle_up Control 1+ + +16=17
(21, 'm') : (MIDDLE, MOUSE_UP, SHIFT_CONTROL), # middle_up Shift Control 1+4+ +16=21
(25, 'm') : (MIDDLE, MOUSE_UP, ALT_CONTROL), # middle_up Alt Control 1+ +8+16=25
(29, 'm') : (MIDDLE, MOUSE_UP, SHIFT_ALT_CONTROL), # middle_up Shift Alt Control 1+4+8+16=29
( 2, 'm') : (RIGHT, MOUSE_UP, NO_MODIFIER), # right_up 2+ + + =2
( 6, 'm') : (RIGHT, MOUSE_UP, SHIFT), # right_up Shift 2+4+ + =6
(10, 'm') : (RIGHT, MOUSE_UP, ALT), # right_up Alt 2+ +8+ =10
(14, 'm') : (RIGHT, MOUSE_UP, SHIFT_ALT), # right_up Shift Alt 2+4+8+ =14
(18, 'm') : (RIGHT, MOUSE_UP, CONTROL), # right_up Control 2+ + +16=18
(22, 'm') : (RIGHT, MOUSE_UP, SHIFT_CONTROL), # right_up Shift Control 2+4+ +16=22
(26, 'm') : (RIGHT, MOUSE_UP, ALT_CONTROL), # right_up Alt Control 2+ +8+16=26
(30, 'm') : (RIGHT, MOUSE_UP, SHIFT_ALT_CONTROL), # right_up Shift Alt Control 2+4+8+16=30
( 0, 'M') : (LEFT, MOUSE_DOWN, NO_MODIFIER), # left_down 0+ + + =0
( 4, 'M') : (LEFT, MOUSE_DOWN, SHIFT), # left_down Shift 0+4+ + =4
( 8, 'M') : (LEFT, MOUSE_DOWN, ALT), # left_down Alt 0+ +8+ =8
(12, 'M') : (LEFT, MOUSE_DOWN, SHIFT_ALT), # left_down Shift Alt 0+4+8+ =12
(16, 'M') : (LEFT, MOUSE_DOWN, CONTROL), # left_down Control 0+ + +16=16
(20, 'M') : (LEFT, MOUSE_DOWN, SHIFT_CONTROL), # left_down Shift Control 0+4+ +16=20
(24, 'M') : (LEFT, MOUSE_DOWN, ALT_CONTROL), # left_down Alt Control 0+ +8+16=24
(28, 'M') : (LEFT, MOUSE_DOWN, SHIFT_ALT_CONTROL), # left_down Shift Alt Control 0+4+8+16=28
( 1, 'M') : (MIDDLE, MOUSE_DOWN, NO_MODIFIER), # middle_down 1+ + + =1
( 5, 'M') : (MIDDLE, MOUSE_DOWN, SHIFT), # middle_down Shift 1+4+ + =5
( 9, 'M') : (MIDDLE, MOUSE_DOWN, ALT), # middle_down Alt 1+ +8+ =9
(13, 'M') : (MIDDLE, MOUSE_DOWN, SHIFT_ALT), # middle_down Shift Alt 1+4+8+ =13
(17, 'M') : (MIDDLE, MOUSE_DOWN, CONTROL), # middle_down Control 1+ + +16=17
(21, 'M') : (MIDDLE, MOUSE_DOWN, SHIFT_CONTROL), # middle_down Shift Control 1+4+ +16=21
(25, 'M') : (MIDDLE, MOUSE_DOWN, ALT_CONTROL), # middle_down Alt Control 1+ +8+16=25
(29, 'M') : (MIDDLE, MOUSE_DOWN, SHIFT_ALT_CONTROL), # middle_down Shift Alt Control 1+4+8+16=29
( 2, 'M') : (RIGHT, MOUSE_DOWN, NO_MODIFIER), # right_down 2+ + + =2
( 6, 'M') : (RIGHT, MOUSE_DOWN, SHIFT), # right_down Shift 2+4+ + =6
(10, 'M') : (RIGHT, MOUSE_DOWN, ALT), # right_down Alt 2+ +8+ =10
(14, 'M') : (RIGHT, MOUSE_DOWN, SHIFT_ALT), # right_down Shift Alt 2+4+8+ =14
(18, 'M') : (RIGHT, MOUSE_DOWN, CONTROL), # right_down Control 2+ + +16=18
(22, 'M') : (RIGHT, MOUSE_DOWN, SHIFT_CONTROL), # right_down Shift Control 2+4+ +16=22
(26, 'M') : (RIGHT, MOUSE_DOWN, ALT_CONTROL), # right_down Alt Control 2+ +8+16=26
(30, 'M') : (RIGHT, MOUSE_DOWN, SHIFT_ALT_CONTROL), # right_down Shift Alt Control 2+4+8+16=30
(32, 'M') : (LEFT, MOUSE_MOVE, NO_MODIFIER), # left_drag 32+ + + =32
(36, 'M') : (LEFT, MOUSE_MOVE, SHIFT), # left_drag Shift 32+4+ + =36
(40, 'M') : (LEFT, MOUSE_MOVE, ALT), # left_drag Alt 32+ +8+ =40
(44, 'M') : (LEFT, MOUSE_MOVE, SHIFT_ALT), # left_drag Shift Alt 32+4+8+ =44
(48, 'M') : (LEFT, MOUSE_MOVE, CONTROL), # left_drag Control 32+ + +16=48
(52, 'M') : (LEFT, MOUSE_MOVE, SHIFT_CONTROL), # left_drag Shift Control 32+4+ +16=52
(56, 'M') : (LEFT, MOUSE_MOVE, ALT_CONTROL), # left_drag Alt Control 32+ +8+16=56
(60, 'M') : (LEFT, MOUSE_MOVE, SHIFT_ALT_CONTROL), # left_drag Shift Alt Control 32+4+8+16=60
(33, 'M') : (MIDDLE, MOUSE_MOVE, NO_MODIFIER), # middle_drag 33+ + + =33
(37, 'M') : (MIDDLE, MOUSE_MOVE, SHIFT), # middle_drag Shift 33+4+ + =37
(41, 'M') : (MIDDLE, MOUSE_MOVE, ALT), # middle_drag Alt 33+ +8+ =41
(45, 'M') : (MIDDLE, MOUSE_MOVE, SHIFT_ALT), # middle_drag Shift Alt 33+4+8+ =45
(49, 'M') : (MIDDLE, MOUSE_MOVE, CONTROL), # middle_drag Control 33+ + +16=49
(53, 'M') : (MIDDLE, MOUSE_MOVE, SHIFT_CONTROL), # middle_drag Shift Control 33+4+ +16=53
(57, 'M') : (MIDDLE, MOUSE_MOVE, ALT_CONTROL), # middle_drag Alt Control 33+ +8+16=57
(61, 'M') : (MIDDLE, MOUSE_MOVE, SHIFT_ALT_CONTROL), # middle_drag Shift Alt Control 33+4+8+16=61
(34, 'M') : (RIGHT, MOUSE_MOVE, NO_MODIFIER), # right_drag 34+ + + =34
(38, 'M') : (RIGHT, MOUSE_MOVE, SHIFT), # right_drag Shift 34+4+ + =38
(42, 'M') : (RIGHT, MOUSE_MOVE, ALT), # right_drag Alt 34+ +8+ =42
(46, 'M') : (RIGHT, MOUSE_MOVE, SHIFT_ALT), # right_drag Shift Alt 34+4+8+ =46
(50, 'M') : (RIGHT, MOUSE_MOVE, CONTROL), # right_drag Control 34+ + +16=50
(54, 'M') : (RIGHT, MOUSE_MOVE, SHIFT_CONTROL), # right_drag Shift Control 34+4+ +16=54
(58, 'M') : (RIGHT, MOUSE_MOVE, ALT_CONTROL), # right_drag Alt Control 34+ +8+16=58
(62, 'M') : (RIGHT, MOUSE_MOVE, SHIFT_ALT_CONTROL), # right_drag Shift Alt Control 34+4+8+16=62
(35, 'M') : (NO_BUTTON, MOUSE_MOVE, NO_MODIFIER), # none_drag 35+ + + =35
(39, 'M') : (NO_BUTTON, MOUSE_MOVE, SHIFT), # none_drag Shift 35+4+ + =39
(43, 'M') : (NO_BUTTON, MOUSE_MOVE, ALT), # none_drag Alt 35+ +8+ =43
(47, 'M') : (NO_BUTTON, MOUSE_MOVE, SHIFT_ALT), # none_drag Shift Alt 35+4+8+ =47
(51, 'M') : (NO_BUTTON, MOUSE_MOVE, CONTROL), # none_drag Control 35+ + +16=51
(55, 'M') : (NO_BUTTON, MOUSE_MOVE, SHIFT_CONTROL), # none_drag Shift Control 35+4+ +16=55
(59, 'M') : (NO_BUTTON, MOUSE_MOVE, ALT_CONTROL), # none_drag Alt Control 35+ +8+16=59
(63, 'M') : (NO_BUTTON, MOUSE_MOVE, SHIFT_ALT_CONTROL), # none_drag Shift Alt Control 35+4+8+16=63
(64, 'M') : (NO_BUTTON, SCROLL_UP, NO_MODIFIER), # scroll_up 64+ + + =64
(68, 'M') : (NO_BUTTON, SCROLL_UP, SHIFT), # scroll_up Shift 64+4+ + =68
(72, 'M') : (NO_BUTTON, SCROLL_UP, ALT), # scroll_up Alt 64+ +8+ =72
(76, 'M') : (NO_BUTTON, SCROLL_UP, SHIFT_ALT), # scroll_up Shift Alt 64+4+8+ =76
(80, 'M') : (NO_BUTTON, SCROLL_UP, CONTROL), # scroll_up Control 64+ + +16=80
(84, 'M') : (NO_BUTTON, SCROLL_UP, SHIFT_CONTROL), # scroll_up Shift Control 64+4+ +16=84
(88, 'M') : (NO_BUTTON, SCROLL_UP, ALT_CONTROL), # scroll_up Alt Control 64+ +8+16=88
(92, 'M') : (NO_BUTTON, SCROLL_UP, SHIFT_ALT_CONTROL), # scroll_up Shift Alt Control 64+4+8+16=92
(65, 'M') : (NO_BUTTON, SCROLL_DOWN, NO_MODIFIER), # scroll_down 64+ + + =65
(69, 'M') : (NO_BUTTON, SCROLL_DOWN, SHIFT), # scroll_down Shift 64+4+ + =69
(73, 'M') : (NO_BUTTON, SCROLL_DOWN, ALT), # scroll_down Alt 64+ +8+ =73
(77, 'M') : (NO_BUTTON, SCROLL_DOWN, SHIFT_ALT), # scroll_down Shift Alt 64+4+8+ =77
(81, 'M') : (NO_BUTTON, SCROLL_DOWN, CONTROL), # scroll_down Control 64+ + +16=81
(85, 'M') : (NO_BUTTON, SCROLL_DOWN, SHIFT_CONTROL), # scroll_down Shift Control 64+4+ +16=85
(89, 'M') : (NO_BUTTON, SCROLL_DOWN, ALT_CONTROL), # scroll_down Alt Control 64+ +8+16=89
(93, 'M') : (NO_BUTTON, SCROLL_DOWN, SHIFT_ALT_CONTROL), # scroll_down Shift Alt Control 64+4+8+16=93
}
typical_mouse_events = {
32: (LEFT , MOUSE_DOWN , UNKNOWN_MODIFIER),
33: (MIDDLE , MOUSE_DOWN , UNKNOWN_MODIFIER),
34: (RIGHT , MOUSE_DOWN , UNKNOWN_MODIFIER),
35: (UNKNOWN_BUTTON , MOUSE_UP , UNKNOWN_MODIFIER),
64: (LEFT , MOUSE_MOVE , UNKNOWN_MODIFIER),
65: (MIDDLE , MOUSE_MOVE , UNKNOWN_MODIFIER),
66: (RIGHT , MOUSE_MOVE , UNKNOWN_MODIFIER),
67: (NO_BUTTON , MOUSE_MOVE , UNKNOWN_MODIFIER),
96: (NO_BUTTON , SCROLL_UP , UNKNOWN_MODIFIER),
97: (NO_BUTTON , SCROLL_DOWN, UNKNOWN_MODIFIER),
}
urxvt_mouse_events={
32: (UNKNOWN_BUTTON, MOUSE_DOWN , UNKNOWN_MODIFIER),
35: (UNKNOWN_BUTTON, MOUSE_UP , UNKNOWN_MODIFIER),
96: (NO_BUTTON , SCROLL_UP , UNKNOWN_MODIFIER),
97: (NO_BUTTON , SCROLL_DOWN, UNKNOWN_MODIFIER),
}
# fmt:on
def load_mouse_bindings() -> KeyBindings:
"""
Key bindings, required for mouse support.
(Mouse events enter through the key binding system.)
"""
key_bindings = KeyBindings()
@key_bindings.add(Keys.Vt100MouseEvent)
def _(event: E) -> "NotImplementedOrNone":
"""
Handling of incoming mouse event.
"""
# TypicaL: "eSC[MaB*"
# Urxvt: "Esc[96;14;13M"
# Xterm SGR: "Esc[<64;85;12M"
# Parse incoming packet.
if event.data[2] == "M":
# Typical.
mouse_event, x, y = map(ord, event.data[3:])
# TODO: Is it possible to add modifiers here?
mouse_button, mouse_event_type, mouse_modifiers = typical_mouse_events[
mouse_event
]
# Handle situations where `PosixStdinReader` used surrogateescapes.
if x >= 0xDC00:
x -= 0xDC00
if y >= 0xDC00:
y -= 0xDC00
x -= 32
y -= 32
else:
# Urxvt and Xterm SGR.
# When the '<' is not present, we are not using the Xterm SGR mode,
# but Urxvt instead.
data = event.data[2:]
if data[:1] == "<":
sgr = True
data = data[1:]
else:
sgr = False
# Extract coordinates.
mouse_event, x, y = map(int, data[:-1].split(";"))
m = data[-1]
# Parse event type.
if sgr:
try:
(
mouse_button,
mouse_event_type,
mouse_modifiers,
) = xterm_sgr_mouse_events[mouse_event, m]
except KeyError:
return NotImplemented
else:
# Some other terminals, like urxvt, Hyper terminal, ...
(
mouse_button,
mouse_event_type,
mouse_modifiers,
) = urxvt_mouse_events.get(
mouse_event, (UNKNOWN_BUTTON, MOUSE_MOVE, UNKNOWN_MODIFIER)
)
x -= 1
y -= 1
# Only handle mouse events when we know the window height.
if event.app.renderer.height_is_known and mouse_event_type is not None:
# Take region above the layout into account. The reported
# coordinates are absolute to the visible part of the terminal.
from prompt_toolkit.renderer import HeightIsUnknownError
try:
y -= event.app.renderer.rows_above_layout
except HeightIsUnknownError:
return NotImplemented
# Call the mouse handler from the renderer.
# Note: This can return `NotImplemented` if no mouse handler was
# found for this position, or if no repainting needs to
# happen. this way, we avoid excessive repaints during mouse
# movements.
handler = event.app.renderer.mouse_handlers.mouse_handlers[y][x]
return handler(
MouseEvent(
position=Point(x=x, y=y),
event_type=mouse_event_type,
button=mouse_button,
modifiers=mouse_modifiers,
)
)
return NotImplemented
@key_bindings.add(Keys.ScrollUp)
def _scroll_up(event: E) -> None:
"""
Scroll up event without cursor position.
"""
# We don't receive a cursor position, so we don't know which window to
# scroll. Just send an 'up' key press instead.
event.key_processor.feed(KeyPress(Keys.Up), first=True)
@key_bindings.add(Keys.ScrollDown)
def _scroll_down(event: E) -> None:
"""
Scroll down event without cursor position.
"""
event.key_processor.feed(KeyPress(Keys.Down), first=True)
@key_bindings.add(Keys.WindowsMouseEvent)
def _mouse(event: E) -> "NotImplementedOrNone":
"""
Handling of mouse events for Windows.
"""
# This key binding should only exist for Windows.
if sys.platform == "win32":
# Parse data.
pieces = event.data.split(";")
button = MouseButton(pieces[0])
event_type = MouseEventType(pieces[1])
x = int(pieces[2])
y = int(pieces[3])
# Make coordinates absolute to the visible part of the terminal.
output = event.app.renderer.output
from prompt_toolkit.output.win32 import Win32Output
from prompt_toolkit.output.windows10 import Windows10_Output
if isinstance(output, (Win32Output, Windows10_Output)):
screen_buffer_info = output.get_win32_screen_buffer_info()
rows_above_cursor = (
screen_buffer_info.dwCursorPosition.Y
- event.app.renderer._cursor_pos.y
)
y -= rows_above_cursor
# Call the mouse event handler.
# (Can return `NotImplemented`.)
handler = event.app.renderer.mouse_handlers.mouse_handlers[y][x]
return handler(
MouseEvent(
position=Point(x=x, y=y),
event_type=event_type,
button=button,
modifiers=UNKNOWN_MODIFIER,
)
)
# No mouse handler found. Return `NotImplemented` so that we don't
# invalidate the UI.
return NotImplemented
return key_bindings

View File

@@ -0,0 +1,687 @@
"""
Key bindings which are also known by GNU Readline by the given names.
See: http://www.delorie.com/gnu/docs/readline/rlman_13.html
"""
from typing import Callable, Dict, TypeVar, Union, cast
from prompt_toolkit.document import Document
from prompt_toolkit.enums import EditingMode
from prompt_toolkit.key_binding.key_bindings import Binding, key_binding
from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout.controls import BufferControl
from prompt_toolkit.search import SearchDirection
from prompt_toolkit.selection import PasteMode
from .completion import display_completions_like_readline, generate_completions
__all__ = [
"get_by_name",
]
# Typing.
_Handler = Callable[[KeyPressEvent], None]
_HandlerOrBinding = Union[_Handler, Binding]
_T = TypeVar("_T", bound=_HandlerOrBinding)
E = KeyPressEvent
# Registry that maps the Readline command names to their handlers.
_readline_commands: Dict[str, Binding] = {}
def register(name: str) -> Callable[[_T], _T]:
"""
Store handler in the `_readline_commands` dictionary.
"""
def decorator(handler: _T) -> _T:
"`handler` is a callable or Binding."
if isinstance(handler, Binding):
_readline_commands[name] = handler
else:
_readline_commands[name] = key_binding()(cast(_Handler, handler))
return handler
return decorator
def get_by_name(name: str) -> Binding:
"""
Return the handler for the (Readline) command with the given name.
"""
try:
return _readline_commands[name]
except KeyError as e:
raise KeyError("Unknown Readline command: %r" % name) from e
#
# Commands for moving
# See: http://www.delorie.com/gnu/docs/readline/rlman_14.html
#
@register("beginning-of-buffer")
def beginning_of_buffer(event: E) -> None:
"""
Move to the start of the buffer.
"""
buff = event.current_buffer
buff.cursor_position = 0
@register("end-of-buffer")
def end_of_buffer(event: E) -> None:
"""
Move to the end of the buffer.
"""
buff = event.current_buffer
buff.cursor_position = len(buff.text)
@register("beginning-of-line")
def beginning_of_line(event: E) -> None:
"""
Move to the start of the current line.
"""
buff = event.current_buffer
buff.cursor_position += buff.document.get_start_of_line_position(
after_whitespace=False
)
@register("end-of-line")
def end_of_line(event: E) -> None:
"""
Move to the end of the line.
"""
buff = event.current_buffer
buff.cursor_position += buff.document.get_end_of_line_position()
@register("forward-char")
def forward_char(event: E) -> None:
"""
Move forward a character.
"""
buff = event.current_buffer
buff.cursor_position += buff.document.get_cursor_right_position(count=event.arg)
@register("backward-char")
def backward_char(event: E) -> None:
"Move back a character."
buff = event.current_buffer
buff.cursor_position += buff.document.get_cursor_left_position(count=event.arg)
@register("forward-word")
def forward_word(event: E) -> None:
"""
Move forward to the end of the next word. Words are composed of letters and
digits.
"""
buff = event.current_buffer
pos = buff.document.find_next_word_ending(count=event.arg)
if pos:
buff.cursor_position += pos
@register("backward-word")
def backward_word(event: E) -> None:
"""
Move back to the start of the current or previous word. Words are composed
of letters and digits.
"""
buff = event.current_buffer
pos = buff.document.find_previous_word_beginning(count=event.arg)
if pos:
buff.cursor_position += pos
@register("clear-screen")
def clear_screen(event: E) -> None:
"""
Clear the screen and redraw everything at the top of the screen.
"""
event.app.renderer.clear()
@register("redraw-current-line")
def redraw_current_line(event: E) -> None:
"""
Refresh the current line.
(Readline defines this command, but prompt-toolkit doesn't have it.)
"""
pass
#
# Commands for manipulating the history.
# See: http://www.delorie.com/gnu/docs/readline/rlman_15.html
#
@register("accept-line")
def accept_line(event: E) -> None:
"""
Accept the line regardless of where the cursor is.
"""
event.current_buffer.validate_and_handle()
@register("previous-history")
def previous_history(event: E) -> None:
"""
Move `back` through the history list, fetching the previous command.
"""
event.current_buffer.history_backward(count=event.arg)
@register("next-history")
def next_history(event: E) -> None:
"""
Move `forward` through the history list, fetching the next command.
"""
event.current_buffer.history_forward(count=event.arg)
@register("beginning-of-history")
def beginning_of_history(event: E) -> None:
"""
Move to the first line in the history.
"""
event.current_buffer.go_to_history(0)
@register("end-of-history")
def end_of_history(event: E) -> None:
"""
Move to the end of the input history, i.e., the line currently being entered.
"""
event.current_buffer.history_forward(count=10**100)
buff = event.current_buffer
buff.go_to_history(len(buff._working_lines) - 1)
@register("reverse-search-history")
def reverse_search_history(event: E) -> None:
"""
Search backward starting at the current line and moving `up` through
the history as necessary. This is an incremental search.
"""
control = event.app.layout.current_control
if isinstance(control, BufferControl) and control.search_buffer_control:
event.app.current_search_state.direction = SearchDirection.BACKWARD
event.app.layout.current_control = control.search_buffer_control
#
# Commands for changing text
#
@register("end-of-file")
def end_of_file(event: E) -> None:
"""
Exit.
"""
event.app.exit()
@register("delete-char")
def delete_char(event: E) -> None:
"""
Delete character before the cursor.
"""
deleted = event.current_buffer.delete(count=event.arg)
if not deleted:
event.app.output.bell()
@register("backward-delete-char")
def backward_delete_char(event: E) -> None:
"""
Delete the character behind the cursor.
"""
if event.arg < 0:
# When a negative argument has been given, this should delete in front
# of the cursor.
deleted = event.current_buffer.delete(count=-event.arg)
else:
deleted = event.current_buffer.delete_before_cursor(count=event.arg)
if not deleted:
event.app.output.bell()
@register("self-insert")
def self_insert(event: E) -> None:
"""
Insert yourself.
"""
event.current_buffer.insert_text(event.data * event.arg)
@register("transpose-chars")
def transpose_chars(event: E) -> None:
"""
Emulate Emacs transpose-char behavior: at the beginning of the buffer,
do nothing. At the end of a line or buffer, swap the characters before
the cursor. Otherwise, move the cursor right, and then swap the
characters before the cursor.
"""
b = event.current_buffer
p = b.cursor_position
if p == 0:
return
elif p == len(b.text) or b.text[p] == "\n":
b.swap_characters_before_cursor()
else:
b.cursor_position += b.document.get_cursor_right_position()
b.swap_characters_before_cursor()
@register("uppercase-word")
def uppercase_word(event: E) -> None:
"""
Uppercase the current (or following) word.
"""
buff = event.current_buffer
for i in range(event.arg):
pos = buff.document.find_next_word_ending()
words = buff.document.text_after_cursor[:pos]
buff.insert_text(words.upper(), overwrite=True)
@register("downcase-word")
def downcase_word(event: E) -> None:
"""
Lowercase the current (or following) word.
"""
buff = event.current_buffer
for i in range(event.arg): # XXX: not DRY: see meta_c and meta_u!!
pos = buff.document.find_next_word_ending()
words = buff.document.text_after_cursor[:pos]
buff.insert_text(words.lower(), overwrite=True)
@register("capitalize-word")
def capitalize_word(event: E) -> None:
"""
Capitalize the current (or following) word.
"""
buff = event.current_buffer
for i in range(event.arg):
pos = buff.document.find_next_word_ending()
words = buff.document.text_after_cursor[:pos]
buff.insert_text(words.title(), overwrite=True)
@register("quoted-insert")
def quoted_insert(event: E) -> None:
"""
Add the next character typed to the line verbatim. This is how to insert
key sequences like C-q, for example.
"""
event.app.quoted_insert = True
#
# Killing and yanking.
#
@register("kill-line")
def kill_line(event: E) -> None:
"""
Kill the text from the cursor to the end of the line.
If we are at the end of the line, this should remove the newline.
(That way, it is possible to delete multiple lines by executing this
command multiple times.)
"""
buff = event.current_buffer
if event.arg < 0:
deleted = buff.delete_before_cursor(
count=-buff.document.get_start_of_line_position()
)
else:
if buff.document.current_char == "\n":
deleted = buff.delete(1)
else:
deleted = buff.delete(count=buff.document.get_end_of_line_position())
event.app.clipboard.set_text(deleted)
@register("kill-word")
def kill_word(event: E) -> None:
"""
Kill from point to the end of the current word, or if between words, to the
end of the next word. Word boundaries are the same as forward-word.
"""
buff = event.current_buffer
pos = buff.document.find_next_word_ending(count=event.arg)
if pos:
deleted = buff.delete(count=pos)
if event.is_repeat:
deleted = event.app.clipboard.get_data().text + deleted
event.app.clipboard.set_text(deleted)
@register("unix-word-rubout")
def unix_word_rubout(event: E, WORD: bool = True) -> None:
"""
Kill the word behind point, using whitespace as a word boundary.
Usually bound to ControlW.
"""
buff = event.current_buffer
pos = buff.document.find_start_of_previous_word(count=event.arg, WORD=WORD)
if pos is None:
# Nothing found? delete until the start of the document. (The
# input starts with whitespace and no words were found before the
# cursor.)
pos = -buff.cursor_position
if pos:
deleted = buff.delete_before_cursor(count=-pos)
# If the previous key press was also Control-W, concatenate deleted
# text.
if event.is_repeat:
deleted += event.app.clipboard.get_data().text
event.app.clipboard.set_text(deleted)
else:
# Nothing to delete. Bell.
event.app.output.bell()
@register("backward-kill-word")
def backward_kill_word(event: E) -> None:
"""
Kills the word before point, using "not a letter nor a digit" as a word boundary.
Usually bound to M-Del or M-Backspace.
"""
unix_word_rubout(event, WORD=False)
@register("delete-horizontal-space")
def delete_horizontal_space(event: E) -> None:
"""
Delete all spaces and tabs around point.
"""
buff = event.current_buffer
text_before_cursor = buff.document.text_before_cursor
text_after_cursor = buff.document.text_after_cursor
delete_before = len(text_before_cursor) - len(text_before_cursor.rstrip("\t "))
delete_after = len(text_after_cursor) - len(text_after_cursor.lstrip("\t "))
buff.delete_before_cursor(count=delete_before)
buff.delete(count=delete_after)
@register("unix-line-discard")
def unix_line_discard(event: E) -> None:
"""
Kill backward from the cursor to the beginning of the current line.
"""
buff = event.current_buffer
if buff.document.cursor_position_col == 0 and buff.document.cursor_position > 0:
buff.delete_before_cursor(count=1)
else:
deleted = buff.delete_before_cursor(
count=-buff.document.get_start_of_line_position()
)
event.app.clipboard.set_text(deleted)
@register("yank")
def yank(event: E) -> None:
"""
Paste before cursor.
"""
event.current_buffer.paste_clipboard_data(
event.app.clipboard.get_data(), count=event.arg, paste_mode=PasteMode.EMACS
)
@register("yank-nth-arg")
def yank_nth_arg(event: E) -> None:
"""
Insert the first argument of the previous command. With an argument, insert
the nth word from the previous command (start counting at 0).
"""
n = event.arg if event.arg_present else None
event.current_buffer.yank_nth_arg(n)
@register("yank-last-arg")
def yank_last_arg(event: E) -> None:
"""
Like `yank_nth_arg`, but if no argument has been given, yank the last word
of each line.
"""
n = event.arg if event.arg_present else None
event.current_buffer.yank_last_arg(n)
@register("yank-pop")
def yank_pop(event: E) -> None:
"""
Rotate the kill ring, and yank the new top. Only works following yank or
yank-pop.
"""
buff = event.current_buffer
doc_before_paste = buff.document_before_paste
clipboard = event.app.clipboard
if doc_before_paste is not None:
buff.document = doc_before_paste
clipboard.rotate()
buff.paste_clipboard_data(clipboard.get_data(), paste_mode=PasteMode.EMACS)
#
# Completion.
#
@register("complete")
def complete(event: E) -> None:
"""
Attempt to perform completion.
"""
display_completions_like_readline(event)
@register("menu-complete")
def menu_complete(event: E) -> None:
"""
Generate completions, or go to the next completion. (This is the default
way of completing input in prompt_toolkit.)
"""
generate_completions(event)
@register("menu-complete-backward")
def menu_complete_backward(event: E) -> None:
"""
Move backward through the list of possible completions.
"""
event.current_buffer.complete_previous()
#
# Keyboard macros.
#
@register("start-kbd-macro")
def start_kbd_macro(event: E) -> None:
"""
Begin saving the characters typed into the current keyboard macro.
"""
event.app.emacs_state.start_macro()
@register("end-kbd-macro")
def end_kbd_macro(event: E) -> None:
"""
Stop saving the characters typed into the current keyboard macro and save
the definition.
"""
event.app.emacs_state.end_macro()
@register("call-last-kbd-macro")
@key_binding(record_in_macro=False)
def call_last_kbd_macro(event: E) -> None:
"""
Re-execute the last keyboard macro defined, by making the characters in the
macro appear as if typed at the keyboard.
Notice that we pass `record_in_macro=False`. This ensures that the 'c-x e'
key sequence doesn't appear in the recording itself. This function inserts
the body of the called macro back into the KeyProcessor, so these keys will
be added later on to the macro of their handlers have `record_in_macro=True`.
"""
# Insert the macro.
macro = event.app.emacs_state.macro
if macro:
event.app.key_processor.feed_multiple(macro, first=True)
@register("print-last-kbd-macro")
def print_last_kbd_macro(event: E) -> None:
"""
Print the last keyboard macro.
"""
# TODO: Make the format suitable for the inputrc file.
def print_macro() -> None:
macro = event.app.emacs_state.macro
if macro:
for k in macro:
print(k)
from prompt_toolkit.application.run_in_terminal import run_in_terminal
run_in_terminal(print_macro)
#
# Miscellaneous Commands.
#
@register("undo")
def undo(event: E) -> None:
"""
Incremental undo.
"""
event.current_buffer.undo()
@register("insert-comment")
def insert_comment(event: E) -> None:
"""
Without numeric argument, comment all lines.
With numeric argument, uncomment all lines.
In any case accept the input.
"""
buff = event.current_buffer
# Transform all lines.
if event.arg != 1:
def change(line: str) -> str:
return line[1:] if line.startswith("#") else line
else:
def change(line: str) -> str:
return "#" + line
buff.document = Document(
text="\n".join(map(change, buff.text.splitlines())), cursor_position=0
)
# Accept input.
buff.validate_and_handle()
@register("vi-editing-mode")
def vi_editing_mode(event: E) -> None:
"""
Switch to Vi editing mode.
"""
event.app.editing_mode = EditingMode.VI
@register("emacs-editing-mode")
def emacs_editing_mode(event: E) -> None:
"""
Switch to Emacs editing mode.
"""
event.app.editing_mode = EditingMode.EMACS
@register("prefix-meta")
def prefix_meta(event: E) -> None:
"""
Metafy the next character typed. This is for keyboards without a meta key.
Sometimes people also want to bind other keys to Meta, e.g. 'jj'::
key_bindings.add_key_binding('j', 'j', filter=ViInsertMode())(prefix_meta)
"""
# ('first' should be true, because we want to insert it at the current
# position in the queue.)
event.app.key_processor.feed(KeyPress(Keys.Escape), first=True)
@register("operate-and-get-next")
def operate_and_get_next(event: E) -> None:
"""
Accept the current line for execution and fetch the next line relative to
the current line from the history for editing.
"""
buff = event.current_buffer
new_index = buff.working_index + 1
# Accept the current input. (This will also redraw the interface in the
# 'done' state.)
buff.validate_and_handle()
# Set the new index at the start of the next run.
def set_working_index() -> None:
if new_index < len(buff._working_lines):
buff.working_index = new_index
event.app.pre_run_callables.append(set_working_index)
@register("edit-and-execute-command")
def edit_and_execute(event: E) -> None:
"""
Invoke an editor on the current command line, and accept the result.
"""
buff = event.current_buffer
buff.open_in_editor(validate_and_handle=True)

View File

@@ -0,0 +1,49 @@
"""
Open in editor key bindings.
"""
from prompt_toolkit.filters import emacs_mode, has_selection, vi_navigation_mode
from ..key_bindings import KeyBindings, KeyBindingsBase, merge_key_bindings
from .named_commands import get_by_name
__all__ = [
"load_open_in_editor_bindings",
"load_emacs_open_in_editor_bindings",
"load_vi_open_in_editor_bindings",
]
def load_open_in_editor_bindings() -> KeyBindingsBase:
"""
Load both the Vi and emacs key bindings for handling edit-and-execute-command.
"""
return merge_key_bindings(
[
load_emacs_open_in_editor_bindings(),
load_vi_open_in_editor_bindings(),
]
)
def load_emacs_open_in_editor_bindings() -> KeyBindings:
"""
Pressing C-X C-E will open the buffer in an external editor.
"""
key_bindings = KeyBindings()
key_bindings.add("c-x", "c-e", filter=emacs_mode & ~has_selection)(
get_by_name("edit-and-execute-command")
)
return key_bindings
def load_vi_open_in_editor_bindings() -> KeyBindings:
"""
Pressing 'v' in navigation mode will open the buffer in an external editor.
"""
key_bindings = KeyBindings()
key_bindings.add("v", filter=vi_navigation_mode)(
get_by_name("edit-and-execute-command")
)
return key_bindings

View File

@@ -0,0 +1,82 @@
"""
Key bindings for extra page navigation: bindings for up/down scrolling through
long pages, like in Emacs or Vi.
"""
from prompt_toolkit.filters import buffer_has_focus, emacs_mode, vi_mode
from prompt_toolkit.key_binding.key_bindings import (
ConditionalKeyBindings,
KeyBindings,
KeyBindingsBase,
merge_key_bindings,
)
from .scroll import (
scroll_backward,
scroll_forward,
scroll_half_page_down,
scroll_half_page_up,
scroll_one_line_down,
scroll_one_line_up,
scroll_page_down,
scroll_page_up,
)
__all__ = [
"load_page_navigation_bindings",
"load_emacs_page_navigation_bindings",
"load_vi_page_navigation_bindings",
]
def load_page_navigation_bindings() -> KeyBindingsBase:
"""
Load both the Vi and Emacs bindings for page navigation.
"""
# Only enable when a `Buffer` is focused, otherwise, we would catch keys
# when another widget is focused (like for instance `c-d` in a
# ptterm.Terminal).
return ConditionalKeyBindings(
merge_key_bindings(
[
load_emacs_page_navigation_bindings(),
load_vi_page_navigation_bindings(),
]
),
buffer_has_focus,
)
def load_emacs_page_navigation_bindings() -> KeyBindingsBase:
"""
Key bindings, for scrolling up and down through pages.
This are separate bindings, because GNU readline doesn't have them.
"""
key_bindings = KeyBindings()
handle = key_bindings.add
handle("c-v")(scroll_page_down)
handle("pagedown")(scroll_page_down)
handle("escape", "v")(scroll_page_up)
handle("pageup")(scroll_page_up)
return ConditionalKeyBindings(key_bindings, emacs_mode)
def load_vi_page_navigation_bindings() -> KeyBindingsBase:
"""
Key bindings, for scrolling up and down through pages.
This are separate bindings, because GNU readline doesn't have them.
"""
key_bindings = KeyBindings()
handle = key_bindings.add
handle("c-f")(scroll_forward)
handle("c-b")(scroll_backward)
handle("c-d")(scroll_half_page_down)
handle("c-u")(scroll_half_page_up)
handle("c-e")(scroll_one_line_down)
handle("c-y")(scroll_one_line_up)
handle("pagedown")(scroll_page_down)
handle("pageup")(scroll_page_up)
return ConditionalKeyBindings(key_bindings, vi_mode)

View File

@@ -0,0 +1,187 @@
"""
Key bindings, for scrolling up and down through pages.
This are separate bindings, because GNU readline doesn't have them, but
they are very useful for navigating through long multiline buffers, like in
Vi, Emacs, etc...
"""
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
__all__ = [
"scroll_forward",
"scroll_backward",
"scroll_half_page_up",
"scroll_half_page_down",
"scroll_one_line_up",
"scroll_one_line_down",
]
E = KeyPressEvent
def scroll_forward(event: E, half: bool = False) -> None:
"""
Scroll window down.
"""
w = event.app.layout.current_window
b = event.app.current_buffer
if w and w.render_info:
info = w.render_info
ui_content = info.ui_content
# Height to scroll.
scroll_height = info.window_height
if half:
scroll_height //= 2
# Calculate how many lines is equivalent to that vertical space.
y = b.document.cursor_position_row + 1
height = 0
while y < ui_content.line_count:
line_height = info.get_height_for_line(y)
if height + line_height < scroll_height:
height += line_height
y += 1
else:
break
b.cursor_position = b.document.translate_row_col_to_index(y, 0)
def scroll_backward(event: E, half: bool = False) -> None:
"""
Scroll window up.
"""
w = event.app.layout.current_window
b = event.app.current_buffer
if w and w.render_info:
info = w.render_info
# Height to scroll.
scroll_height = info.window_height
if half:
scroll_height //= 2
# Calculate how many lines is equivalent to that vertical space.
y = max(0, b.document.cursor_position_row - 1)
height = 0
while y > 0:
line_height = info.get_height_for_line(y)
if height + line_height < scroll_height:
height += line_height
y -= 1
else:
break
b.cursor_position = b.document.translate_row_col_to_index(y, 0)
def scroll_half_page_down(event: E) -> None:
"""
Same as ControlF, but only scroll half a page.
"""
scroll_forward(event, half=True)
def scroll_half_page_up(event: E) -> None:
"""
Same as ControlB, but only scroll half a page.
"""
scroll_backward(event, half=True)
def scroll_one_line_down(event: E) -> None:
"""
scroll_offset += 1
"""
w = event.app.layout.current_window
b = event.app.current_buffer
if w:
# When the cursor is at the top, move to the next line. (Otherwise, only scroll.)
if w.render_info:
info = w.render_info
if w.vertical_scroll < info.content_height - info.window_height:
if info.cursor_position.y <= info.configured_scroll_offsets.top:
b.cursor_position += b.document.get_cursor_down_position()
w.vertical_scroll += 1
def scroll_one_line_up(event: E) -> None:
"""
scroll_offset -= 1
"""
w = event.app.layout.current_window
b = event.app.current_buffer
if w:
# When the cursor is at the bottom, move to the previous line. (Otherwise, only scroll.)
if w.render_info:
info = w.render_info
if w.vertical_scroll > 0:
first_line_height = info.get_height_for_line(info.first_visible_line())
cursor_up = info.cursor_position.y - (
info.window_height
- 1
- first_line_height
- info.configured_scroll_offsets.bottom
)
# Move cursor up, as many steps as the height of the first line.
# TODO: not entirely correct yet, in case of line wrapping and many long lines.
for _ in range(max(0, cursor_up)):
b.cursor_position += b.document.get_cursor_up_position()
# Scroll window
w.vertical_scroll -= 1
def scroll_page_down(event: E) -> None:
"""
Scroll page down. (Prefer the cursor at the top of the page, after scrolling.)
"""
w = event.app.layout.current_window
b = event.app.current_buffer
if w and w.render_info:
# Scroll down one page.
line_index = max(w.render_info.last_visible_line(), w.vertical_scroll + 1)
w.vertical_scroll = line_index
b.cursor_position = b.document.translate_row_col_to_index(line_index, 0)
b.cursor_position += b.document.get_start_of_line_position(
after_whitespace=True
)
def scroll_page_up(event: E) -> None:
"""
Scroll page up. (Prefer the cursor at the bottom of the page, after scrolling.)
"""
w = event.app.layout.current_window
b = event.app.current_buffer
if w and w.render_info:
# Put cursor at the first visible line. (But make sure that the cursor
# moves at least one line up.)
line_index = max(
0,
min(w.render_info.first_visible_line(), b.document.cursor_position_row - 1),
)
b.cursor_position = b.document.translate_row_col_to_index(line_index, 0)
b.cursor_position += b.document.get_start_of_line_position(
after_whitespace=True
)
# Set the scroll offset. We can safely set it to zero; the Window will
# make sure that it scrolls at least until the cursor becomes visible.
w.vertical_scroll = 0

View File

@@ -0,0 +1,93 @@
"""
Search related key bindings.
"""
from prompt_toolkit import search
from prompt_toolkit.application.current import get_app
from prompt_toolkit.filters import Condition, control_is_searchable, is_searching
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from ..key_bindings import key_binding
__all__ = [
"abort_search",
"accept_search",
"start_reverse_incremental_search",
"start_forward_incremental_search",
"reverse_incremental_search",
"forward_incremental_search",
"accept_search_and_accept_input",
]
E = KeyPressEvent
@key_binding(filter=is_searching)
def abort_search(event: E) -> None:
"""
Abort an incremental search and restore the original
line.
(Usually bound to ControlG/ControlC.)
"""
search.stop_search()
@key_binding(filter=is_searching)
def accept_search(event: E) -> None:
"""
When enter pressed in isearch, quit isearch mode. (Multiline
isearch would be too complicated.)
(Usually bound to Enter.)
"""
search.accept_search()
@key_binding(filter=control_is_searchable)
def start_reverse_incremental_search(event: E) -> None:
"""
Enter reverse incremental search.
(Usually ControlR.)
"""
search.start_search(direction=search.SearchDirection.BACKWARD)
@key_binding(filter=control_is_searchable)
def start_forward_incremental_search(event: E) -> None:
"""
Enter forward incremental search.
(Usually ControlS.)
"""
search.start_search(direction=search.SearchDirection.FORWARD)
@key_binding(filter=is_searching)
def reverse_incremental_search(event: E) -> None:
"""
Apply reverse incremental search, but keep search buffer focused.
"""
search.do_incremental_search(search.SearchDirection.BACKWARD, count=event.arg)
@key_binding(filter=is_searching)
def forward_incremental_search(event: E) -> None:
"""
Apply forward incremental search, but keep search buffer focused.
"""
search.do_incremental_search(search.SearchDirection.FORWARD, count=event.arg)
@Condition
def _previous_buffer_is_returnable() -> bool:
"""
True if the previously focused buffer has a return handler.
"""
prev_control = get_app().layout.search_target_buffer_control
return bool(prev_control and prev_control.buffer.is_returnable)
@key_binding(filter=is_searching & _previous_buffer_is_returnable)
def accept_search_and_accept_input(event: E) -> None:
"""
Accept the search operation first, then accept the input.
"""
search.accept_search()
event.current_buffer.validate_and_handle()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
"""
Default key bindings.::
key_bindings = load_key_bindings()
app = Application(key_bindings=key_bindings)
"""
from prompt_toolkit.filters import buffer_has_focus
from prompt_toolkit.key_binding.bindings.basic import load_basic_bindings
from prompt_toolkit.key_binding.bindings.cpr import load_cpr_bindings
from prompt_toolkit.key_binding.bindings.emacs import (
load_emacs_bindings,
load_emacs_search_bindings,
load_emacs_shift_selection_bindings,
)
from prompt_toolkit.key_binding.bindings.mouse import load_mouse_bindings
from prompt_toolkit.key_binding.bindings.vi import (
load_vi_bindings,
load_vi_search_bindings,
)
from prompt_toolkit.key_binding.key_bindings import (
ConditionalKeyBindings,
KeyBindingsBase,
merge_key_bindings,
)
__all__ = [
"load_key_bindings",
]
def load_key_bindings() -> KeyBindingsBase:
"""
Create a KeyBindings object that contains the default key bindings.
"""
all_bindings = merge_key_bindings(
[
# Load basic bindings.
load_basic_bindings(),
# Load emacs bindings.
load_emacs_bindings(),
load_emacs_search_bindings(),
load_emacs_shift_selection_bindings(),
# Load Vi bindings.
load_vi_bindings(),
load_vi_search_bindings(),
]
)
return merge_key_bindings(
[
# Make sure that the above key bindings are only active if the
# currently focused control is a `BufferControl`. For other controls, we
# don't want these key bindings to intervene. (This would break "ptterm"
# for instance, which handles 'Keys.Any' in the user control itself.)
ConditionalKeyBindings(all_bindings, buffer_has_focus),
# Active, even when no buffer has been focused.
load_mouse_bindings(),
load_cpr_bindings(),
]
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
from typing import List, Optional
from .key_processor import KeyPress
__all__ = [
"EmacsState",
]
class EmacsState:
"""
Mutable class to hold Emacs specific state.
"""
def __init__(self) -> None:
# Simple macro recording. (Like Readline does.)
# (For Emacs mode.)
self.macro: Optional[List[KeyPress]] = []
self.current_recording: Optional[List[KeyPress]] = None
def reset(self) -> None:
self.current_recording = None
@property
def is_recording(self) -> bool:
"Tell whether we are recording a macro."
return self.current_recording is not None
def start_macro(self) -> None:
"Start recording macro."
self.current_recording = []
def end_macro(self) -> None:
"End recording macro."
self.macro = self.current_recording
self.current_recording = None

View File

@@ -0,0 +1,672 @@
"""
Key bindings registry.
A `KeyBindings` object is a container that holds a list of key bindings. It has a
very efficient internal data structure for checking which key bindings apply
for a pressed key.
Typical usage::
kb = KeyBindings()
@kb.add(Keys.ControlX, Keys.ControlC, filter=INSERT)
def handler(event):
# Handle ControlX-ControlC key sequence.
pass
It is also possible to combine multiple KeyBindings objects. We do this in the
default key bindings. There are some KeyBindings objects that contain the Emacs
bindings, while others contain the Vi bindings. They are merged together using
`merge_key_bindings`.
We also have a `ConditionalKeyBindings` object that can enable/disable a group of
key bindings at once.
It is also possible to add a filter to a function, before a key binding has
been assigned, through the `key_binding` decorator.::
# First define a key handler with the `filter`.
@key_binding(filter=condition)
def my_key_binding(event):
...
# Later, add it to the key bindings.
kb.add(Keys.A, my_key_binding)
"""
from abc import ABCMeta, abstractmethod, abstractproperty
from inspect import isawaitable
from typing import (
TYPE_CHECKING,
Awaitable,
Callable,
Hashable,
List,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
cast,
)
from prompt_toolkit.cache import SimpleCache
from prompt_toolkit.filters import FilterOrBool, Never, to_filter
from prompt_toolkit.keys import KEY_ALIASES, Keys
if TYPE_CHECKING:
# Avoid circular imports.
from .key_processor import KeyPressEvent
# The only two return values for a mouse hander (and key bindings) 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__ = [
"NotImplementedOrNone",
"Binding",
"KeyBindingsBase",
"KeyBindings",
"ConditionalKeyBindings",
"merge_key_bindings",
"DynamicKeyBindings",
"GlobalOnlyKeyBindings",
]
# Key bindings can be regular functions or coroutines.
# In both cases, if they return `NotImplemented`, the UI won't be invalidated.
# This is mainly used in case of mouse move events, to prevent excessive
# repainting during mouse move events.
KeyHandlerCallable = Callable[
["KeyPressEvent"], Union["NotImplementedOrNone", Awaitable["NotImplementedOrNone"]]
]
class Binding:
"""
Key binding: (key sequence + handler + filter).
(Immutable binding class.)
:param record_in_macro: When True, don't record this key binding when a
macro is recorded.
"""
def __init__(
self,
keys: Tuple[Union[Keys, str], ...],
handler: KeyHandlerCallable,
filter: FilterOrBool = True,
eager: FilterOrBool = False,
is_global: FilterOrBool = False,
save_before: Callable[["KeyPressEvent"], bool] = (lambda e: True),
record_in_macro: FilterOrBool = True,
) -> None:
self.keys = keys
self.handler = handler
self.filter = to_filter(filter)
self.eager = to_filter(eager)
self.is_global = to_filter(is_global)
self.save_before = save_before
self.record_in_macro = to_filter(record_in_macro)
def call(self, event: "KeyPressEvent") -> None:
result = self.handler(event)
# If the handler is a coroutine, create an asyncio task.
if isawaitable(result):
awaitable = cast(Awaitable["NotImplementedOrNone"], result)
async def bg_task() -> None:
result = await awaitable
if result != NotImplemented:
event.app.invalidate()
event.app.create_background_task(bg_task())
elif result != NotImplemented:
event.app.invalidate()
def __repr__(self) -> str:
return "{}(keys={!r}, handler={!r})".format(
self.__class__.__name__,
self.keys,
self.handler,
)
# Sequence of keys presses.
KeysTuple = Tuple[Union[Keys, str], ...]
class KeyBindingsBase(metaclass=ABCMeta):
"""
Interface for a KeyBindings.
"""
@abstractproperty
def _version(self) -> Hashable:
"""
For cache invalidation. - This should increase every time that
something changes.
"""
return 0
@abstractmethod
def get_bindings_for_keys(self, keys: KeysTuple) -> List[Binding]:
"""
Return a list of key bindings that can handle these keys.
(This return also inactive bindings, so the `filter` still has to be
called, for checking it.)
:param keys: tuple of keys.
"""
return []
@abstractmethod
def get_bindings_starting_with_keys(self, keys: KeysTuple) -> List[Binding]:
"""
Return a list of key bindings that handle a key sequence starting with
`keys`. (It does only return bindings for which the sequences are
longer than `keys`. And like `get_bindings_for_keys`, it also includes
inactive bindings.)
:param keys: tuple of keys.
"""
return []
@abstractproperty
def bindings(self) -> List[Binding]:
"""
List of `Binding` objects.
(These need to be exposed, so that `KeyBindings` objects can be merged
together.)
"""
return []
# `add` and `remove` don't have to be part of this interface.
T = TypeVar("T", bound=Union[KeyHandlerCallable, Binding])
class KeyBindings(KeyBindingsBase):
"""
A container for a set of key bindings.
Example usage::
kb = KeyBindings()
@kb.add('c-t')
def _(event):
print('Control-T pressed')
@kb.add('c-a', 'c-b')
def _(event):
print('Control-A pressed, followed by Control-B')
@kb.add('c-x', filter=is_searching)
def _(event):
print('Control-X pressed') # Works only if we are searching.
"""
def __init__(self) -> None:
self._bindings: List[Binding] = []
self._get_bindings_for_keys_cache: SimpleCache[
KeysTuple, List[Binding]
] = SimpleCache(maxsize=10000)
self._get_bindings_starting_with_keys_cache: SimpleCache[
KeysTuple, List[Binding]
] = SimpleCache(maxsize=1000)
self.__version = 0 # For cache invalidation.
def _clear_cache(self) -> None:
self.__version += 1
self._get_bindings_for_keys_cache.clear()
self._get_bindings_starting_with_keys_cache.clear()
@property
def bindings(self) -> List[Binding]:
return self._bindings
@property
def _version(self) -> Hashable:
return self.__version
def add(
self,
*keys: Union[Keys, str],
filter: FilterOrBool = True,
eager: FilterOrBool = False,
is_global: FilterOrBool = False,
save_before: Callable[["KeyPressEvent"], bool] = (lambda e: True),
record_in_macro: FilterOrBool = True,
) -> Callable[[T], T]:
"""
Decorator for adding a key bindings.
:param filter: :class:`~prompt_toolkit.filters.Filter` to determine
when this key binding is active.
:param eager: :class:`~prompt_toolkit.filters.Filter` or `bool`.
When True, ignore potential longer matches when this key binding is
hit. E.g. when there is an active eager key binding for Ctrl-X,
execute the handler immediately and ignore the key binding for
Ctrl-X Ctrl-E of which it is a prefix.
:param is_global: When this key bindings is added to a `Container` or
`Control`, make it a global (always active) binding.
:param save_before: Callable that takes an `Event` and returns True if
we should save the current buffer, before handling the event.
(That's the default.)
:param record_in_macro: Record these key bindings when a macro is
being recorded. (True by default.)
"""
assert keys
keys = tuple(_parse_key(k) for k in keys)
if isinstance(filter, Never):
# When a filter is Never, it will always stay disabled, so in that
# case don't bother putting it in the key bindings. It will slow
# down every key press otherwise.
def decorator(func: T) -> T:
return func
else:
def decorator(func: T) -> T:
if isinstance(func, Binding):
# We're adding an existing Binding object.
self.bindings.append(
Binding(
keys,
func.handler,
filter=func.filter & to_filter(filter),
eager=to_filter(eager) | func.eager,
is_global=to_filter(is_global) | func.is_global,
save_before=func.save_before,
record_in_macro=func.record_in_macro,
)
)
else:
self.bindings.append(
Binding(
keys,
cast(KeyHandlerCallable, func),
filter=filter,
eager=eager,
is_global=is_global,
save_before=save_before,
record_in_macro=record_in_macro,
)
)
self._clear_cache()
return func
return decorator
def remove(self, *args: Union[Keys, str, KeyHandlerCallable]) -> None:
"""
Remove a key binding.
This expects either a function that was given to `add` method as
parameter or a sequence of key bindings.
Raises `ValueError` when no bindings was found.
Usage::
remove(handler) # Pass handler.
remove('c-x', 'c-a') # Or pass the key bindings.
"""
found = False
if callable(args[0]):
assert len(args) == 1
function = args[0]
# Remove the given function.
for b in self.bindings:
if b.handler == function:
self.bindings.remove(b)
found = True
else:
assert len(args) > 0
args = cast(Tuple[Union[Keys, str]], args)
# Remove this sequence of key bindings.
keys = tuple(_parse_key(k) for k in args)
for b in self.bindings:
if b.keys == keys:
self.bindings.remove(b)
found = True
if found:
self._clear_cache()
else:
# No key binding found for this function. Raise ValueError.
raise ValueError(f"Binding not found: {function!r}")
# For backwards-compatibility.
add_binding = add
remove_binding = remove
def get_bindings_for_keys(self, keys: KeysTuple) -> List[Binding]:
"""
Return a list of key bindings that can handle this key.
(This return also inactive bindings, so the `filter` still has to be
called, for checking it.)
:param keys: tuple of keys.
"""
def get() -> List[Binding]:
result: List[Tuple[int, Binding]] = []
for b in self.bindings:
if len(keys) == len(b.keys):
match = True
any_count = 0
for i, j in zip(b.keys, keys):
if i != j and i != Keys.Any:
match = False
break
if i == Keys.Any:
any_count += 1
if match:
result.append((any_count, b))
# Place bindings that have more 'Any' occurrences in them at the end.
result = sorted(result, key=lambda item: -item[0])
return [item[1] for item in result]
return self._get_bindings_for_keys_cache.get(keys, get)
def get_bindings_starting_with_keys(self, keys: KeysTuple) -> List[Binding]:
"""
Return a list of key bindings that handle a key sequence starting with
`keys`. (It does only return bindings for which the sequences are
longer than `keys`. And like `get_bindings_for_keys`, it also includes
inactive bindings.)
:param keys: tuple of keys.
"""
def get() -> List[Binding]:
result = []
for b in self.bindings:
if len(keys) < len(b.keys):
match = True
for i, j in zip(b.keys, keys):
if i != j and i != Keys.Any:
match = False
break
if match:
result.append(b)
return result
return self._get_bindings_starting_with_keys_cache.get(keys, get)
def _parse_key(key: Union[Keys, str]) -> Union[str, Keys]:
"""
Replace key by alias and verify whether it's a valid one.
"""
# Already a parse key? -> Return it.
if isinstance(key, Keys):
return key
# Lookup aliases.
key = KEY_ALIASES.get(key, key)
# Replace 'space' by ' '
if key == "space":
key = " "
# Return as `Key` object when it's a special key.
try:
return Keys(key)
except ValueError:
pass
# Final validation.
if len(key) != 1:
raise ValueError(f"Invalid key: {key}")
return key
def key_binding(
filter: FilterOrBool = True,
eager: FilterOrBool = False,
is_global: FilterOrBool = False,
save_before: Callable[["KeyPressEvent"], bool] = (lambda event: True),
record_in_macro: FilterOrBool = True,
) -> Callable[[KeyHandlerCallable], Binding]:
"""
Decorator that turn a function into a `Binding` object. This can be added
to a `KeyBindings` object when a key binding is assigned.
"""
assert save_before is None or callable(save_before)
filter = to_filter(filter)
eager = to_filter(eager)
is_global = to_filter(is_global)
save_before = save_before
record_in_macro = to_filter(record_in_macro)
keys = ()
def decorator(function: KeyHandlerCallable) -> Binding:
return Binding(
keys,
function,
filter=filter,
eager=eager,
is_global=is_global,
save_before=save_before,
record_in_macro=record_in_macro,
)
return decorator
class _Proxy(KeyBindingsBase):
"""
Common part for ConditionalKeyBindings and _MergedKeyBindings.
"""
def __init__(self) -> None:
# `KeyBindings` to be synchronized with all the others.
self._bindings2: KeyBindingsBase = KeyBindings()
self._last_version: Hashable = ()
def _update_cache(self) -> None:
"""
If `self._last_version` is outdated, then this should update
the version and `self._bindings2`.
"""
raise NotImplementedError
# Proxy methods to self._bindings2.
@property
def bindings(self) -> List[Binding]:
self._update_cache()
return self._bindings2.bindings
@property
def _version(self) -> Hashable:
self._update_cache()
return self._last_version
def get_bindings_for_keys(self, keys: KeysTuple) -> List[Binding]:
self._update_cache()
return self._bindings2.get_bindings_for_keys(keys)
def get_bindings_starting_with_keys(self, keys: KeysTuple) -> List[Binding]:
self._update_cache()
return self._bindings2.get_bindings_starting_with_keys(keys)
class ConditionalKeyBindings(_Proxy):
"""
Wraps around a `KeyBindings`. Disable/enable all the key bindings according to
the given (additional) filter.::
@Condition
def setting_is_true():
return True # or False
registry = ConditionalKeyBindings(key_bindings, setting_is_true)
When new key bindings are added to this object. They are also
enable/disabled according to the given `filter`.
:param registries: List of :class:`.KeyBindings` objects.
:param filter: :class:`~prompt_toolkit.filters.Filter` object.
"""
def __init__(
self, key_bindings: KeyBindingsBase, filter: FilterOrBool = True
) -> None:
_Proxy.__init__(self)
self.key_bindings = key_bindings
self.filter = to_filter(filter)
def _update_cache(self) -> None:
"If the original key bindings was changed. Update our copy version."
expected_version = self.key_bindings._version
if self._last_version != expected_version:
bindings2 = KeyBindings()
# Copy all bindings from `self.key_bindings`, adding our condition.
for b in self.key_bindings.bindings:
bindings2.bindings.append(
Binding(
keys=b.keys,
handler=b.handler,
filter=self.filter & b.filter,
eager=b.eager,
is_global=b.is_global,
save_before=b.save_before,
record_in_macro=b.record_in_macro,
)
)
self._bindings2 = bindings2
self._last_version = expected_version
class _MergedKeyBindings(_Proxy):
"""
Merge multiple registries of key bindings into one.
This class acts as a proxy to multiple :class:`.KeyBindings` objects, but
behaves as if this is just one bigger :class:`.KeyBindings`.
:param registries: List of :class:`.KeyBindings` objects.
"""
def __init__(self, registries: Sequence[KeyBindingsBase]) -> None:
_Proxy.__init__(self)
self.registries = registries
def _update_cache(self) -> None:
"""
If one of the original registries was changed. Update our merged
version.
"""
expected_version = tuple(r._version for r in self.registries)
if self._last_version != expected_version:
bindings2 = KeyBindings()
for reg in self.registries:
bindings2.bindings.extend(reg.bindings)
self._bindings2 = bindings2
self._last_version = expected_version
def merge_key_bindings(bindings: Sequence[KeyBindingsBase]) -> _MergedKeyBindings:
"""
Merge multiple :class:`.Keybinding` objects together.
Usage::
bindings = merge_key_bindings([bindings1, bindings2, ...])
"""
return _MergedKeyBindings(bindings)
class DynamicKeyBindings(_Proxy):
"""
KeyBindings class that can dynamically returns any KeyBindings.
:param get_key_bindings: Callable that returns a :class:`.KeyBindings` instance.
"""
def __init__(
self, get_key_bindings: Callable[[], Optional[KeyBindingsBase]]
) -> None:
self.get_key_bindings = get_key_bindings
self.__version = 0
self._last_child_version = None
self._dummy = KeyBindings() # Empty key bindings.
def _update_cache(self) -> None:
key_bindings = self.get_key_bindings() or self._dummy
assert isinstance(key_bindings, KeyBindingsBase)
version = id(key_bindings), key_bindings._version
self._bindings2 = key_bindings
self._last_version = version
class GlobalOnlyKeyBindings(_Proxy):
"""
Wrapper around a :class:`.KeyBindings` object that only exposes the global
key bindings.
"""
def __init__(self, key_bindings: KeyBindingsBase) -> None:
_Proxy.__init__(self)
self.key_bindings = key_bindings
def _update_cache(self) -> None:
"""
If one of the original registries was changed. Update our merged
version.
"""
expected_version = self.key_bindings._version
if self._last_version != expected_version:
bindings2 = KeyBindings()
for b in self.key_bindings.bindings:
if b.is_global():
bindings2.bindings.append(b)
self._bindings2 = bindings2
self._last_version = expected_version

View File

@@ -0,0 +1,528 @@
"""
An :class:`~.KeyProcessor` receives callbacks for the keystrokes parsed from
the input in the :class:`~prompt_toolkit.inputstream.InputStream` instance.
The `KeyProcessor` will according to the implemented keybindings call the
correct callbacks when new key presses are feed through `feed`.
"""
import weakref
from asyncio import Task, sleep
from collections import deque
from typing import TYPE_CHECKING, Any, Deque, Generator, List, Optional, Union
from prompt_toolkit.application.current import get_app
from prompt_toolkit.enums import EditingMode
from prompt_toolkit.filters.app import vi_navigation_mode
from prompt_toolkit.keys import Keys
from prompt_toolkit.utils import Event
from .key_bindings import Binding, KeyBindingsBase
if TYPE_CHECKING:
from prompt_toolkit.application import Application
from prompt_toolkit.buffer import Buffer
__all__ = [
"KeyProcessor",
"KeyPress",
"KeyPressEvent",
]
class KeyPress:
"""
:param key: A `Keys` instance or text (one character).
:param data: The received string on stdin. (Often vt100 escape codes.)
"""
def __init__(self, key: Union[Keys, str], data: Optional[str] = None) -> None:
assert isinstance(key, Keys) or len(key) == 1
if data is None:
if isinstance(key, Keys):
data = key.value
else:
data = key # 'key' is a one character string.
self.key = key
self.data = data
def __repr__(self) -> str:
return f"{self.__class__.__name__}(key={self.key!r}, data={self.data!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, KeyPress):
return False
return self.key == other.key and self.data == other.data
"""
Helper object to indicate flush operation in the KeyProcessor.
NOTE: the implementation is very similar to the VT100 parser.
"""
_Flush = KeyPress("?", data="_Flush")
class KeyProcessor:
"""
Statemachine that receives :class:`KeyPress` instances and according to the
key bindings in the given :class:`KeyBindings`, calls the matching handlers.
::
p = KeyProcessor(key_bindings)
# Send keys into the processor.
p.feed(KeyPress(Keys.ControlX, '\x18'))
p.feed(KeyPress(Keys.ControlC, '\x03')
# Process all the keys in the queue.
p.process_keys()
# Now the ControlX-ControlC callback will be called if this sequence is
# registered in the key bindings.
:param key_bindings: `KeyBindingsBase` instance.
"""
def __init__(self, key_bindings: KeyBindingsBase) -> None:
self._bindings = key_bindings
self.before_key_press = Event(self)
self.after_key_press = Event(self)
self._flush_wait_task: Optional[Task[None]] = None
self.reset()
def reset(self) -> None:
self._previous_key_sequence: List[KeyPress] = []
self._previous_handler: Optional[Binding] = None
# The queue of keys not yet send to our _process generator/state machine.
self.input_queue: Deque[KeyPress] = deque()
# The key buffer that is matched in the generator state machine.
# (This is at at most the amount of keys that make up for one key binding.)
self.key_buffer: List[KeyPress] = []
#: Readline argument (for repetition of commands.)
#: https://www.gnu.org/software/bash/manual/html_node/Readline-Arguments.html
self.arg: Optional[str] = None
# Start the processor coroutine.
self._process_coroutine = self._process()
self._process_coroutine.send(None) # type: ignore
def _get_matches(self, key_presses: List[KeyPress]) -> List[Binding]:
"""
For a list of :class:`KeyPress` instances. Give the matching handlers
that would handle this.
"""
keys = tuple(k.key for k in key_presses)
# Try match, with mode flag
return [b for b in self._bindings.get_bindings_for_keys(keys) if b.filter()]
def _is_prefix_of_longer_match(self, key_presses: List[KeyPress]) -> bool:
"""
For a list of :class:`KeyPress` instances. Return True if there is any
handler that is bound to a suffix of this keys.
"""
keys = tuple(k.key for k in key_presses)
# Get the filters for all the key bindings that have a longer match.
# Note that we transform it into a `set`, because we don't care about
# the actual bindings and executing it more than once doesn't make
# sense. (Many key bindings share the same filter.)
filters = {
b.filter for b in self._bindings.get_bindings_starting_with_keys(keys)
}
# When any key binding is active, return True.
return any(f() for f in filters)
def _process(self) -> Generator[None, KeyPress, None]:
"""
Coroutine implementing the key match algorithm. Key strokes are sent
into this generator, and it calls the appropriate handlers.
"""
buffer = self.key_buffer
retry = False
while True:
flush = False
if retry:
retry = False
else:
key = yield
if key is _Flush:
flush = True
else:
buffer.append(key)
# If we have some key presses, check for matches.
if buffer:
matches = self._get_matches(buffer)
if flush:
is_prefix_of_longer_match = False
else:
is_prefix_of_longer_match = self._is_prefix_of_longer_match(buffer)
# When eager matches were found, give priority to them and also
# ignore all the longer matches.
eager_matches = [m for m in matches if m.eager()]
if eager_matches:
matches = eager_matches
is_prefix_of_longer_match = False
# Exact matches found, call handler.
if not is_prefix_of_longer_match and matches:
self._call_handler(matches[-1], key_sequence=buffer[:])
del buffer[:] # Keep reference.
# No match found.
elif not is_prefix_of_longer_match and not matches:
retry = True
found = False
# Loop over the input, try longest match first and shift.
for i in range(len(buffer), 0, -1):
matches = self._get_matches(buffer[:i])
if matches:
self._call_handler(matches[-1], key_sequence=buffer[:i])
del buffer[:i]
found = True
break
if not found:
del buffer[:1]
def feed(self, key_press: KeyPress, first: bool = False) -> None:
"""
Add a new :class:`KeyPress` to the input queue.
(Don't forget to call `process_keys` in order to process the queue.)
:param first: If true, insert before everything else.
"""
if first:
self.input_queue.appendleft(key_press)
else:
self.input_queue.append(key_press)
def feed_multiple(self, key_presses: List[KeyPress], first: bool = False) -> None:
"""
:param first: If true, insert before everything else.
"""
if first:
self.input_queue.extendleft(reversed(key_presses))
else:
self.input_queue.extend(key_presses)
def process_keys(self) -> None:
"""
Process all the keys in the `input_queue`.
(To be called after `feed`.)
Note: because of the `feed`/`process_keys` separation, it is
possible to call `feed` from inside a key binding.
This function keeps looping until the queue is empty.
"""
app = get_app()
def not_empty() -> bool:
# When the application result is set, stop processing keys. (E.g.
# if ENTER was received, followed by a few additional key strokes,
# leave the other keys in the queue.)
if app.is_done:
# But if there are still CPRResponse keys in the queue, these
# need to be processed.
return any(k for k in self.input_queue if k.key == Keys.CPRResponse)
else:
return bool(self.input_queue)
def get_next() -> KeyPress:
if app.is_done:
# Only process CPR responses. Everything else is typeahead.
cpr = [k for k in self.input_queue if k.key == Keys.CPRResponse][0]
self.input_queue.remove(cpr)
return cpr
else:
return self.input_queue.popleft()
is_flush = False
while not_empty():
# Process next key.
key_press = get_next()
is_flush = key_press is _Flush
is_cpr = key_press.key == Keys.CPRResponse
if not is_flush and not is_cpr:
self.before_key_press.fire()
try:
self._process_coroutine.send(key_press)
except Exception:
# If for some reason something goes wrong in the parser, (maybe
# an exception was raised) restart the processor for next time.
self.reset()
self.empty_queue()
raise
if not is_flush and not is_cpr:
self.after_key_press.fire()
# Skip timeout if the last key was flush.
if not is_flush:
self._start_timeout()
def empty_queue(self) -> List[KeyPress]:
"""
Empty the input queue. Return the unprocessed input.
"""
key_presses = list(self.input_queue)
self.input_queue.clear()
# Filter out CPRs. We don't want to return these.
key_presses = [k for k in key_presses if k.key != Keys.CPRResponse]
return key_presses
def _call_handler(self, handler: Binding, key_sequence: List[KeyPress]) -> None:
app = get_app()
was_recording_emacs = app.emacs_state.is_recording
was_recording_vi = bool(app.vi_state.recording_register)
was_temporary_navigation_mode = app.vi_state.temporary_navigation_mode
arg = self.arg
self.arg = None
event = KeyPressEvent(
weakref.ref(self),
arg=arg,
key_sequence=key_sequence,
previous_key_sequence=self._previous_key_sequence,
is_repeat=(handler == self._previous_handler),
)
# Save the state of the current buffer.
if handler.save_before(event):
event.app.current_buffer.save_to_undo_stack()
# Call handler.
from prompt_toolkit.buffer import EditReadOnlyBuffer
try:
handler.call(event)
self._fix_vi_cursor_position(event)
except EditReadOnlyBuffer:
# When a key binding does an attempt to change a buffer which is
# read-only, we can ignore that. We sound a bell and go on.
app.output.bell()
if was_temporary_navigation_mode:
self._leave_vi_temp_navigation_mode(event)
self._previous_key_sequence = key_sequence
self._previous_handler = handler
# Record the key sequence in our macro. (Only if we're in macro mode
# before and after executing the key.)
if handler.record_in_macro():
if app.emacs_state.is_recording and was_recording_emacs:
recording = app.emacs_state.current_recording
if recording is not None: # Should always be true, given that
# `was_recording_emacs` is set.
recording.extend(key_sequence)
if app.vi_state.recording_register and was_recording_vi:
for k in key_sequence:
app.vi_state.current_recording += k.data
def _fix_vi_cursor_position(self, event: "KeyPressEvent") -> None:
"""
After every command, make sure that if we are in Vi navigation mode, we
never put the cursor after the last character of a line. (Unless it's
an empty line.)
"""
app = event.app
buff = app.current_buffer
preferred_column = buff.preferred_column
if (
vi_navigation_mode()
and buff.document.is_cursor_at_the_end_of_line
and len(buff.document.current_line) > 0
):
buff.cursor_position -= 1
# Set the preferred_column for arrow up/down again.
# (This was cleared after changing the cursor position.)
buff.preferred_column = preferred_column
def _leave_vi_temp_navigation_mode(self, event: "KeyPressEvent") -> None:
"""
If we're in Vi temporary navigation (normal) mode, return to
insert/replace mode after executing one action.
"""
app = event.app
if app.editing_mode == EditingMode.VI:
# Not waiting for a text object and no argument has been given.
if app.vi_state.operator_func is None and self.arg is None:
app.vi_state.temporary_navigation_mode = False
def _start_timeout(self) -> None:
"""
Start auto flush timeout. Similar to Vim's `timeoutlen` option.
Start a background coroutine with a timer. When this timeout expires
and no key was pressed in the meantime, we flush all data in the queue
and call the appropriate key binding handlers.
"""
app = get_app()
timeout = app.timeoutlen
if timeout is None:
return
async def wait() -> None:
"Wait for timeout."
# This sleep can be cancelled. In that case we don't flush.
await sleep(timeout)
if len(self.key_buffer) > 0:
# (No keys pressed in the meantime.)
flush_keys()
def flush_keys() -> None:
"Flush keys."
self.feed(_Flush)
self.process_keys()
# Automatically flush keys.
if self._flush_wait_task:
self._flush_wait_task.cancel()
self._flush_wait_task = app.create_background_task(wait())
def send_sigint(self) -> None:
"""
Send SIGINT. Immediately call the SIGINT key handler.
"""
self.feed(KeyPress(key=Keys.SIGINT), first=True)
self.process_keys()
class KeyPressEvent:
"""
Key press event, delivered to key bindings.
:param key_processor_ref: Weak reference to the `KeyProcessor`.
:param arg: Repetition argument.
:param key_sequence: List of `KeyPress` instances.
:param previouskey_sequence: Previous list of `KeyPress` instances.
:param is_repeat: True when the previous event was delivered to the same handler.
"""
def __init__(
self,
key_processor_ref: "weakref.ReferenceType[KeyProcessor]",
arg: Optional[str],
key_sequence: List[KeyPress],
previous_key_sequence: List[KeyPress],
is_repeat: bool,
) -> None:
self._key_processor_ref = key_processor_ref
self.key_sequence = key_sequence
self.previous_key_sequence = previous_key_sequence
#: True when the previous key sequence was handled by the same handler.
self.is_repeat = is_repeat
self._arg = arg
self._app = get_app()
def __repr__(self) -> str:
return "KeyPressEvent(arg={!r}, key_sequence={!r}, is_repeat={!r})".format(
self.arg,
self.key_sequence,
self.is_repeat,
)
@property
def data(self) -> str:
return self.key_sequence[-1].data
@property
def key_processor(self) -> KeyProcessor:
processor = self._key_processor_ref()
if processor is None:
raise Exception("KeyProcessor was lost. This should not happen.")
return processor
@property
def app(self) -> "Application[Any]":
"""
The current `Application` object.
"""
return self._app
@property
def current_buffer(self) -> "Buffer":
"""
The current buffer.
"""
return self.app.current_buffer
@property
def arg(self) -> int:
"""
Repetition argument.
"""
if self._arg == "-":
return -1
result = int(self._arg or 1)
# Don't exceed a million.
if int(result) >= 1000000:
result = 1
return result
@property
def arg_present(self) -> bool:
"""
True if repetition argument was explicitly provided.
"""
return self._arg is not None
def append_to_arg_count(self, data: str) -> None:
"""
Add digit to the input argument.
:param data: the typed digit as string
"""
assert data in "-0123456789"
current = self._arg
if data == "-":
assert current is None or current == "-"
result = data
elif current is None:
result = data
else:
result = f"{current}{data}"
self.key_processor.arg = result
@property
def cli(self) -> "Application[Any]":
"For backward-compatibility."
return self.app

View File

@@ -0,0 +1,107 @@
from enum import Enum
from typing import TYPE_CHECKING, Callable, Dict, Optional
from prompt_toolkit.clipboard import ClipboardData
if TYPE_CHECKING:
from .key_bindings.vi import TextObject
from .key_processor import KeyPressEvent
__all__ = [
"InputMode",
"CharacterFind",
"ViState",
]
class InputMode(str, Enum):
value: str
INSERT = "vi-insert"
INSERT_MULTIPLE = "vi-insert-multiple"
NAVIGATION = "vi-navigation" # Normal mode.
REPLACE = "vi-replace"
REPLACE_SINGLE = "vi-replace-single"
class CharacterFind:
def __init__(self, character: str, backwards: bool = False) -> None:
self.character = character
self.backwards = backwards
class ViState:
"""
Mutable class to hold the state of the Vi navigation.
"""
def __init__(self) -> None:
#: None or CharacterFind instance. (This is used to repeat the last
#: search in Vi mode, by pressing the 'n' or 'N' in navigation mode.)
self.last_character_find: Optional[CharacterFind] = None
# When an operator is given and we are waiting for text object,
# -- e.g. in the case of 'dw', after the 'd' --, an operator callback
# is set here.
self.operator_func: Optional[
Callable[["KeyPressEvent", "TextObject"], None]
] = None
self.operator_arg: Optional[int] = None
#: Named registers. Maps register name (e.g. 'a') to
#: :class:`ClipboardData` instances.
self.named_registers: Dict[str, ClipboardData] = {}
#: The Vi mode we're currently in to.
self.__input_mode = InputMode.INSERT
#: Waiting for digraph.
self.waiting_for_digraph = False
self.digraph_symbol1: Optional[str] = None # (None or a symbol.)
#: When true, make ~ act as an operator.
self.tilde_operator = False
#: Register in which we are recording a macro.
#: `None` when not recording anything.
# Note that the recording is only stored in the register after the
# recording is stopped. So we record in a separate `current_recording`
# variable.
self.recording_register: Optional[str] = None
self.current_recording: str = ""
# Temporary navigation (normal) mode.
# This happens when control-o has been pressed in insert or replace
# mode. The user can now do one navigation action and we'll return back
# to insert/replace.
self.temporary_navigation_mode = False
@property
def input_mode(self) -> InputMode:
"Get `InputMode`."
return self.__input_mode
@input_mode.setter
def input_mode(self, value: InputMode) -> None:
"Set `InputMode`."
if value == InputMode.NAVIGATION:
self.waiting_for_digraph = False
self.operator_func = None
self.operator_arg = None
self.__input_mode = value
def reset(self) -> None:
"""
Reset state, go back to the given mode. INSERT by default.
"""
# Go back to insert mode.
self.input_mode = InputMode.INSERT
self.waiting_for_digraph = False
self.operator_func = None
self.operator_arg = None
# Reset recording state.
self.recording_register = None
self.current_recording = ""

View File

@@ -0,0 +1,221 @@
from enum import Enum
from typing import Dict, List
__all__ = [
"Keys",
"ALL_KEYS",
]
class Keys(str, Enum):
"""
List of keys for use in key bindings.
Note that this is an "StrEnum", all values can be compared against
strings.
"""
value: str
Escape = "escape" # Also Control-[
ShiftEscape = "s-escape"
ControlAt = "c-@" # Also Control-Space.
ControlA = "c-a"
ControlB = "c-b"
ControlC = "c-c"
ControlD = "c-d"
ControlE = "c-e"
ControlF = "c-f"
ControlG = "c-g"
ControlH = "c-h"
ControlI = "c-i" # Tab
ControlJ = "c-j" # Newline
ControlK = "c-k"
ControlL = "c-l"
ControlM = "c-m" # Carriage return
ControlN = "c-n"
ControlO = "c-o"
ControlP = "c-p"
ControlQ = "c-q"
ControlR = "c-r"
ControlS = "c-s"
ControlT = "c-t"
ControlU = "c-u"
ControlV = "c-v"
ControlW = "c-w"
ControlX = "c-x"
ControlY = "c-y"
ControlZ = "c-z"
Control1 = "c-1"
Control2 = "c-2"
Control3 = "c-3"
Control4 = "c-4"
Control5 = "c-5"
Control6 = "c-6"
Control7 = "c-7"
Control8 = "c-8"
Control9 = "c-9"
Control0 = "c-0"
ControlShift1 = "c-s-1"
ControlShift2 = "c-s-2"
ControlShift3 = "c-s-3"
ControlShift4 = "c-s-4"
ControlShift5 = "c-s-5"
ControlShift6 = "c-s-6"
ControlShift7 = "c-s-7"
ControlShift8 = "c-s-8"
ControlShift9 = "c-s-9"
ControlShift0 = "c-s-0"
ControlBackslash = "c-\\"
ControlSquareClose = "c-]"
ControlCircumflex = "c-^"
ControlUnderscore = "c-_"
Left = "left"
Right = "right"
Up = "up"
Down = "down"
Home = "home"
End = "end"
Insert = "insert"
Delete = "delete"
PageUp = "pageup"
PageDown = "pagedown"
ControlLeft = "c-left"
ControlRight = "c-right"
ControlUp = "c-up"
ControlDown = "c-down"
ControlHome = "c-home"
ControlEnd = "c-end"
ControlInsert = "c-insert"
ControlDelete = "c-delete"
ControlPageUp = "c-pageup"
ControlPageDown = "c-pagedown"
ShiftLeft = "s-left"
ShiftRight = "s-right"
ShiftUp = "s-up"
ShiftDown = "s-down"
ShiftHome = "s-home"
ShiftEnd = "s-end"
ShiftInsert = "s-insert"
ShiftDelete = "s-delete"
ShiftPageUp = "s-pageup"
ShiftPageDown = "s-pagedown"
ControlShiftLeft = "c-s-left"
ControlShiftRight = "c-s-right"
ControlShiftUp = "c-s-up"
ControlShiftDown = "c-s-down"
ControlShiftHome = "c-s-home"
ControlShiftEnd = "c-s-end"
ControlShiftInsert = "c-s-insert"
ControlShiftDelete = "c-s-delete"
ControlShiftPageUp = "c-s-pageup"
ControlShiftPageDown = "c-s-pagedown"
BackTab = "s-tab" # shift + tab
F1 = "f1"
F2 = "f2"
F3 = "f3"
F4 = "f4"
F5 = "f5"
F6 = "f6"
F7 = "f7"
F8 = "f8"
F9 = "f9"
F10 = "f10"
F11 = "f11"
F12 = "f12"
F13 = "f13"
F14 = "f14"
F15 = "f15"
F16 = "f16"
F17 = "f17"
F18 = "f18"
F19 = "f19"
F20 = "f20"
F21 = "f21"
F22 = "f22"
F23 = "f23"
F24 = "f24"
ControlF1 = "c-f1"
ControlF2 = "c-f2"
ControlF3 = "c-f3"
ControlF4 = "c-f4"
ControlF5 = "c-f5"
ControlF6 = "c-f6"
ControlF7 = "c-f7"
ControlF8 = "c-f8"
ControlF9 = "c-f9"
ControlF10 = "c-f10"
ControlF11 = "c-f11"
ControlF12 = "c-f12"
ControlF13 = "c-f13"
ControlF14 = "c-f14"
ControlF15 = "c-f15"
ControlF16 = "c-f16"
ControlF17 = "c-f17"
ControlF18 = "c-f18"
ControlF19 = "c-f19"
ControlF20 = "c-f20"
ControlF21 = "c-f21"
ControlF22 = "c-f22"
ControlF23 = "c-f23"
ControlF24 = "c-f24"
# Matches any key.
Any = "<any>"
# Special.
ScrollUp = "<scroll-up>"
ScrollDown = "<scroll-down>"
CPRResponse = "<cursor-position-response>"
Vt100MouseEvent = "<vt100-mouse-event>"
WindowsMouseEvent = "<windows-mouse-event>"
BracketedPaste = "<bracketed-paste>"
SIGINT = "<sigint>"
# For internal use: key which is ignored.
# (The key binding for this key should not do anything.)
Ignore = "<ignore>"
# Some 'Key' aliases (for backwards-compatibility).
ControlSpace = ControlAt
Tab = ControlI
Enter = ControlM
Backspace = ControlH
# ShiftControl was renamed to ControlShift in
# 888fcb6fa4efea0de8333177e1bbc792f3ff3c24 (20 Feb 2020).
ShiftControlLeft = ControlShiftLeft
ShiftControlRight = ControlShiftRight
ShiftControlHome = ControlShiftHome
ShiftControlEnd = ControlShiftEnd
ALL_KEYS: List[str] = [k.value for k in Keys]
# Aliases.
KEY_ALIASES: Dict[str, str] = {
"backspace": "c-h",
"c-space": "c-@",
"enter": "c-m",
"tab": "c-i",
# ShiftControl was renamed to ControlShift.
"s-c-left": "c-s-left",
"s-c-right": "c-s-right",
"s-c-home": "c-s-home",
"s-c-end": "c-s-end",
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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,411 @@
"""
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 f"Layout({self.container!r}, current_window={self.current_window!r})"
def find_all_windows(self) -> Generator[Window, None, None]:
"""
Find all the :class:`.UIControl` objects in this layout.
"""
for item in self.walk():
if isinstance(item, Window):
yield item
def find_all_controls(self) -> Iterable[UIControl]:
for container in self.find_all_windows():
yield container.content
def focus(self, value: FocusableElement) -> None:
"""
Focus the given UI element.
`value` can be either:
- a :class:`.UIControl`
- a :class:`.Buffer` instance or the name of a :class:`.Buffer`
- a :class:`.Window`
- Any container object. In this case we will focus the :class:`.Window`
from this container that was focused most recent, or the very first
focusable :class:`.Window` of the container.
"""
# BufferControl by buffer name.
if isinstance(value, str):
for control in self.find_all_controls():
if isinstance(control, BufferControl) and control.buffer.name == value:
self.focus(control)
return
raise ValueError(f"Couldn't find Buffer in the current layout: {value!r}.")
# BufferControl by buffer object.
elif isinstance(value, Buffer):
for control in self.find_all_controls():
if isinstance(control, BufferControl) and control.buffer == value:
self.focus(control)
return
raise ValueError(f"Couldn't find Buffer in the current layout: {value!r}.")
# Focus UIControl.
elif isinstance(value, UIControl):
if value not in self.find_all_controls():
raise ValueError(
"Invalid value. Container does not appear in the layout."
)
if not value.is_focusable():
raise ValueError("Invalid value. UIControl is not focusable.")
self.current_control = value
# Otherwise, expecting any Container object.
else:
value = to_container(value)
if isinstance(value, Window):
# This is a `Window`: focus that.
if value not in self.find_all_windows():
raise ValueError(
"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(
f"Invalid value. Container cannot be focused: {value!r}"
)
def has_focus(self, value: FocusableElement) -> bool:
"""
Check whether the given control has the focus.
:param value: :class:`.UIControl` or :class:`.Window` instance.
"""
if isinstance(value, str):
if self.current_buffer is None:
return False
return self.current_buffer.name == value
if isinstance(value, Buffer):
return self.current_buffer == value
if isinstance(value, UIControl):
return self.current_control == value
else:
value = to_container(value)
if isinstance(value, Window):
return self.current_window == value
else:
# Check whether this "container" is focused. This is true if
# one of the elements inside is focused.
for element in walk(value):
if element == self.current_window:
return True
return False
@property
def current_control(self) -> UIControl:
"""
Get the :class:`.UIControl` to currently has the focus.
"""
return self._stack[-1].content
@current_control.setter
def current_control(self, control: UIControl) -> None:
"""
Set the :class:`.UIControl` to receive the focus.
"""
for window in self.find_all_windows():
if window.content == control:
self.current_window = window
return
raise ValueError("Control not found in the user interface.")
@property
def current_window(self) -> Window:
"Return the :class:`.Window` object that is currently focused."
return self._stack[-1]
@current_window.setter
def current_window(self, value: Window) -> None:
"Set the :class:`.Window` object to be currently focused."
self._stack.append(value)
@property
def is_searching(self) -> bool:
"True if we are searching right now."
return self.current_control in self.search_links
@property
def search_target_buffer_control(self) -> 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.
"""
yield from walk(self.container)
def walk_through_modal_area(self) -> Iterable[Container]:
"""
Walk through all the containers which are in the current 'modal' part
of the layout.
"""
# Go up in the tree, and find the root. (it will be a part of the
# layout, if the focus is in a modal part.)
root: Container = self.current_window
while not root.is_modal() and root in self._child_to_parent:
root = self._child_to_parent[root]
yield from walk(root)
def update_parents_relations(self) -> None:
"""
Update child->parent relationships mapping.
"""
parents = {}
def walk(e: Container) -> None:
for c in e.get_children():
parents[c] = e
walk(c)
walk(self.container)
self._child_to_parent = parents
def reset(self) -> None:
# Remove all search links when the UI starts.
# (Important, for instance when control-c is been pressed while
# searching. The prompt cancels, but next `run()` call the search
# links are still there.)
self.search_links.clear()
self.container.reset()
def get_parent(self, container: Container) -> 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,722 @@
import math
from itertools import zip_longest
from typing import (
TYPE_CHECKING,
Callable,
Dict,
Iterable,
List,
Optional,
Tuple,
TypeVar,
Union,
cast,
)
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import CompletionState
from prompt_toolkit.completion import Completion
from prompt_toolkit.data_structures import Point
from prompt_toolkit.filters import (
Condition,
FilterOrBool,
has_completions,
is_done,
to_filter,
)
from prompt_toolkit.formatted_text import (
StyleAndTextTuples,
fragment_list_width,
to_formatted_text,
)
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.layout.utils import explode_text_fragments
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from prompt_toolkit.utils import get_cwidth
from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window
from .controls import GetLinePrefixCallable, UIContent, UIControl
from .dimension import Dimension
from .margins import ScrollbarMargin
if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_bindings import (
KeyBindings,
NotImplementedOrNone,
)
__all__ = [
"CompletionsMenu",
"MultiColumnCompletionsMenu",
]
E = KeyPressEvent
class CompletionsMenuControl(UIControl):
"""
Helper for drawing the complete menu to the screen.
:param scroll_offset: Number (integer) representing the preferred amount of
completions to be displayed before and after the current one. When this
is a very high number, the current completion will be shown in the
middle most of the time.
"""
# Preferred minimum size of the menu control.
# The CompletionsMenu class defines a width of 8, and there is a scrollbar
# of 1.)
MIN_WIDTH = 7
def has_focus(self) -> bool:
return False
def preferred_width(self, max_available_width: int) -> Optional[int]:
complete_state = get_app().current_buffer.complete_state
if complete_state:
menu_width = self._get_menu_width(500, complete_state)
menu_meta_width = self._get_menu_meta_width(500, complete_state)
return menu_width + menu_meta_width
else:
return 0
def preferred_height(
self,
width: int,
max_available_height: int,
wrap_lines: bool,
get_line_prefix: Optional[GetLinePrefixCallable],
) -> Optional[int]:
complete_state = get_app().current_buffer.complete_state
if complete_state:
return len(complete_state.completions)
else:
return 0
def create_content(self, width: int, height: int) -> UIContent:
"""
Create a UIContent object for this control.
"""
complete_state = get_app().current_buffer.complete_state
if complete_state:
completions = complete_state.completions
index = complete_state.complete_index # Can be None!
# Calculate width of completions menu.
menu_width = self._get_menu_width(width, complete_state)
menu_meta_width = self._get_menu_meta_width(
width - menu_width, complete_state
)
show_meta = self._show_meta(complete_state)
def get_line(i: int) -> StyleAndTextTuples:
c = completions[i]
is_current_completion = i == index
result = _get_menu_item_fragments(
c, is_current_completion, menu_width, space_after=True
)
if show_meta:
result += self._get_menu_item_meta_fragments(
c, is_current_completion, menu_meta_width
)
return result
return UIContent(
get_line=get_line,
cursor_position=Point(x=0, y=index or 0),
line_count=len(completions),
)
return UIContent()
def _show_meta(self, complete_state: CompletionState) -> bool:
"""
Return ``True`` if we need to show a column with meta information.
"""
return any(c.display_meta_text for c in complete_state.completions)
def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int:
"""
Return the width of the main column.
"""
return min(
max_width,
max(
self.MIN_WIDTH,
max(get_cwidth(c.display_text) for c in complete_state.completions) + 2,
),
)
def _get_menu_meta_width(
self, max_width: int, complete_state: CompletionState
) -> int:
"""
Return the width of the meta column.
"""
def meta_width(completion: Completion) -> int:
return get_cwidth(completion.display_meta_text)
if self._show_meta(complete_state):
return min(
max_width, max(meta_width(c) for c in complete_state.completions) + 2
)
else:
return 0
def _get_menu_item_meta_fragments(
self, completion: Completion, is_current_completion: bool, width: int
) -> StyleAndTextTuples:
if is_current_completion:
style_str = "class:completion-menu.meta.completion.current"
else:
style_str = "class:completion-menu.meta.completion"
text, tw = _trim_formatted_text(completion.display_meta, width - 2)
padding = " " * (width - 1 - tw)
return to_formatted_text(
cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
style=style_str,
)
def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone":
"""
Handle mouse events: clicking and scrolling.
"""
b = get_app().current_buffer
if mouse_event.event_type == MouseEventType.MOUSE_UP:
# Select completion.
b.go_to_completion(mouse_event.position.y)
b.complete_state = None
elif mouse_event.event_type == MouseEventType.SCROLL_DOWN:
# Scroll up.
b.complete_next(count=3, disable_wrap_around=True)
elif mouse_event.event_type == MouseEventType.SCROLL_UP:
# Scroll down.
b.complete_previous(count=3, disable_wrap_around=True)
return None
def _get_menu_item_fragments(
completion: Completion,
is_current_completion: bool,
width: int,
space_after: bool = False,
) -> StyleAndTextTuples:
"""
Get the style/text tuples for a menu item, styled and trimmed to the given
width.
"""
if is_current_completion:
style_str = "class:completion-menu.completion.current {} {}".format(
completion.style,
completion.selected_style,
)
else:
style_str = "class:completion-menu.completion " + completion.style
text, tw = _trim_formatted_text(
completion.display, (width - 2 if space_after else width - 1)
)
padding = " " * (width - 1 - tw)
return to_formatted_text(
cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
style=style_str,
)
def _trim_formatted_text(
formatted_text: StyleAndTextTuples, max_width: int
) -> Tuple[StyleAndTextTuples, int]:
"""
Trim the text to `max_width`, append dots when the text is too long.
Returns (text, width) tuple.
"""
width = fragment_list_width(formatted_text)
# When the text is too wide, trim it.
if width > max_width:
result = [] # Text fragments.
remaining_width = max_width - 3
for style_and_ch in explode_text_fragments(formatted_text):
ch_width = get_cwidth(style_and_ch[1])
if ch_width <= remaining_width:
result.append(style_and_ch)
remaining_width -= ch_width
else:
break
result.append(("", "..."))
return result, max_width - remaining_width
else:
return formatted_text, width
class CompletionsMenu(ConditionalContainer):
# NOTE: We use a pretty big z_index by default. Menus are supposed to be
# above anything else. We also want to make sure that the content is
# visible at the point where we draw this menu.
def __init__(
self,
max_height: 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,54 @@
from collections import defaultdict
from typing import TYPE_CHECKING, Callable, DefaultDict
from prompt_toolkit.mouse_events import MouseEvent
if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
__all__ = [
"MouseHandler",
"MouseHandlers",
]
MouseHandler = Callable[[MouseEvent], "NotImplementedOrNone"]
class MouseHandlers:
"""
Two dimensional raster of callbacks for mouse events.
"""
def __init__(self) -> None:
def dummy_callback(mouse_event: MouseEvent) -> "NotImplementedOrNone":
"""
:param mouse_event: `MouseEvent` instance.
"""
return NotImplemented
# NOTE: Previously, the data structure was a dictionary mapping (x,y)
# to the handlers. This however would be more inefficient when copying
# over the mouse handlers of the visible region in the scrollable pane.
# Map y (row) to x (column) to handlers.
self.mouse_handlers: DefaultDict[
int, DefaultDict[int, MouseHandler]
] = defaultdict(lambda: defaultdict(lambda: dummy_callback))
def set_mouse_handler_for_range(
self,
x_min: int,
x_max: int,
y_min: int,
y_max: int,
handler: Callable[[MouseEvent], "NotImplementedOrNone"],
) -> None:
"""
Set mouse handler for a region.
"""
for y in range(y_min, y_max):
row = self.mouse_handlers[y]
for x in range(x_min, x_max):
row[x] = handler

Some files were not shown because too many files have changed in this diff Show More