Major fixes and new features
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
54
venv/lib/python3.12/site-packages/prompt_toolkit/__init__.py
Normal file
54
venv/lib/python3.12/site-packages/prompt_toolkit/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
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 __future__ import annotations
|
||||
|
||||
import re
|
||||
from importlib import metadata
|
||||
|
||||
# note: this is a bit more lax than the actual pep 440 to allow for a/b/rc/dev without a number
|
||||
pep440 = re.compile(
|
||||
r"^([1-9]\d*!)?(0|[1-9]\d*)(\.(0|[1-9]\d*))*((a|b|rc)(0|[1-9]\d*)?)?(\.post(0|[1-9]\d*))?(\.dev(0|[1-9]\d*)?)?$",
|
||||
re.UNICODE,
|
||||
)
|
||||
from .application import Application
|
||||
from .formatted_text import ANSI, HTML
|
||||
from .shortcuts import PromptSession, choice, print_formatted_text, prompt
|
||||
|
||||
# Don't forget to update in `docs/conf.py`!
|
||||
__version__ = metadata.version("prompt_toolkit")
|
||||
|
||||
assert pep440.match(__version__)
|
||||
|
||||
# Version tuple.
|
||||
VERSION = tuple(int(v.rstrip("abrc")) for v in __version__.split(".")[:3])
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Application.
|
||||
"Application",
|
||||
# Shortcuts.
|
||||
"prompt",
|
||||
"choice",
|
||||
"PromptSession",
|
||||
"print_formatted_text",
|
||||
# Formatted text.
|
||||
"HTML",
|
||||
"ANSI",
|
||||
# Version info.
|
||||
"__version__",
|
||||
"VERSION",
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING, Any, Generator
|
||||
|
||||
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`
|
||||
explicitly.
|
||||
:param output: Use this as a default output.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, input: Input | None = None, output: Output | None = 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: Application[Any] | None = 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() -> Application[Any] | None:
|
||||
"""
|
||||
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: Input | None = None, output: Output | None = 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 a Telnet/SSH server.
|
||||
"""
|
||||
# If no input/output is specified, fall back to the current input/output,
|
||||
# if there was one that was set/created for the current session.
|
||||
# (Note that we check `_input`/`_output` and not `input`/`output`. This is
|
||||
# because we don't want to accidentally create a new input/output objects
|
||||
# here and store it in the "parent" `AppSession`. Especially, when
|
||||
# combining pytest's `capsys` fixture and `create_app_session`, sys.stdin
|
||||
# and sys.stderr are patched for every test, so we don't want to leak
|
||||
# those outputs object across `AppSession`s.)
|
||||
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
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from prompt_toolkit.eventloop import InputHook
|
||||
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: Callable[[], None] | None = None,
|
||||
set_exception_handler: bool = True,
|
||||
handle_sigint: bool = True,
|
||||
in_thread: bool = False,
|
||||
inputhook: InputHook | None = None,
|
||||
) -> None:
|
||||
raise NotImplementedError("A DummyApplication is not supposed to run.")
|
||||
|
||||
async def run_async(
|
||||
self,
|
||||
pre_run: Callable[[], None] | 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
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Tools for running functions on the terminal above the current application or prompt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Future, ensure_future
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Awaitable, Callable, TypeVar
|
||||
|
||||
from prompt_toolkit.eventloop import run_in_executor_with_context
|
||||
|
||||
from .current import get_app_or_none
|
||||
|
||||
__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:
|
||||
# (Check for `.done()`, because it can be that this future was
|
||||
# cancelled.)
|
||||
if not new_run_in_terminal_f.done():
|
||||
new_run_in_terminal_f.set_result(None)
|
||||
177
venv/lib/python3.12/site-packages/prompt_toolkit/auto_suggest.py
Normal file
177
venv/lib/python3.12/site-packages/prompt_toolkit/auto_suggest.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
`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 __future__ import annotations
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
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 f"Suggestion({self.text})"
|
||||
|
||||
|
||||
class AutoSuggest(metaclass=ABCMeta):
|
||||
"""
|
||||
Base class for auto suggestion implementations.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
|
||||
"""
|
||||
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
|
||||
) -> Suggestion | None:
|
||||
"""
|
||||
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) -> Suggestion | None:
|
||||
return self.auto_suggest.get_suggestion(buff, document)
|
||||
|
||||
async def get_suggestion_async(
|
||||
self, buff: Buffer, document: Document
|
||||
) -> Suggestion | None:
|
||||
"""
|
||||
Run the `get_suggestion` function in a thread.
|
||||
"""
|
||||
|
||||
def run_get_suggestion_thread() -> Suggestion | None:
|
||||
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) -> Suggestion | None:
|
||||
return None # No suggestion
|
||||
|
||||
|
||||
class AutoSuggestFromHistory(AutoSuggest):
|
||||
"""
|
||||
Give suggestions based on the lines in the history.
|
||||
"""
|
||||
|
||||
def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
|
||||
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: bool | Filter) -> None:
|
||||
self.auto_suggest = auto_suggest
|
||||
self.filter = to_filter(filter)
|
||||
|
||||
def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
|
||||
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[[], AutoSuggest | None]) -> None:
|
||||
self.get_auto_suggest = get_auto_suggest
|
||||
|
||||
def get_suggestion(self, buff: Buffer, document: Document) -> Suggestion | None:
|
||||
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
|
||||
) -> Suggestion | None:
|
||||
auto_suggest = self.get_auto_suggest() or DummyAutoSuggest()
|
||||
return await auto_suggest.get_suggestion_async(buff, document)
|
||||
2029
venv/lib/python3.12/site-packages/prompt_toolkit/buffer.py
Normal file
2029
venv/lib/python3.12/site-packages/prompt_toolkit/buffer.py
Normal file
File diff suppressed because it is too large
Load Diff
127
venv/lib/python3.12/site-packages/prompt_toolkit/cache.py
Normal file
127
venv/lib/python3.12/site-packages/prompt_toolkit/cache.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, 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
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Clipboard for command line interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import Callable
|
||||
|
||||
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[[], Clipboard | None]) -> 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()
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
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: ClipboardData | None = 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())
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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: ClipboardData | None = 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,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,438 @@
|
||||
""" """
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import AsyncGenerator, Callable, Iterable, Sequence
|
||||
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.eventloop import aclosing, 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: AnyFormattedText | None = None,
|
||||
display_meta: AnyFormattedText | None = 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 f"{self.__class__.__name__}(text={self.text!r}, start_position={self.start_position!r})"
|
||||
else:
|
||||
return f"{self.__class__.__name__}(text={self.text!r}, start_position={self.start_position!r}, display={self.display!r})"
|
||||
|
||||
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 f"{self.__class__.__name__}(text_inserted={self.text_inserted!r}, completion_requested={self.completion_requested!r})"
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
# NOTE: Right now, we are consuming the `get_completions` generator in
|
||||
# a synchronous background thread, then passing the results one
|
||||
# at a time over a queue, and consuming this queue in the main
|
||||
# thread (that's what `generator_to_async_generator` does). That
|
||||
# means that if the completer is *very* slow, we'll be showing
|
||||
# completions in the UI once they are computed.
|
||||
|
||||
# It's very tempting to replace this implementation with the
|
||||
# commented code below for several reasons:
|
||||
|
||||
# - `generator_to_async_generator` is not perfect and hard to get
|
||||
# right. It's a lot of complexity for little gain. The
|
||||
# implementation needs a huge buffer for it to be efficient
|
||||
# when there are many completions (like 50k+).
|
||||
# - Normally, a completer is supposed to be fast, users can have
|
||||
# "complete while typing" enabled, and want to see the
|
||||
# completions within a second. Handling one completion at a
|
||||
# time, and rendering once we get it here doesn't make any
|
||||
# sense if this is quick anyway.
|
||||
# - Completers like `FuzzyCompleter` prepare all completions
|
||||
# anyway so that they can be sorted by accuracy before they are
|
||||
# yielded. At the point that we start yielding completions
|
||||
# here, we already have all completions.
|
||||
# - The `Buffer` class has complex logic to invalidate the UI
|
||||
# while it is consuming the completions. We don't want to
|
||||
# invalidate the UI for every completion (if there are many),
|
||||
# but we want to do it often enough so that completions are
|
||||
# being displayed while they are produced.
|
||||
|
||||
# We keep the current behavior mainly for backward-compatibility.
|
||||
# Similarly, it would be better for this function to not return
|
||||
# an async generator, but simply be a coroutine that returns a
|
||||
# list of `Completion` objects, containing all completions at
|
||||
# once.
|
||||
|
||||
# Note that this argument doesn't mean we shouldn't use
|
||||
# `ThreadedCompleter`. It still makes sense to produce
|
||||
# completions in a background thread, because we don't want to
|
||||
# freeze the UI while the user is typing. But sending the
|
||||
# completions one at a time to the UI maybe isn't worth it.
|
||||
|
||||
# def get_all_in_thread() -> List[Completion]:
|
||||
# return list(self.get_completions(document, complete_event))
|
||||
|
||||
# completions = await get_running_loop().run_in_executor(None, get_all_in_thread)
|
||||
# for completion in completions:
|
||||
# yield completion
|
||||
|
||||
async with aclosing(
|
||||
generator_to_async_generator(
|
||||
lambda: self.completer.get_completions(document, complete_event)
|
||||
)
|
||||
) as async_generator:
|
||||
async for completion in async_generator:
|
||||
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[[], Completer | None]) -> 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 with aclosing(
|
||||
self.completer.get_completions_async(document, complete_event)
|
||||
) as async_generator:
|
||||
async for item in async_generator:
|
||||
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 with aclosing(
|
||||
completer.get_completions_async(document, complete_event)
|
||||
) as async_generator:
|
||||
async for item in async_generator:
|
||||
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
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
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
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Callable, Iterable
|
||||
|
||||
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: Callable[[], list[str]] | None = None,
|
||||
file_filter: Callable[[str], bool] | None = 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,
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Callable, Iterable, NamedTuple, Sequence
|
||||
|
||||
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: str | None = None,
|
||||
enable_fuzzy: FilterOrBool = True,
|
||||
) -> None:
|
||||
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),
|
||||
)
|
||||
|
||||
inner_completions = list(
|
||||
self.completer.get_completions(document2, complete_event)
|
||||
)
|
||||
|
||||
fuzzy_matches: list[_FuzzyMatch] = []
|
||||
|
||||
if word_before_cursor == "":
|
||||
# If word before the cursor is an empty string, consider all
|
||||
# completions, without filtering everything with an empty regex
|
||||
# pattern.
|
||||
fuzzy_matches = [_FuzzyMatch(0, 0, compl) for compl in inner_completions]
|
||||
else:
|
||||
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 inner_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),
|
||||
# We access to private `_display_meta` attribute, because that one is lazy.
|
||||
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.
|
||||
"""
|
||||
|
||||
def get_display() -> AnyFormattedText:
|
||||
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
|
||||
|
||||
return get_display()
|
||||
|
||||
|
||||
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: Sequence[str] | Callable[[], Sequence[str]],
|
||||
meta_dict: dict[str, str] | None = 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
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Nestedcompleter for completion of hierarchical data structures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Mapping, 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, Completer | None], 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, Completer | None] = {}
|
||||
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(dict.fromkeys(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)
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Iterable, Mapping, Pattern, Sequence
|
||||
|
||||
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: Sequence[str] | Callable[[], Sequence[str]],
|
||||
ignore_case: bool = False,
|
||||
display_dict: Mapping[str, AnyFormattedText] | None = None,
|
||||
meta_dict: Mapping[str, AnyFormattedText] | None = None,
|
||||
WORD: bool = False,
|
||||
sentence: bool = False,
|
||||
match_middle: bool = False,
|
||||
pattern: Pattern[str] | None = 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,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .system import SystemCompleter
|
||||
|
||||
__all__ = ["SystemCompleter"]
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
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 color.
|
||||
- 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 __future__ import annotations
|
||||
|
||||
from .compiler import compile
|
||||
|
||||
__all__ = ["compile"]
|
||||
@@ -0,0 +1,579 @@
|
||||
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"
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Callable, Dict, Iterable, Iterator, Pattern, TypeVar, overload
|
||||
from typing import Match as RegexMatch
|
||||
|
||||
from .regex_parser import (
|
||||
AnyNode,
|
||||
Lookahead,
|
||||
Node,
|
||||
NodeSequence,
|
||||
Regex,
|
||||
Repeat,
|
||||
Variable,
|
||||
parse_regex,
|
||||
tokenize_regex,
|
||||
)
|
||||
|
||||
__all__ = ["compile", "Match", "Variables"]
|
||||
|
||||
|
||||
# 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: EscapeFuncDict | None = None,
|
||||
unescape_funcs: EscapeFuncDict | None = 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 = f"n{counter[0]}"
|
||||
self._group_names_to_nodes[name] = node.varname
|
||||
counter[0] += 1
|
||||
return name
|
||||
|
||||
# Compile regex strings.
|
||||
self._re_pattern = f"^{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 "(?:{})".format("|".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 f"(?P<{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 f"(?:{node.regex})?"
|
||||
|
||||
elif isinstance(node, Lookahead):
|
||||
if node.negative:
|
||||
yield f"(?!{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(f"Got {node!r}")
|
||||
|
||||
for r in transform(root_node):
|
||||
yield f"^(?:{r})$"
|
||||
|
||||
def match(self, string: str) -> Match | None:
|
||||
"""
|
||||
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) -> Match | None:
|
||||
"""
|
||||
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) -> MatchVariable | None:
|
||||
"""
|
||||
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]))
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
@overload
|
||||
def get(self, key: str) -> str | None: ...
|
||||
|
||||
@overload
|
||||
def get(self, key: str, default: str | _T) -> str | _T: ...
|
||||
|
||||
def get(self, key: str, default: str | _T | None = None) -> str | _T | None:
|
||||
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) -> str | None:
|
||||
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: EscapeFuncDict | None = None,
|
||||
unescape_funcs: EscapeFuncDict | None = 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: EscapeFuncDict | None = None,
|
||||
unescape_funcs: EscapeFuncDict | None = None,
|
||||
) -> _CompiledGrammar:
|
||||
"""
|
||||
Compile grammar (given as parse tree), returning a `CompiledGrammar`
|
||||
instance.
|
||||
"""
|
||||
return _CompiledGrammar(
|
||||
root_node, escape_funcs=escape_funcs, unescape_funcs=unescape_funcs
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Completer for a regular grammar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
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:
|
||||
yield from self._remove_duplicates(
|
||||
self._get_completions_for_match(m, complete_event)
|
||||
)
|
||||
|
||||
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]) -> Iterable[Completion]:
|
||||
"""
|
||||
Remove duplicates, while keeping the order.
|
||||
(Sometimes we have duplicates, because the there several matches of the
|
||||
same grammar, each yielding similar completions.)
|
||||
"""
|
||||
|
||||
def hash_completion(completion: Completion) -> tuple[str, int]:
|
||||
return completion.text, completion.start_position
|
||||
|
||||
yielded_so_far: set[tuple[str, int]] = set()
|
||||
|
||||
for completion in items:
|
||||
hash_value = hash_completion(completion)
|
||||
|
||||
if hash_value not in yielded_so_far:
|
||||
yielded_so_far.add(hash_value)
|
||||
yield completion
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
`GrammarLexer` is compatible with other lexers and can be used to highlight
|
||||
the input using a regular grammar with annotations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
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: dict[str, Lexer] | None = 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
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
__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 f"{self.__class__.__name__}(childnode={self.childnode!r}, varname={self.varname!r})"
|
||||
|
||||
|
||||
class Repeat(Node):
|
||||
def __init__(
|
||||
self,
|
||||
childnode: Node,
|
||||
min_repeat: int = 0,
|
||||
max_repeat: int | None = 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(f"{t!r} not supported")
|
||||
|
||||
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
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Validator for a regular language.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .server import PromptToolkitSSHServer, PromptToolkitSSHSession
|
||||
|
||||
__all__ = [
|
||||
"PromptToolkitSSHSession",
|
||||
"PromptToolkitSSHServer",
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Utility for running a prompt_toolkit application in an asyncssh server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from asyncio import get_running_loop
|
||||
from typing import Any, Callable, Coroutine, TextIO, cast
|
||||
|
||||
import asyncssh
|
||||
|
||||
from prompt_toolkit.application.current import AppSession, create_app_session
|
||||
from prompt_toolkit.data_structures import Size
|
||||
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], Coroutine[Any, Any, None]],
|
||||
*,
|
||||
enable_cpr: bool,
|
||||
) -> None:
|
||||
self.interact = interact
|
||||
self.enable_cpr = enable_cpr
|
||||
self.interact_task: asyncio.Task[None] | None = None
|
||||
self._chan: Any | None = None
|
||||
self.app_session: AppSession | None = 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: PipeInput | None = None
|
||||
self._output: Vt100_Output | None = 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_running_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, enable_cpr=self.enable_cpr
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
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_running_loop()
|
||||
loop.run_until_complete(
|
||||
asyncssh.create_server(
|
||||
lambda: MySSHServer(interact),
|
||||
"",
|
||||
port,
|
||||
server_host_keys=["/etc/ssh/..."],
|
||||
)
|
||||
)
|
||||
loop.run_forever()
|
||||
|
||||
:param enable_cpr: When `True`, the default, try to detect whether the SSH
|
||||
client runs in a terminal that responds to "cursor position requests".
|
||||
That way, we can properly determine how much space there is available
|
||||
for the UI (especially for drop down menus) to render.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
interact: Callable[[PromptToolkitSSHSession], Coroutine[Any, Any, None]],
|
||||
*,
|
||||
enable_cpr: bool = True,
|
||||
) -> None:
|
||||
self.interact = interact
|
||||
self.enable_cpr = enable_cpr
|
||||
|
||||
def begin_auth(self, username: str) -> bool:
|
||||
# No authentication.
|
||||
return False
|
||||
|
||||
def session_requested(self) -> PromptToolkitSSHSession:
|
||||
return PromptToolkitSSHSession(self.interact, enable_cpr=self.enable_cpr)
|
||||
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .server import TelnetServer
|
||||
|
||||
__all__ = [
|
||||
"TelnetServer",
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Python logger for the telnet server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__package__)
|
||||
|
||||
__all__ = [
|
||||
"logger",
|
||||
]
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
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`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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))
|
||||
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
Telnet server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import socket
|
||||
from asyncio import get_running_loop
|
||||
from typing import Any, Callable, Coroutine, TextIO, 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.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,
|
||||
)
|
||||
|
||||
__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 negotiation 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(f"Couldn't send data over socket: {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], Coroutine[Any, Any, None]],
|
||||
server: TelnetServer,
|
||||
encoding: str,
|
||||
style: BaseStyle | None,
|
||||
vt100_input: PipeInput,
|
||||
enable_cpr: bool = True,
|
||||
) -> 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.enable_cpr = enable_cpr
|
||||
self.vt100_output: Vt100_Output | None = 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, enable_cpr=enable_cpr
|
||||
)
|
||||
self._ready.set()
|
||||
|
||||
self.parser = TelnetProtocolParser(data_received, size_received, ttype_received)
|
||||
self.context: contextvars.Context | None = 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}".format(*self.addr))
|
||||
self.close()
|
||||
|
||||
# Add reader.
|
||||
loop = get_running_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_running_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.
|
||||
|
||||
Example::
|
||||
|
||||
async def interact(connection):
|
||||
connection.send("Welcome")
|
||||
session = PromptSession()
|
||||
result = await session.prompt_async(message="Say something: ")
|
||||
connection.send(f"You said: {result}\n")
|
||||
|
||||
async def main():
|
||||
server = TelnetServer(interact=interact, port=2323)
|
||||
await server.run()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 23,
|
||||
interact: Callable[
|
||||
[TelnetConnection], Coroutine[Any, Any, None]
|
||||
] = _dummy_interact,
|
||||
encoding: str = "utf-8",
|
||||
style: BaseStyle | None = None,
|
||||
enable_cpr: bool = True,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.interact = interact
|
||||
self.encoding = encoding
|
||||
self.style = style
|
||||
self.enable_cpr = enable_cpr
|
||||
|
||||
self._run_task: asyncio.Task[None] | None = None
|
||||
self._application_tasks: list[asyncio.Task[None]] = []
|
||||
|
||||
self.connections: set[TelnetConnection] = set()
|
||||
|
||||
@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
|
||||
|
||||
async def run(self, ready_cb: Callable[[], None] | None = None) -> None:
|
||||
"""
|
||||
Run the telnet server, until this gets cancelled.
|
||||
|
||||
:param ready_cb: Callback that will be called at the point that we're
|
||||
actually listening.
|
||||
"""
|
||||
socket = self._create_socket(self.host, self.port)
|
||||
logger.info(
|
||||
"Listening for telnet connections on %s port %r", self.host, self.port
|
||||
)
|
||||
|
||||
get_running_loop().add_reader(socket, lambda: self._accept(socket))
|
||||
|
||||
if ready_cb:
|
||||
ready_cb()
|
||||
|
||||
try:
|
||||
# Run forever, until cancelled.
|
||||
await asyncio.Future()
|
||||
finally:
|
||||
get_running_loop().remove_reader(socket)
|
||||
socket.close()
|
||||
|
||||
# Wait for all applications to finish.
|
||||
for t in self._application_tasks:
|
||||
t.cancel()
|
||||
|
||||
# (This is similar to
|
||||
# `Application.cancel_and_wait_for_background_tasks`. We wait for the
|
||||
# background tasks to complete, but don't propagate exceptions, because
|
||||
# we can't use `ExceptionGroup` yet.)
|
||||
if len(self._application_tasks) > 0:
|
||||
await asyncio.wait(
|
||||
self._application_tasks,
|
||||
timeout=None,
|
||||
return_when=asyncio.ALL_COMPLETED,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Deprecated: Use `.run()` instead.
|
||||
|
||||
Start the telnet server (stop by calling and awaiting `stop()`).
|
||||
"""
|
||||
if self._run_task is not None:
|
||||
# Already running.
|
||||
return
|
||||
|
||||
self._run_task = get_running_loop().create_task(self.run())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""
|
||||
Deprecated: Use `.run()` instead.
|
||||
|
||||
Stop a telnet server that was started using `.start()` and wait for the
|
||||
cancellation to complete.
|
||||
"""
|
||||
if self._run_task is not None:
|
||||
self._run_task.cancel()
|
||||
try:
|
||||
await self._run_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def _accept(self, listen_socket: socket.socket) -> None:
|
||||
"""
|
||||
Accept new incoming connection.
|
||||
"""
|
||||
conn, addr = 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,
|
||||
enable_cpr=self.enable_cpr,
|
||||
)
|
||||
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(f"Got {type(e).__name__}", e)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
self._application_tasks.remove(task)
|
||||
|
||||
task = get_running_loop().create_task(run())
|
||||
self._application_tasks.append(task)
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 implementation to
|
||||
# interfere 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 in {
|
||||
InputMode.NAVIGATION,
|
||||
}:
|
||||
return CursorShape.BLOCK
|
||||
if application.vi_state.input_mode in {
|
||||
InputMode.INSERT,
|
||||
InputMode.INSERT_MULTIPLE,
|
||||
}:
|
||||
return CursorShape.BEAM
|
||||
if application.vi_state.input_mode in {
|
||||
InputMode.REPLACE,
|
||||
InputMode.REPLACE_SINGLE,
|
||||
}:
|
||||
return CursorShape.UNDERLINE
|
||||
elif application.editing_mode == EditingMode.EMACS:
|
||||
# like vi's INSERT
|
||||
return CursorShape.BEAM
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
__all__ = [
|
||||
"Point",
|
||||
"Size",
|
||||
]
|
||||
|
||||
|
||||
class Point(NamedTuple):
|
||||
x: int
|
||||
y: int
|
||||
|
||||
|
||||
class Size(NamedTuple):
|
||||
rows: int
|
||||
columns: int
|
||||
1182
venv/lib/python3.12/site-packages/prompt_toolkit/document.py
Normal file
1182
venv/lib/python3.12/site-packages/prompt_toolkit/document.py
Normal file
File diff suppressed because it is too large
Load Diff
19
venv/lib/python3.12/site-packages/prompt_toolkit/enums.py
Normal file
19
venv/lib/python3.12/site-packages/prompt_toolkit/enums.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .async_generator import aclosing, generator_to_async_generator
|
||||
from .inputhook import (
|
||||
InputHook,
|
||||
InputHookContext,
|
||||
InputHookSelector,
|
||||
new_eventloop_with_inputhook,
|
||||
set_eventloop_with_inputhook,
|
||||
)
|
||||
from .utils import (
|
||||
call_soon_threadsafe,
|
||||
get_traceback_from_context,
|
||||
run_in_executor_with_context,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Async generator
|
||||
"generator_to_async_generator",
|
||||
"aclosing",
|
||||
# Utils.
|
||||
"run_in_executor_with_context",
|
||||
"call_soon_threadsafe",
|
||||
"get_traceback_from_context",
|
||||
# Inputhooks.
|
||||
"InputHook",
|
||||
"new_eventloop_with_inputhook",
|
||||
"set_eventloop_with_inputhook",
|
||||
"InputHookSelector",
|
||||
"InputHookContext",
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Implementation for async generators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import get_running_loop
|
||||
from contextlib import asynccontextmanager
|
||||
from queue import Empty, Full, Queue
|
||||
from typing import Any, AsyncGenerator, Callable, Iterable, TypeVar
|
||||
|
||||
from .utils import run_in_executor_with_context
|
||||
|
||||
__all__ = [
|
||||
"aclosing",
|
||||
"generator_to_async_generator",
|
||||
]
|
||||
|
||||
_T_Generator = TypeVar("_T_Generator", bound=AsyncGenerator[Any, None])
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def aclosing(
|
||||
thing: _T_Generator,
|
||||
) -> AsyncGenerator[_T_Generator, None]:
|
||||
"Similar to `contextlib.aclosing`, in Python 3.10."
|
||||
try:
|
||||
yield thing
|
||||
finally:
|
||||
await thing.aclose()
|
||||
|
||||
|
||||
# By default, choose a buffer size that's a good balance between having enough
|
||||
# throughput, but not consuming too much memory. We use this to consume a sync
|
||||
# generator of completions as an async generator. If the queue size is very
|
||||
# small (like 1), consuming the completions goes really slow (when there are a
|
||||
# lot of items). If the queue size would be unlimited or too big, this can
|
||||
# cause overconsumption of memory, and cause CPU time spent producing items
|
||||
# that are no longer needed (if the consumption of the async generator stops at
|
||||
# some point). We need a fixed size in order to get some back pressure from the
|
||||
# async consumer to the sync producer. We choose 1000 by default here. If we
|
||||
# have around 50k completions, measurements show that 1000 is still
|
||||
# significantly faster than a buffer of 100.
|
||||
DEFAULT_BUFFER_SIZE: int = 1000
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class _Done:
|
||||
pass
|
||||
|
||||
|
||||
async def generator_to_async_generator(
|
||||
get_iterable: Callable[[], Iterable[_T]],
|
||||
buffer_size: int = DEFAULT_BUFFER_SIZE,
|
||||
) -> 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.
|
||||
:param buffer_size: Size of the queue between the async consumer and the
|
||||
synchronous generator that produces items.
|
||||
"""
|
||||
quitting = False
|
||||
# NOTE: We are limiting the queue size in order to have back-pressure.
|
||||
q: Queue[_T | _Done] = Queue(maxsize=buffer_size)
|
||||
loop = get_running_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:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
q.put(item, timeout=1)
|
||||
except Full:
|
||||
if quitting:
|
||||
return
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
finally:
|
||||
while True:
|
||||
try:
|
||||
q.put(_Done(), timeout=1)
|
||||
except Full:
|
||||
if quitting:
|
||||
return
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
# Start background thread.
|
||||
runner_f = run_in_executor_with_context(runner)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
item = q.get_nowait()
|
||||
except Empty:
|
||||
item = await loop.run_in_executor(None, 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 last item is yielded).
|
||||
await runner_f
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import select
|
||||
import selectors
|
||||
import sys
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop, get_running_loop
|
||||
from selectors import BaseSelector, SelectorKey
|
||||
from typing import TYPE_CHECKING, Any, Callable, Mapping
|
||||
|
||||
__all__ = [
|
||||
"new_eventloop_with_inputhook",
|
||||
"set_eventloop_with_inputhook",
|
||||
"InputHookSelector",
|
||||
"InputHookContext",
|
||||
"InputHook",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import FileDescriptorLike
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
_EventMask = int
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
InputHook: TypeAlias = Callable[[InputHookContext], None]
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
# Deprecated!
|
||||
|
||||
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: float | None = None
|
||||
) -> list[tuple[SelectorKey, _EventMask]]:
|
||||
# If there are tasks in the current event loop,
|
||||
# don't run the input hook.
|
||||
if len(getattr(get_running_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()
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import sys
|
||||
import time
|
||||
from asyncio import get_running_loop
|
||||
from types import TracebackType
|
||||
from typing import Any, Awaitable, Callable, TypeVar, cast
|
||||
|
||||
__all__ = [
|
||||
"run_in_executor_with_context",
|
||||
"call_soon_threadsafe",
|
||||
"get_traceback_from_context",
|
||||
]
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def run_in_executor_with_context(
|
||||
func: Callable[..., _T],
|
||||
*args: Any,
|
||||
loop: asyncio.AbstractEventLoop | None = 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_running_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: float | None = None,
|
||||
loop: asyncio.AbstractEventLoop | None = 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_running_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]) -> TracebackType | None:
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 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) -> HANDLE | None:
|
||||
"""
|
||||
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.
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
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 __future__ import annotations
|
||||
|
||||
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)
|
||||
419
venv/lib/python3.12/site-packages/prompt_toolkit/filters/app.py
Normal file
419
venv/lib/python3.12/site-packages/prompt_toolkit/filters/app.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Filters that accept a `Application` as argument.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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_suggestion",
|
||||
"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",
|
||||
]
|
||||
|
||||
|
||||
# NOTE: `has_focus` below should *not* be `memoized`. It can reference any user
|
||||
# control. For instance, if we would continuously create new
|
||||
# `PromptSession` instances, then previous instances won't be released,
|
||||
# because this memoize (which caches results in the global scope) will
|
||||
# still refer to each instance.
|
||||
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 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(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_suggestion() -> bool:
|
||||
"""
|
||||
Enable when the current buffer has a suggestion.
|
||||
"""
|
||||
buffer = get_app().current_buffer
|
||||
return buffer.suggestion is not None and buffer.suggestion.text != ""
|
||||
|
||||
|
||||
@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()
|
||||
260
venv/lib/python3.12/site-packages/prompt_toolkit/filters/base.py
Normal file
260
venv/lib/python3.12/site-packages/prompt_toolkit/filters/base.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import Callable, Iterable, 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.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._and_cache: dict[Filter, Filter] = {}
|
||||
self._or_cache: dict[Filter, Filter] = {}
|
||||
self._invert_result: Filter | None = None
|
||||
|
||||
@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.
|
||||
"""
|
||||
assert isinstance(other, Filter), f"Expecting filter, got {other!r}"
|
||||
|
||||
if isinstance(other, Always):
|
||||
return self
|
||||
if isinstance(other, Never):
|
||||
return other
|
||||
|
||||
if other in self._and_cache:
|
||||
return self._and_cache[other]
|
||||
|
||||
result = _AndList.create([self, other])
|
||||
self._and_cache[other] = result
|
||||
return result
|
||||
|
||||
def __or__(self, other: Filter) -> Filter:
|
||||
"""
|
||||
Chaining of filters using the | operator.
|
||||
"""
|
||||
assert isinstance(other, Filter), f"Expecting filter, got {other!r}"
|
||||
|
||||
if isinstance(other, Always):
|
||||
return other
|
||||
if isinstance(other, Never):
|
||||
return self
|
||||
|
||||
if other in self._or_cache:
|
||||
return self._or_cache[other]
|
||||
|
||||
result = _OrList.create([self, other])
|
||||
self._or_cache[other] = result
|
||||
return result
|
||||
|
||||
def __invert__(self) -> Filter:
|
||||
"""
|
||||
Inverting of filters using the ~ operator.
|
||||
"""
|
||||
if self._invert_result is None:
|
||||
self._invert_result = _Invert(self)
|
||||
|
||||
return self._invert_result
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
def _remove_duplicates(filters: list[Filter]) -> list[Filter]:
|
||||
result = []
|
||||
for f in filters:
|
||||
if f not in result:
|
||||
result.append(f)
|
||||
return result
|
||||
|
||||
|
||||
class _AndList(Filter):
|
||||
"""
|
||||
Result of &-operation between several filters.
|
||||
"""
|
||||
|
||||
def __init__(self, filters: list[Filter]) -> None:
|
||||
super().__init__()
|
||||
self.filters = filters
|
||||
|
||||
@classmethod
|
||||
def create(cls, filters: Iterable[Filter]) -> Filter:
|
||||
"""
|
||||
Create a new filter by applying an `&` operator between them.
|
||||
|
||||
If there's only one unique filter in the given iterable, it will return
|
||||
that one filter instead of an `_AndList`.
|
||||
"""
|
||||
filters_2: list[Filter] = []
|
||||
|
||||
for f in filters:
|
||||
if isinstance(f, _AndList): # Turn nested _AndLists into one.
|
||||
filters_2.extend(f.filters)
|
||||
else:
|
||||
filters_2.append(f)
|
||||
|
||||
# Remove duplicates. This could speed up execution, and doesn't make a
|
||||
# difference for the evaluation.
|
||||
filters = _remove_duplicates(filters_2)
|
||||
|
||||
# If only one filter is left, return that without wrapping into an
|
||||
# `_AndList`.
|
||||
if len(filters) == 1:
|
||||
return filters[0]
|
||||
|
||||
return cls(filters)
|
||||
|
||||
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: list[Filter]) -> None:
|
||||
super().__init__()
|
||||
self.filters = filters
|
||||
|
||||
@classmethod
|
||||
def create(cls, filters: Iterable[Filter]) -> Filter:
|
||||
"""
|
||||
Create a new filter by applying an `|` operator between them.
|
||||
|
||||
If there's only one unique filter in the given iterable, it will return
|
||||
that one filter instead of an `_OrList`.
|
||||
"""
|
||||
filters_2: list[Filter] = []
|
||||
|
||||
for f in filters:
|
||||
if isinstance(f, _OrList): # Turn nested _AndLists into one.
|
||||
filters_2.extend(f.filters)
|
||||
else:
|
||||
filters_2.append(f)
|
||||
|
||||
# Remove duplicates. This could speed up execution, and doesn't make a
|
||||
# difference for the evaluation.
|
||||
filters = _remove_duplicates(filters_2)
|
||||
|
||||
# If only one filter is left, return that without wrapping into an
|
||||
# `_AndList`.
|
||||
if len(filters) == 1:
|
||||
return filters[0]
|
||||
|
||||
return cls(filters)
|
||||
|
||||
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:
|
||||
super().__init__()
|
||||
self.filter = filter
|
||||
|
||||
def __call__(self) -> bool:
|
||||
return not self.filter()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"~{self.filter!r}"
|
||||
|
||||
|
||||
class Always(Filter):
|
||||
"""
|
||||
Always enable feature.
|
||||
"""
|
||||
|
||||
def __call__(self) -> bool:
|
||||
return True
|
||||
|
||||
def __or__(self, other: Filter) -> Filter:
|
||||
return self
|
||||
|
||||
def __and__(self, other: Filter) -> Filter:
|
||||
return other
|
||||
|
||||
def __invert__(self) -> Never:
|
||||
return Never()
|
||||
|
||||
|
||||
class Never(Filter):
|
||||
"""
|
||||
Never enable feature.
|
||||
"""
|
||||
|
||||
def __call__(self) -> bool:
|
||||
return False
|
||||
|
||||
def __and__(self, other: Filter) -> Filter:
|
||||
return self
|
||||
|
||||
def __or__(self, other: Filter) -> Filter:
|
||||
return other
|
||||
|
||||
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:
|
||||
super().__init__()
|
||||
self.func = func
|
||||
|
||||
def __call__(self) -> bool:
|
||||
return self.func()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Condition({self.func!r})"
|
||||
|
||||
|
||||
# Often used as type annotation.
|
||||
FilterOrBool = Union[Filter, bool]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
For backwards-compatibility. keep this file.
|
||||
(Many people are going to have key bindings that rely on this file.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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(f"Expecting a bool or a Filter instance. Got {bool_or_filter!r}")
|
||||
|
||||
|
||||
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)()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
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 __future__ import annotations
|
||||
|
||||
from .ansi import ANSI
|
||||
from .base import (
|
||||
AnyFormattedText,
|
||||
FormattedText,
|
||||
OneStyleAndTextTuple,
|
||||
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",
|
||||
"OneStyleAndTextTuple",
|
||||
"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",
|
||||
]
|
||||
@@ -0,0 +1,302 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from string import Formatter
|
||||
from typing import Generator
|
||||
|
||||
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: str | None = None
|
||||
self._bgcolor: str | None = None
|
||||
self._bold = False
|
||||
self._dim = 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._dim = 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
|
||||
self._dim = False
|
||||
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._dim = 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 = (
|
||||
f"#{attrs.pop():02x}{attrs.pop():02x}{attrs.pop():02x}"
|
||||
)
|
||||
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._dim:
|
||||
result.append("dim")
|
||||
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()
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
|
||||
|
||||
__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], "NotImplementedOrNone"]]
|
||||
]
|
||||
|
||||
# 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: 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, "
|
||||
f"HTML, ANSI or a FormattedText instance. Got {value!r}"
|
||||
)
|
||||
|
||||
# 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 f"FormattedText({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
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.dom.minidom as minidom
|
||||
from string import Formatter
|
||||
from typing import Any
|
||||
|
||||
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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
FORMATTER = HTMLFormatter()
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
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 __future__ import annotations
|
||||
|
||||
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: Iterable[OneStyleAndTextTuple],
|
||||
) -> 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: Iterable 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]:
|
||||
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
|
||||
306
venv/lib/python3.12/site-packages/prompt_toolkit/history.py
Normal file
306
venv/lib/python3.12/site-packages/prompt_toolkit/history.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import threading
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from asyncio import get_running_loop
|
||||
from typing import AsyncGenerator, Iterable, Sequence, Union
|
||||
|
||||
__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: threading.Thread | None = 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_running_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: Sequence[str] | None = 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
|
||||
|
||||
|
||||
_StrOrBytesPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"]
|
||||
|
||||
|
||||
class FileHistory(History):
|
||||
"""
|
||||
:class:`.History` class that stores all strings in a file.
|
||||
"""
|
||||
|
||||
def __init__(self, filename: _StrOrBytesPath) -> 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(f"\n# {datetime.datetime.now()}\n")
|
||||
for line in string.split("\n"):
|
||||
write(f"+{line}\n")
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
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 __future__ import annotations
|
||||
|
||||
from ..keys import Keys
|
||||
|
||||
__all__ = [
|
||||
"ANSI_SEQUENCES",
|
||||
"REVERSE_ANSI_SEQUENCES",
|
||||
]
|
||||
|
||||
# Mapping of vt100 escape codes to Keys.
|
||||
ANSI_SEQUENCES: dict[str, 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()
|
||||
154
venv/lib/python3.12/site-packages/prompt_toolkit/input/base.py
Normal file
154
venv/lib/python3.12/site-packages/prompt_toolkit/input/base.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Abstraction of CLI Input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Callable, ContextManager, Generator
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
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 f"dummy-{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
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
from typing import ContextManager, TextIO
|
||||
|
||||
from .base import DummyInput, Input, PipeInput
|
||||
|
||||
__all__ = [
|
||||
"create_input",
|
||||
"create_pipe_input",
|
||||
]
|
||||
|
||||
|
||||
def create_input(stdin: TextIO | None = 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 obj in [sys.stdin, sys.stdout, sys.stderr]:
|
||||
if obj.isatty():
|
||||
stdin = obj
|
||||
break
|
||||
|
||||
# If we can't access the file descriptor for the selected stdin, return
|
||||
# a `DummyInput` instead. This can happen for instance in unit tests,
|
||||
# when `sys.stdin` is patched by something that's not an actual file.
|
||||
# (Instantiating `Vt100Input` would fail in this case.)
|
||||
try:
|
||||
stdin.fileno()
|
||||
except io.UnsupportedOperation:
|
||||
return DummyInput()
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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}"
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
unrecognized 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)
|
||||
@@ -0,0 +1,78 @@
|
||||
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 __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
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] = []
|
||||
309
venv/lib/python3.12/site-packages/prompt_toolkit/input/vt100.py
Normal file
309
venv/lib/python3.12/site-packages/prompt_toolkit/input/vt100.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
assert sys.platform != "win32"
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import termios
|
||||
import tty
|
||||
from asyncio import AbstractEventLoop, get_running_loop
|
||||
from typing import Callable, ContextManager, Generator, TextIO
|
||||
|
||||
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], Callable[[], None] | 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_running_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
|
||||
# `PermissionError` (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_running_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: list[int | list[bytes | int]] | None
|
||||
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
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Parser for VT100 input stream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Callable, Dict, Generator
|
||||
|
||||
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) -> 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, 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: 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()
|
||||
904
venv/lib/python3.12/site-packages/prompt_toolkit/input/win32.py
Normal file
904
venv/lib/python3.12/site-packages/prompt_toolkit/input/win32.py
Normal file
@@ -0,0 +1,904 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from abc import abstractmethod
|
||||
from asyncio import get_running_loop
|
||||
from contextlib import contextmanager
|
||||
|
||||
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, byref, pointer
|
||||
from ctypes.wintypes import DWORD, HANDLE
|
||||
from typing import Callable, ContextManager, Iterable, Iterator, 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
|
||||
from .vt100_parser import Vt100Parser
|
||||
|
||||
__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
|
||||
|
||||
# See: https://msdn.microsoft.com/pl-pl/library/windows/desktop/ms686033(v=vs.85).aspx
|
||||
ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
|
||||
|
||||
|
||||
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: TextIO | None = None) -> None:
|
||||
super().__init__()
|
||||
self._use_virtual_terminal_input = _is_win_vt100_input_enabled()
|
||||
|
||||
self.console_input_reader: Vt100ConsoleInputReader | ConsoleInputReader
|
||||
|
||||
if self._use_virtual_terminal_input:
|
||||
self.console_input_reader = Vt100ConsoleInputReader()
|
||||
else:
|
||||
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_keys(self) -> list[KeyPress]:
|
||||
return self.console_input_reader.flush_keys()
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return False
|
||||
|
||||
def raw_mode(self) -> ContextManager[None]:
|
||||
return raw_mode(
|
||||
use_win10_virtual_terminal_input=self._use_virtual_terminal_input
|
||||
)
|
||||
|
||||
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: KeyPress | None
|
||||
|
||||
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 flush_keys(self) -> list[KeyPress]:
|
||||
# Method only needed for structural compatibility with `Vt100ConsoleInputReader`.
|
||||
return []
|
||||
|
||||
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 isinstance(ev, KEY_EVENT_RECORD) and ev.KeyDown:
|
||||
yield from self._event_to_key_presses(ev)
|
||||
|
||||
elif isinstance(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 isinstance(ev, KEY_EVENT_RECORD) and ev.KeyDown
|
||||
|
||||
result: KeyPress | None = 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: MouseEventType | None = 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 Vt100ConsoleInputReader:
|
||||
"""
|
||||
Similar to `ConsoleInputReader`, but for usage when
|
||||
`ENABLE_VIRTUAL_TERMINAL_INPUT` is enabled. This assumes that Windows sends
|
||||
us the right vt100 escape sequences and we parse those with our vt100
|
||||
parser.
|
||||
|
||||
(Using this instead of `ConsoleInputReader` results in the "data" attribute
|
||||
from the `KeyPress` instances to be more correct in edge cases, because
|
||||
this responds to for instance the terminal being in application cursor keys
|
||||
mode.)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._fdcon = None
|
||||
|
||||
self._buffer: list[KeyPress] = [] # Buffer to collect the Key objects.
|
||||
self._vt100_parser = Vt100Parser(
|
||||
lambda key_press: self._buffer.append(key_press)
|
||||
)
|
||||
|
||||
# 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.
|
||||
for key_data in self._get_keys(read, input_records):
|
||||
self._vt100_parser.feed(key_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
|
||||
|
||||
def _get_keys(
|
||||
self, read: DWORD, input_records: Array[INPUT_RECORD]
|
||||
) -> Iterator[str]:
|
||||
"""
|
||||
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 isinstance(ev, KEY_EVENT_RECORD) and ev.KeyDown:
|
||||
u_char = ev.uChar.UnicodeChar
|
||||
if u_char != "\x00":
|
||||
yield u_char
|
||||
|
||||
|
||||
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_running_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) -> Callable[[], None] | 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: int | None = None, use_win10_virtual_terminal_input: bool = False
|
||||
) -> None:
|
||||
self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
|
||||
self.use_win10_virtual_terminal_input = use_win10_virtual_terminal_input
|
||||
|
||||
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
|
||||
|
||||
new_mode = self.original_mode.value & ~(
|
||||
ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT
|
||||
)
|
||||
|
||||
if self.use_win10_virtual_terminal_input:
|
||||
new_mode |= ENABLE_VIRTUAL_TERMINAL_INPUT
|
||||
|
||||
windll.kernel32.SetConsoleMode(self.handle, new_mode)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def _is_win_vt100_input_enabled() -> bool:
|
||||
"""
|
||||
Returns True when we're running Windows and VT100 escape sequences are
|
||||
supported.
|
||||
"""
|
||||
hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
|
||||
|
||||
# Get original console mode.
|
||||
original_mode = DWORD(0)
|
||||
windll.kernel32.GetConsoleMode(hconsole, byref(original_mode))
|
||||
|
||||
try:
|
||||
# Try to enable VT100 sequences.
|
||||
result: int = windll.kernel32.SetConsoleMode(
|
||||
hconsole, DWORD(ENABLE_VIRTUAL_TERMINAL_INPUT)
|
||||
)
|
||||
|
||||
return result == 1
|
||||
finally:
|
||||
windll.kernel32.SetConsoleMode(hconsole, original_mode)
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
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}"
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Key bindings for auto suggestion (for fish-style auto suggestion).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -0,0 +1,257 @@
|
||||
# pylint: disable=function-redefined
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
@Condition
|
||||
def has_text_before_cursor() -> bool:
|
||||
return bool(get_app().current_buffer.text)
|
||||
|
||||
|
||||
@Condition
|
||||
def in_quoted_insert() -> bool:
|
||||
return get_app().quoted_insert
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Key binding handlers for displaying completions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -0,0 +1,563 @@
|
||||
# pylint: disable=function-redefined
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
@Condition
|
||||
def is_returnable() -> bool:
|
||||
return get_app().current_buffer.is_returnable
|
||||
|
||||
|
||||
@Condition
|
||||
def is_arg() -> bool:
|
||||
return get_app().key_processor.arg == "-"
|
||||
|
||||
|
||||
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=is_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 = "-"
|
||||
|
||||
# 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[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)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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
|
||||
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
|
||||
@@ -0,0 +1,691 @@
|
||||
"""
|
||||
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 __future__ import annotations
|
||||
|
||||
from typing import Callable, 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(f"Unknown Readline command: {name!r}") 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)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Open in editor key bindings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Key bindings for extra page navigation: bindings for up/down scrolling through
|
||||
long pages, like in Emacs or Vi.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
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 __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Search related key bindings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Default key bindings.::
|
||||
|
||||
key_bindings = load_key_bindings()
|
||||
app = Application(key_bindings=key_bindings)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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: list[KeyPress] | None = []
|
||||
self.current_recording: list[KeyPress] | None = 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
|
||||
@@ -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 __future__ import annotations
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from inspect import isawaitable
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Coroutine,
|
||||
Hashable,
|
||||
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 handler (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", Coroutine[Any, Any, "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[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(Coroutine[Any, Any, "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 (
|
||||
f"{self.__class__.__name__}(keys={self.keys!r}, handler={self.handler!r})"
|
||||
)
|
||||
|
||||
|
||||
# Sequence of keys presses.
|
||||
KeysTuple = Tuple[Union[Keys, str], ...]
|
||||
|
||||
|
||||
class KeyBindingsBase(metaclass=ABCMeta):
|
||||
"""
|
||||
Interface for a KeyBindings.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
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 []
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
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: 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: 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: Keys | str) -> 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[[], KeyBindingsBase | None]) -> 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
|
||||
@@ -0,0 +1,526 @@
|
||||
"""
|
||||
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`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from asyncio import Task, sleep
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING, Any, Generator
|
||||
|
||||
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: Keys | str, data: str | None = 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: Task[None] | None = None
|
||||
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._previous_key_sequence: list[KeyPress] = []
|
||||
self._previous_handler: Binding | None = 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: str | None = 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: str | None,
|
||||
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 f"KeyPressEvent(arg={self.arg!r}, key_sequence={self.key_sequence!r}, is_repeat={self.is_repeat!r})"
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from prompt_toolkit.clipboard import ClipboardData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .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: CharacterFind | None = 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: None | (Callable[[KeyPressEvent, TextObject], None]) = None
|
||||
self.operator_arg: int | None = 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: str | None = 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: str | None = 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 = ""
|
||||
222
venv/lib/python3.12/site-packages/prompt_toolkit/keys.py
Normal file
222
venv/lib/python3.12/site-packages/prompt_toolkit/keys.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
__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",
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Command line layout definitions
|
||||
-------------------------------
|
||||
|
||||
The layout of a command line interface is defined by a Container instance.
|
||||
There are two main groups of classes here. Containers and controls:
|
||||
|
||||
- A container can contain other containers or controls, it can have multiple
|
||||
children and it decides about the dimensions.
|
||||
- A control is responsible for rendering the actual content to a screen.
|
||||
A control can propose some dimensions, but it's the container who decides
|
||||
about the dimensions -- or when the control consumes more space -- which part
|
||||
of the control will be visible.
|
||||
|
||||
|
||||
Container classes::
|
||||
|
||||
- Container (Abstract base class)
|
||||
|- HSplit (Horizontal split)
|
||||
|- VSplit (Vertical split)
|
||||
|- FloatContainer (Container which can also contain menus and other floats)
|
||||
`- Window (Container which contains one actual control
|
||||
|
||||
Control classes::
|
||||
|
||||
- UIControl (Abstract base class)
|
||||
|- FormattedTextControl (Renders formatted text, or a simple list of text fragments)
|
||||
`- BufferControl (Renders an input buffer.)
|
||||
|
||||
|
||||
Usually, you end up wrapping every control inside a `Window` object, because
|
||||
that's the only way to render it in a layout.
|
||||
|
||||
There are some prepared toolbars which are ready to use::
|
||||
|
||||
- SystemToolbar (Shows the 'system' input buffer, for entering system commands.)
|
||||
- ArgToolbar (Shows the input 'arg', for repetition of input commands.)
|
||||
- SearchToolbar (Shows the 'search' input buffer, for incremental search.)
|
||||
- CompletionsToolbar (Shows the completions of the current buffer.)
|
||||
- ValidationToolbar (Shows validation errors of the current buffer.)
|
||||
|
||||
And one prepared menu:
|
||||
|
||||
- CompletionsMenu
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .containers import (
|
||||
AnyContainer,
|
||||
ColorColumn,
|
||||
ConditionalContainer,
|
||||
Container,
|
||||
DynamicContainer,
|
||||
Float,
|
||||
FloatContainer,
|
||||
HorizontalAlign,
|
||||
HSplit,
|
||||
ScrollOffsets,
|
||||
VerticalAlign,
|
||||
VSplit,
|
||||
Window,
|
||||
WindowAlign,
|
||||
WindowRenderInfo,
|
||||
is_container,
|
||||
to_container,
|
||||
to_window,
|
||||
)
|
||||
from .controls import (
|
||||
BufferControl,
|
||||
DummyControl,
|
||||
FormattedTextControl,
|
||||
SearchBufferControl,
|
||||
UIContent,
|
||||
UIControl,
|
||||
)
|
||||
from .dimension import (
|
||||
AnyDimension,
|
||||
D,
|
||||
Dimension,
|
||||
is_dimension,
|
||||
max_layout_dimensions,
|
||||
sum_layout_dimensions,
|
||||
to_dimension,
|
||||
)
|
||||
from .layout import InvalidLayoutError, Layout, walk
|
||||
from .margins import (
|
||||
ConditionalMargin,
|
||||
Margin,
|
||||
NumberedMargin,
|
||||
PromptMargin,
|
||||
ScrollbarMargin,
|
||||
)
|
||||
from .menus import CompletionsMenu, MultiColumnCompletionsMenu
|
||||
from .scrollable_pane import ScrollablePane
|
||||
|
||||
__all__ = [
|
||||
# Layout.
|
||||
"Layout",
|
||||
"InvalidLayoutError",
|
||||
"walk",
|
||||
# Dimensions.
|
||||
"AnyDimension",
|
||||
"Dimension",
|
||||
"D",
|
||||
"sum_layout_dimensions",
|
||||
"max_layout_dimensions",
|
||||
"to_dimension",
|
||||
"is_dimension",
|
||||
# Containers.
|
||||
"AnyContainer",
|
||||
"Container",
|
||||
"HorizontalAlign",
|
||||
"VerticalAlign",
|
||||
"HSplit",
|
||||
"VSplit",
|
||||
"FloatContainer",
|
||||
"Float",
|
||||
"WindowAlign",
|
||||
"Window",
|
||||
"WindowRenderInfo",
|
||||
"ConditionalContainer",
|
||||
"ScrollOffsets",
|
||||
"ColorColumn",
|
||||
"to_container",
|
||||
"to_window",
|
||||
"is_container",
|
||||
"DynamicContainer",
|
||||
"ScrollablePane",
|
||||
# Controls.
|
||||
"BufferControl",
|
||||
"SearchBufferControl",
|
||||
"DummyControl",
|
||||
"FormattedTextControl",
|
||||
"UIControl",
|
||||
"UIContent",
|
||||
# Margins.
|
||||
"Margin",
|
||||
"NumberedMargin",
|
||||
"ScrollbarMargin",
|
||||
"ConditionalMargin",
|
||||
"PromptMargin",
|
||||
# Menus.
|
||||
"CompletionsMenu",
|
||||
"MultiColumnCompletionsMenu",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,956 @@
|
||||
"""
|
||||
User interface Controls for the layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import TYPE_CHECKING, Callable, Hashable, Iterable, NamedTuple
|
||||
|
||||
from prompt_toolkit.application.current import get_app
|
||||
from prompt_toolkit.buffer import Buffer
|
||||
from prompt_toolkit.cache import SimpleCache
|
||||
from prompt_toolkit.data_structures import Point
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.filters import FilterOrBool, to_filter
|
||||
from prompt_toolkit.formatted_text import (
|
||||
AnyFormattedText,
|
||||
StyleAndTextTuples,
|
||||
to_formatted_text,
|
||||
)
|
||||
from prompt_toolkit.formatted_text.utils import (
|
||||
fragment_list_to_text,
|
||||
fragment_list_width,
|
||||
split_lines,
|
||||
)
|
||||
from prompt_toolkit.lexers import Lexer, SimpleLexer
|
||||
from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
|
||||
from prompt_toolkit.search import SearchState
|
||||
from prompt_toolkit.selection import SelectionType
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
from .processors import (
|
||||
DisplayMultipleCursors,
|
||||
HighlightIncrementalSearchProcessor,
|
||||
HighlightSearchProcessor,
|
||||
HighlightSelectionProcessor,
|
||||
Processor,
|
||||
TransformationInput,
|
||||
merge_processors,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prompt_toolkit.key_binding.key_bindings import (
|
||||
KeyBindingsBase,
|
||||
NotImplementedOrNone,
|
||||
)
|
||||
from prompt_toolkit.utils import Event
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BufferControl",
|
||||
"SearchBufferControl",
|
||||
"DummyControl",
|
||||
"FormattedTextControl",
|
||||
"UIControl",
|
||||
"UIContent",
|
||||
]
|
||||
|
||||
GetLinePrefixCallable = Callable[[int, int], AnyFormattedText]
|
||||
|
||||
|
||||
class UIControl(metaclass=ABCMeta):
|
||||
"""
|
||||
Base class for all user interface controls.
|
||||
"""
|
||||
|
||||
def reset(self) -> None:
|
||||
# Default reset. (Doesn't have to be implemented.)
|
||||
pass
|
||||
|
||||
def preferred_width(self, max_available_width: int) -> int | None:
|
||||
return None
|
||||
|
||||
def preferred_height(
|
||||
self,
|
||||
width: int,
|
||||
max_available_height: int,
|
||||
wrap_lines: bool,
|
||||
get_line_prefix: GetLinePrefixCallable | None,
|
||||
) -> int | None:
|
||||
return None
|
||||
|
||||
def is_focusable(self) -> bool:
|
||||
"""
|
||||
Tell whether this user control is focusable.
|
||||
"""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def create_content(self, width: int, height: int) -> UIContent:
|
||||
"""
|
||||
Generate the content for this user control.
|
||||
|
||||
Returns a :class:`.UIContent` instance.
|
||||
"""
|
||||
|
||||
def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
|
||||
"""
|
||||
Handle mouse events.
|
||||
|
||||
When `NotImplemented` is returned, it means that the given event is not
|
||||
handled by the `UIControl` itself. The `Window` or key bindings can
|
||||
decide to handle this event as scrolling or changing focus.
|
||||
|
||||
:param mouse_event: `MouseEvent` instance.
|
||||
"""
|
||||
return NotImplemented
|
||||
|
||||
def move_cursor_down(self) -> None:
|
||||
"""
|
||||
Request to move the cursor down.
|
||||
This happens when scrolling down and the cursor is completely at the
|
||||
top.
|
||||
"""
|
||||
|
||||
def move_cursor_up(self) -> None:
|
||||
"""
|
||||
Request to move the cursor up.
|
||||
"""
|
||||
|
||||
def get_key_bindings(self) -> KeyBindingsBase | None:
|
||||
"""
|
||||
The key bindings that are specific for this user control.
|
||||
|
||||
Return a :class:`.KeyBindings` object if some key bindings are
|
||||
specified, or `None` otherwise.
|
||||
"""
|
||||
|
||||
def get_invalidate_events(self) -> Iterable[Event[object]]:
|
||||
"""
|
||||
Return a list of `Event` objects. This can be a generator.
|
||||
(The application collects all these events, in order to bind redraw
|
||||
handlers to these events.)
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class UIContent:
|
||||
"""
|
||||
Content generated by a user control. This content consists of a list of
|
||||
lines.
|
||||
|
||||
:param get_line: Callable that takes a line number and returns the current
|
||||
line. This is a list of (style_str, text) tuples.
|
||||
:param line_count: The number of lines.
|
||||
:param cursor_position: a :class:`.Point` for the cursor position.
|
||||
:param menu_position: a :class:`.Point` for the menu position.
|
||||
:param show_cursor: Make the cursor visible.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
get_line: Callable[[int], StyleAndTextTuples] = (lambda i: []),
|
||||
line_count: int = 0,
|
||||
cursor_position: Point | None = None,
|
||||
menu_position: Point | None = None,
|
||||
show_cursor: bool = True,
|
||||
):
|
||||
self.get_line = get_line
|
||||
self.line_count = line_count
|
||||
self.cursor_position = cursor_position or Point(x=0, y=0)
|
||||
self.menu_position = menu_position
|
||||
self.show_cursor = show_cursor
|
||||
|
||||
# Cache for line heights. Maps cache key -> height
|
||||
self._line_heights_cache: dict[Hashable, int] = {}
|
||||
|
||||
def __getitem__(self, lineno: int) -> StyleAndTextTuples:
|
||||
"Make it iterable (iterate line by line)."
|
||||
if lineno < self.line_count:
|
||||
return self.get_line(lineno)
|
||||
else:
|
||||
raise IndexError
|
||||
|
||||
def get_height_for_line(
|
||||
self,
|
||||
lineno: int,
|
||||
width: int,
|
||||
get_line_prefix: GetLinePrefixCallable | None,
|
||||
slice_stop: int | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Return the height that a given line would need if it is rendered in a
|
||||
space with the given width (using line wrapping).
|
||||
|
||||
:param get_line_prefix: None or a `Window.get_line_prefix` callable
|
||||
that returns the prefix to be inserted before this line.
|
||||
:param slice_stop: Wrap only "line[:slice_stop]" and return that
|
||||
partial result. This is needed for scrolling the window correctly
|
||||
when line wrapping.
|
||||
:returns: The computed height.
|
||||
"""
|
||||
# Instead of using `get_line_prefix` as key, we use render_counter
|
||||
# instead. This is more reliable, because this function could still be
|
||||
# the same, while the content would change over time.
|
||||
key = get_app().render_counter, lineno, width, slice_stop
|
||||
|
||||
try:
|
||||
return self._line_heights_cache[key]
|
||||
except KeyError:
|
||||
if width == 0:
|
||||
height = 10**8
|
||||
else:
|
||||
# Calculate line width first.
|
||||
line = fragment_list_to_text(self.get_line(lineno))[:slice_stop]
|
||||
text_width = get_cwidth(line)
|
||||
|
||||
if get_line_prefix:
|
||||
# Add prefix width.
|
||||
text_width += fragment_list_width(
|
||||
to_formatted_text(get_line_prefix(lineno, 0))
|
||||
)
|
||||
|
||||
# Slower path: compute path when there's a line prefix.
|
||||
height = 1
|
||||
|
||||
# Keep wrapping as long as the line doesn't fit.
|
||||
# Keep adding new prefixes for every wrapped line.
|
||||
while text_width > width:
|
||||
height += 1
|
||||
text_width -= width
|
||||
|
||||
fragments2 = to_formatted_text(
|
||||
get_line_prefix(lineno, height - 1)
|
||||
)
|
||||
prefix_width = get_cwidth(fragment_list_to_text(fragments2))
|
||||
|
||||
if prefix_width >= width: # Prefix doesn't fit.
|
||||
height = 10**8
|
||||
break
|
||||
|
||||
text_width += prefix_width
|
||||
else:
|
||||
# Fast path: compute height when there's no line prefix.
|
||||
try:
|
||||
quotient, remainder = divmod(text_width, width)
|
||||
except ZeroDivisionError:
|
||||
height = 10**8
|
||||
else:
|
||||
if remainder:
|
||||
quotient += 1 # Like math.ceil.
|
||||
height = max(1, quotient)
|
||||
|
||||
# Cache and return
|
||||
self._line_heights_cache[key] = height
|
||||
return height
|
||||
|
||||
|
||||
class FormattedTextControl(UIControl):
|
||||
"""
|
||||
Control that displays formatted text. This can be either plain text, an
|
||||
:class:`~prompt_toolkit.formatted_text.HTML` object an
|
||||
:class:`~prompt_toolkit.formatted_text.ANSI` object, a list of ``(style_str,
|
||||
text)`` tuples or a callable that takes no argument and returns one of
|
||||
those, depending on how you prefer to do the formatting. See
|
||||
``prompt_toolkit.layout.formatted_text`` for more information.
|
||||
|
||||
(It's mostly optimized for rather small widgets, like toolbars, menus, etc...)
|
||||
|
||||
When this UI control has the focus, the cursor will be shown in the upper
|
||||
left corner of this control by default. There are two ways for specifying
|
||||
the cursor position:
|
||||
|
||||
- Pass a `get_cursor_position` function which returns a `Point` instance
|
||||
with the current cursor position.
|
||||
|
||||
- If the (formatted) text is passed as a list of ``(style, text)`` tuples
|
||||
and there is one that looks like ``('[SetCursorPosition]', '')``, then
|
||||
this will specify the cursor position.
|
||||
|
||||
Mouse support:
|
||||
|
||||
The list of fragments can also contain tuples of three items, looking like:
|
||||
(style_str, text, handler). When mouse support is enabled and the user
|
||||
clicks on this fragment, then the given handler is called. That handler
|
||||
should accept two inputs: (Application, MouseEvent) and it should
|
||||
either handle the event or return `NotImplemented` in case we want the
|
||||
containing Window to handle this event.
|
||||
|
||||
:param focusable: `bool` or :class:`.Filter`: Tell whether this control is
|
||||
focusable.
|
||||
|
||||
:param text: Text or formatted text to be displayed.
|
||||
:param style: Style string applied to the content. (If you want to style
|
||||
the whole :class:`~prompt_toolkit.layout.Window`, pass the style to the
|
||||
:class:`~prompt_toolkit.layout.Window` instead.)
|
||||
:param key_bindings: a :class:`.KeyBindings` object.
|
||||
:param get_cursor_position: A callable that returns the cursor position as
|
||||
a `Point` instance.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: AnyFormattedText = "",
|
||||
style: str = "",
|
||||
focusable: FilterOrBool = False,
|
||||
key_bindings: KeyBindingsBase | None = None,
|
||||
show_cursor: bool = True,
|
||||
modal: bool = False,
|
||||
get_cursor_position: Callable[[], Point | None] | None = None,
|
||||
) -> None:
|
||||
self.text = text # No type check on 'text'. This is done dynamically.
|
||||
self.style = style
|
||||
self.focusable = to_filter(focusable)
|
||||
|
||||
# Key bindings.
|
||||
self.key_bindings = key_bindings
|
||||
self.show_cursor = show_cursor
|
||||
self.modal = modal
|
||||
self.get_cursor_position = get_cursor_position
|
||||
|
||||
#: Cache for the content.
|
||||
self._content_cache: SimpleCache[Hashable, UIContent] = SimpleCache(maxsize=18)
|
||||
self._fragment_cache: SimpleCache[int, StyleAndTextTuples] = SimpleCache(
|
||||
maxsize=1
|
||||
)
|
||||
# Only cache one fragment list. We don't need the previous item.
|
||||
|
||||
# Render info for the mouse support.
|
||||
self._fragments: StyleAndTextTuples | None = None
|
||||
|
||||
def reset(self) -> None:
|
||||
self._fragments = None
|
||||
|
||||
def is_focusable(self) -> bool:
|
||||
return self.focusable()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.text!r})"
|
||||
|
||||
def _get_formatted_text_cached(self) -> StyleAndTextTuples:
|
||||
"""
|
||||
Get fragments, but only retrieve fragments once during one render run.
|
||||
(This function is called several times during one rendering, because
|
||||
we also need those for calculating the dimensions.)
|
||||
"""
|
||||
return self._fragment_cache.get(
|
||||
get_app().render_counter, lambda: to_formatted_text(self.text, self.style)
|
||||
)
|
||||
|
||||
def preferred_width(self, max_available_width: int) -> int:
|
||||
"""
|
||||
Return the preferred width for this control.
|
||||
That is the width of the longest line.
|
||||
"""
|
||||
text = fragment_list_to_text(self._get_formatted_text_cached())
|
||||
line_lengths = [get_cwidth(l) for l in text.split("\n")]
|
||||
return max(line_lengths)
|
||||
|
||||
def preferred_height(
|
||||
self,
|
||||
width: int,
|
||||
max_available_height: int,
|
||||
wrap_lines: bool,
|
||||
get_line_prefix: GetLinePrefixCallable | None,
|
||||
) -> int | None:
|
||||
"""
|
||||
Return the preferred height for this control.
|
||||
"""
|
||||
content = self.create_content(width, None)
|
||||
if wrap_lines:
|
||||
height = 0
|
||||
for i in range(content.line_count):
|
||||
height += content.get_height_for_line(i, width, get_line_prefix)
|
||||
if height >= max_available_height:
|
||||
return max_available_height
|
||||
return height
|
||||
else:
|
||||
return content.line_count
|
||||
|
||||
def create_content(self, width: int, height: int | None) -> UIContent:
|
||||
# Get fragments
|
||||
fragments_with_mouse_handlers = self._get_formatted_text_cached()
|
||||
fragment_lines_with_mouse_handlers = list(
|
||||
split_lines(fragments_with_mouse_handlers)
|
||||
)
|
||||
|
||||
# Strip mouse handlers from fragments.
|
||||
fragment_lines: list[StyleAndTextTuples] = [
|
||||
[(item[0], item[1]) for item in line]
|
||||
for line in fragment_lines_with_mouse_handlers
|
||||
]
|
||||
|
||||
# Keep track of the fragments with mouse handler, for later use in
|
||||
# `mouse_handler`.
|
||||
self._fragments = fragments_with_mouse_handlers
|
||||
|
||||
# If there is a `[SetCursorPosition]` in the fragment list, set the
|
||||
# cursor position here.
|
||||
def get_cursor_position(
|
||||
fragment: str = "[SetCursorPosition]",
|
||||
) -> Point | None:
|
||||
for y, line in enumerate(fragment_lines):
|
||||
x = 0
|
||||
for style_str, text, *_ in line:
|
||||
if fragment in style_str:
|
||||
return Point(x=x, y=y)
|
||||
x += len(text)
|
||||
return None
|
||||
|
||||
# If there is a `[SetMenuPosition]`, set the menu over here.
|
||||
def get_menu_position() -> Point | None:
|
||||
return get_cursor_position("[SetMenuPosition]")
|
||||
|
||||
cursor_position = (self.get_cursor_position or get_cursor_position)()
|
||||
|
||||
# Create content, or take it from the cache.
|
||||
key = (tuple(fragments_with_mouse_handlers), width, cursor_position)
|
||||
|
||||
def get_content() -> UIContent:
|
||||
return UIContent(
|
||||
get_line=lambda i: fragment_lines[i],
|
||||
line_count=len(fragment_lines),
|
||||
show_cursor=self.show_cursor,
|
||||
cursor_position=cursor_position,
|
||||
menu_position=get_menu_position(),
|
||||
)
|
||||
|
||||
return self._content_cache.get(key, get_content)
|
||||
|
||||
def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
|
||||
"""
|
||||
Handle mouse events.
|
||||
|
||||
(When the fragment list contained mouse handlers and the user clicked on
|
||||
on any of these, the matching handler is called. This handler can still
|
||||
return `NotImplemented` in case we want the
|
||||
:class:`~prompt_toolkit.layout.Window` to handle this particular
|
||||
event.)
|
||||
"""
|
||||
if self._fragments:
|
||||
# Read the generator.
|
||||
fragments_for_line = list(split_lines(self._fragments))
|
||||
|
||||
try:
|
||||
fragments = fragments_for_line[mouse_event.position.y]
|
||||
except IndexError:
|
||||
return NotImplemented
|
||||
else:
|
||||
# Find position in the fragment list.
|
||||
xpos = mouse_event.position.x
|
||||
|
||||
# Find mouse handler for this character.
|
||||
count = 0
|
||||
for item in fragments:
|
||||
count += len(item[1])
|
||||
if count > xpos:
|
||||
if len(item) >= 3:
|
||||
# Handler found. Call it.
|
||||
# (Handler can return NotImplemented, so return
|
||||
# that result.)
|
||||
handler = item[2]
|
||||
return handler(mouse_event)
|
||||
else:
|
||||
break
|
||||
|
||||
# Otherwise, don't handle here.
|
||||
return NotImplemented
|
||||
|
||||
def is_modal(self) -> bool:
|
||||
return self.modal
|
||||
|
||||
def get_key_bindings(self) -> KeyBindingsBase | None:
|
||||
return self.key_bindings
|
||||
|
||||
|
||||
class DummyControl(UIControl):
|
||||
"""
|
||||
A dummy control object that doesn't paint any content.
|
||||
|
||||
Useful for filling a :class:`~prompt_toolkit.layout.Window`. (The
|
||||
`fragment` and `char` attributes of the `Window` class can be used to
|
||||
define the filling.)
|
||||
"""
|
||||
|
||||
def create_content(self, width: int, height: int) -> UIContent:
|
||||
def get_line(i: int) -> StyleAndTextTuples:
|
||||
return []
|
||||
|
||||
return UIContent(get_line=get_line, line_count=100**100) # Something very big.
|
||||
|
||||
def is_focusable(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _ProcessedLine(NamedTuple):
|
||||
fragments: StyleAndTextTuples
|
||||
source_to_display: Callable[[int], int]
|
||||
display_to_source: Callable[[int], int]
|
||||
|
||||
|
||||
class BufferControl(UIControl):
|
||||
"""
|
||||
Control for visualizing the content of a :class:`.Buffer`.
|
||||
|
||||
:param buffer: The :class:`.Buffer` object to be displayed.
|
||||
:param input_processors: A list of
|
||||
:class:`~prompt_toolkit.layout.processors.Processor` objects.
|
||||
:param include_default_input_processors: When True, include the default
|
||||
processors for highlighting of selection, search and displaying of
|
||||
multiple cursors.
|
||||
:param lexer: :class:`.Lexer` instance for syntax highlighting.
|
||||
:param preview_search: `bool` or :class:`.Filter`: Show search while
|
||||
typing. When this is `True`, probably you want to add a
|
||||
``HighlightIncrementalSearchProcessor`` as well. Otherwise only the
|
||||
cursor position will move, but the text won't be highlighted.
|
||||
:param focusable: `bool` or :class:`.Filter`: Tell whether this control is focusable.
|
||||
:param focus_on_click: Focus this buffer when it's click, but not yet focused.
|
||||
:param key_bindings: a :class:`.KeyBindings` object.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
buffer: Buffer | None = None,
|
||||
input_processors: list[Processor] | None = None,
|
||||
include_default_input_processors: bool = True,
|
||||
lexer: Lexer | None = None,
|
||||
preview_search: FilterOrBool = False,
|
||||
focusable: FilterOrBool = True,
|
||||
search_buffer_control: (
|
||||
None | SearchBufferControl | Callable[[], SearchBufferControl]
|
||||
) = None,
|
||||
menu_position: Callable[[], int | None] | None = None,
|
||||
focus_on_click: FilterOrBool = False,
|
||||
key_bindings: KeyBindingsBase | None = None,
|
||||
):
|
||||
self.input_processors = input_processors
|
||||
self.include_default_input_processors = include_default_input_processors
|
||||
|
||||
self.default_input_processors = [
|
||||
HighlightSearchProcessor(),
|
||||
HighlightIncrementalSearchProcessor(),
|
||||
HighlightSelectionProcessor(),
|
||||
DisplayMultipleCursors(),
|
||||
]
|
||||
|
||||
self.preview_search = to_filter(preview_search)
|
||||
self.focusable = to_filter(focusable)
|
||||
self.focus_on_click = to_filter(focus_on_click)
|
||||
|
||||
self.buffer = buffer or Buffer()
|
||||
self.menu_position = menu_position
|
||||
self.lexer = lexer or SimpleLexer()
|
||||
self.key_bindings = key_bindings
|
||||
self._search_buffer_control = search_buffer_control
|
||||
|
||||
#: Cache for the lexer.
|
||||
#: Often, due to cursor movement, undo/redo and window resizing
|
||||
#: operations, it happens that a short time, the same document has to be
|
||||
#: lexed. This is a fairly easy way to cache such an expensive operation.
|
||||
self._fragment_cache: SimpleCache[
|
||||
Hashable, Callable[[int], StyleAndTextTuples]
|
||||
] = SimpleCache(maxsize=8)
|
||||
|
||||
self._last_click_timestamp: float | None = None
|
||||
self._last_get_processed_line: Callable[[int], _ProcessedLine] | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} buffer={self.buffer!r} at {id(self)!r}>"
|
||||
|
||||
@property
|
||||
def search_buffer_control(self) -> SearchBufferControl | None:
|
||||
result: SearchBufferControl | None
|
||||
|
||||
if callable(self._search_buffer_control):
|
||||
result = self._search_buffer_control()
|
||||
else:
|
||||
result = self._search_buffer_control
|
||||
|
||||
assert result is None or isinstance(result, SearchBufferControl)
|
||||
return result
|
||||
|
||||
@property
|
||||
def search_buffer(self) -> Buffer | None:
|
||||
control = self.search_buffer_control
|
||||
if control is not None:
|
||||
return control.buffer
|
||||
return None
|
||||
|
||||
@property
|
||||
def search_state(self) -> SearchState:
|
||||
"""
|
||||
Return the `SearchState` for searching this `BufferControl`. This is
|
||||
always associated with the search control. If one search bar is used
|
||||
for searching multiple `BufferControls`, then they share the same
|
||||
`SearchState`.
|
||||
"""
|
||||
search_buffer_control = self.search_buffer_control
|
||||
if search_buffer_control:
|
||||
return search_buffer_control.searcher_search_state
|
||||
else:
|
||||
return SearchState()
|
||||
|
||||
def is_focusable(self) -> bool:
|
||||
return self.focusable()
|
||||
|
||||
def preferred_width(self, max_available_width: int) -> int | None:
|
||||
"""
|
||||
This should return the preferred width.
|
||||
|
||||
Note: We don't specify a preferred width according to the content,
|
||||
because it would be too expensive. Calculating the preferred
|
||||
width can be done by calculating the longest line, but this would
|
||||
require applying all the processors to each line. This is
|
||||
unfeasible for a larger document, and doing it for small
|
||||
documents only would result in inconsistent behavior.
|
||||
"""
|
||||
return None
|
||||
|
||||
def preferred_height(
|
||||
self,
|
||||
width: int,
|
||||
max_available_height: int,
|
||||
wrap_lines: bool,
|
||||
get_line_prefix: GetLinePrefixCallable | None,
|
||||
) -> int | None:
|
||||
# Calculate the content height, if it was drawn on a screen with the
|
||||
# given width.
|
||||
height = 0
|
||||
content = self.create_content(width, height=1) # Pass a dummy '1' as height.
|
||||
|
||||
# When line wrapping is off, the height should be equal to the amount
|
||||
# of lines.
|
||||
if not wrap_lines:
|
||||
return content.line_count
|
||||
|
||||
# When the number of lines exceeds the max_available_height, just
|
||||
# return max_available_height. No need to calculate anything.
|
||||
if content.line_count >= max_available_height:
|
||||
return max_available_height
|
||||
|
||||
for i in range(content.line_count):
|
||||
height += content.get_height_for_line(i, width, get_line_prefix)
|
||||
|
||||
if height >= max_available_height:
|
||||
return max_available_height
|
||||
|
||||
return height
|
||||
|
||||
def _get_formatted_text_for_line_func(
|
||||
self, document: Document
|
||||
) -> Callable[[int], StyleAndTextTuples]:
|
||||
"""
|
||||
Create a function that returns the fragments for a given line.
|
||||
"""
|
||||
|
||||
# Cache using `document.text`.
|
||||
def get_formatted_text_for_line() -> Callable[[int], StyleAndTextTuples]:
|
||||
return self.lexer.lex_document(document)
|
||||
|
||||
key = (document.text, self.lexer.invalidation_hash())
|
||||
return self._fragment_cache.get(key, get_formatted_text_for_line)
|
||||
|
||||
def _create_get_processed_line_func(
|
||||
self, document: Document, width: int, height: int
|
||||
) -> Callable[[int], _ProcessedLine]:
|
||||
"""
|
||||
Create a function that takes a line number of the current document and
|
||||
returns a _ProcessedLine(processed_fragments, source_to_display, display_to_source)
|
||||
tuple.
|
||||
"""
|
||||
# Merge all input processors together.
|
||||
input_processors = self.input_processors or []
|
||||
if self.include_default_input_processors:
|
||||
input_processors = self.default_input_processors + input_processors
|
||||
|
||||
merged_processor = merge_processors(input_processors)
|
||||
|
||||
def transform(
|
||||
lineno: int,
|
||||
fragments: StyleAndTextTuples,
|
||||
get_line: Callable[[int], 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,
|
||||
get_line,
|
||||
)
|
||||
)
|
||||
|
||||
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), get_line)
|
||||
cache[i] = processed_line
|
||||
return processed_line
|
||||
|
||||
return get_processed_line
|
||||
|
||||
return create_func()
|
||||
|
||||
def create_content(
|
||||
self, width: int, height: int, preview_search: bool = False
|
||||
) -> UIContent:
|
||||
"""
|
||||
Create a UIContent.
|
||||
"""
|
||||
buffer = self.buffer
|
||||
|
||||
# Trigger history loading of the buffer. We do this during the
|
||||
# rendering of the UI here, because it needs to happen when an
|
||||
# `Application` with its event loop is running. During the rendering of
|
||||
# the buffer control is the earliest place we can achieve this, where
|
||||
# we're sure the right event loop is active, and don't require user
|
||||
# interaction (like in a key binding).
|
||||
buffer.load_history_if_not_yet_loaded()
|
||||
|
||||
# Get the document to be shown. If we are currently searching (the
|
||||
# search buffer has focus, and the preview_search filter is enabled),
|
||||
# then use the search document, which has possibly a different
|
||||
# text/cursor position.)
|
||||
search_control = self.search_buffer_control
|
||||
preview_now = preview_search or bool(
|
||||
# Only if this feature is enabled.
|
||||
self.preview_search()
|
||||
and
|
||||
# And something was typed in the associated search field.
|
||||
search_control
|
||||
and search_control.buffer.text
|
||||
and
|
||||
# And we are searching in this control. (Many controls can point to
|
||||
# the same search field, like in Pyvim.)
|
||||
get_app().layout.search_target_buffer_control == self
|
||||
)
|
||||
|
||||
if preview_now and search_control is not None:
|
||||
ss = self.search_state
|
||||
|
||||
document = buffer.document_for_search(
|
||||
SearchState(
|
||||
text=search_control.buffer.text,
|
||||
direction=ss.direction,
|
||||
ignore_case=ss.ignore_case,
|
||||
)
|
||||
)
|
||||
else:
|
||||
document = buffer.document
|
||||
|
||||
get_processed_line = self._create_get_processed_line_func(
|
||||
document, width, height
|
||||
)
|
||||
self._last_get_processed_line = get_processed_line
|
||||
|
||||
def translate_rowcol(row: int, col: int) -> Point:
|
||||
"Return the content column for this coordinate."
|
||||
return Point(x=get_processed_line(row).source_to_display(col), y=row)
|
||||
|
||||
def get_line(i: int) -> StyleAndTextTuples:
|
||||
"Return the fragments for a given line number."
|
||||
fragments = get_processed_line(i).fragments
|
||||
|
||||
# Add a space at the end, because that is a possible cursor
|
||||
# position. (When inserting after the input.) We should do this on
|
||||
# all the lines, not just the line containing the cursor. (Because
|
||||
# otherwise, line wrapping/scrolling could change when moving the
|
||||
# cursor around.)
|
||||
fragments = fragments + [("", " ")]
|
||||
return fragments
|
||||
|
||||
content = UIContent(
|
||||
get_line=get_line,
|
||||
line_count=document.line_count,
|
||||
cursor_position=translate_rowcol(
|
||||
document.cursor_position_row, document.cursor_position_col
|
||||
),
|
||||
)
|
||||
|
||||
# If there is an auto completion going on, use that start point for a
|
||||
# pop-up menu position. (But only when this buffer has the focus --
|
||||
# there is only one place for a menu, determined by the focused buffer.)
|
||||
if get_app().layout.current_control == self:
|
||||
menu_position = self.menu_position() if self.menu_position else None
|
||||
if menu_position is not None:
|
||||
assert isinstance(menu_position, int)
|
||||
menu_row, menu_col = buffer.document.translate_index_to_position(
|
||||
menu_position
|
||||
)
|
||||
content.menu_position = translate_rowcol(menu_row, menu_col)
|
||||
elif buffer.complete_state:
|
||||
# Position for completion menu.
|
||||
# Note: We use 'min', because the original cursor position could be
|
||||
# behind the input string when the actual completion is for
|
||||
# some reason shorter than the text we had before. (A completion
|
||||
# can change and shorten the input.)
|
||||
menu_row, menu_col = buffer.document.translate_index_to_position(
|
||||
min(
|
||||
buffer.cursor_position,
|
||||
buffer.complete_state.original_document.cursor_position,
|
||||
)
|
||||
)
|
||||
content.menu_position = translate_rowcol(menu_row, menu_col)
|
||||
else:
|
||||
content.menu_position = None
|
||||
|
||||
return content
|
||||
|
||||
def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
|
||||
"""
|
||||
Mouse handler for this control.
|
||||
"""
|
||||
buffer = self.buffer
|
||||
position = mouse_event.position
|
||||
|
||||
# Focus buffer when clicked.
|
||||
if get_app().layout.current_control == self:
|
||||
if self._last_get_processed_line:
|
||||
processed_line = self._last_get_processed_line(position.y)
|
||||
|
||||
# Translate coordinates back to the cursor position of the
|
||||
# original input.
|
||||
xpos = processed_line.display_to_source(position.x)
|
||||
index = buffer.document.translate_row_col_to_index(position.y, xpos)
|
||||
|
||||
# Set the cursor position.
|
||||
if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
|
||||
buffer.exit_selection()
|
||||
buffer.cursor_position = index
|
||||
|
||||
elif (
|
||||
mouse_event.event_type == MouseEventType.MOUSE_MOVE
|
||||
and mouse_event.button != MouseButton.NONE
|
||||
):
|
||||
# Click and drag to highlight a selection
|
||||
if (
|
||||
buffer.selection_state is None
|
||||
and abs(buffer.cursor_position - index) > 0
|
||||
):
|
||||
buffer.start_selection(selection_type=SelectionType.CHARACTERS)
|
||||
buffer.cursor_position = index
|
||||
|
||||
elif mouse_event.event_type == MouseEventType.MOUSE_UP:
|
||||
# When the cursor was moved to another place, select the text.
|
||||
# (The >1 is actually a small but acceptable workaround for
|
||||
# selecting text in Vi navigation mode. In navigation mode,
|
||||
# the cursor can never be after the text, so the cursor
|
||||
# will be repositioned automatically.)
|
||||
if abs(buffer.cursor_position - index) > 1:
|
||||
if buffer.selection_state is None:
|
||||
buffer.start_selection(
|
||||
selection_type=SelectionType.CHARACTERS
|
||||
)
|
||||
buffer.cursor_position = index
|
||||
|
||||
# Select word around cursor on double click.
|
||||
# Two MOUSE_UP events in a short timespan are considered a double click.
|
||||
double_click = (
|
||||
self._last_click_timestamp
|
||||
and time.time() - self._last_click_timestamp < 0.3
|
||||
)
|
||||
self._last_click_timestamp = time.time()
|
||||
|
||||
if double_click:
|
||||
start, end = buffer.document.find_boundaries_of_current_word()
|
||||
buffer.cursor_position += start
|
||||
buffer.start_selection(selection_type=SelectionType.CHARACTERS)
|
||||
buffer.cursor_position += end - start
|
||||
else:
|
||||
# Don't handle scroll events here.
|
||||
return NotImplemented
|
||||
|
||||
# Not focused, but focusing on click events.
|
||||
else:
|
||||
if (
|
||||
self.focus_on_click()
|
||||
and mouse_event.event_type == MouseEventType.MOUSE_UP
|
||||
):
|
||||
# Focus happens on mouseup. (If we did this on mousedown, the
|
||||
# up event will be received at the point where this widget is
|
||||
# focused and be handled anyway.)
|
||||
get_app().layout.current_control = self
|
||||
else:
|
||||
return NotImplemented
|
||||
|
||||
return None
|
||||
|
||||
def move_cursor_down(self) -> None:
|
||||
b = self.buffer
|
||||
b.cursor_position += b.document.get_cursor_down_position()
|
||||
|
||||
def move_cursor_up(self) -> None:
|
||||
b = self.buffer
|
||||
b.cursor_position += b.document.get_cursor_up_position()
|
||||
|
||||
def get_key_bindings(self) -> KeyBindingsBase | None:
|
||||
"""
|
||||
When additional key bindings are given. Return these.
|
||||
"""
|
||||
return self.key_bindings
|
||||
|
||||
def get_invalidate_events(self) -> Iterable[Event[object]]:
|
||||
"""
|
||||
Return the Window invalidate events.
|
||||
"""
|
||||
# Whenever the buffer changes, the UI has to be updated.
|
||||
yield self.buffer.on_text_changed
|
||||
yield self.buffer.on_cursor_position_changed
|
||||
|
||||
yield self.buffer.on_completions_changed
|
||||
yield self.buffer.on_suggestion_set
|
||||
|
||||
|
||||
class SearchBufferControl(BufferControl):
|
||||
"""
|
||||
:class:`.BufferControl` which is used for searching another
|
||||
:class:`.BufferControl`.
|
||||
|
||||
:param ignore_case: Search case insensitive.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
buffer: Buffer | None = None,
|
||||
input_processors: list[Processor] | None = None,
|
||||
lexer: Lexer | None = None,
|
||||
focus_on_click: FilterOrBool = False,
|
||||
key_bindings: KeyBindingsBase | None = None,
|
||||
ignore_case: FilterOrBool = False,
|
||||
):
|
||||
super().__init__(
|
||||
buffer=buffer,
|
||||
input_processors=input_processors,
|
||||
lexer=lexer,
|
||||
focus_on_click=focus_on_click,
|
||||
key_bindings=key_bindings,
|
||||
)
|
||||
|
||||
# If this BufferControl is used as a search field for one or more other
|
||||
# BufferControls, then represents the search state.
|
||||
self.searcher_search_state = SearchState(ignore_case=ignore_case)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Layout dimensions are used to give the minimum, maximum and preferred
|
||||
dimensions for containers and controls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Union
|
||||
|
||||
__all__ = [
|
||||
"Dimension",
|
||||
"D",
|
||||
"sum_layout_dimensions",
|
||||
"max_layout_dimensions",
|
||||
"AnyDimension",
|
||||
"to_dimension",
|
||||
"is_dimension",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import TypeGuard
|
||||
|
||||
|
||||
class Dimension:
|
||||
"""
|
||||
Specified dimension (width/height) of a user control or window.
|
||||
|
||||
The layout engine tries to honor the preferred size. If that is not
|
||||
possible, because the terminal is larger or smaller, it tries to keep in
|
||||
between min and max.
|
||||
|
||||
:param min: Minimum size.
|
||||
:param max: Maximum size.
|
||||
:param weight: For a VSplit/HSplit, the actual size will be determined
|
||||
by taking the proportion of weights from all the children.
|
||||
E.g. When there are two children, one with a weight of 1,
|
||||
and the other with a weight of 2, the second will always be
|
||||
twice as big as the first, if the min/max values allow it.
|
||||
:param preferred: Preferred size.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min: int | None = None,
|
||||
max: int | None = None,
|
||||
weight: int | None = None,
|
||||
preferred: int | None = None,
|
||||
) -> None:
|
||||
if weight is not None:
|
||||
assert weight >= 0 # Also cannot be a float.
|
||||
|
||||
assert min is None or min >= 0
|
||||
assert max is None or max >= 0
|
||||
assert preferred is None or preferred >= 0
|
||||
|
||||
self.min_specified = min is not None
|
||||
self.max_specified = max is not None
|
||||
self.preferred_specified = preferred is not None
|
||||
self.weight_specified = weight is not None
|
||||
|
||||
if min is None:
|
||||
min = 0 # Smallest possible value.
|
||||
if max is None: # 0-values are allowed, so use "is None"
|
||||
max = 1000**10 # Something huge.
|
||||
if preferred is None:
|
||||
preferred = min
|
||||
if weight is None:
|
||||
weight = 1
|
||||
|
||||
self.min = min
|
||||
self.max = max
|
||||
self.preferred = preferred
|
||||
self.weight = weight
|
||||
|
||||
# Don't allow situations where max < min. (This would be a bug.)
|
||||
if max < min:
|
||||
raise ValueError("Invalid Dimension: max < min.")
|
||||
|
||||
# Make sure that the 'preferred' size is always in the min..max range.
|
||||
if self.preferred < self.min:
|
||||
self.preferred = self.min
|
||||
|
||||
if self.preferred > self.max:
|
||||
self.preferred = self.max
|
||||
|
||||
@classmethod
|
||||
def exact(cls, amount: int) -> Dimension:
|
||||
"""
|
||||
Return a :class:`.Dimension` with an exact size. (min, max and
|
||||
preferred set to ``amount``).
|
||||
"""
|
||||
return cls(min=amount, max=amount, preferred=amount)
|
||||
|
||||
@classmethod
|
||||
def zero(cls) -> Dimension:
|
||||
"""
|
||||
Create a dimension that represents a zero size. (Used for 'invisible'
|
||||
controls.)
|
||||
"""
|
||||
return cls.exact(amount=0)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
fields = []
|
||||
if self.min_specified:
|
||||
fields.append(f"min={self.min!r}")
|
||||
if self.max_specified:
|
||||
fields.append(f"max={self.max!r}")
|
||||
if self.preferred_specified:
|
||||
fields.append(f"preferred={self.preferred!r}")
|
||||
if self.weight_specified:
|
||||
fields.append(f"weight={self.weight!r}")
|
||||
|
||||
return "Dimension({})".format(", ".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.preferred == 0 and d.max == 0 for d in dimensions):
|
||||
return Dimension.zero()
|
||||
|
||||
# Ignore empty dimensions. (They should not reduce the size of others.)
|
||||
dimensions = [d for d in dimensions if d.preferred != 0 and d.max != 0]
|
||||
|
||||
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
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Dummy layout. Used when somebody creates an `Application` without specifying a
|
||||
`Layout`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||
|
||||
from .containers import Window
|
||||
from .controls import FormattedTextControl
|
||||
from .dimension import D
|
||||
from .layout import Layout
|
||||
|
||||
__all__ = [
|
||||
"create_dummy_layout",
|
||||
]
|
||||
|
||||
E = KeyPressEvent
|
||||
|
||||
|
||||
def create_dummy_layout() -> Layout:
|
||||
"""
|
||||
Create a dummy layout for use in an 'Application' that doesn't have a
|
||||
layout specified. When ENTER is pressed, the application quits.
|
||||
"""
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add("enter")
|
||||
def enter(event: E) -> None:
|
||||
event.app.exit()
|
||||
|
||||
control = FormattedTextControl(
|
||||
HTML("No layout specified. Press <reverse>ENTER</reverse> to quit."),
|
||||
key_bindings=kb,
|
||||
)
|
||||
window = Window(content=control, height=D(min=1))
|
||||
return Layout(container=window, focused_element=window)
|
||||
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
Wrapper for the layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generator, Iterable, Union
|
||||
|
||||
from prompt_toolkit.buffer import Buffer
|
||||
|
||||
from .containers import (
|
||||
AnyContainer,
|
||||
ConditionalContainer,
|
||||
Container,
|
||||
Window,
|
||||
to_container,
|
||||
)
|
||||
from .controls import BufferControl, SearchBufferControl, UIControl
|
||||
|
||||
__all__ = [
|
||||
"Layout",
|
||||
"InvalidLayoutError",
|
||||
"walk",
|
||||
]
|
||||
|
||||
FocusableElement = Union[str, Buffer, UIControl, AnyContainer]
|
||||
|
||||
|
||||
class Layout:
|
||||
"""
|
||||
The layout for a prompt_toolkit
|
||||
:class:`~prompt_toolkit.application.Application`.
|
||||
This also keeps track of which user control is focused.
|
||||
|
||||
:param container: The "root" container for the layout.
|
||||
:param focused_element: element to be focused initially. (Can be anything
|
||||
the `focus` function accepts.)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
container: AnyContainer,
|
||||
focused_element: FocusableElement | None = None,
|
||||
) -> None:
|
||||
self.container = to_container(container)
|
||||
self._stack: list[Window] = []
|
||||
|
||||
# Map search BufferControl back to the original BufferControl.
|
||||
# This is used to keep track of when exactly we are searching, and for
|
||||
# applying the search.
|
||||
# When a link exists in this dictionary, that means the search is
|
||||
# currently active.
|
||||
# Map: search_buffer_control -> original buffer control.
|
||||
self.search_links: dict[SearchBufferControl, BufferControl] = {}
|
||||
|
||||
# Mapping that maps the children in the layout to their parent.
|
||||
# This relationship is calculated dynamically, each time when the UI
|
||||
# is rendered. (UI elements have only references to their children.)
|
||||
self._child_to_parent: dict[Container, Container] = {}
|
||||
|
||||
if focused_element is None:
|
||||
try:
|
||||
self._stack.append(next(self.find_all_windows()))
|
||||
except StopIteration as e:
|
||||
raise InvalidLayoutError(
|
||||
"Invalid layout. The layout does not contain any Window object."
|
||||
) from e
|
||||
else:
|
||||
self.focus(focused_element)
|
||||
|
||||
# List of visible windows.
|
||||
self.visible_windows: list[Window] = [] # List of `Window` objects.
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Layout({self.container!r}, current_window={self.current_window!r})"
|
||||
|
||||
def find_all_windows(self) -> Generator[Window, None, None]:
|
||||
"""
|
||||
Find all the :class:`.UIControl` objects in this layout.
|
||||
"""
|
||||
for item in self.walk():
|
||||
if isinstance(item, Window):
|
||||
yield item
|
||||
|
||||
def find_all_controls(self) -> Iterable[UIControl]:
|
||||
for container in self.find_all_windows():
|
||||
yield container.content
|
||||
|
||||
def focus(self, value: FocusableElement) -> None:
|
||||
"""
|
||||
Focus the given UI element.
|
||||
|
||||
`value` can be either:
|
||||
|
||||
- a :class:`.UIControl`
|
||||
- a :class:`.Buffer` instance or the name of a :class:`.Buffer`
|
||||
- a :class:`.Window`
|
||||
- Any container object. In this case we will focus the :class:`.Window`
|
||||
from this container that was focused most recent, or the very first
|
||||
focusable :class:`.Window` of the container.
|
||||
"""
|
||||
# BufferControl by buffer name.
|
||||
if isinstance(value, str):
|
||||
for control in self.find_all_controls():
|
||||
if isinstance(control, BufferControl) and control.buffer.name == value:
|
||||
self.focus(control)
|
||||
return
|
||||
raise ValueError(f"Couldn't find Buffer in the current layout: {value!r}.")
|
||||
|
||||
# BufferControl by buffer object.
|
||||
elif isinstance(value, Buffer):
|
||||
for control in self.find_all_controls():
|
||||
if isinstance(control, BufferControl) and control.buffer == value:
|
||||
self.focus(control)
|
||||
return
|
||||
raise ValueError(f"Couldn't find Buffer in the current layout: {value!r}.")
|
||||
|
||||
# Focus UIControl.
|
||||
elif isinstance(value, UIControl):
|
||||
if value not in self.find_all_controls():
|
||||
raise ValueError(
|
||||
"Invalid value. Container does not appear in the layout."
|
||||
)
|
||||
if not value.is_focusable():
|
||||
raise ValueError("Invalid value. UIControl is not focusable.")
|
||||
|
||||
self.current_control = value
|
||||
|
||||
# Otherwise, expecting any Container object.
|
||||
else:
|
||||
value = to_container(value)
|
||||
|
||||
if isinstance(value, Window):
|
||||
# This is a `Window`: focus that.
|
||||
if value not in self.find_all_windows():
|
||||
raise ValueError(
|
||||
f"Invalid value. Window does not appear in the layout: {value!r}"
|
||||
)
|
||||
|
||||
self.current_window = value
|
||||
else:
|
||||
# Focus a window in this container.
|
||||
# If we have many windows as part of this container, and some
|
||||
# of them have been focused before, take the last focused
|
||||
# item. (This is very useful when the UI is composed of more
|
||||
# complex sub components.)
|
||||
windows = []
|
||||
for c in walk(value, skip_hidden=True):
|
||||
if isinstance(c, Window) and c.content.is_focusable():
|
||||
windows.append(c)
|
||||
|
||||
# Take the first one that was focused before.
|
||||
for w in reversed(self._stack):
|
||||
if w in windows:
|
||||
self.current_window = w
|
||||
return
|
||||
|
||||
# None was focused before: take the very first focusable window.
|
||||
if windows:
|
||||
self.current_window = windows[0]
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid value. Container cannot be focused: {value!r}"
|
||||
)
|
||||
|
||||
def has_focus(self, value: FocusableElement) -> bool:
|
||||
"""
|
||||
Check whether the given control has the focus.
|
||||
:param value: :class:`.UIControl` or :class:`.Window` instance.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
if self.current_buffer is None:
|
||||
return False
|
||||
return self.current_buffer.name == value
|
||||
if isinstance(value, Buffer):
|
||||
return self.current_buffer == value
|
||||
if isinstance(value, UIControl):
|
||||
return self.current_control == value
|
||||
else:
|
||||
value = to_container(value)
|
||||
if isinstance(value, Window):
|
||||
return self.current_window == value
|
||||
else:
|
||||
# Check whether this "container" is focused. This is true if
|
||||
# one of the elements inside is focused.
|
||||
for element in walk(value):
|
||||
if element == self.current_window:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def current_control(self) -> UIControl:
|
||||
"""
|
||||
Get the :class:`.UIControl` to currently has the focus.
|
||||
"""
|
||||
return self._stack[-1].content
|
||||
|
||||
@current_control.setter
|
||||
def current_control(self, control: UIControl) -> None:
|
||||
"""
|
||||
Set the :class:`.UIControl` to receive the focus.
|
||||
"""
|
||||
for window in self.find_all_windows():
|
||||
if window.content == control:
|
||||
self.current_window = window
|
||||
return
|
||||
|
||||
raise ValueError("Control not found in the user interface.")
|
||||
|
||||
@property
|
||||
def current_window(self) -> Window:
|
||||
"Return the :class:`.Window` object that is currently focused."
|
||||
return self._stack[-1]
|
||||
|
||||
@current_window.setter
|
||||
def current_window(self, value: Window) -> None:
|
||||
"Set the :class:`.Window` object to be currently focused."
|
||||
self._stack.append(value)
|
||||
|
||||
@property
|
||||
def is_searching(self) -> bool:
|
||||
"True if we are searching right now."
|
||||
return self.current_control in self.search_links
|
||||
|
||||
@property
|
||||
def search_target_buffer_control(self) -> BufferControl | None:
|
||||
"""
|
||||
Return the :class:`.BufferControl` in which we are searching or `None`.
|
||||
"""
|
||||
# Not every `UIControl` is a `BufferControl`. This only applies to
|
||||
# `BufferControl`.
|
||||
control = self.current_control
|
||||
|
||||
if isinstance(control, SearchBufferControl):
|
||||
return self.search_links.get(control)
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_focusable_windows(self) -> Iterable[Window]:
|
||||
"""
|
||||
Return all the :class:`.Window` objects which are focusable (in the
|
||||
'modal' area).
|
||||
"""
|
||||
for w in self.walk_through_modal_area():
|
||||
if isinstance(w, Window) and w.content.is_focusable():
|
||||
yield w
|
||||
|
||||
def get_visible_focusable_windows(self) -> list[Window]:
|
||||
"""
|
||||
Return a list of :class:`.Window` objects that are focusable.
|
||||
"""
|
||||
# focusable windows are windows that are visible, but also part of the
|
||||
# modal container. Make sure to keep the ordering.
|
||||
visible_windows = self.visible_windows
|
||||
return [w for w in self.get_focusable_windows() if w in visible_windows]
|
||||
|
||||
@property
|
||||
def current_buffer(self) -> Buffer | None:
|
||||
"""
|
||||
The currently focused :class:`~.Buffer` or `None`.
|
||||
"""
|
||||
ui_control = self.current_control
|
||||
if isinstance(ui_control, BufferControl):
|
||||
return ui_control.buffer
|
||||
return None
|
||||
|
||||
def get_buffer_by_name(self, buffer_name: str) -> Buffer | None:
|
||||
"""
|
||||
Look in the layout for a buffer with the given name.
|
||||
Return `None` when nothing was found.
|
||||
"""
|
||||
for w in self.walk():
|
||||
if isinstance(w, Window) and isinstance(w.content, BufferControl):
|
||||
if w.content.buffer.name == buffer_name:
|
||||
return w.content.buffer
|
||||
return None
|
||||
|
||||
@property
|
||||
def buffer_has_focus(self) -> bool:
|
||||
"""
|
||||
Return `True` if the currently focused control is a
|
||||
:class:`.BufferControl`. (For instance, used to determine whether the
|
||||
default key bindings should be active or not.)
|
||||
"""
|
||||
ui_control = self.current_control
|
||||
return isinstance(ui_control, BufferControl)
|
||||
|
||||
@property
|
||||
def previous_control(self) -> UIControl:
|
||||
"""
|
||||
Get the :class:`.UIControl` to previously had the focus.
|
||||
"""
|
||||
try:
|
||||
return self._stack[-2].content
|
||||
except IndexError:
|
||||
return self._stack[-1].content
|
||||
|
||||
def focus_last(self) -> None:
|
||||
"""
|
||||
Give the focus to the last focused control.
|
||||
"""
|
||||
if len(self._stack) > 1:
|
||||
self._stack = self._stack[:-1]
|
||||
|
||||
def focus_next(self) -> None:
|
||||
"""
|
||||
Focus the next visible/focusable Window.
|
||||
"""
|
||||
windows = self.get_visible_focusable_windows()
|
||||
|
||||
if len(windows) > 0:
|
||||
try:
|
||||
index = windows.index(self.current_window)
|
||||
except ValueError:
|
||||
index = 0
|
||||
else:
|
||||
index = (index + 1) % len(windows)
|
||||
|
||||
self.focus(windows[index])
|
||||
|
||||
def focus_previous(self) -> None:
|
||||
"""
|
||||
Focus the previous visible/focusable Window.
|
||||
"""
|
||||
windows = self.get_visible_focusable_windows()
|
||||
|
||||
if len(windows) > 0:
|
||||
try:
|
||||
index = windows.index(self.current_window)
|
||||
except ValueError:
|
||||
index = 0
|
||||
else:
|
||||
index = (index - 1) % len(windows)
|
||||
|
||||
self.focus(windows[index])
|
||||
|
||||
def walk(self) -> Iterable[Container]:
|
||||
"""
|
||||
Walk through all the layout nodes (and their children) and yield them.
|
||||
"""
|
||||
yield from walk(self.container)
|
||||
|
||||
def walk_through_modal_area(self) -> Iterable[Container]:
|
||||
"""
|
||||
Walk through all the containers which are in the current 'modal' part
|
||||
of the layout.
|
||||
"""
|
||||
# Go up in the tree, and find the root. (it will be a part of the
|
||||
# layout, if the focus is in a modal part.)
|
||||
root: Container = self.current_window
|
||||
while not root.is_modal() and root in self._child_to_parent:
|
||||
root = self._child_to_parent[root]
|
||||
|
||||
yield from walk(root)
|
||||
|
||||
def update_parents_relations(self) -> None:
|
||||
"""
|
||||
Update child->parent relationships mapping.
|
||||
"""
|
||||
parents = {}
|
||||
|
||||
def walk(e: Container) -> None:
|
||||
for c in e.get_children():
|
||||
parents[c] = e
|
||||
walk(c)
|
||||
|
||||
walk(self.container)
|
||||
|
||||
self._child_to_parent = parents
|
||||
|
||||
def reset(self) -> None:
|
||||
# Remove all search links when the UI starts.
|
||||
# (Important, for instance when control-c is been pressed while
|
||||
# searching. The prompt cancels, but next `run()` call the search
|
||||
# links are still there.)
|
||||
self.search_links.clear()
|
||||
|
||||
self.container.reset()
|
||||
|
||||
def get_parent(self, container: Container) -> Container | None:
|
||||
"""
|
||||
Return the parent container for the given container, or ``None``, if it
|
||||
wasn't found.
|
||||
"""
|
||||
try:
|
||||
return self._child_to_parent[container]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
class InvalidLayoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def walk(container: Container, skip_hidden: bool = False) -> Iterable[Container]:
|
||||
"""
|
||||
Walk through layout, starting at this container.
|
||||
"""
|
||||
# When `skip_hidden` is set, don't go into disabled ConditionalContainer containers.
|
||||
if (
|
||||
skip_hidden
|
||||
and isinstance(container, ConditionalContainer)
|
||||
and not container.filter()
|
||||
):
|
||||
return
|
||||
|
||||
yield container
|
||||
|
||||
for c in container.get_children():
|
||||
# yield from walk(c)
|
||||
yield from walk(c, skip_hidden=skip_hidden)
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Margin implementations for a :class:`~prompt_toolkit.layout.containers.Window`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from prompt_toolkit.filters import FilterOrBool, to_filter
|
||||
from prompt_toolkit.formatted_text import (
|
||||
StyleAndTextTuples,
|
||||
fragment_list_to_text,
|
||||
to_formatted_text,
|
||||
)
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
from .controls import UIContent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .containers import WindowRenderInfo
|
||||
|
||||
__all__ = [
|
||||
"Margin",
|
||||
"NumberedMargin",
|
||||
"ScrollbarMargin",
|
||||
"ConditionalMargin",
|
||||
"PromptMargin",
|
||||
]
|
||||
|
||||
|
||||
class Margin(metaclass=ABCMeta):
|
||||
"""
|
||||
Base interface for a margin.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
|
||||
"""
|
||||
Return the width that this margin is going to consume.
|
||||
|
||||
:param get_ui_content: Callable that asks the user control to create
|
||||
a :class:`.UIContent` instance. This can be used for instance to
|
||||
obtain the number of lines.
|
||||
"""
|
||||
return 0
|
||||
|
||||
@abstractmethod
|
||||
def create_margin(
|
||||
self, window_render_info: WindowRenderInfo, width: int, height: int
|
||||
) -> StyleAndTextTuples:
|
||||
"""
|
||||
Creates a margin.
|
||||
This should return a list of (style_str, text) tuples.
|
||||
|
||||
:param window_render_info:
|
||||
:class:`~prompt_toolkit.layout.containers.WindowRenderInfo`
|
||||
instance, generated after rendering and copying the visible part of
|
||||
the :class:`~prompt_toolkit.layout.controls.UIControl` into the
|
||||
:class:`~prompt_toolkit.layout.containers.Window`.
|
||||
:param width: The width that's available for this margin. (As reported
|
||||
by :meth:`.get_width`.)
|
||||
:param height: The height that's available for this margin. (The height
|
||||
of the :class:`~prompt_toolkit.layout.containers.Window`.)
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class NumberedMargin(Margin):
|
||||
"""
|
||||
Margin that displays the line numbers.
|
||||
|
||||
:param relative: Number relative to the cursor position. Similar to the Vi
|
||||
'relativenumber' option.
|
||||
:param display_tildes: Display tildes after the end of the document, just
|
||||
like Vi does.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, relative: FilterOrBool = False, display_tildes: FilterOrBool = False
|
||||
) -> None:
|
||||
self.relative = to_filter(relative)
|
||||
self.display_tildes = to_filter(display_tildes)
|
||||
|
||||
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
|
||||
line_count = get_ui_content().line_count
|
||||
return max(3, len(f"{line_count}") + 1)
|
||||
|
||||
def create_margin(
|
||||
self, window_render_info: WindowRenderInfo, width: int, height: int
|
||||
) -> StyleAndTextTuples:
|
||||
relative = self.relative()
|
||||
|
||||
style = "class:line-number"
|
||||
style_current = "class:line-number.current"
|
||||
|
||||
# Get current line number.
|
||||
current_lineno = window_render_info.ui_content.cursor_position.y
|
||||
|
||||
# Construct margin.
|
||||
result: StyleAndTextTuples = []
|
||||
last_lineno = None
|
||||
|
||||
for y, lineno in enumerate(window_render_info.displayed_lines):
|
||||
# Only display line number if this line is not a continuation of the previous line.
|
||||
if lineno != last_lineno:
|
||||
if lineno is None:
|
||||
pass
|
||||
elif lineno == current_lineno:
|
||||
# Current line.
|
||||
if relative:
|
||||
# Left align current number in relative mode.
|
||||
result.append((style_current, "%i" % (lineno + 1)))
|
||||
else:
|
||||
result.append(
|
||||
(style_current, ("%i " % (lineno + 1)).rjust(width))
|
||||
)
|
||||
else:
|
||||
# Other lines.
|
||||
if relative:
|
||||
lineno = abs(lineno - current_lineno) - 1
|
||||
|
||||
result.append((style, ("%i " % (lineno + 1)).rjust(width)))
|
||||
|
||||
last_lineno = lineno
|
||||
result.append(("", "\n"))
|
||||
|
||||
# Fill with tildes.
|
||||
if self.display_tildes():
|
||||
while y < window_render_info.window_height:
|
||||
result.append(("class:tilde", "~\n"))
|
||||
y += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ConditionalMargin(Margin):
|
||||
"""
|
||||
Wrapper around other :class:`.Margin` classes to show/hide them.
|
||||
"""
|
||||
|
||||
def __init__(self, margin: Margin, filter: FilterOrBool) -> None:
|
||||
self.margin = margin
|
||||
self.filter = to_filter(filter)
|
||||
|
||||
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
|
||||
if self.filter():
|
||||
return self.margin.get_width(get_ui_content)
|
||||
else:
|
||||
return 0
|
||||
|
||||
def create_margin(
|
||||
self, window_render_info: WindowRenderInfo, width: int, height: int
|
||||
) -> StyleAndTextTuples:
|
||||
if width and self.filter():
|
||||
return self.margin.create_margin(window_render_info, width, height)
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
class ScrollbarMargin(Margin):
|
||||
"""
|
||||
Margin displaying a scrollbar.
|
||||
|
||||
:param display_arrows: Display scroll up/down arrows.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
display_arrows: FilterOrBool = False,
|
||||
up_arrow_symbol: str = "^",
|
||||
down_arrow_symbol: str = "v",
|
||||
) -> None:
|
||||
self.display_arrows = to_filter(display_arrows)
|
||||
self.up_arrow_symbol = up_arrow_symbol
|
||||
self.down_arrow_symbol = down_arrow_symbol
|
||||
|
||||
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
|
||||
return 1
|
||||
|
||||
def create_margin(
|
||||
self, window_render_info: WindowRenderInfo, width: int, height: int
|
||||
) -> StyleAndTextTuples:
|
||||
content_height = window_render_info.content_height
|
||||
window_height = window_render_info.window_height
|
||||
display_arrows = self.display_arrows()
|
||||
|
||||
if display_arrows:
|
||||
window_height -= 2
|
||||
|
||||
try:
|
||||
fraction_visible = len(window_render_info.displayed_lines) / float(
|
||||
content_height
|
||||
)
|
||||
fraction_above = window_render_info.vertical_scroll / float(content_height)
|
||||
|
||||
scrollbar_height = int(
|
||||
min(window_height, max(1, window_height * fraction_visible))
|
||||
)
|
||||
scrollbar_top = int(window_height * fraction_above)
|
||||
except ZeroDivisionError:
|
||||
return []
|
||||
else:
|
||||
|
||||
def is_scroll_button(row: int) -> bool:
|
||||
"True if we should display a button on this row."
|
||||
return scrollbar_top <= row <= scrollbar_top + scrollbar_height
|
||||
|
||||
# Up arrow.
|
||||
result: StyleAndTextTuples = []
|
||||
if display_arrows:
|
||||
result.extend(
|
||||
[
|
||||
("class:scrollbar.arrow", self.up_arrow_symbol),
|
||||
("class:scrollbar", "\n"),
|
||||
]
|
||||
)
|
||||
|
||||
# Scrollbar body.
|
||||
scrollbar_background = "class:scrollbar.background"
|
||||
scrollbar_background_start = "class:scrollbar.background,scrollbar.start"
|
||||
scrollbar_button = "class:scrollbar.button"
|
||||
scrollbar_button_end = "class:scrollbar.button,scrollbar.end"
|
||||
|
||||
for i in range(window_height):
|
||||
if is_scroll_button(i):
|
||||
if not is_scroll_button(i + 1):
|
||||
# Give the last cell a different style, because we
|
||||
# want to underline this.
|
||||
result.append((scrollbar_button_end, " "))
|
||||
else:
|
||||
result.append((scrollbar_button, " "))
|
||||
else:
|
||||
if is_scroll_button(i + 1):
|
||||
result.append((scrollbar_background_start, " "))
|
||||
else:
|
||||
result.append((scrollbar_background, " "))
|
||||
result.append(("", "\n"))
|
||||
|
||||
# Down arrow
|
||||
if display_arrows:
|
||||
result.append(("class:scrollbar.arrow", self.down_arrow_symbol))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class PromptMargin(Margin):
|
||||
"""
|
||||
[Deprecated]
|
||||
|
||||
Create margin that displays a prompt.
|
||||
This can display one prompt at the first line, and a continuation prompt
|
||||
(e.g, just dots) on all the following lines.
|
||||
|
||||
This `PromptMargin` implementation has been largely superseded in favor of
|
||||
the `get_line_prefix` attribute of `Window`. The reason is that a margin is
|
||||
always a fixed width, while `get_line_prefix` can return a variable width
|
||||
prefix in front of every line, making it more powerful, especially for line
|
||||
continuations.
|
||||
|
||||
:param get_prompt: Callable returns formatted text or a list of
|
||||
`(style_str, type)` tuples to be shown as the prompt at the first line.
|
||||
:param get_continuation: Callable that takes three inputs. The width (int),
|
||||
line_number (int), and is_soft_wrap (bool). It should return formatted
|
||||
text or a list of `(style_str, type)` tuples for the next lines of the
|
||||
input.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
get_prompt: Callable[[], StyleAndTextTuples],
|
||||
get_continuation: None
|
||||
| (Callable[[int, int, bool], StyleAndTextTuples]) = None,
|
||||
) -> None:
|
||||
self.get_prompt = get_prompt
|
||||
self.get_continuation = get_continuation
|
||||
|
||||
def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
|
||||
"Width to report to the `Window`."
|
||||
# Take the width from the first line.
|
||||
text = fragment_list_to_text(self.get_prompt())
|
||||
return get_cwidth(text)
|
||||
|
||||
def create_margin(
|
||||
self, window_render_info: WindowRenderInfo, width: int, height: int
|
||||
) -> StyleAndTextTuples:
|
||||
get_continuation = self.get_continuation
|
||||
result: StyleAndTextTuples = []
|
||||
|
||||
# First line.
|
||||
result.extend(to_formatted_text(self.get_prompt()))
|
||||
|
||||
# Next lines.
|
||||
if get_continuation:
|
||||
last_y = None
|
||||
|
||||
for y in window_render_info.displayed_lines[1:]:
|
||||
result.append(("", "\n"))
|
||||
result.extend(
|
||||
to_formatted_text(get_continuation(width, y, y == last_y))
|
||||
)
|
||||
last_y = y
|
||||
|
||||
return result
|
||||
748
venv/lib/python3.12/site-packages/prompt_toolkit/layout/menus.py
Normal file
748
venv/lib/python3.12/site-packages/prompt_toolkit/layout/menus.py
Normal file
@@ -0,0 +1,748 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from itertools import zip_longest
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, Sequence, TypeVar, cast
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from prompt_toolkit.application.current import get_app
|
||||
from prompt_toolkit.buffer import CompletionState
|
||||
from prompt_toolkit.completion import Completion
|
||||
from prompt_toolkit.data_structures import Point
|
||||
from prompt_toolkit.filters import (
|
||||
Condition,
|
||||
FilterOrBool,
|
||||
has_completions,
|
||||
is_done,
|
||||
to_filter,
|
||||
)
|
||||
from prompt_toolkit.formatted_text import (
|
||||
StyleAndTextTuples,
|
||||
fragment_list_width,
|
||||
to_formatted_text,
|
||||
)
|
||||
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||
from prompt_toolkit.layout.utils import explode_text_fragments
|
||||
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window
|
||||
from .controls import GetLinePrefixCallable, UIContent, UIControl
|
||||
from .dimension import Dimension
|
||||
from .margins import ScrollbarMargin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prompt_toolkit.key_binding.key_bindings import (
|
||||
KeyBindings,
|
||||
NotImplementedOrNone,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CompletionsMenu",
|
||||
"MultiColumnCompletionsMenu",
|
||||
]
|
||||
|
||||
E = KeyPressEvent
|
||||
|
||||
|
||||
class CompletionsMenuControl(UIControl):
|
||||
"""
|
||||
Helper for drawing the complete menu to the screen.
|
||||
|
||||
:param scroll_offset: Number (integer) representing the preferred amount of
|
||||
completions to be displayed before and after the current one. When this
|
||||
is a very high number, the current completion will be shown in the
|
||||
middle most of the time.
|
||||
"""
|
||||
|
||||
# Preferred minimum size of the menu control.
|
||||
# The CompletionsMenu class defines a width of 8, and there is a scrollbar
|
||||
# of 1.)
|
||||
MIN_WIDTH = 7
|
||||
|
||||
def has_focus(self) -> bool:
|
||||
return False
|
||||
|
||||
def preferred_width(self, max_available_width: int) -> int | None:
|
||||
complete_state = get_app().current_buffer.complete_state
|
||||
if complete_state:
|
||||
menu_width = self._get_menu_width(500, complete_state)
|
||||
menu_meta_width = self._get_menu_meta_width(500, complete_state)
|
||||
|
||||
return menu_width + menu_meta_width
|
||||
else:
|
||||
return 0
|
||||
|
||||
def preferred_height(
|
||||
self,
|
||||
width: int,
|
||||
max_available_height: int,
|
||||
wrap_lines: bool,
|
||||
get_line_prefix: GetLinePrefixCallable | None,
|
||||
) -> int | None:
|
||||
complete_state = get_app().current_buffer.complete_state
|
||||
if complete_state:
|
||||
return len(complete_state.completions)
|
||||
else:
|
||||
return 0
|
||||
|
||||
def create_content(self, width: int, height: int) -> UIContent:
|
||||
"""
|
||||
Create a UIContent object for this control.
|
||||
"""
|
||||
complete_state = get_app().current_buffer.complete_state
|
||||
if complete_state:
|
||||
completions = complete_state.completions
|
||||
index = complete_state.complete_index # Can be None!
|
||||
|
||||
# Calculate width of completions menu.
|
||||
menu_width = self._get_menu_width(width, complete_state)
|
||||
menu_meta_width = self._get_menu_meta_width(
|
||||
width - menu_width, complete_state
|
||||
)
|
||||
show_meta = self._show_meta(complete_state)
|
||||
|
||||
def get_line(i: int) -> StyleAndTextTuples:
|
||||
c = completions[i]
|
||||
is_current_completion = i == index
|
||||
result = _get_menu_item_fragments(
|
||||
c, is_current_completion, menu_width, space_after=True
|
||||
)
|
||||
|
||||
if show_meta:
|
||||
result += self._get_menu_item_meta_fragments(
|
||||
c, is_current_completion, menu_meta_width
|
||||
)
|
||||
return result
|
||||
|
||||
return UIContent(
|
||||
get_line=get_line,
|
||||
cursor_position=Point(x=0, y=index or 0),
|
||||
line_count=len(completions),
|
||||
)
|
||||
|
||||
return UIContent()
|
||||
|
||||
def _show_meta(self, complete_state: CompletionState) -> bool:
|
||||
"""
|
||||
Return ``True`` if we need to show a column with meta information.
|
||||
"""
|
||||
return any(c.display_meta_text for c in complete_state.completions)
|
||||
|
||||
def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int:
|
||||
"""
|
||||
Return the width of the main column.
|
||||
"""
|
||||
return min(
|
||||
max_width,
|
||||
max(
|
||||
self.MIN_WIDTH,
|
||||
max(get_cwidth(c.display_text) for c in complete_state.completions) + 2,
|
||||
),
|
||||
)
|
||||
|
||||
def _get_menu_meta_width(
|
||||
self, max_width: int, complete_state: CompletionState
|
||||
) -> int:
|
||||
"""
|
||||
Return the width of the meta column.
|
||||
"""
|
||||
|
||||
def meta_width(completion: Completion) -> int:
|
||||
return get_cwidth(completion.display_meta_text)
|
||||
|
||||
if self._show_meta(complete_state):
|
||||
# If the amount of completions is over 200, compute the width based
|
||||
# on the first 200 completions, otherwise this can be very slow.
|
||||
completions = complete_state.completions
|
||||
if len(completions) > 200:
|
||||
completions = completions[:200]
|
||||
|
||||
return min(max_width, max(meta_width(c) for c in completions) + 2)
|
||||
else:
|
||||
return 0
|
||||
|
||||
def _get_menu_item_meta_fragments(
|
||||
self, completion: Completion, is_current_completion: bool, width: int
|
||||
) -> StyleAndTextTuples:
|
||||
if is_current_completion:
|
||||
style_str = "class:completion-menu.meta.completion.current"
|
||||
else:
|
||||
style_str = "class:completion-menu.meta.completion"
|
||||
|
||||
text, tw = _trim_formatted_text(completion.display_meta, width - 2)
|
||||
padding = " " * (width - 1 - tw)
|
||||
|
||||
return to_formatted_text(
|
||||
cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
|
||||
style=style_str,
|
||||
)
|
||||
|
||||
def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
|
||||
"""
|
||||
Handle mouse events: clicking and scrolling.
|
||||
"""
|
||||
b = get_app().current_buffer
|
||||
|
||||
if mouse_event.event_type == MouseEventType.MOUSE_UP:
|
||||
# Select completion.
|
||||
b.go_to_completion(mouse_event.position.y)
|
||||
b.complete_state = None
|
||||
|
||||
elif mouse_event.event_type == MouseEventType.SCROLL_DOWN:
|
||||
# Scroll up.
|
||||
b.complete_next(count=3, disable_wrap_around=True)
|
||||
|
||||
elif mouse_event.event_type == MouseEventType.SCROLL_UP:
|
||||
# Scroll down.
|
||||
b.complete_previous(count=3, disable_wrap_around=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_menu_item_fragments(
|
||||
completion: Completion,
|
||||
is_current_completion: bool,
|
||||
width: int,
|
||||
space_after: bool = False,
|
||||
) -> StyleAndTextTuples:
|
||||
"""
|
||||
Get the style/text tuples for a menu item, styled and trimmed to the given
|
||||
width.
|
||||
"""
|
||||
if is_current_completion:
|
||||
style_str = f"class:completion-menu.completion.current {completion.style} {completion.selected_style}"
|
||||
else:
|
||||
style_str = "class:completion-menu.completion " + completion.style
|
||||
|
||||
text, tw = _trim_formatted_text(
|
||||
completion.display, (width - 2 if space_after else width - 1)
|
||||
)
|
||||
|
||||
padding = " " * (width - 1 - tw)
|
||||
|
||||
return to_formatted_text(
|
||||
cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
|
||||
style=style_str,
|
||||
)
|
||||
|
||||
|
||||
def _trim_formatted_text(
|
||||
formatted_text: StyleAndTextTuples, max_width: int
|
||||
) -> tuple[StyleAndTextTuples, int]:
|
||||
"""
|
||||
Trim the text to `max_width`, append dots when the text is too long.
|
||||
Returns (text, width) tuple.
|
||||
"""
|
||||
width = fragment_list_width(formatted_text)
|
||||
|
||||
# When the text is too wide, trim it.
|
||||
if width > max_width:
|
||||
result = [] # Text fragments.
|
||||
remaining_width = max_width - 3
|
||||
|
||||
for style_and_ch in explode_text_fragments(formatted_text):
|
||||
ch_width = get_cwidth(style_and_ch[1])
|
||||
|
||||
if ch_width <= remaining_width:
|
||||
result.append(style_and_ch)
|
||||
remaining_width -= ch_width
|
||||
else:
|
||||
break
|
||||
|
||||
result.append(("", "..."))
|
||||
|
||||
return result, max_width - remaining_width
|
||||
else:
|
||||
return formatted_text, width
|
||||
|
||||
|
||||
class CompletionsMenu(ConditionalContainer):
|
||||
# NOTE: We use a pretty big z_index by default. Menus are supposed to be
|
||||
# above anything else. We also want to make sure that the content is
|
||||
# visible at the point where we draw this menu.
|
||||
def __init__(
|
||||
self,
|
||||
max_height: int | None = None,
|
||||
scroll_offset: int | Callable[[], int] = 0,
|
||||
extra_filter: FilterOrBool = True,
|
||||
display_arrows: FilterOrBool = False,
|
||||
z_index: int = 10**8,
|
||||
) -> None:
|
||||
extra_filter = to_filter(extra_filter)
|
||||
display_arrows = to_filter(display_arrows)
|
||||
|
||||
super().__init__(
|
||||
content=Window(
|
||||
content=CompletionsMenuControl(),
|
||||
width=Dimension(min=8),
|
||||
height=Dimension(min=1, max=max_height),
|
||||
scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset),
|
||||
right_margins=[ScrollbarMargin(display_arrows=display_arrows)],
|
||||
dont_extend_width=True,
|
||||
style="class:completion-menu",
|
||||
z_index=z_index,
|
||||
),
|
||||
# Show when there are completions but not at the point we are
|
||||
# returning the input.
|
||||
filter=extra_filter & has_completions & ~is_done,
|
||||
)
|
||||
|
||||
|
||||
class MultiColumnCompletionMenuControl(UIControl):
|
||||
"""
|
||||
Completion menu that displays all the completions in several columns.
|
||||
When there are more completions than space for them to be displayed, an
|
||||
arrow is shown on the left or right side.
|
||||
|
||||
`min_rows` indicates how many rows will be available in any possible case.
|
||||
When this is larger than one, it will try to use less columns and more
|
||||
rows until this value is reached.
|
||||
Be careful passing in a too big value, if less than the given amount of
|
||||
rows are available, more columns would have been required, but
|
||||
`preferred_width` doesn't know about that and reports a too small value.
|
||||
This results in less completions displayed and additional scrolling.
|
||||
(It's a limitation of how the layout engine currently works: first the
|
||||
widths are calculated, then the heights.)
|
||||
|
||||
:param suggested_max_column_width: The suggested max width of a column.
|
||||
The column can still be bigger than this, but if there is place for two
|
||||
columns of this width, we will display two columns. This to avoid that
|
||||
if there is one very wide completion, that it doesn't significantly
|
||||
reduce the amount of columns.
|
||||
"""
|
||||
|
||||
_required_margin = 3 # One extra padding on the right + space for arrows.
|
||||
|
||||
def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None:
|
||||
assert min_rows >= 1
|
||||
|
||||
self.min_rows = min_rows
|
||||
self.suggested_max_column_width = suggested_max_column_width
|
||||
self.scroll = 0
|
||||
|
||||
# Cache for column width computations. This computation is not cheap,
|
||||
# so we don't want to do it over and over again while the user
|
||||
# navigates through the completions.
|
||||
# (map `completion_state` to `(completion_count, width)`. We remember
|
||||
# the count, because a completer can add new completions to the
|
||||
# `CompletionState` while loading.)
|
||||
self._column_width_for_completion_state: WeakKeyDictionary[
|
||||
CompletionState, tuple[int, int]
|
||||
] = WeakKeyDictionary()
|
||||
|
||||
# Info of last rendering.
|
||||
self._rendered_rows = 0
|
||||
self._rendered_columns = 0
|
||||
self._total_columns = 0
|
||||
self._render_pos_to_completion: dict[tuple[int, int], Completion] = {}
|
||||
self._render_left_arrow = False
|
||||
self._render_right_arrow = False
|
||||
self._render_width = 0
|
||||
|
||||
def reset(self) -> None:
|
||||
self.scroll = 0
|
||||
|
||||
def has_focus(self) -> bool:
|
||||
return False
|
||||
|
||||
def preferred_width(self, max_available_width: int) -> int | None:
|
||||
"""
|
||||
Preferred width: prefer to use at least min_rows, but otherwise as much
|
||||
as possible horizontally.
|
||||
"""
|
||||
complete_state = get_app().current_buffer.complete_state
|
||||
if complete_state is None:
|
||||
return 0
|
||||
|
||||
column_width = self._get_column_width(complete_state)
|
||||
result = int(
|
||||
column_width
|
||||
* math.ceil(len(complete_state.completions) / float(self.min_rows))
|
||||
)
|
||||
|
||||
# When the desired width is still more than the maximum available,
|
||||
# reduce by removing columns until we are less than the available
|
||||
# width.
|
||||
while (
|
||||
result > column_width
|
||||
and result > max_available_width - self._required_margin
|
||||
):
|
||||
result -= column_width
|
||||
return result + self._required_margin
|
||||
|
||||
def preferred_height(
|
||||
self,
|
||||
width: int,
|
||||
max_available_height: int,
|
||||
wrap_lines: bool,
|
||||
get_line_prefix: GetLinePrefixCallable | None,
|
||||
) -> int | None:
|
||||
"""
|
||||
Preferred height: as much as needed in order to display all the completions.
|
||||
"""
|
||||
complete_state = get_app().current_buffer.complete_state
|
||||
if complete_state is None:
|
||||
return 0
|
||||
|
||||
column_width = self._get_column_width(complete_state)
|
||||
column_count = max(1, (width - self._required_margin) // column_width)
|
||||
|
||||
return int(math.ceil(len(complete_state.completions) / float(column_count)))
|
||||
|
||||
def create_content(self, width: int, height: int) -> UIContent:
|
||||
"""
|
||||
Create a UIContent object for this menu.
|
||||
"""
|
||||
complete_state = get_app().current_buffer.complete_state
|
||||
if complete_state is None:
|
||||
return UIContent()
|
||||
|
||||
column_width = self._get_column_width(complete_state)
|
||||
self._render_pos_to_completion = {}
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
def grouper(
|
||||
n: int, iterable: Iterable[_T], fillvalue: _T | None = None
|
||||
) -> Iterable[Sequence[_T | None]]:
|
||||
"grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx"
|
||||
args = [iter(iterable)] * n
|
||||
return zip_longest(fillvalue=fillvalue, *args)
|
||||
|
||||
def is_current_completion(completion: Completion) -> bool:
|
||||
"Returns True when this completion is the currently selected one."
|
||||
return (
|
||||
complete_state is not None
|
||||
and complete_state.complete_index is not None
|
||||
and c == complete_state.current_completion
|
||||
)
|
||||
|
||||
# Space required outside of the regular columns, for displaying the
|
||||
# left and right arrow.
|
||||
HORIZONTAL_MARGIN_REQUIRED = 3
|
||||
|
||||
# There should be at least one column, but it cannot be wider than
|
||||
# the available width.
|
||||
column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width)
|
||||
|
||||
# However, when the columns tend to be very wide, because there are
|
||||
# some very wide entries, shrink it anyway.
|
||||
if column_width > self.suggested_max_column_width:
|
||||
# `column_width` can still be bigger that `suggested_max_column_width`,
|
||||
# but if there is place for two columns, we divide by two.
|
||||
column_width //= column_width // self.suggested_max_column_width
|
||||
|
||||
visible_columns = max(1, (width - self._required_margin) // column_width)
|
||||
|
||||
columns_ = list(grouper(height, complete_state.completions))
|
||||
rows_ = list(zip(*columns_))
|
||||
|
||||
# Make sure the current completion is always visible: update scroll offset.
|
||||
selected_column = (complete_state.complete_index or 0) // height
|
||||
self.scroll = min(
|
||||
selected_column, max(self.scroll, selected_column - visible_columns + 1)
|
||||
)
|
||||
|
||||
render_left_arrow = self.scroll > 0
|
||||
render_right_arrow = self.scroll < len(rows_[0]) - visible_columns
|
||||
|
||||
# Write completions to screen.
|
||||
fragments_for_line = []
|
||||
|
||||
for row_index, row in enumerate(rows_):
|
||||
fragments: StyleAndTextTuples = []
|
||||
middle_row = row_index == len(rows_) // 2
|
||||
|
||||
# Draw left arrow if we have hidden completions on the left.
|
||||
if render_left_arrow:
|
||||
fragments.append(("class:scrollbar", "<" if middle_row else " "))
|
||||
elif render_right_arrow:
|
||||
# Reserve one column empty space. (If there is a right
|
||||
# arrow right now, there can be a left arrow as well.)
|
||||
fragments.append(("", " "))
|
||||
|
||||
# Draw row content.
|
||||
for column_index, c in enumerate(row[self.scroll :][:visible_columns]):
|
||||
if c is not None:
|
||||
fragments += _get_menu_item_fragments(
|
||||
c, is_current_completion(c), column_width, space_after=False
|
||||
)
|
||||
|
||||
# Remember render position for mouse click handler.
|
||||
for x in range(column_width):
|
||||
self._render_pos_to_completion[
|
||||
(column_index * column_width + x, row_index)
|
||||
] = c
|
||||
else:
|
||||
fragments.append(("class:completion", " " * column_width))
|
||||
|
||||
# Draw trailing padding for this row.
|
||||
# (_get_menu_item_fragments only returns padding on the left.)
|
||||
if render_left_arrow or render_right_arrow:
|
||||
fragments.append(("class:completion", " "))
|
||||
|
||||
# Draw right arrow if we have hidden completions on the right.
|
||||
if render_right_arrow:
|
||||
fragments.append(("class:scrollbar", ">" if middle_row else " "))
|
||||
elif render_left_arrow:
|
||||
fragments.append(("class:completion", " "))
|
||||
|
||||
# Add line.
|
||||
fragments_for_line.append(
|
||||
to_formatted_text(fragments, style="class:completion-menu")
|
||||
)
|
||||
|
||||
self._rendered_rows = height
|
||||
self._rendered_columns = visible_columns
|
||||
self._total_columns = len(columns_)
|
||||
self._render_left_arrow = render_left_arrow
|
||||
self._render_right_arrow = render_right_arrow
|
||||
self._render_width = (
|
||||
column_width * visible_columns + render_left_arrow + render_right_arrow + 1
|
||||
)
|
||||
|
||||
def get_line(i: int) -> StyleAndTextTuples:
|
||||
return fragments_for_line[i]
|
||||
|
||||
return UIContent(get_line=get_line, line_count=len(rows_))
|
||||
|
||||
def _get_column_width(self, completion_state: CompletionState) -> int:
|
||||
"""
|
||||
Return the width of each column.
|
||||
"""
|
||||
try:
|
||||
count, width = self._column_width_for_completion_state[completion_state]
|
||||
if count != len(completion_state.completions):
|
||||
# Number of completions changed, recompute.
|
||||
raise KeyError
|
||||
return width
|
||||
except KeyError:
|
||||
result = (
|
||||
max(get_cwidth(c.display_text) for c in completion_state.completions)
|
||||
+ 1
|
||||
)
|
||||
self._column_width_for_completion_state[completion_state] = (
|
||||
len(completion_state.completions),
|
||||
result,
|
||||
)
|
||||
return result
|
||||
|
||||
def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
|
||||
"""
|
||||
Handle scroll and click events.
|
||||
"""
|
||||
b = get_app().current_buffer
|
||||
|
||||
def scroll_left() -> None:
|
||||
b.complete_previous(count=self._rendered_rows, disable_wrap_around=True)
|
||||
self.scroll = max(0, self.scroll - 1)
|
||||
|
||||
def scroll_right() -> None:
|
||||
b.complete_next(count=self._rendered_rows, disable_wrap_around=True)
|
||||
self.scroll = min(
|
||||
self._total_columns - self._rendered_columns, self.scroll + 1
|
||||
)
|
||||
|
||||
if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
|
||||
scroll_right()
|
||||
|
||||
elif mouse_event.event_type == MouseEventType.SCROLL_UP:
|
||||
scroll_left()
|
||||
|
||||
elif mouse_event.event_type == MouseEventType.MOUSE_UP:
|
||||
x = mouse_event.position.x
|
||||
y = mouse_event.position.y
|
||||
|
||||
# Mouse click on left arrow.
|
||||
if x == 0:
|
||||
if self._render_left_arrow:
|
||||
scroll_left()
|
||||
|
||||
# Mouse click on right arrow.
|
||||
elif x == self._render_width - 1:
|
||||
if self._render_right_arrow:
|
||||
scroll_right()
|
||||
|
||||
# Mouse click on completion.
|
||||
else:
|
||||
completion = self._render_pos_to_completion.get((x, y))
|
||||
if completion:
|
||||
b.apply_completion(completion)
|
||||
|
||||
return None
|
||||
|
||||
def get_key_bindings(self) -> KeyBindings:
|
||||
"""
|
||||
Expose key bindings that handle the left/right arrow keys when the menu
|
||||
is displayed.
|
||||
"""
|
||||
from prompt_toolkit.key_binding.key_bindings import KeyBindings
|
||||
|
||||
kb = KeyBindings()
|
||||
|
||||
@Condition
|
||||
def filter() -> bool:
|
||||
"Only handle key bindings if this menu is visible."
|
||||
app = get_app()
|
||||
complete_state = app.current_buffer.complete_state
|
||||
|
||||
# There need to be completions, and one needs to be selected.
|
||||
if complete_state is None or complete_state.complete_index is None:
|
||||
return False
|
||||
|
||||
# This menu needs to be visible.
|
||||
return any(window.content == self for window in app.layout.visible_windows)
|
||||
|
||||
def move(right: bool = False) -> None:
|
||||
buff = get_app().current_buffer
|
||||
complete_state = buff.complete_state
|
||||
|
||||
if complete_state is not None and complete_state.complete_index is not None:
|
||||
# Calculate new complete index.
|
||||
new_index = complete_state.complete_index
|
||||
if right:
|
||||
new_index += self._rendered_rows
|
||||
else:
|
||||
new_index -= self._rendered_rows
|
||||
|
||||
if 0 <= new_index < len(complete_state.completions):
|
||||
buff.go_to_completion(new_index)
|
||||
|
||||
# NOTE: the is_global is required because the completion menu will
|
||||
# never be focussed.
|
||||
|
||||
@kb.add("left", is_global=True, filter=filter)
|
||||
def _left(event: E) -> None:
|
||||
move()
|
||||
|
||||
@kb.add("right", is_global=True, filter=filter)
|
||||
def _right(event: E) -> None:
|
||||
move(True)
|
||||
|
||||
return kb
|
||||
|
||||
|
||||
class MultiColumnCompletionsMenu(HSplit):
|
||||
"""
|
||||
Container that displays the completions in several columns.
|
||||
When `show_meta` (a :class:`~prompt_toolkit.filters.Filter`) evaluates
|
||||
to True, it shows the meta information at the bottom.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_rows: int = 3,
|
||||
suggested_max_column_width: int = 30,
|
||||
show_meta: FilterOrBool = True,
|
||||
extra_filter: FilterOrBool = True,
|
||||
z_index: int = 10**8,
|
||||
) -> None:
|
||||
show_meta = to_filter(show_meta)
|
||||
extra_filter = to_filter(extra_filter)
|
||||
|
||||
# Display filter: show when there are completions but not at the point
|
||||
# we are returning the input.
|
||||
full_filter = extra_filter & has_completions & ~is_done
|
||||
|
||||
@Condition
|
||||
def any_completion_has_meta() -> bool:
|
||||
complete_state = get_app().current_buffer.complete_state
|
||||
return complete_state is not None and any(
|
||||
c.display_meta for c in complete_state.completions
|
||||
)
|
||||
|
||||
# Create child windows.
|
||||
# NOTE: We don't set style='class:completion-menu' to the
|
||||
# `MultiColumnCompletionMenuControl`, because this is used in a
|
||||
# Float that is made transparent, and the size of the control
|
||||
# doesn't always correspond exactly with the size of the
|
||||
# generated content.
|
||||
completions_window = ConditionalContainer(
|
||||
content=Window(
|
||||
content=MultiColumnCompletionMenuControl(
|
||||
min_rows=min_rows,
|
||||
suggested_max_column_width=suggested_max_column_width,
|
||||
),
|
||||
width=Dimension(min=8),
|
||||
height=Dimension(min=1),
|
||||
),
|
||||
filter=full_filter,
|
||||
)
|
||||
|
||||
meta_window = ConditionalContainer(
|
||||
content=Window(content=_SelectedCompletionMetaControl()),
|
||||
filter=full_filter & show_meta & any_completion_has_meta,
|
||||
)
|
||||
|
||||
# Initialize split.
|
||||
super().__init__([completions_window, meta_window], z_index=z_index)
|
||||
|
||||
|
||||
class _SelectedCompletionMetaControl(UIControl):
|
||||
"""
|
||||
Control that shows the meta information of the selected completion.
|
||||
"""
|
||||
|
||||
def preferred_width(self, max_available_width: int) -> int | None:
|
||||
"""
|
||||
Report the width of the longest meta text as the preferred width of this control.
|
||||
|
||||
It could be that we use less width, but this way, we're sure that the
|
||||
layout doesn't change when we select another completion (E.g. that
|
||||
completions are suddenly shown in more or fewer columns.)
|
||||
"""
|
||||
app = get_app()
|
||||
if app.current_buffer.complete_state:
|
||||
state = app.current_buffer.complete_state
|
||||
|
||||
if len(state.completions) >= 30:
|
||||
# When there are many completions, calling `get_cwidth` for
|
||||
# every `display_meta_text` is too expensive. In this case,
|
||||
# just return the max available width. There will be enough
|
||||
# columns anyway so that the whole screen is filled with
|
||||
# completions and `create_content` will then take up as much
|
||||
# space as needed.
|
||||
return max_available_width
|
||||
|
||||
return 2 + max(
|
||||
get_cwidth(c.display_meta_text) for c in state.completions[:100]
|
||||
)
|
||||
else:
|
||||
return 0
|
||||
|
||||
def preferred_height(
|
||||
self,
|
||||
width: int,
|
||||
max_available_height: int,
|
||||
wrap_lines: bool,
|
||||
get_line_prefix: GetLinePrefixCallable | None,
|
||||
) -> int | None:
|
||||
return 1
|
||||
|
||||
def create_content(self, width: int, height: int) -> UIContent:
|
||||
fragments = self._get_text_fragments()
|
||||
|
||||
def get_line(i: int) -> StyleAndTextTuples:
|
||||
return fragments
|
||||
|
||||
return UIContent(get_line=get_line, line_count=1 if fragments else 0)
|
||||
|
||||
def _get_text_fragments(self) -> StyleAndTextTuples:
|
||||
style = "class:completion-menu.multi-column-meta"
|
||||
state = get_app().current_buffer.complete_state
|
||||
|
||||
if (
|
||||
state
|
||||
and state.current_completion
|
||||
and state.current_completion.display_meta_text
|
||||
):
|
||||
return to_formatted_text(
|
||||
cast(StyleAndTextTuples, [("", " ")])
|
||||
+ state.current_completion.display_meta
|
||||
+ [("", " ")],
|
||||
style=style,
|
||||
)
|
||||
|
||||
return []
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from prompt_toolkit.mouse_events import MouseEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
|
||||
|
||||
__all__ = [
|
||||
"MouseHandler",
|
||||
"MouseHandlers",
|
||||
]
|
||||
|
||||
|
||||
MouseHandler = Callable[[MouseEvent], "NotImplementedOrNone"]
|
||||
|
||||
|
||||
class MouseHandlers:
|
||||
"""
|
||||
Two dimensional raster of callbacks for mouse events.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def dummy_callback(mouse_event: MouseEvent) -> NotImplementedOrNone:
|
||||
"""
|
||||
:param mouse_event: `MouseEvent` instance.
|
||||
"""
|
||||
return NotImplemented
|
||||
|
||||
# NOTE: Previously, the data structure was a dictionary mapping (x,y)
|
||||
# to the handlers. This however would be more inefficient when copying
|
||||
# over the mouse handlers of the visible region in the scrollable pane.
|
||||
|
||||
# Map y (row) to x (column) to handlers.
|
||||
self.mouse_handlers: defaultdict[int, defaultdict[int, MouseHandler]] = (
|
||||
defaultdict(lambda: defaultdict(lambda: dummy_callback))
|
||||
)
|
||||
|
||||
def set_mouse_handler_for_range(
|
||||
self,
|
||||
x_min: int,
|
||||
x_max: int,
|
||||
y_min: int,
|
||||
y_max: int,
|
||||
handler: Callable[[MouseEvent], NotImplementedOrNone],
|
||||
) -> None:
|
||||
"""
|
||||
Set mouse handler for a region.
|
||||
"""
|
||||
for y in range(y_min, y_max):
|
||||
row = self.mouse_handlers[y]
|
||||
|
||||
for x in range(x_min, x_max):
|
||||
row[x] = handler
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from prompt_toolkit.cache import FastDictCache
|
||||
from prompt_toolkit.data_structures import Point
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .containers import Window
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Screen",
|
||||
"Char",
|
||||
]
|
||||
|
||||
|
||||
class Char:
|
||||
"""
|
||||
Represent a single character in a :class:`.Screen`.
|
||||
|
||||
This should be considered immutable.
|
||||
|
||||
:param char: A single character (can be a double-width character).
|
||||
:param style: A style string. (Can contain classnames.)
|
||||
"""
|
||||
|
||||
__slots__ = ("char", "style", "width")
|
||||
|
||||
# If we end up having one of these special control sequences in the input string,
|
||||
# we should display them as follows:
|
||||
# Usually this happens after a "quoted insert".
|
||||
display_mappings: dict[str, str] = {
|
||||
"\x00": "^@", # Control space
|
||||
"\x01": "^A",
|
||||
"\x02": "^B",
|
||||
"\x03": "^C",
|
||||
"\x04": "^D",
|
||||
"\x05": "^E",
|
||||
"\x06": "^F",
|
||||
"\x07": "^G",
|
||||
"\x08": "^H",
|
||||
"\x09": "^I",
|
||||
"\x0a": "^J",
|
||||
"\x0b": "^K",
|
||||
"\x0c": "^L",
|
||||
"\x0d": "^M",
|
||||
"\x0e": "^N",
|
||||
"\x0f": "^O",
|
||||
"\x10": "^P",
|
||||
"\x11": "^Q",
|
||||
"\x12": "^R",
|
||||
"\x13": "^S",
|
||||
"\x14": "^T",
|
||||
"\x15": "^U",
|
||||
"\x16": "^V",
|
||||
"\x17": "^W",
|
||||
"\x18": "^X",
|
||||
"\x19": "^Y",
|
||||
"\x1a": "^Z",
|
||||
"\x1b": "^[", # Escape
|
||||
"\x1c": "^\\",
|
||||
"\x1d": "^]",
|
||||
"\x1e": "^^",
|
||||
"\x1f": "^_",
|
||||
"\x7f": "^?", # ASCII Delete (backspace).
|
||||
# Special characters. All visualized like Vim does.
|
||||
"\x80": "<80>",
|
||||
"\x81": "<81>",
|
||||
"\x82": "<82>",
|
||||
"\x83": "<83>",
|
||||
"\x84": "<84>",
|
||||
"\x85": "<85>",
|
||||
"\x86": "<86>",
|
||||
"\x87": "<87>",
|
||||
"\x88": "<88>",
|
||||
"\x89": "<89>",
|
||||
"\x8a": "<8a>",
|
||||
"\x8b": "<8b>",
|
||||
"\x8c": "<8c>",
|
||||
"\x8d": "<8d>",
|
||||
"\x8e": "<8e>",
|
||||
"\x8f": "<8f>",
|
||||
"\x90": "<90>",
|
||||
"\x91": "<91>",
|
||||
"\x92": "<92>",
|
||||
"\x93": "<93>",
|
||||
"\x94": "<94>",
|
||||
"\x95": "<95>",
|
||||
"\x96": "<96>",
|
||||
"\x97": "<97>",
|
||||
"\x98": "<98>",
|
||||
"\x99": "<99>",
|
||||
"\x9a": "<9a>",
|
||||
"\x9b": "<9b>",
|
||||
"\x9c": "<9c>",
|
||||
"\x9d": "<9d>",
|
||||
"\x9e": "<9e>",
|
||||
"\x9f": "<9f>",
|
||||
# For the non-breaking space: visualize like Emacs does by default.
|
||||
# (Print a space, but attach the 'nbsp' class that applies the
|
||||
# underline style.)
|
||||
"\xa0": " ",
|
||||
}
|
||||
|
||||
def __init__(self, char: str = " ", style: str = "") -> None:
|
||||
# If this character has to be displayed otherwise, take that one.
|
||||
if char in self.display_mappings:
|
||||
if char == "\xa0":
|
||||
style += " class:nbsp " # Will be underlined.
|
||||
else:
|
||||
style += " class:control-character "
|
||||
|
||||
char = self.display_mappings[char]
|
||||
|
||||
self.char = char
|
||||
self.style = style
|
||||
|
||||
# Calculate width. (We always need this, so better to store it directly
|
||||
# as a member for performance.)
|
||||
self.width = get_cwidth(char)
|
||||
|
||||
# In theory, `other` can be any type of object, but because of performance
|
||||
# we don't want to do an `isinstance` check every time. We assume "other"
|
||||
# is always a "Char".
|
||||
def _equal(self, other: Char) -> bool:
|
||||
return self.char == other.char and self.style == other.style
|
||||
|
||||
def _not_equal(self, other: Char) -> bool:
|
||||
# Not equal: We don't do `not char.__eq__` here, because of the
|
||||
# performance of calling yet another function.
|
||||
return self.char != other.char or self.style != other.style
|
||||
|
||||
if not TYPE_CHECKING:
|
||||
__eq__ = _equal
|
||||
__ne__ = _not_equal
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.char!r}, {self.style!r})"
|
||||
|
||||
|
||||
_CHAR_CACHE: FastDictCache[tuple[str, str], Char] = FastDictCache(
|
||||
Char, size=1000 * 1000
|
||||
)
|
||||
Transparent = "[transparent]"
|
||||
|
||||
|
||||
class Screen:
|
||||
"""
|
||||
Two dimensional buffer of :class:`.Char` instances.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default_char: Char | None = None,
|
||||
initial_width: int = 0,
|
||||
initial_height: int = 0,
|
||||
) -> None:
|
||||
if default_char is None:
|
||||
default_char2 = _CHAR_CACHE[" ", Transparent]
|
||||
else:
|
||||
default_char2 = default_char
|
||||
|
||||
self.data_buffer: defaultdict[int, defaultdict[int, Char]] = defaultdict(
|
||||
lambda: defaultdict(lambda: default_char2)
|
||||
)
|
||||
|
||||
#: Escape sequences to be injected.
|
||||
self.zero_width_escapes: defaultdict[int, defaultdict[int, str]] = defaultdict(
|
||||
lambda: defaultdict(str)
|
||||
)
|
||||
|
||||
#: Position of the cursor.
|
||||
self.cursor_positions: dict[
|
||||
Window, Point
|
||||
] = {} # Map `Window` objects to `Point` objects.
|
||||
|
||||
#: Visibility of the cursor.
|
||||
self.show_cursor = True
|
||||
|
||||
#: (Optional) Where to position the menu. E.g. at the start of a completion.
|
||||
#: (We can't use the cursor position, because we don't want the
|
||||
#: completion menu to change its position when we browse through all the
|
||||
#: completions.)
|
||||
self.menu_positions: dict[
|
||||
Window, Point
|
||||
] = {} # Map `Window` objects to `Point` objects.
|
||||
|
||||
#: Currently used width/height of the screen. This will increase when
|
||||
#: data is written to the screen.
|
||||
self.width = initial_width or 0
|
||||
self.height = initial_height or 0
|
||||
|
||||
# Windows that have been drawn. (Each `Window` class will add itself to
|
||||
# this list.)
|
||||
self.visible_windows_to_write_positions: dict[Window, WritePosition] = {}
|
||||
|
||||
# List of (z_index, draw_func)
|
||||
self._draw_float_functions: list[tuple[int, Callable[[], None]]] = []
|
||||
|
||||
@property
|
||||
def visible_windows(self) -> list[Window]:
|
||||
return list(self.visible_windows_to_write_positions.keys())
|
||||
|
||||
def set_cursor_position(self, window: Window, position: Point) -> None:
|
||||
"""
|
||||
Set the cursor position for a given window.
|
||||
"""
|
||||
self.cursor_positions[window] = position
|
||||
|
||||
def set_menu_position(self, window: Window, position: Point) -> None:
|
||||
"""
|
||||
Set the cursor position for a given window.
|
||||
"""
|
||||
self.menu_positions[window] = position
|
||||
|
||||
def get_cursor_position(self, window: Window) -> Point:
|
||||
"""
|
||||
Get the cursor position for a given window.
|
||||
Returns a `Point`.
|
||||
"""
|
||||
try:
|
||||
return self.cursor_positions[window]
|
||||
except KeyError:
|
||||
return Point(x=0, y=0)
|
||||
|
||||
def get_menu_position(self, window: Window) -> Point:
|
||||
"""
|
||||
Get the menu position for a given window.
|
||||
(This falls back to the cursor position if no menu position was set.)
|
||||
"""
|
||||
try:
|
||||
return self.menu_positions[window]
|
||||
except KeyError:
|
||||
try:
|
||||
return self.cursor_positions[window]
|
||||
except KeyError:
|
||||
return Point(x=0, y=0)
|
||||
|
||||
def draw_with_z_index(self, z_index: int, draw_func: Callable[[], None]) -> None:
|
||||
"""
|
||||
Add a draw-function for a `Window` which has a >= 0 z_index.
|
||||
This will be postponed until `draw_all_floats` is called.
|
||||
"""
|
||||
self._draw_float_functions.append((z_index, draw_func))
|
||||
|
||||
def draw_all_floats(self) -> None:
|
||||
"""
|
||||
Draw all float functions in order of z-index.
|
||||
"""
|
||||
# We keep looping because some draw functions could add new functions
|
||||
# to this list. See `FloatContainer`.
|
||||
while self._draw_float_functions:
|
||||
# Sort the floats that we have so far by z_index.
|
||||
functions = sorted(self._draw_float_functions, key=lambda item: item[0])
|
||||
|
||||
# Draw only one at a time, then sort everything again. Now floats
|
||||
# might have been added.
|
||||
self._draw_float_functions = functions[1:]
|
||||
functions[0][1]()
|
||||
|
||||
def append_style_to_content(self, style_str: str) -> None:
|
||||
"""
|
||||
For all the characters in the screen.
|
||||
Set the style string to the given `style_str`.
|
||||
"""
|
||||
b = self.data_buffer
|
||||
char_cache = _CHAR_CACHE
|
||||
|
||||
append_style = " " + style_str
|
||||
|
||||
for y, row in b.items():
|
||||
for x, char in row.items():
|
||||
row[x] = char_cache[char.char, char.style + append_style]
|
||||
|
||||
def fill_area(
|
||||
self, write_position: WritePosition, style: str = "", after: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Fill the content of this area, using the given `style`.
|
||||
The style is prepended before whatever was here before.
|
||||
"""
|
||||
if not style.strip():
|
||||
return
|
||||
|
||||
xmin = write_position.xpos
|
||||
xmax = write_position.xpos + write_position.width
|
||||
char_cache = _CHAR_CACHE
|
||||
data_buffer = self.data_buffer
|
||||
|
||||
if after:
|
||||
append_style = " " + style
|
||||
prepend_style = ""
|
||||
else:
|
||||
append_style = ""
|
||||
prepend_style = style + " "
|
||||
|
||||
for y in range(
|
||||
write_position.ypos, write_position.ypos + write_position.height
|
||||
):
|
||||
row = data_buffer[y]
|
||||
for x in range(xmin, xmax):
|
||||
cell = row[x]
|
||||
row[x] = char_cache[
|
||||
cell.char, prepend_style + cell.style + append_style
|
||||
]
|
||||
|
||||
|
||||
class WritePosition:
|
||||
def __init__(self, xpos: int, ypos: int, width: int, height: int) -> None:
|
||||
assert height >= 0
|
||||
assert width >= 0
|
||||
# xpos and ypos can be negative. (A float can be partially visible.)
|
||||
|
||||
self.xpos = xpos
|
||||
self.ypos = ypos
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(x={self.xpos!r}, y={self.ypos!r}, width={self.width!r}, height={self.height!r})"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user