back to Reference (Gold) summary
Reference (Gold): python-prompt-toolkit
Pytest Summary for test tests
status | count |
---|---|
passed | 150 |
xpassed | 1 |
total | 151 |
collected | 151 |
Failed pytests:
test_memory_leaks.py::test_prompt_session_memory_leak
test_memory_leaks.py::test_prompt_session_memory_leak
Patch diff
diff --git a/src/prompt_toolkit/application/application.py b/src/prompt_toolkit/application/application.py
index a630e551..d4637811 100644
--- a/src/prompt_toolkit/application/application.py
+++ b/src/prompt_toolkit/application/application.py
@@ -1,4 +1,5 @@
from __future__ import annotations
+
import asyncio
import contextvars
import os
@@ -7,27 +8,62 @@ import signal
import sys
import threading
import time
-from asyncio import AbstractEventLoop, Future, Task, ensure_future, get_running_loop, sleep
+from asyncio import (
+ AbstractEventLoop,
+ Future,
+ Task,
+ ensure_future,
+ get_running_loop,
+ sleep,
+)
from contextlib import ExitStack, contextmanager
from subprocess import Popen
from traceback import format_tb
-from typing import Any, Callable, Coroutine, Generator, Generic, Hashable, Iterable, Iterator, TypeVar, cast, overload
+from typing import (
+ Any,
+ Callable,
+ Coroutine,
+ Generator,
+ Generic,
+ Hashable,
+ Iterable,
+ Iterator,
+ TypeVar,
+ cast,
+ overload,
+)
+
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.cache import SimpleCache
from prompt_toolkit.clipboard import Clipboard, InMemoryClipboard
from prompt_toolkit.cursor_shapes import AnyCursorShapeConfig, to_cursor_shape_config
from prompt_toolkit.data_structures import Size
from prompt_toolkit.enums import EditingMode
-from prompt_toolkit.eventloop import InputHook, get_traceback_from_context, new_eventloop_with_inputhook, run_in_executor_with_context
+from prompt_toolkit.eventloop import (
+ InputHook,
+ get_traceback_from_context,
+ new_eventloop_with_inputhook,
+ run_in_executor_with_context,
+)
from prompt_toolkit.eventloop.utils import call_soon_threadsafe
from prompt_toolkit.filters import Condition, Filter, FilterOrBool, to_filter
from prompt_toolkit.formatted_text import AnyFormattedText
from prompt_toolkit.input.base import Input
from prompt_toolkit.input.typeahead import get_typeahead, store_typeahead
-from prompt_toolkit.key_binding.bindings.page_navigation import load_page_navigation_bindings
+from prompt_toolkit.key_binding.bindings.page_navigation import (
+ load_page_navigation_bindings,
+)
from prompt_toolkit.key_binding.defaults import load_key_bindings
from prompt_toolkit.key_binding.emacs_state import EmacsState
-from prompt_toolkit.key_binding.key_bindings import Binding, ConditionalKeyBindings, GlobalOnlyKeyBindings, KeyBindings, KeyBindingsBase, KeysTuple, merge_key_bindings
+from prompt_toolkit.key_binding.key_bindings import (
+ Binding,
+ ConditionalKeyBindings,
+ GlobalOnlyKeyBindings,
+ KeyBindings,
+ KeyBindingsBase,
+ KeysTuple,
+ merge_key_bindings,
+)
from prompt_toolkit.key_binding.key_processor import KeyPressEvent, KeyProcessor
from prompt_toolkit.key_binding.vi_state import ViState
from prompt_toolkit.keys import Keys
@@ -38,16 +74,32 @@ from prompt_toolkit.layout.layout import Layout, walk
from prompt_toolkit.output import ColorDepth, Output
from prompt_toolkit.renderer import Renderer, print_formatted_text
from prompt_toolkit.search import SearchState
-from prompt_toolkit.styles import BaseStyle, DummyStyle, DummyStyleTransformation, DynamicStyle, StyleTransformation, default_pygments_style, default_ui_style, merge_styles
+from prompt_toolkit.styles import (
+ BaseStyle,
+ DummyStyle,
+ DummyStyleTransformation,
+ DynamicStyle,
+ StyleTransformation,
+ default_pygments_style,
+ default_ui_style,
+ merge_styles,
+)
from prompt_toolkit.utils import Event, in_main_thread
+
from .current import get_app_session, set_app
from .run_in_terminal import in_terminal, run_in_terminal
-__all__ = ['Application']
+
+__all__ = [
+ "Application",
+]
+
+
E = KeyPressEvent
-_AppResult = TypeVar('_AppResult')
-ApplicationEventHandler = Callable[['Application[_AppResult]'], None]
-_SIGWINCH = getattr(signal, 'SIGWINCH', None)
-_SIGTSTP = getattr(signal, 'SIGTSTP', None)
+_AppResult = TypeVar("_AppResult")
+ApplicationEventHandler = Callable[["Application[_AppResult]"], None]
+
+_SIGWINCH = getattr(signal, "SIGWINCH", None)
+_SIGTSTP = getattr(signal, "SIGTSTP", None)
class Application(Generic[_AppResult]):
@@ -131,50 +183,67 @@ class Application(Generic[_AppResult]):
await app.run_async()
"""
- def __init__(self, layout: (Layout | None)=None, style: (BaseStyle |
- None)=None, include_default_pygments_style: FilterOrBool=True,
- style_transformation: (StyleTransformation | None)=None,
- key_bindings: (KeyBindingsBase | None)=None, clipboard: (Clipboard |
- None)=None, full_screen: bool=False, color_depth: (ColorDepth |
- Callable[[], ColorDepth | None] | None)=None, mouse_support:
- FilterOrBool=False, enable_page_navigation_bindings: (None |
- FilterOrBool)=None, paste_mode: FilterOrBool=False, editing_mode:
- EditingMode=EditingMode.EMACS, erase_when_done: bool=False,
- reverse_vi_search_direction: FilterOrBool=False,
- min_redraw_interval: (float | int | None)=None,
- max_render_postpone_time: (float | int | None)=0.01,
- refresh_interval: (float | None)=None,
- terminal_size_polling_interval: (float | None)=0.5, cursor:
- AnyCursorShapeConfig=None, on_reset: (ApplicationEventHandler[
- _AppResult] | None)=None, on_invalidate: (ApplicationEventHandler[
- _AppResult] | None)=None, before_render: (ApplicationEventHandler[
- _AppResult] | None)=None, after_render: (ApplicationEventHandler[
- _AppResult] | None)=None, input: (Input | None)=None, output: (
- Output | None)=None) ->None:
+ def __init__(
+ self,
+ layout: Layout | None = None,
+ style: BaseStyle | None = None,
+ include_default_pygments_style: FilterOrBool = True,
+ style_transformation: StyleTransformation | None = None,
+ key_bindings: KeyBindingsBase | None = None,
+ clipboard: Clipboard | None = None,
+ full_screen: bool = False,
+ color_depth: (ColorDepth | Callable[[], ColorDepth | None] | None) = None,
+ mouse_support: FilterOrBool = False,
+ enable_page_navigation_bindings: None
+ | (FilterOrBool) = None, # Can be None, True or False.
+ paste_mode: FilterOrBool = False,
+ editing_mode: EditingMode = EditingMode.EMACS,
+ erase_when_done: bool = False,
+ reverse_vi_search_direction: FilterOrBool = False,
+ min_redraw_interval: float | int | None = None,
+ max_render_postpone_time: float | int | None = 0.01,
+ refresh_interval: float | None = None,
+ terminal_size_polling_interval: float | None = 0.5,
+ cursor: AnyCursorShapeConfig = None,
+ on_reset: ApplicationEventHandler[_AppResult] | None = None,
+ on_invalidate: ApplicationEventHandler[_AppResult] | None = None,
+ before_render: ApplicationEventHandler[_AppResult] | None = None,
+ after_render: ApplicationEventHandler[_AppResult] | None = None,
+ # I/O.
+ input: Input | None = None,
+ output: Output | None = None,
+ ) -> None:
+ # If `enable_page_navigation_bindings` is not specified, enable it in
+ # case of full screen applications only. This can be overridden by the user.
if enable_page_navigation_bindings is None:
- enable_page_navigation_bindings = Condition(lambda : self.
- full_screen)
+ enable_page_navigation_bindings = Condition(lambda: self.full_screen)
+
paste_mode = to_filter(paste_mode)
mouse_support = to_filter(mouse_support)
reverse_vi_search_direction = to_filter(reverse_vi_search_direction)
- enable_page_navigation_bindings = to_filter(
- enable_page_navigation_bindings)
- include_default_pygments_style = to_filter(
- include_default_pygments_style)
+ enable_page_navigation_bindings = to_filter(enable_page_navigation_bindings)
+ include_default_pygments_style = to_filter(include_default_pygments_style)
+
if layout is None:
layout = create_dummy_layout()
+
if style_transformation is None:
style_transformation = DummyStyleTransformation()
+
self.style = style
self.style_transformation = style_transformation
+
+ # Key bindings.
self.key_bindings = key_bindings
self._default_bindings = load_key_bindings()
self._page_navigation_bindings = load_page_navigation_bindings()
+
self.layout = layout
self.clipboard = clipboard or InMemoryClipboard()
self.full_screen: bool = full_screen
self._color_depth = color_depth
self.mouse_support = mouse_support
+
self.paste_mode = paste_mode
self.editing_mode = editing_mode
self.erase_when_done = erase_when_done
@@ -184,49 +253,114 @@ class Application(Generic[_AppResult]):
self.max_render_postpone_time = max_render_postpone_time
self.refresh_interval = refresh_interval
self.terminal_size_polling_interval = terminal_size_polling_interval
+
self.cursor = to_cursor_shape_config(cursor)
+
+ # Events.
self.on_invalidate = Event(self, on_invalidate)
self.on_reset = Event(self, on_reset)
self.before_render = Event(self, before_render)
self.after_render = Event(self, after_render)
+
+ # I/O.
session = get_app_session()
self.output = output or session.output
self.input = input or session.input
+
+ # List of 'extra' functions to execute before a Application.run.
self.pre_run_callables: list[Callable[[], None]] = []
+
self._is_running = False
self.future: Future[_AppResult] | None = None
self.loop: AbstractEventLoop | None = None
self._loop_thread: threading.Thread | None = None
self.context: contextvars.Context | None = None
+
+ #: Quoted insert. This flag is set if we go into quoted insert mode.
self.quoted_insert = False
+
+ #: Vi state. (For Vi key bindings.)
self.vi_state = ViState()
self.emacs_state = EmacsState()
- self.ttimeoutlen = 0.5
+
+ #: When to flush the input (For flushing escape keys.) This is important
+ #: on terminals that use vt100 input. We can't distinguish the escape
+ #: key from for instance the left-arrow key, if we don't know what follows
+ #: after "\x1b". This little timer will consider "\x1b" to be escape if
+ #: nothing did follow in this time span.
+ #: This seems to work like the `ttimeoutlen` option in Vim.
+ self.ttimeoutlen = 0.5 # Seconds.
+
+ #: Like Vim's `timeoutlen` option. This can be `None` or a float. For
+ #: instance, suppose that we have a key binding AB and a second key
+ #: binding A. If the uses presses A and then waits, we don't handle
+ #: this binding yet (unless it was marked 'eager'), because we don't
+ #: know what will follow. This timeout is the maximum amount of time
+ #: that we wait until we call the handlers anyway. Pass `None` to
+ #: disable this timeout.
self.timeoutlen = 1.0
- self._merged_style = self._create_merged_style(
- include_default_pygments_style)
- self.renderer = Renderer(self._merged_style, self.output,
- full_screen=full_screen, mouse_support=mouse_support,
- cpr_not_supported_callback=self.cpr_not_supported_callback)
+
+ #: The `Renderer` instance.
+ # Make sure that the same stdout is used, when a custom renderer has been passed.
+ self._merged_style = self._create_merged_style(include_default_pygments_style)
+
+ self.renderer = Renderer(
+ self._merged_style,
+ self.output,
+ full_screen=full_screen,
+ mouse_support=mouse_support,
+ cpr_not_supported_callback=self.cpr_not_supported_callback,
+ )
+
+ #: Render counter. This one is increased every time the UI is rendered.
+ #: It can be used as a key for caching certain information during one
+ #: rendering.
self.render_counter = 0
+
+ # Invalidate flag. When 'True', a repaint has been scheduled.
self._invalidated = False
- self._invalidate_events: list[Event[object]] = []
- self._last_redraw_time = 0.0
+ self._invalidate_events: list[
+ Event[object]
+ ] = [] # Collection of 'invalidate' Event objects.
+ self._last_redraw_time = 0.0 # Unix timestamp of last redraw. Used when
+ # `min_redraw_interval` is given.
+
+ #: The `InputProcessor` instance.
self.key_processor = KeyProcessor(_CombinedRegistry(self))
+
+ # If `run_in_terminal` was called. This will point to a `Future` what will be
+ # set at the point when the previous run finishes.
self._running_in_terminal = False
self._running_in_terminal_f: Future[None] | None = None
+
+ # Trigger initialize callback.
self.reset()
- def _create_merged_style(self, include_default_pygments_style: Filter
- ) ->BaseStyle:
+ def _create_merged_style(self, include_default_pygments_style: Filter) -> BaseStyle:
"""
Create a `Style` object that merges the default UI style, the default
pygments style, and the custom user style.
"""
- pass
+ dummy_style = DummyStyle()
+ pygments_style = default_pygments_style()
+
+ @DynamicStyle
+ def conditional_pygments_style() -> BaseStyle:
+ if include_default_pygments_style():
+ return pygments_style
+ else:
+ return dummy_style
+
+ return merge_styles(
+ [
+ default_ui_style(),
+ conditional_pygments_style,
+ DynamicStyle(lambda: self.style),
+ ]
+ )
@property
- def color_depth(self) ->ColorDepth:
+ def color_depth(self) -> ColorDepth:
"""
The active :class:`.ColorDepth`.
@@ -239,10 +373,18 @@ class Application(Generic[_AppResult]):
created using `output.defaults.create_output`, then this value is
coming from the $PROMPT_TOOLKIT_COLOR_DEPTH environment variable.
"""
- pass
+ depth = self._color_depth
+
+ if callable(depth):
+ depth = depth()
+
+ if depth is None:
+ depth = self.output.get_default_color_depth()
+
+ return depth
@property
- def current_buffer(self) ->Buffer:
+ def current_buffer(self) -> Buffer:
"""
The currently focused :class:`~.Buffer`.
@@ -250,57 +392,192 @@ class Application(Generic[_AppResult]):
has the focus. In this case, it's really not practical to check for
`None` values or catch exceptions every time.)
"""
- pass
+ return self.layout.current_buffer or Buffer(
+ name="dummy-buffer"
+ ) # Dummy buffer.
@property
- def current_search_state(self) ->SearchState:
+ def current_search_state(self) -> SearchState:
"""
Return the current :class:`.SearchState`. (The one for the focused
:class:`.BufferControl`.)
"""
- pass
+ ui_control = self.layout.current_control
+ if isinstance(ui_control, BufferControl):
+ return ui_control.search_state
+ else:
+ return SearchState() # Dummy search state. (Don't return None!)
- def reset(self) ->None:
+ def reset(self) -> None:
"""
Reset everything, for reading the next input.
"""
- pass
+ # Notice that we don't reset the buffers. (This happens just before
+ # returning, and when we have multiple buffers, we clearly want the
+ # content in the other buffers to remain unchanged between several
+ # calls of `run`. (And the same is true for the focus stack.)
+
+ self.exit_style = ""
+
+ self._background_tasks: set[Task[None]] = set()
+
+ self.renderer.reset()
+ self.key_processor.reset()
+ self.layout.reset()
+ self.vi_state.reset()
+ self.emacs_state.reset()
- def invalidate(self) ->None:
+ # Trigger reset event.
+ self.on_reset.fire()
+
+ # Make sure that we have a 'focusable' widget focused.
+ # (The `Layout` class can't determine this.)
+ layout = self.layout
+
+ if not layout.current_control.is_focusable():
+ for w in layout.find_all_windows():
+ if w.content.is_focusable():
+ layout.current_window = w
+ break
+
+ def invalidate(self) -> None:
"""
Thread safe way of sending a repaint trigger to the input event loop.
"""
- pass
+ if not self._is_running:
+ # Don't schedule a redraw if we're not running.
+ # Otherwise, `get_running_loop()` in `call_soon_threadsafe` can fail.
+ # See: https://github.com/dbcli/mycli/issues/797
+ return
+
+ # `invalidate()` called if we don't have a loop yet (not running?), or
+ # after the event loop was closed.
+ if self.loop is None or self.loop.is_closed():
+ return
+
+ # Never schedule a second redraw, when a previous one has not yet been
+ # executed. (This should protect against other threads calling
+ # 'invalidate' many times, resulting in 100% CPU.)
+ if self._invalidated:
+ return
+ else:
+ self._invalidated = True
+
+ # Trigger event.
+ self.loop.call_soon_threadsafe(self.on_invalidate.fire)
+
+ def redraw() -> None:
+ self._invalidated = False
+ self._redraw()
+
+ def schedule_redraw() -> None:
+ call_soon_threadsafe(
+ redraw, max_postpone_time=self.max_render_postpone_time, loop=self.loop
+ )
+
+ if self.min_redraw_interval:
+ # When a minimum redraw interval is set, wait minimum this amount
+ # of time between redraws.
+ diff = time.time() - self._last_redraw_time
+ if diff < self.min_redraw_interval:
+
+ async def redraw_in_future() -> None:
+ await sleep(cast(float, self.min_redraw_interval) - diff)
+ schedule_redraw()
+
+ self.loop.call_soon_threadsafe(
+ lambda: self.create_background_task(redraw_in_future())
+ )
+ else:
+ schedule_redraw()
+ else:
+ schedule_redraw()
@property
- def invalidated(self) ->bool:
- """True when a redraw operation has been scheduled."""
- pass
+ def invalidated(self) -> bool:
+ "True when a redraw operation has been scheduled."
+ return self._invalidated
- def _redraw(self, render_as_done: bool=False) ->None:
+ def _redraw(self, render_as_done: bool = False) -> None:
"""
Render the command line again. (Not thread safe!) (From other threads,
or if unsure, use :meth:`.Application.invalidate`.)
:param render_as_done: make sure to put the cursor after the UI.
"""
- pass
- def _start_auto_refresh_task(self) ->None:
+ def run_in_context() -> None:
+ # Only draw when no sub application was started.
+ if self._is_running and not self._running_in_terminal:
+ if self.min_redraw_interval:
+ self._last_redraw_time = time.time()
+
+ # Render
+ self.render_counter += 1
+ self.before_render.fire()
+
+ if render_as_done:
+ if self.erase_when_done:
+ self.renderer.erase()
+ else:
+ # Draw in 'done' state and reset renderer.
+ self.renderer.render(self, self.layout, is_done=render_as_done)
+ else:
+ self.renderer.render(self, self.layout)
+
+ self.layout.update_parents_relations()
+
+ # Fire render event.
+ self.after_render.fire()
+
+ self._update_invalidate_events()
+
+ # NOTE: We want to make sure this Application is the active one. The
+ # invalidate function is often called from a context where this
+ # application is not the active one. (Like the
+ # `PromptSession._auto_refresh_context`).
+ # We copy the context in case the context was already active, to
+ # prevent RuntimeErrors. (The rendering is not supposed to change
+ # any context variables.)
+ if self.context is not None:
+ self.context.copy().run(run_in_context)
+
+ def _start_auto_refresh_task(self) -> None:
"""
Start a while/true loop in the background for automatic invalidation of
the UI.
"""
- pass
+ if self.refresh_interval is not None and self.refresh_interval != 0:
- def _update_invalidate_events(self) ->None:
+ async def auto_refresh(refresh_interval: float) -> None:
+ while True:
+ await sleep(refresh_interval)
+ self.invalidate()
+
+ self.create_background_task(auto_refresh(self.refresh_interval))
+
+ def _update_invalidate_events(self) -> None:
"""
Make sure to attach 'invalidate' handlers to all invalidate events in
the UI.
"""
- pass
+ # Remove all the original event handlers. (Components can be removed
+ # from the UI.)
+ for ev in self._invalidate_events:
+ ev -= self._invalidate_handler
+
+ # Gather all new events.
+ # (All controls are able to invalidate themselves.)
+ def gather_events() -> Iterable[Event[object]]:
+ for c in self.layout.find_all_controls():
+ yield from c.get_invalidate_events()
- def _invalidate_handler(self, sender: object) ->None:
+ self._invalidate_events = list(gather_events())
+
+ for ev in self._invalidate_events:
+ ev += self._invalidate_handler
+
+ def _invalidate_handler(self, sender: object) -> None:
"""
Handler for invalidate events coming from UIControls.
@@ -308,17 +585,21 @@ class Application(Generic[_AppResult]):
`self.invalidate`. It also needs to be a method -not a nested
function-, so that we can remove it again .)
"""
- pass
+ self.invalidate()
- def _on_resize(self) ->None:
+ def _on_resize(self) -> None:
"""
When the window size changes, we erase the current output and request
again the cursor position. When the CPR answer arrives, the output is
drawn again.
"""
- pass
+ # Erase, request position (when cursor is at the start position)
+ # and redraw again. -- The order is important.
+ self.renderer.erase(leave_alternate_screen=False)
+ self._request_absolute_cursor_position()
+ self._redraw()
- def _pre_run(self, pre_run: (Callable[[], None] | None)=None) ->None:
+ def _pre_run(self, pre_run: Callable[[], None] | None = None) -> None:
"""
Called during `run`.
@@ -328,11 +609,21 @@ class Application(Generic[_AppResult]):
another thread that would call `Application.exit`. (See the progress
bar code for an example.)
"""
- pass
-
- 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) ->_AppResult:
+ if pre_run:
+ pre_run()
+
+ # Process registered "pre_run_callables" and clear list.
+ for c in self.pre_run_callables:
+ c()
+ del self.pre_run_callables[:]
+
+ 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,
+ ) -> _AppResult:
"""
Run the prompt_toolkit :class:`~prompt_toolkit.application.Application`
until :meth:`~prompt_toolkit.application.Application.exit` has been
@@ -357,12 +648,263 @@ class Application(Generic[_AppResult]):
because exceptionally, the drawing of the app, which happens in the
event loop, can take a bit longer from time to time.
"""
- pass
-
- 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
- ) ->_AppResult:
+ assert not self._is_running, "Application is already running."
+
+ if not in_main_thread() or sys.platform == "win32":
+ # Handling signals in other threads is not supported.
+ # Also on Windows, `add_signal_handler(signal.SIGINT, ...)` raises
+ # `NotImplementedError`.
+ # See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1553
+ handle_sigint = False
+
+ async def _run_async(f: asyncio.Future[_AppResult]) -> _AppResult:
+ context = contextvars.copy_context()
+ self.context = context
+
+ # Counter for cancelling 'flush' timeouts. Every time when a key is
+ # pressed, we start a 'flush' timer for flushing our escape key. But
+ # when any subsequent input is received, a new timer is started and
+ # the current timer will be ignored.
+ flush_task: asyncio.Task[None] | None = None
+
+ # Reset.
+ # (`self.future` needs to be set when `pre_run` is called.)
+ self.reset()
+ self._pre_run(pre_run)
+
+ # Feed type ahead input first.
+ self.key_processor.feed_multiple(get_typeahead(self.input))
+ self.key_processor.process_keys()
+
+ def read_from_input() -> None:
+ nonlocal flush_task
+
+ # Ignore when we aren't running anymore. This callback will
+ # removed from the loop next time. (It could be that it was
+ # still in the 'tasks' list of the loop.)
+ # Except: if we need to process incoming CPRs.
+ if not self._is_running and not self.renderer.waiting_for_cpr:
+ return
+
+ # Get keys from the input object.
+ keys = self.input.read_keys()
+
+ # Feed to key processor.
+ self.key_processor.feed_multiple(keys)
+ self.key_processor.process_keys()
+
+ # Quit when the input stream was closed.
+ if self.input.closed:
+ if not f.done():
+ f.set_exception(EOFError)
+ else:
+ # Automatically flush keys.
+ if flush_task:
+ flush_task.cancel()
+ flush_task = self.create_background_task(auto_flush_input())
+
+ def read_from_input_in_context() -> None:
+ # Ensure that key bindings callbacks are always executed in the
+ # current context. This is important when key bindings are
+ # accessing contextvars. (These callbacks are currently being
+ # called from a different context. Underneath,
+ # `loop.add_reader` is used to register the stdin FD.)
+ # (We copy the context to avoid a `RuntimeError` in case the
+ # context is already active.)
+ context.copy().run(read_from_input)
+
+ async def auto_flush_input() -> None:
+ # Flush input after timeout.
+ # (Used for flushing the enter key.)
+ # This sleep can be cancelled, in that case we won't flush yet.
+ await sleep(self.ttimeoutlen)
+ flush_input()
+
+ def flush_input() -> None:
+ if not self.is_done:
+ # Get keys, and feed to key processor.
+ keys = self.input.flush_keys()
+ self.key_processor.feed_multiple(keys)
+ self.key_processor.process_keys()
+
+ if self.input.closed:
+ f.set_exception(EOFError)
+
+ # Enter raw mode, attach input and attach WINCH event handler.
+ with self.input.raw_mode(), self.input.attach(
+ read_from_input_in_context
+ ), attach_winch_signal_handler(self._on_resize):
+ # Draw UI.
+ self._request_absolute_cursor_position()
+ self._redraw()
+ self._start_auto_refresh_task()
+
+ self.create_background_task(self._poll_output_size())
+
+ # Wait for UI to finish.
+ try:
+ result = await f
+ finally:
+ # In any case, when the application finishes.
+ # (Successful, or because of an error.)
+ try:
+ self._redraw(render_as_done=True)
+ finally:
+ # _redraw has a good chance to fail if it calls widgets
+ # with bad code. Make sure to reset the renderer
+ # anyway.
+ self.renderer.reset()
+
+ # Unset `is_running`, this ensures that possibly
+ # scheduled draws won't paint during the following
+ # yield.
+ self._is_running = False
+
+ # Detach event handlers for invalidate events.
+ # (Important when a UIControl is embedded in multiple
+ # applications, like ptterm in pymux. An invalidate
+ # should not trigger a repaint in terminated
+ # applications.)
+ for ev in self._invalidate_events:
+ ev -= self._invalidate_handler
+ self._invalidate_events = []
+
+ # Wait for CPR responses.
+ if self.output.responds_to_cpr:
+ await self.renderer.wait_for_cpr_responses()
+
+ # Wait for the run-in-terminals to terminate.
+ previous_run_in_terminal_f = self._running_in_terminal_f
+
+ if previous_run_in_terminal_f:
+ await previous_run_in_terminal_f
+
+ # Store unprocessed input as typeahead for next time.
+ store_typeahead(self.input, self.key_processor.empty_queue())
+
+ return result
+
+ @contextmanager
+ def set_loop() -> Iterator[AbstractEventLoop]:
+ loop = get_running_loop()
+ self.loop = loop
+ self._loop_thread = threading.current_thread()
+
+ try:
+ yield loop
+ finally:
+ self.loop = None
+ self._loop_thread = None
+
+ @contextmanager
+ def set_is_running() -> Iterator[None]:
+ self._is_running = True
+ try:
+ yield
+ finally:
+ self._is_running = False
+
+ @contextmanager
+ def set_handle_sigint(loop: AbstractEventLoop) -> Iterator[None]:
+ if handle_sigint:
+ with _restore_sigint_from_ctypes():
+ # save sigint handlers (python and os level)
+ # See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1576
+ loop.add_signal_handler(
+ signal.SIGINT,
+ lambda *_: loop.call_soon_threadsafe(
+ self.key_processor.send_sigint
+ ),
+ )
+ try:
+ yield
+ finally:
+ loop.remove_signal_handler(signal.SIGINT)
+ else:
+ yield
+
+ @contextmanager
+ def set_exception_handler_ctx(loop: AbstractEventLoop) -> Iterator[None]:
+ if set_exception_handler:
+ previous_exc_handler = loop.get_exception_handler()
+ loop.set_exception_handler(self._handle_exception)
+ try:
+ yield
+ finally:
+ loop.set_exception_handler(previous_exc_handler)
+
+ else:
+ yield
+
+ @contextmanager
+ def set_callback_duration(loop: AbstractEventLoop) -> Iterator[None]:
+ # Set slow_callback_duration.
+ original_slow_callback_duration = loop.slow_callback_duration
+ loop.slow_callback_duration = slow_callback_duration
+ try:
+ yield
+ finally:
+ # Reset slow_callback_duration.
+ loop.slow_callback_duration = original_slow_callback_duration
+
+ @contextmanager
+ def create_future(
+ loop: AbstractEventLoop,
+ ) -> Iterator[asyncio.Future[_AppResult]]:
+ f = loop.create_future()
+ self.future = f # XXX: make sure to set this before calling '_redraw'.
+
+ try:
+ yield f
+ finally:
+ # Also remove the Future again. (This brings the
+ # application back to its initial state, where it also
+ # doesn't have a Future.)
+ self.future = None
+
+ with ExitStack() as stack:
+ stack.enter_context(set_is_running())
+
+ # Make sure to set `_invalidated` to `False` to begin with,
+ # otherwise we're not going to paint anything. This can happen if
+ # this application had run before on a different event loop, and a
+ # paint was scheduled using `call_soon_threadsafe` with
+ # `max_postpone_time`.
+ self._invalidated = False
+
+ loop = stack.enter_context(set_loop())
+
+ stack.enter_context(set_handle_sigint(loop))
+ stack.enter_context(set_exception_handler_ctx(loop))
+ stack.enter_context(set_callback_duration(loop))
+ stack.enter_context(set_app(self))
+ stack.enter_context(self._enable_breakpointhook())
+
+ f = stack.enter_context(create_future(loop))
+
+ try:
+ return await _run_async(f)
+ finally:
+ # Wait for the background tasks to be done. This needs to
+ # go in the finally! If `_run_async` raises
+ # `KeyboardInterrupt`, we still want to wait for the
+ # background tasks.
+ await self.cancel_and_wait_for_background_tasks()
+
+ # The `ExitStack` above is defined in typeshed in a way that it can
+ # swallow exceptions. Without next line, mypy would think that there's
+ # a possibility we don't return here. See:
+ # https://github.com/python/mypy/issues/7726
+ assert False, "unreachable"
+
+ 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,
+ ) -> _AppResult:
"""
A blocking 'run' call that waits until the UI is finished.
@@ -387,26 +929,122 @@ class Application(Generic[_AppResult]):
:param handle_sigint: Handle SIGINT signal. Call the key binding for
`Keys.SIGINT`. (This only works in the main thread.)
"""
- pass
-
- def _handle_exception(self, loop: AbstractEventLoop, context: dict[str,
- Any]) ->None:
+ if in_thread:
+ result: _AppResult
+ exception: BaseException | None = None
+
+ def run_in_thread() -> None:
+ nonlocal result, exception
+ try:
+ result = self.run(
+ pre_run=pre_run,
+ set_exception_handler=set_exception_handler,
+ # Signal handling only works in the main thread.
+ handle_sigint=False,
+ inputhook=inputhook,
+ )
+ except BaseException as e:
+ exception = e
+
+ thread = threading.Thread(target=run_in_thread)
+ thread.start()
+ thread.join()
+
+ if exception is not None:
+ raise exception
+ return result
+
+ coro = self.run_async(
+ pre_run=pre_run,
+ set_exception_handler=set_exception_handler,
+ handle_sigint=handle_sigint,
+ )
+
+ def _called_from_ipython() -> bool:
+ try:
+ return (
+ sys.modules["IPython"].version_info < (8, 18, 0, "")
+ and "IPython/terminal/interactiveshell.py"
+ in sys._getframe(3).f_code.co_filename
+ )
+ except BaseException:
+ return False
+
+ if inputhook is not None:
+ # Create new event loop with given input hook and run the app.
+ # In Python 3.12, we can use asyncio.run(loop_factory=...)
+ # For now, use `run_until_complete()`.
+ loop = new_eventloop_with_inputhook(inputhook)
+ result = loop.run_until_complete(coro)
+ loop.run_until_complete(loop.shutdown_asyncgens())
+ loop.close()
+ return result
+
+ elif _called_from_ipython():
+ # workaround to make input hooks work for IPython until
+ # https://github.com/ipython/ipython/pull/14241 is merged.
+ # IPython was setting the input hook by installing an event loop
+ # previously.
+ try:
+ # See whether a loop was installed already. If so, use that.
+ # That's required for the input hooks to work, they are
+ # installed using `set_event_loop`.
+ loop = asyncio.get_event_loop()
+ except RuntimeError:
+ # No loop installed. Run like usual.
+ return asyncio.run(coro)
+ else:
+ # Use existing loop.
+ return loop.run_until_complete(coro)
+
+ else:
+ # No loop installed. Run like usual.
+ return asyncio.run(coro)
+
+ def _handle_exception(
+ self, loop: AbstractEventLoop, context: dict[str, Any]
+ ) -> None:
"""
Handler for event loop exceptions.
This will print the exception, using run_in_terminal.
"""
- pass
+ # For Python 2: we have to get traceback at this point, because
+ # we're still in the 'except:' block of the event loop where the
+ # traceback is still available. Moving this code in the
+ # 'print_exception' coroutine will loose the exception.
+ tb = get_traceback_from_context(context)
+ formatted_tb = "".join(format_tb(tb))
+
+ async def in_term() -> None:
+ async with in_terminal():
+ # Print output. Similar to 'loop.default_exception_handler',
+ # but don't use logger. (This works better on Python 2.)
+ print("\nUnhandled exception in event loop:")
+ print(formatted_tb)
+ print("Exception {}".format(context.get("exception")))
+
+ await _do_wait_for_enter("Press ENTER to continue...")
+
+ ensure_future(in_term())
@contextmanager
- def _enable_breakpointhook(self) ->Generator[None, None, None]:
+ def _enable_breakpointhook(self) -> Generator[None, None, None]:
"""
Install our custom breakpointhook for the duration of this context
manager. (We will only install the hook if no other custom hook was
set.)
"""
- pass
+ if sys.breakpointhook == sys.__breakpointhook__:
+ sys.breakpointhook = self._breakpointhook
+
+ try:
+ yield
+ finally:
+ sys.breakpointhook = sys.__breakpointhook__
+ else:
+ yield
- def _breakpointhook(self, *a: object, **kw: object) ->None:
+ def _breakpointhook(self, *a: object, **kw: object) -> None:
"""
Breakpointhook which uses PDB, but ensures that the application is
hidden and input echoing is restored during each debugger dispatch.
@@ -415,10 +1053,85 @@ class Application(Generic[_AppResult]):
event loop will be blocked while the PDB input is displayed. The event
will continue after leaving the debugger.
"""
- pass
-
- def create_background_task(self, coroutine: Coroutine[Any, Any, None]
- ) ->asyncio.Task[None]:
+ app = self
+ # Inline import on purpose. We don't want to import pdb, if not needed.
+ import pdb
+ from types import FrameType
+
+ TraceDispatch = Callable[[FrameType, str, Any], Any]
+
+ @contextmanager
+ def hide_app_from_eventloop_thread() -> Generator[None, None, None]:
+ """Stop application if `__breakpointhook__` is called from within
+ the App's event loop."""
+ # Hide application.
+ app.renderer.erase()
+
+ # Detach input and dispatch to debugger.
+ with app.input.detach():
+ with app.input.cooked_mode():
+ yield
+
+ # Note: we don't render the application again here, because
+ # there's a good chance that there's a breakpoint on the next
+ # line. This paint/erase cycle would move the PDB prompt back
+ # to the middle of the screen.
+
+ @contextmanager
+ def hide_app_from_other_thread() -> Generator[None, None, None]:
+ """Stop application if `__breakpointhook__` is called from a
+ thread other than the App's event loop."""
+ ready = threading.Event()
+ done = threading.Event()
+
+ async def in_loop() -> None:
+ # from .run_in_terminal import in_terminal
+ # async with in_terminal():
+ # ready.set()
+ # await asyncio.get_running_loop().run_in_executor(None, done.wait)
+ # return
+
+ # Hide application.
+ app.renderer.erase()
+
+ # Detach input and dispatch to debugger.
+ with app.input.detach():
+ with app.input.cooked_mode():
+ ready.set()
+ # Here we block the App's event loop thread until the
+ # debugger resumes. We could have used `with
+ # run_in_terminal.in_terminal():` like the commented
+ # code above, but it seems to work better if we
+ # completely stop the main event loop while debugging.
+ done.wait()
+
+ self.create_background_task(in_loop())
+ ready.wait()
+ try:
+ yield
+ finally:
+ done.set()
+
+ class CustomPdb(pdb.Pdb):
+ def trace_dispatch(
+ self, frame: FrameType, event: str, arg: Any
+ ) -> TraceDispatch:
+ if app._loop_thread is None:
+ return super().trace_dispatch(frame, event, arg)
+
+ if app._loop_thread == threading.current_thread():
+ with hide_app_from_eventloop_thread():
+ return super().trace_dispatch(frame, event, arg)
+
+ with hide_app_from_other_thread():
+ return super().trace_dispatch(frame, event, arg)
+
+ frame = sys._getframe().f_back
+ CustomPdb(stdout=sys.__stdout__).set_trace(frame)
+
+ def create_background_task(
+ self, coroutine: Coroutine[Any, Any, None]
+ ) -> asyncio.Task[None]:
"""
Start a background task (coroutine) for the running application. When
the `Application` terminates, unfinished background tasks will be
@@ -435,16 +1148,35 @@ class Application(Generic[_AppResult]):
This is not threadsafe.
"""
- pass
+ loop = self.loop or get_running_loop()
+ task: asyncio.Task[None] = loop.create_task(coroutine)
+ self._background_tasks.add(task)
+
+ task.add_done_callback(self._on_background_task_done)
+ return task
- def _on_background_task_done(self, task: asyncio.Task[None]) ->None:
+ def _on_background_task_done(self, task: asyncio.Task[None]) -> None:
"""
Called when a background task completes. Remove it from
`_background_tasks`, and handle exceptions if any.
"""
- pass
-
- async def cancel_and_wait_for_background_tasks(self) ->None:
+ self._background_tasks.discard(task)
+
+ if task.cancelled():
+ return
+
+ exc = task.exception()
+ if exc is not None:
+ get_running_loop().call_exception_handler(
+ {
+ "message": f"prompt_toolkit.Application background task {task!r} "
+ "raised an unexpected exception.",
+ "exception": exc,
+ "task": task,
+ }
+ )
+
+ async def cancel_and_wait_for_background_tasks(self) -> None:
"""
Cancel all background tasks, and wait for the cancellation to complete.
If any of the background tasks raised an exception, this will also
@@ -453,9 +1185,30 @@ class Application(Generic[_AppResult]):
(If we had nurseries like Trio, this would be the `__aexit__` of a
nursery.)
"""
- pass
-
- async def _poll_output_size(self) ->None:
+ for task in self._background_tasks:
+ task.cancel()
+
+ # Wait until the cancellation of the background tasks completes.
+ # `asyncio.wait()` does not propagate exceptions raised within any of
+ # these tasks, which is what we want. Otherwise, we can't distinguish
+ # between a `CancelledError` raised in this task because it got
+ # cancelled, and a `CancelledError` raised on this `await` checkpoint,
+ # because *we* got cancelled during the teardown of the application.
+ # (If we get cancelled here, then it's important to not suppress the
+ # `CancelledError`, and have it propagate.)
+ # NOTE: Currently, if we get cancelled at this point then we can't wait
+ # for the cancellation to complete (in the future, we should be
+ # using anyio or Python's 3.11 TaskGroup.)
+ # Also, if we had exception groups, we could propagate an
+ # `ExceptionGroup` if something went wrong here. Right now, we
+ # don't propagate exceptions, but have them printed in
+ # `_on_background_task_done`.
+ if len(self._background_tasks) > 0:
+ await asyncio.wait(
+ self._background_tasks, timeout=None, return_when=asyncio.ALL_COMPLETED
+ )
+
+ async def _poll_output_size(self) -> None:
"""
Coroutine for polling the terminal dimensions.
@@ -463,33 +1216,55 @@ class Application(Generic[_AppResult]):
- If we are not running in the main thread.
- On Windows.
"""
- pass
+ size: Size | None = None
+ interval = self.terminal_size_polling_interval
- def cpr_not_supported_callback(self) ->None:
+ if interval is None:
+ return
+
+ while True:
+ await asyncio.sleep(interval)
+ new_size = self.output.get_size()
+
+ if size is not None and new_size != size:
+ self._on_resize()
+ size = new_size
+
+ def cpr_not_supported_callback(self) -> None:
"""
Called when we don't receive the cursor position response in time.
"""
- pass
+ if not self.output.responds_to_cpr:
+ return # We know about this already.
- @overload
- def exit(self) ->None:
- """Exit without arguments."""
- pass
+ def in_terminal() -> None:
+ self.output.write(
+ "WARNING: your terminal doesn't support cursor position requests (CPR).\r\n"
+ )
+ self.output.flush()
+
+ run_in_terminal(in_terminal)
@overload
- def exit(self, *, result: _AppResult, style: str='') ->None:
- """Exit with `_AppResult`."""
- pass
+ def exit(self) -> None:
+ "Exit without arguments."
@overload
- def exit(self, *, exception: (BaseException | type[BaseException]),
- style: str='') ->None:
- """Exit with exception."""
- pass
+ def exit(self, *, result: _AppResult, style: str = "") -> None:
+ "Exit with `_AppResult`."
- def exit(self, result: (_AppResult | None)=None, exception: (
- BaseException | type[BaseException] | None)=None, style: str=''
- ) ->None:
+ @overload
+ def exit(
+ self, *, exception: BaseException | type[BaseException], style: str = ""
+ ) -> None:
+ "Exit with exception."
+
+ def exit(
+ self,
+ result: _AppResult | None = None,
+ exception: BaseException | type[BaseException] | None = None,
+ style: str = "",
+ ) -> None:
"""
Exit application.
@@ -508,17 +1283,38 @@ class Application(Generic[_AppResult]):
often this is 'class:exiting' for a prompt. (Used when
`erase_when_done` is not set.)
"""
- pass
+ assert result is None or exception is None
+
+ if self.future is None:
+ raise Exception("Application is not running. Application.exit() failed.")
+
+ if self.future.done():
+ raise Exception("Return value already set. Application.exit() failed.")
+
+ self.exit_style = style
+
+ if exception is not None:
+ self.future.set_exception(exception)
+ else:
+ self.future.set_result(cast(_AppResult, result))
- def _request_absolute_cursor_position(self) ->None:
+ def _request_absolute_cursor_position(self) -> None:
"""
Send CPR request.
"""
- pass
-
- async def run_system_command(self, command: str, wait_for_enter: bool=
- True, display_before_text: AnyFormattedText='', wait_text: str=
- 'Press ENTER to continue...') ->None:
+ # Note: only do this if the input queue is not empty, and a return
+ # value has not been set. Otherwise, we won't be able to read the
+ # response anyway.
+ if not self.key_processor.input_queue and not self.is_done:
+ self.renderer.request_absolute_cursor_position()
+
+ async def run_system_command(
+ self,
+ command: str,
+ wait_for_enter: bool = True,
+ display_before_text: AnyFormattedText = "",
+ wait_text: str = "Press ENTER to continue...",
+ ) -> None:
"""
Run system command (While hiding the prompt. When finished, all the
output will scroll above the prompt.)
@@ -530,9 +1326,31 @@ class Application(Generic[_AppResult]):
command executes.
:return: A `Future` object.
"""
- pass
-
- def suspend_to_background(self, suspend_group: bool=True) ->None:
+ async with in_terminal():
+ # Try to use the same input/output file descriptors as the one,
+ # used to run this application.
+ try:
+ input_fd = self.input.fileno()
+ except AttributeError:
+ input_fd = sys.stdin.fileno()
+ try:
+ output_fd = self.output.fileno()
+ except AttributeError:
+ output_fd = sys.stdout.fileno()
+
+ # Run sub process.
+ def run_command() -> None:
+ self.print_text(display_before_text)
+ p = Popen(command, shell=True, stdin=input_fd, stdout=output_fd)
+ p.wait()
+
+ await run_in_executor_with_context(run_command)
+
+ # Wait for the user to press enter.
+ if wait_for_enter:
+ await _do_wait_for_enter(wait_text)
+
+ def suspend_to_background(self, suspend_group: bool = True) -> None:
"""
(Not thread safe -- to be called from inside the key bindings.)
Suspend process.
@@ -540,10 +1358,27 @@ class Application(Generic[_AppResult]):
:param suspend_group: When true, suspend the whole process group.
(This is the default, and probably what you want.)
"""
- pass
-
- def print_text(self, text: AnyFormattedText, style: (BaseStyle | None)=None
- ) ->None:
+ # Only suspend when the operating system supports it.
+ # (Not on Windows.)
+ if _SIGTSTP is not None:
+
+ def run() -> None:
+ signal = cast(int, _SIGTSTP)
+ # Send `SIGTSTP` to own process.
+ # This will cause it to suspend.
+
+ # Usually we want the whole process group to be suspended. This
+ # handles the case when input is piped from another process.
+ if suspend_group:
+ os.kill(0, signal)
+ else:
+ os.kill(os.getpid(), signal)
+
+ run_in_terminal(run)
+
+ def print_text(
+ self, text: AnyFormattedText, style: BaseStyle | None = None
+ ) -> None:
"""
Print a list of (style_str, text) tuples to the output.
(When the UI is running, this method has to be called through
@@ -552,19 +1387,39 @@ class Application(Generic[_AppResult]):
:param text: List of ``(style_str, text)`` tuples.
:param style: Style class to use. Defaults to the active style in the CLI.
"""
- pass
+ print_formatted_text(
+ output=self.output,
+ formatted_text=text,
+ style=style or self._merged_style,
+ color_depth=self.color_depth,
+ style_transformation=self.style_transformation,
+ )
@property
- def is_running(self) ->bool:
- """`True` when the application is currently active/running."""
- pass
+ def is_running(self) -> bool:
+ "`True` when the application is currently active/running."
+ return self._is_running
- def get_used_style_strings(self) ->list[str]:
+ @property
+ def is_done(self) -> bool:
+ if self.future:
+ return self.future.done()
+ return False
+
+ def get_used_style_strings(self) -> list[str]:
"""
Return a list of used style strings. This is helpful for debugging, and
for writing a new `Style`.
"""
- pass
+ attrs_for_style = self.renderer._attrs_for_style
+
+ if attrs_for_style:
+ return sorted(
+ re.sub(r"\s+", " ", style_str).strip()
+ for style_str in attrs_for_style.keys()
+ )
+
+ return []
class _CombinedRegistry(KeyBindingsBase):
@@ -574,45 +1429,128 @@ class _CombinedRegistry(KeyBindingsBase):
control.
"""
- def __init__(self, app: Application[_AppResult]) ->None:
+ def __init__(self, app: Application[_AppResult]) -> None:
self.app = app
- self._cache: SimpleCache[tuple[Window, frozenset[UIControl]],
- KeyBindingsBase] = SimpleCache()
+ self._cache: SimpleCache[
+ tuple[Window, frozenset[UIControl]], KeyBindingsBase
+ ] = SimpleCache()
@property
- def _version(self) ->Hashable:
+ def _version(self) -> Hashable:
"""Not needed - this object is not going to be wrapped in another
KeyBindings object."""
- pass
+ raise NotImplementedError
@property
- def bindings(self) ->list[Binding]:
+ def bindings(self) -> list[Binding]:
"""Not needed - this object is not going to be wrapped in another
KeyBindings object."""
- pass
+ raise NotImplementedError
- def _create_key_bindings(self, current_window: Window, other_controls:
- list[UIControl]) ->KeyBindingsBase:
+ def _create_key_bindings(
+ self, current_window: Window, other_controls: list[UIControl]
+ ) -> KeyBindingsBase:
"""
Create a `KeyBindings` object that merges the `KeyBindings` from the
`UIControl` with all the parent controls and the global key bindings.
"""
- pass
+ key_bindings = []
+ collected_containers = set()
+
+ # Collect key bindings from currently focused control and all parent
+ # controls. Don't include key bindings of container parent controls.
+ container: Container = current_window
+ while True:
+ collected_containers.add(container)
+ kb = container.get_key_bindings()
+ if kb is not None:
+ key_bindings.append(kb)
+
+ if container.is_modal():
+ break
+
+ parent = self.app.layout.get_parent(container)
+ if parent is None:
+ break
+ else:
+ container = parent
+
+ # Include global bindings (starting at the top-model container).
+ for c in walk(container):
+ if c not in collected_containers:
+ kb = c.get_key_bindings()
+ if kb is not None:
+ key_bindings.append(GlobalOnlyKeyBindings(kb))
+
+ # Add App key bindings
+ if self.app.key_bindings:
+ key_bindings.append(self.app.key_bindings)
+
+ # Add mouse bindings.
+ key_bindings.append(
+ ConditionalKeyBindings(
+ self.app._page_navigation_bindings,
+ self.app.enable_page_navigation_bindings,
+ )
+ )
+ key_bindings.append(self.app._default_bindings)
+
+ # Reverse this list. The current control's key bindings should come
+ # last. They need priority.
+ key_bindings = key_bindings[::-1]
+
+ return merge_key_bindings(key_bindings)
+
+ @property
+ def _key_bindings(self) -> KeyBindingsBase:
+ current_window = self.app.layout.current_window
+ other_controls = list(self.app.layout.find_all_controls())
+ key = current_window, frozenset(other_controls)
+
+ return self._cache.get(
+ key, lambda: self._create_key_bindings(current_window, other_controls)
+ )
+ def get_bindings_for_keys(self, keys: KeysTuple) -> list[Binding]:
+ return self._key_bindings.get_bindings_for_keys(keys)
-async def _do_wait_for_enter(wait_text: AnyFormattedText) ->None:
+ def get_bindings_starting_with_keys(self, keys: KeysTuple) -> list[Binding]:
+ return self._key_bindings.get_bindings_starting_with_keys(keys)
+
+
+async def _do_wait_for_enter(wait_text: AnyFormattedText) -> None:
"""
Create a sub application to wait for the enter key press.
This has two advantages over using 'input'/'raw_input':
- This will share the same input/output I/O.
- This doesn't block the event loop.
"""
- pass
+ from prompt_toolkit.shortcuts import PromptSession
+
+ key_bindings = KeyBindings()
+
+ @key_bindings.add("enter")
+ def _ok(event: E) -> None:
+ event.app.exit()
+
+ @key_bindings.add(Keys.Any)
+ def _ignore(event: E) -> None:
+ "Disallow typing."
+ pass
+
+ session: PromptSession[None] = PromptSession(
+ message=wait_text, key_bindings=key_bindings
+ )
+ try:
+ await session.app.run_async()
+ except KeyboardInterrupt:
+ pass # Control-c pressed. Don't propagate this error.
@contextmanager
-def attach_winch_signal_handler(handler: Callable[[], None]) ->Generator[
- None, None, None]:
+def attach_winch_signal_handler(
+ handler: Callable[[], None],
+) -> Generator[None, None, None]:
"""
Attach the given callback as a WINCH signal handler within the context
manager. Restore the original signal handler when done.
@@ -624,4 +1562,64 @@ def attach_winch_signal_handler(handler: Callable[[], None]) ->Generator[
SIGWINCH. This is why it's important to restore the handler when the app
terminates.
"""
- pass
+ # The tricky part here is that signals are registered in the Unix event
+ # loop with a wakeup fd, but another application could have registered
+ # signals using signal.signal directly. For now, the implementation is
+ # hard-coded for the `asyncio.unix_events._UnixSelectorEventLoop`.
+
+ # No WINCH? Then don't do anything.
+ sigwinch = getattr(signal, "SIGWINCH", None)
+ if sigwinch is None or not in_main_thread():
+ yield
+ return
+
+ # Keep track of the previous handler.
+ # (Only UnixSelectorEventloop has `_signal_handlers`.)
+ loop = get_running_loop()
+ previous_winch_handler = getattr(loop, "_signal_handlers", {}).get(sigwinch)
+
+ try:
+ loop.add_signal_handler(sigwinch, handler)
+ yield
+ finally:
+ # Restore the previous signal handler.
+ loop.remove_signal_handler(sigwinch)
+ if previous_winch_handler is not None:
+ loop.add_signal_handler(
+ sigwinch,
+ previous_winch_handler._callback,
+ *previous_winch_handler._args,
+ )
+
+
+@contextmanager
+def _restore_sigint_from_ctypes() -> Generator[None, None, None]:
+ # The following functions are part of the stable ABI since python 3.2
+ # See: https://docs.python.org/3/c-api/sys.html#c.PyOS_getsig
+ # Inline import: these are not available on Pypy.
+ try:
+ from ctypes import c_int, c_void_p, pythonapi
+ except ImportError:
+ # Any of the above imports don't exist? Don't do anything here.
+ yield
+ return
+
+ # PyOS_sighandler_t PyOS_getsig(int i)
+ pythonapi.PyOS_getsig.restype = c_void_p
+ pythonapi.PyOS_getsig.argtypes = (c_int,)
+
+ # PyOS_sighandler_t PyOS_setsig(int i, PyOS_sighandler_t h)
+ pythonapi.PyOS_setsig.restype = c_void_p
+ pythonapi.PyOS_setsig.argtypes = (
+ c_int,
+ c_void_p,
+ )
+
+ sigint = signal.getsignal(signal.SIGINT)
+ sigint_os = pythonapi.PyOS_getsig(signal.SIGINT)
+
+ try:
+ yield
+ finally:
+ signal.signal(signal.SIGINT, sigint)
+ pythonapi.PyOS_setsig(signal.SIGINT, sigint_os)
diff --git a/src/prompt_toolkit/application/current.py b/src/prompt_toolkit/application/current.py
index 74edbaba..908141a4 100644
--- a/src/prompt_toolkit/application/current.py
+++ b/src/prompt_toolkit/application/current.py
@@ -1,13 +1,24 @@
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']
+
+__all__ = [
+ "AppSession",
+ "get_app_session",
+ "get_app",
+ "get_app_or_none",
+ "set_app",
+ "create_app_session",
+ "create_app_session_from_tty",
+]
class AppSession:
@@ -27,21 +38,46 @@ class AppSession:
:param output: Use this as a default output.
"""
- def __init__(self, input: (Input | None)=None, output: (Output | None)=None
- ) ->None:
+ 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})'
+ 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())
+ "_current_app_session", default=AppSession()
+)
-def get_app() ->Application[Any]:
+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
@@ -60,19 +96,26 @@ def get_app() ->Application[Any]:
(For applications like pymux where we can have more than one `Application`,
we'll use a work-around to handle that.)
"""
- pass
+ 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):
+
+def get_app_or_none() -> Application[Any] | None:
"""
Get the current active (running) Application, or return `None` if no
application is running.
"""
- pass
+ session = _current_app_session.get()
+ return session.app
@contextmanager
-def set_app(app: Application[Any]) ->Generator[None, None, None]:
+def set_app(app: Application[Any]) -> Generator[None, None, None]:
"""
Context manager that sets the given :class:`.Application` active in an
`AppSession`.
@@ -83,23 +126,45 @@ def set_app(app: Application[Any]) ->Generator[None, None, None]:
the case, use `contextvars.copy_context()`, or use `Application.context` to
run it in the appropriate context.
"""
- pass
+ 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]:
+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 an Telnet/SSH server.
"""
- pass
+ # If no input/output is specified, fall back to the current input/output,
+ # whatever that is.
+ if input is None:
+ input = get_app_session().input
+ if output is None:
+ output = get_app_session().output
+
+ # Create new `AppSession` and activate.
+ session = AppSession(input=input, output=output)
+
+ token = _current_app_session.set(session)
+ try:
+ yield session
+ finally:
+ _current_app_session.reset(token)
@contextmanager
-def create_app_session_from_tty() ->Generator[AppSession, None, None]:
+def create_app_session_from_tty() -> Generator[AppSession, None, None]:
"""
Create `AppSession` that always prefers the TTY input/output.
@@ -114,4 +179,11 @@ def create_app_session_from_tty() ->Generator[AppSession, None, None]:
with create_app_session_from_tty():
prompt('>')
"""
- pass
+ 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
diff --git a/src/prompt_toolkit/application/dummy.py b/src/prompt_toolkit/application/dummy.py
index cfa188c6..43819e1e 100644
--- a/src/prompt_toolkit/application/dummy.py
+++ b/src/prompt_toolkit/application/dummy.py
@@ -1,11 +1,17 @@
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']
+
+__all__ = [
+ "DummyApplication",
+]
class DummyApplication(Application[None]):
@@ -14,5 +20,36 @@ class DummyApplication(Application[None]):
:func:`.get_app` will run an instance of this :class:`.DummyApplication` instead.
"""
- def __init__(self) ->None:
+ 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
diff --git a/src/prompt_toolkit/application/run_in_terminal.py b/src/prompt_toolkit/application/run_in_terminal.py
index 2d3682ca..1e4da2d9 100644
--- a/src/prompt_toolkit/application/run_in_terminal.py
+++ b/src/prompt_toolkit/application/run_in_terminal.py
@@ -2,17 +2,26 @@
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')
+__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]:
+
+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.
@@ -34,12 +43,19 @@ def run_in_terminal(func: Callable[[], _T], render_cli_done: bool=False,
:returns: A `Future`.
"""
- pass
+
+ 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
- ]:
+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.
@@ -51,4 +67,47 @@ async def in_terminal(render_cli_done: bool=False) ->AsyncGenerator[None, None
call_some_function()
await call_some_async_function()
"""
- pass
+ app = get_app_or_none()
+ if app is None or not app._is_running:
+ yield
+ return
+
+ # When a previous `run_in_terminal` call was in progress. Wait for that
+ # to finish, before starting this one. Chain to previous call.
+ previous_run_in_terminal_f = app._running_in_terminal_f
+ new_run_in_terminal_f: Future[None] = Future()
+ app._running_in_terminal_f = new_run_in_terminal_f
+
+ # Wait for the previous `run_in_terminal` to finish.
+ if previous_run_in_terminal_f is not None:
+ await previous_run_in_terminal_f
+
+ # Wait for all CPRs to arrive. We don't want to detach the input until
+ # all cursor position responses have been arrived. Otherwise, the tty
+ # will echo its input and can show stuff like ^[[39;1R.
+ if app.output.responds_to_cpr:
+ await app.renderer.wait_for_cpr_responses()
+
+ # Draw interface in 'done' state, or erase.
+ if render_cli_done:
+ app._redraw(render_as_done=True)
+ else:
+ app.renderer.erase()
+
+ # Disable rendering.
+ app._running_in_terminal = True
+
+ # Detach input.
+ try:
+ with app.input.detach():
+ with app.input.cooked_mode():
+ yield
+ finally:
+ # Redraw interface again.
+ try:
+ app._running_in_terminal = False
+ app.renderer.reset()
+ app._request_absolute_cursor_position()
+ app._redraw()
+ finally:
+ new_run_in_terminal_f.set_result(None)
diff --git a/src/prompt_toolkit/auto_suggest.py b/src/prompt_toolkit/auto_suggest.py
index 2e73a69c..98cb4ddd 100644
--- a/src/prompt_toolkit/auto_suggest.py
+++ b/src/prompt_toolkit/auto_suggest.py
@@ -12,16 +12,27 @@ 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']
+
+__all__ = [
+ "Suggestion",
+ "AutoSuggest",
+ "ThreadedAutoSuggest",
+ "DummyAutoSuggest",
+ "AutoSuggestFromHistory",
+ "ConditionalAutoSuggest",
+ "DynamicAutoSuggest",
+]
class Suggestion:
@@ -31,11 +42,11 @@ class Suggestion:
:param text: The suggestion text.
"""
- def __init__(self, text: str) ->None:
+ def __init__(self, text: str) -> None:
self.text = text
- def __repr__(self) ->str:
- return 'Suggestion(%s)' % self.text
+ def __repr__(self) -> str:
+ return "Suggestion(%s)" % self.text
class AutoSuggest(metaclass=ABCMeta):
@@ -44,8 +55,7 @@ class AutoSuggest(metaclass=ABCMeta):
"""
@abstractmethod
- def get_suggestion(self, buffer: Buffer, document: Document) ->(Suggestion
- | None):
+ def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
"""
Return `None` or a :class:`.Suggestion` instance.
@@ -60,16 +70,16 @@ class AutoSuggest(metaclass=ABCMeta):
:param buffer: The :class:`~prompt_toolkit.buffer.Buffer` instance.
:param document: The :class:`~prompt_toolkit.document.Document` instance.
"""
- pass
- async def get_suggestion_async(self, buff: Buffer, document: Document) ->(
- Suggestion | None):
+ 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.
"""
- pass
+ return self.get_suggestion(buff, document)
class ThreadedAutoSuggest(AutoSuggest):
@@ -79,15 +89,23 @@ class ThreadedAutoSuggest(AutoSuggest):
generation of suggestions takes too much time.)
"""
- def __init__(self, auto_suggest: AutoSuggest) ->None:
+ def __init__(self, auto_suggest: AutoSuggest) -> None:
self.auto_suggest = auto_suggest
- async def get_suggestion_async(self, buff: Buffer, document: Document) ->(
- Suggestion | None):
+ 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.
"""
- pass
+
+ 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):
@@ -95,23 +113,47 @@ 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:
+ 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):
"""
@@ -120,6 +162,15 @@ class DynamicAutoSuggest(AutoSuggest):
:param get_validator: Callable that returns a :class:`.Validator` instance.
"""
- def __init__(self, get_auto_suggest: Callable[[], AutoSuggest | None]
- ) ->None:
+ 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)
diff --git a/src/prompt_toolkit/buffer.py b/src/prompt_toolkit/buffer.py
index b2382429..100ca78d 100644
--- a/src/prompt_toolkit/buffer.py
+++ b/src/prompt_toolkit/buffer.py
@@ -3,6 +3,7 @@ Data structures for the Buffer.
It holds the text, cursor position, history, etc...
"""
from __future__ import annotations
+
import asyncio
import logging
import os
@@ -15,12 +16,19 @@ from collections import deque
from enum import Enum
from functools import wraps
from typing import Any, Callable, Coroutine, Iterable, TypeVar, cast
+
from .application.current import get_app
from .application.run_in_terminal import run_in_terminal
from .auto_suggest import AutoSuggest, Suggestion
from .cache import FastDictCache
from .clipboard import ClipboardData
-from .completion import CompleteEvent, Completer, Completion, DummyCompleter, get_common_complete_suffix
+from .completion import (
+ CompleteEvent,
+ Completer,
+ Completion,
+ DummyCompleter,
+ get_common_complete_suffix,
+)
from .document import Document
from .eventloop import aclosing
from .filters import FilterOrBool, to_filter
@@ -29,20 +37,29 @@ from .search import SearchDirection, SearchState
from .selection import PasteMode, SelectionState, SelectionType
from .utils import Event, to_str
from .validation import ValidationError, Validator
-__all__ = ['EditReadOnlyBuffer', 'Buffer', 'CompletionState', 'indent',
- 'unindent', 'reshape_text']
+
+__all__ = [
+ "EditReadOnlyBuffer",
+ "Buffer",
+ "CompletionState",
+ "indent",
+ "unindent",
+ "reshape_text",
+]
+
logger = logging.getLogger(__name__)
class EditReadOnlyBuffer(Exception):
- """Attempt editing of read-only :class:`.Buffer`."""
+ "Attempt editing of read-only :class:`.Buffer`."
class ValidationState(Enum):
- """The validation state of a buffer. This is set after the validation."""
- VALID = 'VALID'
- INVALID = 'INVALID'
- UNKNOWN = 'UNKNOWN'
+ "The validation state of a buffer. This is set after the validation."
+
+ VALID = "VALID"
+ INVALID = "INVALID"
+ UNKNOWN = "UNKNOWN"
class CompletionState:
@@ -50,41 +67,73 @@ class CompletionState:
Immutable class that contains a completion state.
"""
- def __init__(self, original_document: Document, completions: (list[
- Completion] | None)=None, complete_index: (int | None)=None) ->None:
+ def __init__(
+ self,
+ original_document: Document,
+ completions: list[Completion] | None = None,
+ complete_index: int | None = None,
+ ) -> None:
+ #: Document as it was when the completion started.
self.original_document = original_document
+
+ #: List of all the current Completion instances which are possible at
+ #: this point.
self.completions = completions or []
- self.complete_index = complete_index
- def __repr__(self) ->str:
- return '{}({!r}, <{!r}> completions, index={!r})'.format(self.
- __class__.__name__, self.original_document, len(self.
- completions), self.complete_index)
+ #: Position in the `completions` array.
+ #: This can be `None` to indicate "no completion", the original text.
+ self.complete_index = complete_index # Position in the `_completions` array.
- def go_to_index(self, index: (int | None)) ->None:
+ def __repr__(self) -> str:
+ return "{}({!r}, <{!r}> completions, index={!r})".format(
+ self.__class__.__name__,
+ self.original_document,
+ len(self.completions),
+ self.complete_index,
+ )
+
+ def go_to_index(self, index: int | None) -> None:
"""
Create a new :class:`.CompletionState` object with the new index.
When `index` is `None` deselect the completion.
"""
- pass
+ if self.completions:
+ assert index is None or 0 <= index < len(self.completions)
+ self.complete_index = index
- def new_text_and_position(self) ->tuple[str, int]:
+ def new_text_and_position(self) -> tuple[str, int]:
"""
Return (new_text, new_cursor_position) for this completion.
"""
- pass
+ if self.complete_index is None:
+ return self.original_document.text, self.original_document.cursor_position
+ else:
+ original_text_before_cursor = self.original_document.text_before_cursor
+ original_text_after_cursor = self.original_document.text_after_cursor
+
+ c = self.completions[self.complete_index]
+ if c.start_position == 0:
+ before = original_text_before_cursor
+ else:
+ before = original_text_before_cursor[: c.start_position]
+
+ new_text = before + c.text + original_text_after_cursor
+ new_cursor_position = len(before) + len(c.text)
+ return new_text, new_cursor_position
@property
- def current_completion(self) ->(Completion | None):
+ def current_completion(self) -> Completion | None:
"""
Return the current completion, or return `None` when no completion is
selected.
"""
- pass
+ if self.complete_index is not None:
+ return self.completions[self.complete_index]
+ return None
-_QUOTED_WORDS_RE = re.compile('(\\s+|".*?"|\'.*?\')')
+_QUOTED_WORDS_RE = re.compile(r"""(\s+|".*?"|'.*?')""")
class YankNthArgState:
@@ -92,21 +141,24 @@ class YankNthArgState:
For yank-last-arg/yank-nth-arg: Keep track of where we are in the history.
"""
- def __init__(self, history_position: int=0, n: int=-1,
- previous_inserted_word: str='') ->None:
+ def __init__(
+ self, history_position: int = 0, n: int = -1, previous_inserted_word: str = ""
+ ) -> None:
self.history_position = history_position
self.previous_inserted_word = previous_inserted_word
self.n = n
- def __repr__(self) ->str:
- return (
- '{}(history_position={!r}, n={!r}, previous_inserted_word={!r})'
- .format(self.__class__.__name__, self.history_position, self.n,
- self.previous_inserted_word))
+ def __repr__(self) -> str:
+ return "{}(history_position={!r}, n={!r}, previous_inserted_word={!r})".format(
+ self.__class__.__name__,
+ self.history_position,
+ self.n,
+ self.previous_inserted_word,
+ )
-BufferEventHandler = Callable[['Buffer'], None]
-BufferAcceptHandler = Callable[['Buffer'], bool]
+BufferEventHandler = Callable[["Buffer"], None]
+BufferAcceptHandler = Callable[["Buffer"], bool]
class Buffer:
@@ -166,24 +218,35 @@ class Buffer:
pressing `Esc-Enter` is required.
"""
- def __init__(self, completer: (Completer | None)=None, auto_suggest: (
- AutoSuggest | None)=None, history: (History | None)=None, validator:
- (Validator | None)=None, tempfile_suffix: (str | Callable[[], str])
- ='', tempfile: (str | Callable[[], str])='', name: str='',
- complete_while_typing: FilterOrBool=False, validate_while_typing:
- FilterOrBool=False, enable_history_search: FilterOrBool=False,
- document: (Document | None)=None, accept_handler: (
- BufferAcceptHandler | None)=None, read_only: FilterOrBool=False,
- multiline: FilterOrBool=True, on_text_changed: (BufferEventHandler |
- None)=None, on_text_insert: (BufferEventHandler | None)=None,
- on_cursor_position_changed: (BufferEventHandler | None)=None,
- on_completions_changed: (BufferEventHandler | None)=None,
- on_suggestion_set: (BufferEventHandler | None)=None):
+ def __init__(
+ self,
+ completer: Completer | None = None,
+ auto_suggest: AutoSuggest | None = None,
+ history: History | None = None,
+ validator: Validator | None = None,
+ tempfile_suffix: str | Callable[[], str] = "",
+ tempfile: str | Callable[[], str] = "",
+ name: str = "",
+ complete_while_typing: FilterOrBool = False,
+ validate_while_typing: FilterOrBool = False,
+ enable_history_search: FilterOrBool = False,
+ document: Document | None = None,
+ accept_handler: BufferAcceptHandler | None = None,
+ read_only: FilterOrBool = False,
+ multiline: FilterOrBool = True,
+ on_text_changed: BufferEventHandler | None = None,
+ on_text_insert: BufferEventHandler | None = None,
+ on_cursor_position_changed: BufferEventHandler | None = None,
+ on_completions_changed: BufferEventHandler | None = None,
+ on_suggestion_set: BufferEventHandler | None = None,
+ ):
+ # Accept both filters and booleans as input.
enable_history_search = to_filter(enable_history_search)
complete_while_typing = to_filter(complete_while_typing)
validate_while_typing = to_filter(validate_while_typing)
read_only = to_filter(read_only)
multiline = to_filter(multiline)
+
self.completer = completer or DummyCompleter()
self.auto_suggest = auto_suggest
self.validator = validator
@@ -191,44 +254,123 @@ class Buffer:
self.tempfile = tempfile
self.name = name
self.accept_handler = accept_handler
+
+ # Filters. (Usually, used by the key bindings to drive the buffer.)
self.complete_while_typing = complete_while_typing
self.validate_while_typing = validate_while_typing
self.enable_history_search = enable_history_search
self.read_only = read_only
self.multiline = multiline
+
+ # Text width. (For wrapping, used by the Vi 'gq' operator.)
self.text_width = 0
+
+ #: The command buffer history.
+ # Note that we shouldn't use a lazy 'or' here. bool(history) could be
+ # False when empty.
self.history = InMemoryHistory() if history is None else history
+
self.__cursor_position = 0
+
+ # Events
self.on_text_changed: Event[Buffer] = Event(self, on_text_changed)
self.on_text_insert: Event[Buffer] = Event(self, on_text_insert)
- self.on_cursor_position_changed: Event[Buffer] = Event(self,
- on_cursor_position_changed)
- self.on_completions_changed: Event[Buffer] = Event(self,
- on_completions_changed)
+ self.on_cursor_position_changed: Event[Buffer] = Event(
+ self, on_cursor_position_changed
+ )
+ self.on_completions_changed: Event[Buffer] = Event(self, on_completions_changed)
self.on_suggestion_set: Event[Buffer] = Event(self, on_suggestion_set)
- self._document_cache: FastDictCache[tuple[str, int, SelectionState |
- None], Document] = FastDictCache(Document, size=10)
+
+ # Document cache. (Avoid creating new Document instances.)
+ self._document_cache: FastDictCache[
+ tuple[str, int, SelectionState | None], Document
+ ] = FastDictCache(Document, size=10)
+
+ # Create completer / auto suggestion / validation coroutines.
self._async_suggester = self._create_auto_suggest_coroutine()
self._async_completer = self._create_completer_coroutine()
self._async_validator = self._create_auto_validate_coroutine()
+
+ # Asyncio task for populating the history.
self._load_history_task: asyncio.Future[None] | None = None
+
+ # Reset other attributes.
self.reset(document=document)
- def __repr__(self) ->str:
+ def __repr__(self) -> str:
if len(self.text) < 15:
text = self.text
else:
- text = self.text[:12] + '...'
- return f'<Buffer(name={self.name!r}, text={text!r}) at {id(self)!r}>'
+ text = self.text[:12] + "..."
+
+ return f"<Buffer(name={self.name!r}, text={text!r}) at {id(self)!r}>"
- def reset(self, document: (Document | None)=None, append_to_history:
- bool=False) ->None:
+ def reset(
+ self, document: Document | None = None, append_to_history: bool = False
+ ) -> None:
"""
:param append_to_history: Append current input to history first.
"""
- pass
+ if append_to_history:
+ self.append_to_history()
+
+ document = document or Document()
+
+ self.__cursor_position = document.cursor_position
+
+ # `ValidationError` instance. (Will be set when the input is wrong.)
+ self.validation_error: ValidationError | None = None
+ self.validation_state: ValidationState | None = ValidationState.UNKNOWN
- def load_history_if_not_yet_loaded(self) ->None:
+ # State of the selection.
+ self.selection_state: SelectionState | None = None
+
+ # Multiple cursor mode. (When we press 'I' or 'A' in visual-block mode,
+ # we can insert text on multiple lines at once. This is implemented by
+ # using multiple cursors.)
+ self.multiple_cursor_positions: list[int] = []
+
+ # When doing consecutive up/down movements, prefer to stay at this column.
+ self.preferred_column: int | None = None
+
+ # State of complete browser
+ # For interactive completion through Ctrl-N/Ctrl-P.
+ self.complete_state: CompletionState | None = None
+
+ # State of Emacs yank-nth-arg completion.
+ self.yank_nth_arg_state: YankNthArgState | None = None # for yank-nth-arg.
+
+ # Remember the document that we had *right before* the last paste
+ # operation. This is used for rotating through the kill ring.
+ self.document_before_paste: Document | None = None
+
+ # Current suggestion.
+ self.suggestion: Suggestion | None = None
+
+ # The history search text. (Used for filtering the history when we
+ # browse through it.)
+ self.history_search_text: str | None = None
+
+ # Undo/redo stacks (stack of `(text, cursor_position)`).
+ self._undo_stack: list[tuple[str, int]] = []
+ self._redo_stack: list[tuple[str, int]] = []
+
+ # Cancel history loader. If history loading was still ongoing.
+ # Cancel the `_load_history_task`, so that next repaint of the
+ # `BufferControl` we will repopulate it.
+ if self._load_history_task is not None:
+ self._load_history_task.cancel()
+ self._load_history_task = None
+
+ #: The working lines. Similar to history, except that this can be
+ #: modified. The user can press arrow_up and edit previous entries.
+ #: Ctrl-C should reset this, and copy the whole history back in here.
+ #: Enter should process the current command and append to the real
+ #: history.
+ self._working_lines: deque[str] = deque([document.text])
+ self.__working_index = 0
+
+ def load_history_if_not_yet_loaded(self) -> None:
"""
Create task for populating the buffer history (if not yet done).
@@ -249,52 +391,192 @@ class Buffer:
thread, but history loading is the only place where it matters, and
this solves it.
"""
- pass
-
- def _set_text(self, value: str) ->bool:
+ if self._load_history_task is None:
+
+ async def load_history() -> None:
+ async for item in self.history.load():
+ self._working_lines.appendleft(item)
+ self.__working_index += 1
+
+ self._load_history_task = get_app().create_background_task(load_history())
+
+ def load_history_done(f: asyncio.Future[None]) -> None:
+ """
+ Handle `load_history` result when either done, cancelled, or
+ when an exception was raised.
+ """
+ try:
+ f.result()
+ except asyncio.CancelledError:
+ # Ignore cancellation. But handle it, so that we don't get
+ # this traceback.
+ pass
+ except GeneratorExit:
+ # Probably not needed, but we had situations where
+ # `GeneratorExit` was raised in `load_history` during
+ # cancellation.
+ pass
+ except BaseException:
+ # Log error if something goes wrong. (We don't have a
+ # caller to which we can propagate this exception.)
+ logger.exception("Loading history failed")
+
+ self._load_history_task.add_done_callback(load_history_done)
+
+ # <getters/setters>
+
+ def _set_text(self, value: str) -> bool:
"""set text at current working_index. Return whether it changed."""
- pass
-
- def _set_cursor_position(self, value: int) ->bool:
+ working_index = self.working_index
+ working_lines = self._working_lines
+
+ original_value = working_lines[working_index]
+ working_lines[working_index] = value
+
+ # Return True when this text has been changed.
+ if len(value) != len(original_value):
+ # For Python 2, it seems that when two strings have a different
+ # length and one is a prefix of the other, Python still scans
+ # character by character to see whether the strings are different.
+ # (Some benchmarking showed significant differences for big
+ # documents. >100,000 of lines.)
+ return True
+ elif value != original_value:
+ return True
+ return False
+
+ def _set_cursor_position(self, value: int) -> bool:
"""Set cursor position. Return whether it changed."""
- pass
+ original_position = self.__cursor_position
+ self.__cursor_position = max(0, value)
+
+ return self.__cursor_position != original_position
+
+ @property
+ def text(self) -> str:
+ return self._working_lines[self.working_index]
@text.setter
- def text(self, value: str) ->None:
+ def text(self, value: str) -> None:
"""
Setting text. (When doing this, make sure that the cursor_position is
valid for this text. text/cursor_position should be consistent at any time,
otherwise set a Document instead.)
"""
- pass
+ # Ensure cursor position remains within the size of the text.
+ if self.cursor_position > len(value):
+ self.cursor_position = len(value)
+
+ # Don't allow editing of read-only buffers.
+ if self.read_only():
+ raise EditReadOnlyBuffer()
+
+ changed = self._set_text(value)
+
+ if changed:
+ self._text_changed()
+
+ # Reset history search text.
+ # (Note that this doesn't need to happen when working_index
+ # changes, which is when we traverse the history. That's why we
+ # don't do this in `self._text_changed`.)
+ self.history_search_text = None
+
+ @property
+ def cursor_position(self) -> int:
+ return self.__cursor_position
@cursor_position.setter
- def cursor_position(self, value: int) ->None:
+ def cursor_position(self, value: int) -> None:
"""
Setting cursor position.
"""
- pass
+ assert isinstance(value, int)
+
+ # Ensure cursor position is within the size of the text.
+ if value > len(self.text):
+ value = len(self.text)
+ if value < 0:
+ value = 0
+
+ changed = self._set_cursor_position(value)
+
+ if changed:
+ self._cursor_position_changed()
+
+ @property
+ def working_index(self) -> int:
+ return self.__working_index
+
+ @working_index.setter
+ def working_index(self, value: int) -> None:
+ if self.__working_index != value:
+ self.__working_index = value
+ # Make sure to reset the cursor position, otherwise we end up in
+ # situations where the cursor position is out of the bounds of the
+ # text.
+ self.cursor_position = 0
+ self._text_changed()
+
+ def _text_changed(self) -> None:
+ # Remove any validation errors and complete state.
+ self.validation_error = None
+ self.validation_state = ValidationState.UNKNOWN
+ self.complete_state = None
+ self.yank_nth_arg_state = None
+ self.document_before_paste = None
+ self.selection_state = None
+ self.suggestion = None
+ self.preferred_column = None
+
+ # fire 'on_text_changed' event.
+ self.on_text_changed.fire()
+
+ # Input validation.
+ # (This happens on all change events, unlike auto completion, also when
+ # deleting text.)
+ if self.validator and self.validate_while_typing():
+ get_app().create_background_task(self._async_validator())
+
+ def _cursor_position_changed(self) -> None:
+ # Remove any complete state.
+ # (Input validation should only be undone when the cursor position
+ # changes.)
+ self.complete_state = None
+ self.yank_nth_arg_state = None
+ self.document_before_paste = None
+
+ # Unset preferred_column. (Will be set after the cursor movement, if
+ # required.)
+ self.preferred_column = None
+
+ # Note that the cursor position can change if we have a selection the
+ # new position of the cursor determines the end of the selection.
+
+ # fire 'on_cursor_position_changed' event.
+ self.on_cursor_position_changed.fire()
@property
- def document(self) ->Document:
+ def document(self) -> Document:
"""
Return :class:`~prompt_toolkit.document.Document` instance from the
current text, cursor position and selection state.
"""
- pass
+ return self._document_cache[
+ self.text, self.cursor_position, self.selection_state
+ ]
@document.setter
- def document(self, value: Document) ->None:
+ def document(self, value: Document) -> None:
"""
Set :class:`~prompt_toolkit.document.Document` instance.
This will set both the text and cursor position at the same time, but
atomically. (Change events will be triggered only after both have been set.)
"""
- pass
+ self.set_document(value)
- def set_document(self, value: Document, bypass_readonly: bool=False
- ) ->None:
+ def set_document(self, value: Document, bypass_readonly: bool = False) -> None:
"""
Set :class:`~prompt_toolkit.document.Document` instance. Like the
``document`` property, but accept an ``bypass_readonly`` argument.
@@ -312,24 +594,53 @@ class Buffer:
you expect, and there won't be a stack trace. Use try/finally
around this function if you need some cleanup code.
"""
- pass
+ # Don't allow editing of read-only buffers.
+ if not bypass_readonly and self.read_only():
+ raise EditReadOnlyBuffer()
+
+ # Set text and cursor position first.
+ text_changed = self._set_text(value.text)
+ cursor_position_changed = self._set_cursor_position(value.cursor_position)
+
+ # Now handle change events. (We do this when text/cursor position is
+ # both set and consistent.)
+ if text_changed:
+ self._text_changed()
+ self.history_search_text = None
+
+ if cursor_position_changed:
+ self._cursor_position_changed()
@property
- def is_returnable(self) ->bool:
+ def is_returnable(self) -> bool:
"""
True when there is something handling accept.
"""
- pass
+ return bool(self.accept_handler)
- def save_to_undo_stack(self, clear_redo_stack: bool=True) ->None:
+ # End of <getters/setters>
+
+ def save_to_undo_stack(self, clear_redo_stack: bool = True) -> None:
"""
Safe current state (input text and cursor position), so that we can
restore it by calling undo.
"""
- pass
+ # Safe if the text is different from the text at the top of the stack
+ # is different. If the text is the same, just update the cursor position.
+ if self._undo_stack and self._undo_stack[-1][0] == self.text:
+ self._undo_stack[-1] = (self._undo_stack[-1][0], self.cursor_position)
+ else:
+ self._undo_stack.append((self.text, self.cursor_position))
+
+ # Saving anything to the undo stack, clears the redo stack.
+ if clear_redo_stack:
+ self._redo_stack = []
- def transform_lines(self, line_index_iterator: Iterable[int],
- transform_callback: Callable[[str], str]) ->str:
+ def transform_lines(
+ self,
+ line_index_iterator: Iterable[int],
+ transform_callback: Callable[[str], str],
+ ) -> str:
"""
Transforms the text on a range of lines.
When the iterator yield an index not in the range of lines that the
@@ -345,19 +656,36 @@ class Buffer:
:returns: The new text.
"""
- pass
+ # Split lines
+ lines = self.text.split("\n")
- def transform_current_line(self, transform_callback: Callable[[str], str]
- ) ->None:
+ # Apply transformation
+ for index in line_index_iterator:
+ try:
+ lines[index] = transform_callback(lines[index])
+ except IndexError:
+ pass
+
+ return "\n".join(lines)
+
+ def transform_current_line(self, transform_callback: Callable[[str], str]) -> None:
"""
Apply the given transformation function to the current line.
:param transform_callback: callable that takes a string and return a new string.
"""
- pass
+ document = self.document
+ a = document.cursor_position + document.get_start_of_line_position()
+ b = document.cursor_position + document.get_end_of_line_position()
+ self.text = (
+ document.text[:a]
+ + transform_callback(document.text[a:b])
+ + document.text[b:]
+ )
- def transform_region(self, from_: int, to: int, transform_callback:
- Callable[[str], str]) ->None:
+ def transform_region(
+ self, from_: int, to: int, transform_callback: Callable[[str], str]
+ ) -> None:
"""
Transform a part of the input string.
@@ -366,150 +694,374 @@ class Buffer:
:param transform_callback: Callable which accepts a string and returns
the transformed string.
"""
- pass
+ assert from_ < to
+
+ self.text = "".join(
+ [
+ self.text[:from_]
+ + transform_callback(self.text[from_:to])
+ + self.text[to:]
+ ]
+ )
- def cursor_up(self, count: int=1) ->None:
+ def cursor_left(self, count: int = 1) -> None:
+ self.cursor_position += self.document.get_cursor_left_position(count=count)
+
+ def cursor_right(self, count: int = 1) -> None:
+ self.cursor_position += self.document.get_cursor_right_position(count=count)
+
+ def cursor_up(self, count: int = 1) -> None:
"""(for multiline edit). Move cursor to the previous line."""
- pass
+ original_column = self.preferred_column or self.document.cursor_position_col
+ self.cursor_position += self.document.get_cursor_up_position(
+ count=count, preferred_column=original_column
+ )
+
+ # Remember the original column for the next up/down movement.
+ self.preferred_column = original_column
- def cursor_down(self, count: int=1) ->None:
+ def cursor_down(self, count: int = 1) -> None:
"""(for multiline edit). Move cursor to the next line."""
- pass
+ original_column = self.preferred_column or self.document.cursor_position_col
+ self.cursor_position += self.document.get_cursor_down_position(
+ count=count, preferred_column=original_column
+ )
- def auto_up(self, count: int=1, go_to_start_of_line_if_history_changes:
- bool=False) ->None:
+ # Remember the original column for the next up/down movement.
+ self.preferred_column = original_column
+
+ def auto_up(
+ self, count: int = 1, go_to_start_of_line_if_history_changes: bool = False
+ ) -> None:
"""
If we're not on the first line (of a multiline input) go a line up,
otherwise go back in history. (If nothing is selected.)
"""
- pass
+ if self.complete_state:
+ self.complete_previous(count=count)
+ elif self.document.cursor_position_row > 0:
+ self.cursor_up(count=count)
+ elif not self.selection_state:
+ self.history_backward(count=count)
+
+ # Go to the start of the line?
+ if go_to_start_of_line_if_history_changes:
+ self.cursor_position += self.document.get_start_of_line_position()
- def auto_down(self, count: int=1,
- go_to_start_of_line_if_history_changes: bool=False) ->None:
+ def auto_down(
+ self, count: int = 1, go_to_start_of_line_if_history_changes: bool = False
+ ) -> None:
"""
If we're not on the last line (of a multiline input) go a line down,
otherwise go forward in history. (If nothing is selected.)
"""
- pass
+ if self.complete_state:
+ self.complete_next(count=count)
+ elif self.document.cursor_position_row < self.document.line_count - 1:
+ self.cursor_down(count=count)
+ elif not self.selection_state:
+ self.history_forward(count=count)
+
+ # Go to the start of the line?
+ if go_to_start_of_line_if_history_changes:
+ self.cursor_position += self.document.get_start_of_line_position()
- def delete_before_cursor(self, count: int=1) ->str:
+ def delete_before_cursor(self, count: int = 1) -> str:
"""
Delete specified number of characters before cursor and return the
deleted text.
"""
- pass
+ assert count >= 0
+ deleted = ""
- def delete(self, count: int=1) ->str:
+ if self.cursor_position > 0:
+ deleted = self.text[self.cursor_position - count : self.cursor_position]
+
+ new_text = (
+ self.text[: self.cursor_position - count]
+ + self.text[self.cursor_position :]
+ )
+ new_cursor_position = self.cursor_position - len(deleted)
+
+ # Set new Document atomically.
+ self.document = Document(new_text, new_cursor_position)
+
+ return deleted
+
+ def delete(self, count: int = 1) -> str:
"""
Delete specified number of characters and Return the deleted text.
"""
- pass
+ if self.cursor_position < len(self.text):
+ deleted = self.document.text_after_cursor[:count]
+ self.text = (
+ self.text[: self.cursor_position]
+ + self.text[self.cursor_position + len(deleted) :]
+ )
+ return deleted
+ else:
+ return ""
- def join_next_line(self, separator: str=' ') ->None:
+ def join_next_line(self, separator: str = " ") -> None:
"""
Join the next line to the current one by deleting the line ending after
the current line.
"""
- pass
+ if not self.document.on_last_line:
+ self.cursor_position += self.document.get_end_of_line_position()
+ self.delete()
+
+ # Remove spaces.
+ self.text = (
+ self.document.text_before_cursor
+ + separator
+ + self.document.text_after_cursor.lstrip(" ")
+ )
- def join_selected_lines(self, separator: str=' ') ->None:
+ def join_selected_lines(self, separator: str = " ") -> None:
"""
Join the selected lines.
"""
- pass
+ assert self.selection_state
- def swap_characters_before_cursor(self) ->None:
+ # Get lines.
+ from_, to = sorted(
+ [self.cursor_position, self.selection_state.original_cursor_position]
+ )
+
+ before = self.text[:from_]
+ lines = self.text[from_:to].splitlines()
+ after = self.text[to:]
+
+ # Replace leading spaces with just one space.
+ lines = [l.lstrip(" ") + separator for l in lines]
+
+ # Set new document.
+ self.document = Document(
+ text=before + "".join(lines) + after,
+ cursor_position=len(before + "".join(lines[:-1])) - 1,
+ )
+
+ def swap_characters_before_cursor(self) -> None:
"""
Swap the last two characters before the cursor.
"""
- pass
+ pos = self.cursor_position
+
+ if pos >= 2:
+ a = self.text[pos - 2]
+ b = self.text[pos - 1]
+
+ self.text = self.text[: pos - 2] + b + a + self.text[pos:]
- def go_to_history(self, index: int) ->None:
+ def go_to_history(self, index: int) -> None:
"""
Go to this item in the history.
"""
- pass
+ if index < len(self._working_lines):
+ self.working_index = index
+ self.cursor_position = len(self.text)
- def complete_next(self, count: int=1, disable_wrap_around: bool=False
- ) ->None:
+ def complete_next(self, count: int = 1, disable_wrap_around: bool = False) -> None:
"""
Browse to the next completions.
(Does nothing if there are no completion.)
"""
- pass
+ index: int | None
- def complete_previous(self, count: int=1, disable_wrap_around: bool=False
- ) ->None:
+ if self.complete_state:
+ completions_count = len(self.complete_state.completions)
+
+ if self.complete_state.complete_index is None:
+ index = 0
+ elif self.complete_state.complete_index == completions_count - 1:
+ index = None
+
+ if disable_wrap_around:
+ return
+ else:
+ index = min(
+ completions_count - 1, self.complete_state.complete_index + count
+ )
+ self.go_to_completion(index)
+
+ def complete_previous(
+ self, count: int = 1, disable_wrap_around: bool = False
+ ) -> None:
"""
Browse to the previous completions.
(Does nothing if there are no completion.)
"""
- pass
+ index: int | None
+
+ if self.complete_state:
+ if self.complete_state.complete_index == 0:
+ index = None
+
+ if disable_wrap_around:
+ return
+ elif self.complete_state.complete_index is None:
+ index = len(self.complete_state.completions) - 1
+ else:
+ index = max(0, self.complete_state.complete_index - count)
+
+ self.go_to_completion(index)
- def cancel_completion(self) ->None:
+ def cancel_completion(self) -> None:
"""
Cancel completion, go back to the original text.
"""
- pass
+ if self.complete_state:
+ self.go_to_completion(None)
+ self.complete_state = None
- def _set_completions(self, completions: list[Completion]
- ) ->CompletionState:
+ def _set_completions(self, completions: list[Completion]) -> CompletionState:
"""
Start completions. (Generate list of completions and initialize.)
By default, no completion will be selected.
"""
- pass
+ self.complete_state = CompletionState(
+ original_document=self.document, completions=completions
+ )
- def start_history_lines_completion(self) ->None:
+ # Trigger event. This should eventually invalidate the layout.
+ self.on_completions_changed.fire()
+
+ return self.complete_state
+
+ def start_history_lines_completion(self) -> None:
"""
Start a completion based on all the other lines in the document and the
history.
"""
- pass
+ found_completions: set[str] = set()
+ completions = []
+
+ # For every line of the whole history, find matches with the current line.
+ current_line = self.document.current_line_before_cursor.lstrip()
+
+ for i, string in enumerate(self._working_lines):
+ for j, l in enumerate(string.split("\n")):
+ l = l.strip()
+ if l and l.startswith(current_line):
+ # When a new line has been found.
+ if l not in found_completions:
+ found_completions.add(l)
+
+ # Create completion.
+ if i == self.working_index:
+ display_meta = "Current, line %s" % (j + 1)
+ else:
+ display_meta = f"History {i + 1}, line {j + 1}"
+
+ completions.append(
+ Completion(
+ text=l,
+ start_position=-len(current_line),
+ display_meta=display_meta,
+ )
+ )
- def go_to_completion(self, index: (int | None)) ->None:
+ self._set_completions(completions=completions[::-1])
+ self.go_to_completion(0)
+
+ def go_to_completion(self, index: int | None) -> None:
"""
Select a completion from the list of current completions.
"""
- pass
+ assert self.complete_state
+
+ # Set new completion
+ state = self.complete_state
+ state.go_to_index(index)
+
+ # Set text/cursor position
+ new_text, new_cursor_position = state.new_text_and_position()
+ self.document = Document(new_text, new_cursor_position)
- def apply_completion(self, completion: Completion) ->None:
+ # (changing text/cursor position will unset complete_state.)
+ self.complete_state = state
+
+ def apply_completion(self, completion: Completion) -> None:
"""
Insert a given completion.
"""
- pass
+ # If there was already a completion active, cancel that one.
+ if self.complete_state:
+ self.go_to_completion(None)
+ self.complete_state = None
+
+ # Insert text from the given completion.
+ self.delete_before_cursor(-completion.start_position)
+ self.insert_text(completion.text)
- def _set_history_search(self) ->None:
+ def _set_history_search(self) -> None:
"""
Set `history_search_text`.
(The text before the cursor will be used for filtering the history.)
"""
- pass
+ if self.enable_history_search():
+ if self.history_search_text is None:
+ self.history_search_text = self.document.text_before_cursor
+ else:
+ self.history_search_text = None
- def _history_matches(self, i: int) ->bool:
+ def _history_matches(self, i: int) -> bool:
"""
True when the current entry matches the history search.
(when we don't have history search, it's also True.)
"""
- pass
+ return self.history_search_text is None or self._working_lines[i].startswith(
+ self.history_search_text
+ )
- def history_forward(self, count: int=1) ->None:
+ def history_forward(self, count: int = 1) -> None:
"""
Move forwards through the history.
:param count: Amount of items to move forward.
"""
- pass
+ self._set_history_search()
+
+ # Go forward in history.
+ found_something = False
+
+ for i in range(self.working_index + 1, len(self._working_lines)):
+ if self._history_matches(i):
+ self.working_index = i
+ count -= 1
+ found_something = True
+ if count == 0:
+ break
- def history_backward(self, count: int=1) ->None:
+ # If we found an entry, move cursor to the end of the first line.
+ if found_something:
+ self.cursor_position = 0
+ self.cursor_position += self.document.get_end_of_line_position()
+
+ def history_backward(self, count: int = 1) -> None:
"""
Move backwards through history.
"""
- pass
+ self._set_history_search()
+
+ # Go back in history.
+ found_something = False
+
+ for i in range(self.working_index - 1, -1, -1):
+ if self._history_matches(i):
+ self.working_index = i
+ count -= 1
+ found_something = True
+ if count == 0:
+ break
- def yank_nth_arg(self, n: (int | None)=None, _yank_last_arg: bool=False
- ) ->None:
+ # If we move to another entry, move cursor to the end of the line.
+ if found_something:
+ self.cursor_position = len(self.text)
+
+ def yank_nth_arg(self, n: int | None = None, _yank_last_arg: bool = False) -> None:
"""
Pick nth word from previous history entry (depending on current
`yank_nth_arg_state`) and insert it at current position. Rotate through
@@ -519,23 +1071,63 @@ class Buffer:
:param n: (None or int), The index of the word from the previous line
to take.
"""
- pass
+ assert n is None or isinstance(n, int)
+ history_strings = self.history.get_strings()
+
+ if not len(history_strings):
+ return
+
+ # Make sure we have a `YankNthArgState`.
+ if self.yank_nth_arg_state is None:
+ state = YankNthArgState(n=-1 if _yank_last_arg else 1)
+ else:
+ state = self.yank_nth_arg_state
+
+ if n is not None:
+ state.n = n
+
+ # Get new history position.
+ new_pos = state.history_position - 1
+ if -new_pos > len(history_strings):
+ new_pos = -1
- def yank_last_arg(self, n: (int | None)=None) ->None:
+ # Take argument from line.
+ line = history_strings[new_pos]
+
+ words = [w.strip() for w in _QUOTED_WORDS_RE.split(line)]
+ words = [w for w in words if w]
+ try:
+ word = words[state.n]
+ except IndexError:
+ word = ""
+
+ # Insert new argument.
+ if state.previous_inserted_word:
+ self.delete_before_cursor(len(state.previous_inserted_word))
+ self.insert_text(word)
+
+ # Save state again for next completion. (Note that the 'insert'
+ # operation from above clears `self.yank_nth_arg_state`.)
+ state.previous_inserted_word = word
+ state.history_position = new_pos
+ self.yank_nth_arg_state = state
+
+ def yank_last_arg(self, n: int | None = None) -> None:
"""
Like `yank_nth_arg`, but if no argument has been given, yank the last
word by default.
"""
- pass
+ self.yank_nth_arg(n=n, _yank_last_arg=True)
- def start_selection(self, selection_type: SelectionType=SelectionType.
- CHARACTERS) ->None:
+ def start_selection(
+ self, selection_type: SelectionType = SelectionType.CHARACTERS
+ ) -> None:
"""
Take the current cursor position as the start of this selection.
"""
- pass
+ self.selection_state = SelectionState(self.cursor_position, selection_type)
- def copy_selection(self, _cut: bool=False) ->ClipboardData:
+ def copy_selection(self, _cut: bool = False) -> ClipboardData:
"""
Copy selected text and return :class:`.ClipboardData` instance.
@@ -547,58 +1139,183 @@ class Buffer:
data = buffer.copy_selection()
get_app().clipboard.set_data(data)
"""
- pass
+ new_document, clipboard_data = self.document.cut_selection()
+ if _cut:
+ self.document = new_document
+
+ self.selection_state = None
+ return clipboard_data
- def cut_selection(self) ->ClipboardData:
+ def cut_selection(self) -> ClipboardData:
"""
Delete selected text and return :class:`.ClipboardData` instance.
"""
- pass
+ return self.copy_selection(_cut=True)
- def paste_clipboard_data(self, data: ClipboardData, paste_mode:
- PasteMode=PasteMode.EMACS, count: int=1) ->None:
+ def paste_clipboard_data(
+ self,
+ data: ClipboardData,
+ paste_mode: PasteMode = PasteMode.EMACS,
+ count: int = 1,
+ ) -> None:
"""
Insert the data from the clipboard.
"""
- pass
+ assert isinstance(data, ClipboardData)
+ assert paste_mode in (PasteMode.VI_BEFORE, PasteMode.VI_AFTER, PasteMode.EMACS)
+
+ original_document = self.document
+ self.document = self.document.paste_clipboard_data(
+ data, paste_mode=paste_mode, count=count
+ )
- def newline(self, copy_margin: bool=True) ->None:
+ # Remember original document. This assignment should come at the end,
+ # because assigning to 'document' will erase it.
+ self.document_before_paste = original_document
+
+ def newline(self, copy_margin: bool = True) -> None:
"""
Insert a line ending at the current position.
"""
- pass
+ if copy_margin:
+ self.insert_text("\n" + self.document.leading_whitespace_in_current_line)
+ else:
+ self.insert_text("\n")
- def insert_line_above(self, copy_margin: bool=True) ->None:
+ def insert_line_above(self, copy_margin: bool = True) -> None:
"""
Insert a new line above the current one.
"""
- pass
+ if copy_margin:
+ insert = self.document.leading_whitespace_in_current_line + "\n"
+ else:
+ insert = "\n"
- def insert_line_below(self, copy_margin: bool=True) ->None:
+ self.cursor_position += self.document.get_start_of_line_position()
+ self.insert_text(insert)
+ self.cursor_position -= 1
+
+ def insert_line_below(self, copy_margin: bool = True) -> None:
"""
Insert a new line below the current one.
"""
- pass
+ if copy_margin:
+ insert = "\n" + self.document.leading_whitespace_in_current_line
+ else:
+ insert = "\n"
+
+ self.cursor_position += self.document.get_end_of_line_position()
+ self.insert_text(insert)
- def insert_text(self, data: str, overwrite: bool=False, move_cursor:
- bool=True, fire_event: bool=True) ->None:
+ def insert_text(
+ self,
+ data: str,
+ overwrite: bool = False,
+ move_cursor: bool = True,
+ fire_event: bool = True,
+ ) -> None:
"""
Insert characters at cursor position.
:param fire_event: Fire `on_text_insert` event. This is mainly used to
trigger autocompletion while typing.
"""
- pass
+ # Original text & cursor position.
+ otext = self.text
+ ocpos = self.cursor_position
- def validate(self, set_cursor: bool=False) ->bool:
+ # In insert/text mode.
+ if overwrite:
+ # Don't overwrite the newline itself. Just before the line ending,
+ # it should act like insert mode.
+ overwritten_text = otext[ocpos : ocpos + len(data)]
+ if "\n" in overwritten_text:
+ overwritten_text = overwritten_text[: overwritten_text.find("\n")]
+
+ text = otext[:ocpos] + data + otext[ocpos + len(overwritten_text) :]
+ else:
+ text = otext[:ocpos] + data + otext[ocpos:]
+
+ if move_cursor:
+ cpos = self.cursor_position + len(data)
+ else:
+ cpos = self.cursor_position
+
+ # Set new document.
+ # (Set text and cursor position at the same time. Otherwise, setting
+ # the text will fire a change event before the cursor position has been
+ # set. It works better to have this atomic.)
+ self.document = Document(text, cpos)
+
+ # Fire 'on_text_insert' event.
+ if fire_event: # XXX: rename to `start_complete`.
+ self.on_text_insert.fire()
+
+ # Only complete when "complete_while_typing" is enabled.
+ if self.completer and self.complete_while_typing():
+ get_app().create_background_task(self._async_completer())
+
+ # Call auto_suggest.
+ if self.auto_suggest:
+ get_app().create_background_task(self._async_suggester())
+
+ def undo(self) -> None:
+ # Pop from the undo-stack until we find a text that if different from
+ # the current text. (The current logic of `save_to_undo_stack` will
+ # cause that the top of the undo stack is usually the same as the
+ # current text, so in that case we have to pop twice.)
+ while self._undo_stack:
+ text, pos = self._undo_stack.pop()
+
+ if text != self.text:
+ # Push current text to redo stack.
+ self._redo_stack.append((self.text, self.cursor_position))
+
+ # Set new text/cursor_position.
+ self.document = Document(text, cursor_position=pos)
+ break
+
+ def redo(self) -> None:
+ if self._redo_stack:
+ # Copy current state on undo stack.
+ self.save_to_undo_stack(clear_redo_stack=False)
+
+ # Pop state from redo stack.
+ text, pos = self._redo_stack.pop()
+ self.document = Document(text, cursor_position=pos)
+
+ def validate(self, set_cursor: bool = False) -> bool:
"""
Returns `True` if valid.
:param set_cursor: Set the cursor position, if an error was found.
"""
- pass
-
- async def _validate_async(self) ->None:
+ # Don't call the validator again, if it was already called for the
+ # current input.
+ if self.validation_state != ValidationState.UNKNOWN:
+ return self.validation_state == ValidationState.VALID
+
+ # Call validator.
+ if self.validator:
+ try:
+ self.validator.validate(self.document)
+ except ValidationError as e:
+ # Set cursor position (don't allow invalid values.)
+ if set_cursor:
+ self.cursor_position = min(
+ max(0, e.cursor_position), len(self.text)
+ )
+
+ self.validation_state = ValidationState.INVALID
+ self.validation_error = e
+ return False
+
+ # Handle validation result.
+ self.validation_state = ValidationState.VALID
+ self.validation_error = None
+ return True
+
+ async def _validate_async(self) -> None:
"""
Asynchronous version of `validate()`.
This one doesn't set the cursor position.
@@ -611,23 +1328,135 @@ class Buffer:
An asynchronous version is required if we have `validate_while_typing`
enabled.
"""
- pass
+ while True:
+ # Don't call the validator again, if it was already called for the
+ # current input.
+ if self.validation_state != ValidationState.UNKNOWN:
+ return
+
+ # Call validator.
+ error = None
+ document = self.document
+
+ if self.validator:
+ try:
+ await self.validator.validate_async(self.document)
+ except ValidationError as e:
+ error = e
+
+ # If the document changed during the validation, try again.
+ if self.document != document:
+ continue
- def append_to_history(self) ->None:
+ # Handle validation result.
+ if error:
+ self.validation_state = ValidationState.INVALID
+ else:
+ self.validation_state = ValidationState.VALID
+
+ self.validation_error = error
+ get_app().invalidate() # Trigger redraw (display error).
+
+ def append_to_history(self) -> None:
"""
Append the current input to the history.
"""
- pass
+ # Save at the tail of the history. (But don't if the last entry the
+ # history is already the same.)
+ if self.text:
+ history_strings = self.history.get_strings()
+ if not len(history_strings) or history_strings[-1] != self.text:
+ self.history.append_string(self.text)
- def _search(self, search_state: SearchState, include_current_position:
- bool=False, count: int=1) ->(tuple[int, int] | None):
+ def _search(
+ self,
+ search_state: SearchState,
+ include_current_position: bool = False,
+ count: int = 1,
+ ) -> tuple[int, int] | None:
"""
Execute search. Return (working_index, cursor_position) tuple when this
search is applied. Returns `None` when this text cannot be found.
"""
- pass
-
- def document_for_search(self, search_state: SearchState) ->Document:
+ assert count > 0
+
+ text = search_state.text
+ direction = search_state.direction
+ ignore_case = search_state.ignore_case()
+
+ def search_once(
+ working_index: int, document: Document
+ ) -> tuple[int, Document] | None:
+ """
+ Do search one time.
+ Return (working_index, document) or `None`
+ """
+ if direction == SearchDirection.FORWARD:
+ # Try find at the current input.
+ new_index = document.find(
+ text,
+ include_current_position=include_current_position,
+ ignore_case=ignore_case,
+ )
+
+ if new_index is not None:
+ return (
+ working_index,
+ Document(document.text, document.cursor_position + new_index),
+ )
+ else:
+ # No match, go forward in the history. (Include len+1 to wrap around.)
+ # (Here we should always include all cursor positions, because
+ # it's a different line.)
+ for i in range(working_index + 1, len(self._working_lines) + 1):
+ i %= len(self._working_lines)
+
+ document = Document(self._working_lines[i], 0)
+ new_index = document.find(
+ text, include_current_position=True, ignore_case=ignore_case
+ )
+ if new_index is not None:
+ return (i, Document(document.text, new_index))
+ else:
+ # Try find at the current input.
+ new_index = document.find_backwards(text, ignore_case=ignore_case)
+
+ if new_index is not None:
+ return (
+ working_index,
+ Document(document.text, document.cursor_position + new_index),
+ )
+ else:
+ # No match, go back in the history. (Include -1 to wrap around.)
+ for i in range(working_index - 1, -2, -1):
+ i %= len(self._working_lines)
+
+ document = Document(
+ self._working_lines[i], len(self._working_lines[i])
+ )
+ new_index = document.find_backwards(
+ text, ignore_case=ignore_case
+ )
+ if new_index is not None:
+ return (
+ i,
+ Document(document.text, len(document.text) + new_index),
+ )
+ return None
+
+ # Do 'count' search iterations.
+ working_index = self.working_index
+ document = self.document
+ for _ in range(count):
+ result = search_once(working_index, document)
+ if result is None:
+ return None # Nothing found.
+ else:
+ working_index, document = result
+
+ return (working_index, document.cursor_position)
+
+ def document_for_search(self, search_state: SearchState) -> Document:
"""
Return a :class:`~prompt_toolkit.document.Document` instance that has
the text/cursor position for this search, if we would apply it. This
@@ -635,95 +1464,437 @@ class Buffer:
:class:`~prompt_toolkit.layout.BufferControl` to display feedback while
searching.
"""
- pass
+ search_result = self._search(search_state, include_current_position=True)
+
+ if search_result is None:
+ return self.document
+ else:
+ working_index, cursor_position = search_result
+
+ # Keep selection, when `working_index` was not changed.
+ if working_index == self.working_index:
+ selection = self.selection_state
+ else:
+ selection = None
- def get_search_position(self, search_state: SearchState,
- include_current_position: bool=True, count: int=1) ->int:
+ return Document(
+ self._working_lines[working_index], cursor_position, selection=selection
+ )
+
+ def get_search_position(
+ self,
+ search_state: SearchState,
+ include_current_position: bool = True,
+ count: int = 1,
+ ) -> int:
"""
Get the cursor position for this search.
(This operation won't change the `working_index`. It's won't go through
the history. Vi text objects can't span multiple items.)
"""
- pass
+ search_result = self._search(
+ search_state, include_current_position=include_current_position, count=count
+ )
+
+ if search_result is None:
+ return self.cursor_position
+ else:
+ working_index, cursor_position = search_result
+ return cursor_position
- def apply_search(self, search_state: SearchState,
- include_current_position: bool=True, count: int=1) ->None:
+ def apply_search(
+ self,
+ search_state: SearchState,
+ include_current_position: bool = True,
+ count: int = 1,
+ ) -> None:
"""
Apply search. If something is found, set `working_index` and
`cursor_position`.
"""
- pass
+ search_result = self._search(
+ search_state, include_current_position=include_current_position, count=count
+ )
- def _editor_simple_tempfile(self) ->tuple[str, Callable[[], None]]:
+ if search_result is not None:
+ working_index, cursor_position = search_result
+ self.working_index = working_index
+ self.cursor_position = cursor_position
+
+ def exit_selection(self) -> None:
+ self.selection_state = None
+
+ def _editor_simple_tempfile(self) -> tuple[str, Callable[[], None]]:
"""
Simple (file) tempfile implementation.
Return (tempfile, cleanup_func).
"""
- pass
+ suffix = to_str(self.tempfile_suffix)
+ descriptor, filename = tempfile.mkstemp(suffix)
+
+ os.write(descriptor, self.text.encode("utf-8"))
+ os.close(descriptor)
+
+ def cleanup() -> None:
+ os.unlink(filename)
+
+ return filename, cleanup
+
+ def _editor_complex_tempfile(self) -> tuple[str, Callable[[], None]]:
+ # Complex (directory) tempfile implementation.
+ headtail = to_str(self.tempfile)
+ if not headtail:
+ # Revert to simple case.
+ return self._editor_simple_tempfile()
+ headtail = str(headtail)
+
+ # Try to make according to tempfile logic.
+ head, tail = os.path.split(headtail)
+ if os.path.isabs(head):
+ head = head[1:]
+
+ dirpath = tempfile.mkdtemp()
+ if head:
+ dirpath = os.path.join(dirpath, head)
+ # Assume there is no issue creating dirs in this temp dir.
+ os.makedirs(dirpath)
+
+ # Open the filename and write current text.
+ filename = os.path.join(dirpath, tail)
+ with open(filename, "w", encoding="utf-8") as fh:
+ fh.write(self.text)
- def open_in_editor(self, validate_and_handle: bool=False) ->asyncio.Task[
- None]:
+ def cleanup() -> None:
+ shutil.rmtree(dirpath)
+
+ return filename, cleanup
+
+ def open_in_editor(self, validate_and_handle: bool = False) -> asyncio.Task[None]:
"""
Open code in editor.
This returns a future, and runs in a thread executor.
"""
- pass
+ if self.read_only():
+ raise EditReadOnlyBuffer()
+
+ # Write current text to temporary file
+ if self.tempfile:
+ filename, cleanup_func = self._editor_complex_tempfile()
+ else:
+ filename, cleanup_func = self._editor_simple_tempfile()
+
+ async def run() -> None:
+ try:
+ # Open in editor
+ # (We need to use `run_in_terminal`, because not all editors go to
+ # the alternate screen buffer, and some could influence the cursor
+ # position.)
+ success = await run_in_terminal(
+ lambda: self._open_file_in_editor(filename), in_executor=True
+ )
+
+ # Read content again.
+ if success:
+ with open(filename, "rb") as f:
+ text = f.read().decode("utf-8")
- def _open_file_in_editor(self, filename: str) ->bool:
+ # Drop trailing newline. (Editors are supposed to add it at the
+ # end, but we don't need it.)
+ if text.endswith("\n"):
+ text = text[:-1]
+
+ self.document = Document(text=text, cursor_position=len(text))
+
+ # Accept the input.
+ if validate_and_handle:
+ self.validate_and_handle()
+
+ finally:
+ # Clean up temp dir/file.
+ cleanup_func()
+
+ return get_app().create_background_task(run())
+
+ def _open_file_in_editor(self, filename: str) -> bool:
"""
Call editor executable.
Return True when we received a zero return code.
"""
- pass
-
- def start_completion(self, select_first: bool=False, select_last: bool=
- False, insert_common_part: bool=False, complete_event: (
- CompleteEvent | None)=None) ->None:
+ # If the 'VISUAL' or 'EDITOR' environment variable has been set, use that.
+ # Otherwise, fall back to the first available editor that we can find.
+ visual = os.environ.get("VISUAL")
+ editor = os.environ.get("EDITOR")
+
+ editors = [
+ visual,
+ editor,
+ # Order of preference.
+ "/usr/bin/editor",
+ "/usr/bin/nano",
+ "/usr/bin/pico",
+ "/usr/bin/vi",
+ "/usr/bin/emacs",
+ ]
+
+ for e in editors:
+ if e:
+ try:
+ # Use 'shlex.split()', because $VISUAL can contain spaces
+ # and quotes.
+ returncode = subprocess.call(shlex.split(e) + [filename])
+ return returncode == 0
+
+ except OSError:
+ # Executable does not exist, try the next one.
+ pass
+
+ return False
+
+ def start_completion(
+ self,
+ select_first: bool = False,
+ select_last: bool = False,
+ insert_common_part: bool = False,
+ complete_event: CompleteEvent | None = None,
+ ) -> None:
"""
Start asynchronous autocompletion of this buffer.
(This will do nothing if a previous completion was still in progress.)
"""
- pass
+ # Only one of these options can be selected.
+ assert select_first + select_last + insert_common_part <= 1
- def _create_completer_coroutine(self) ->Callable[..., Coroutine[Any,
- Any, None]]:
+ get_app().create_background_task(
+ self._async_completer(
+ select_first=select_first,
+ select_last=select_last,
+ insert_common_part=insert_common_part,
+ complete_event=complete_event
+ or CompleteEvent(completion_requested=True),
+ )
+ )
+
+ def _create_completer_coroutine(self) -> Callable[..., Coroutine[Any, Any, None]]:
"""
Create function for asynchronous autocompletion.
(This consumes the asynchronous completer generator, which possibly
runs the completion algorithm in another thread.)
"""
- pass
- def _create_auto_suggest_coroutine(self) ->Callable[[], Coroutine[Any,
- Any, None]]:
+ def completion_does_nothing(document: Document, completion: Completion) -> bool:
+ """
+ Return `True` if applying this completion doesn't have any effect.
+ (When it doesn't insert any new text.
+ """
+ text_before_cursor = document.text_before_cursor
+ replaced_text = text_before_cursor[
+ len(text_before_cursor) + completion.start_position :
+ ]
+ return replaced_text == completion.text
+
+ @_only_one_at_a_time
+ async def async_completer(
+ select_first: bool = False,
+ select_last: bool = False,
+ insert_common_part: bool = False,
+ complete_event: CompleteEvent | None = None,
+ ) -> None:
+ document = self.document
+ complete_event = complete_event or CompleteEvent(text_inserted=True)
+
+ # Don't complete when we already have completions.
+ if self.complete_state or not self.completer:
+ return
+
+ # Create an empty CompletionState.
+ complete_state = CompletionState(original_document=self.document)
+ self.complete_state = complete_state
+
+ def proceed() -> bool:
+ """Keep retrieving completions. Input text has not yet changed
+ while generating completions."""
+ return self.complete_state == complete_state
+
+ refresh_needed = asyncio.Event()
+
+ async def refresh_while_loading() -> None:
+ """Background loop to refresh the UI at most 3 times a second
+ while the completion are loading. Calling
+ `on_completions_changed.fire()` for every completion that we
+ receive is too expensive when there are many completions. (We
+ could tune `Application.max_render_postpone_time` and
+ `Application.min_redraw_interval`, but having this here is a
+ better approach.)
+ """
+ while True:
+ self.on_completions_changed.fire()
+ refresh_needed.clear()
+ await asyncio.sleep(0.3)
+ await refresh_needed.wait()
+
+ refresh_task = asyncio.ensure_future(refresh_while_loading())
+ try:
+ # Load.
+ async with aclosing(
+ self.completer.get_completions_async(document, complete_event)
+ ) as async_generator:
+ async for completion in async_generator:
+ complete_state.completions.append(completion)
+ refresh_needed.set()
+
+ # If the input text changes, abort.
+ if not proceed():
+ break
+ finally:
+ refresh_task.cancel()
+
+ # Refresh one final time after we got everything.
+ self.on_completions_changed.fire()
+
+ completions = complete_state.completions
+
+ # When there is only one completion, which has nothing to add, ignore it.
+ if len(completions) == 1 and completion_does_nothing(
+ document, completions[0]
+ ):
+ del completions[:]
+
+ # Set completions if the text was not yet changed.
+ if proceed():
+ # When no completions were found, or when the user selected
+ # already a completion by using the arrow keys, don't do anything.
+ if (
+ not self.complete_state
+ or self.complete_state.complete_index is not None
+ ):
+ return
+
+ # When there are no completions, reset completion state anyway.
+ if not completions:
+ self.complete_state = None
+ # Render the ui if the completion menu was shown
+ # it is needed especially if there is one completion and it was deleted.
+ self.on_completions_changed.fire()
+ return
+
+ # Select first/last or insert common part, depending on the key
+ # binding. (For this we have to wait until all completions are
+ # loaded.)
+
+ if select_first:
+ self.go_to_completion(0)
+
+ elif select_last:
+ self.go_to_completion(len(completions) - 1)
+
+ elif insert_common_part:
+ common_part = get_common_complete_suffix(document, completions)
+ if common_part:
+ # Insert the common part, update completions.
+ self.insert_text(common_part)
+ if len(completions) > 1:
+ # (Don't call `async_completer` again, but
+ # recalculate completions. See:
+ # https://github.com/ipython/ipython/issues/9658)
+ completions[:] = [
+ c.new_completion_from_position(len(common_part))
+ for c in completions
+ ]
+
+ self._set_completions(completions=completions)
+ else:
+ self.complete_state = None
+ else:
+ # When we were asked to insert the "common"
+ # prefix, but there was no common suffix but
+ # still exactly one match, then select the
+ # first. (It could be that we have a completion
+ # which does * expansion, like '*.py', with
+ # exactly one match.)
+ if len(completions) == 1:
+ self.go_to_completion(0)
+
+ else:
+ # If the last operation was an insert, (not a delete), restart
+ # the completion coroutine.
+
+ if self.document.text_before_cursor == document.text_before_cursor:
+ return # Nothing changed.
+
+ if self.document.text_before_cursor.startswith(
+ document.text_before_cursor
+ ):
+ raise _Retry
+
+ return async_completer
+
+ def _create_auto_suggest_coroutine(self) -> Callable[[], Coroutine[Any, Any, None]]:
"""
Create function for asynchronous auto suggestion.
(This can be in another thread.)
"""
- pass
- def _create_auto_validate_coroutine(self) ->Callable[[], Coroutine[Any,
- Any, None]]:
+ @_only_one_at_a_time
+ async def async_suggestor() -> None:
+ document = self.document
+
+ # Don't suggest when we already have a suggestion.
+ if self.suggestion or not self.auto_suggest:
+ return
+
+ suggestion = await self.auto_suggest.get_suggestion_async(self, document)
+
+ # Set suggestion only if the text was not yet changed.
+ if self.document == document:
+ # Set suggestion and redraw interface.
+ self.suggestion = suggestion
+ self.on_suggestion_set.fire()
+ else:
+ # Otherwise, restart thread.
+ raise _Retry
+
+ return async_suggestor
+
+ def _create_auto_validate_coroutine(
+ self,
+ ) -> Callable[[], Coroutine[Any, Any, None]]:
"""
Create a function for asynchronous validation while typing.
(This can be in another thread.)
"""
- pass
- def validate_and_handle(self) ->None:
+ @_only_one_at_a_time
+ async def async_validator() -> None:
+ await self._validate_async()
+
+ return async_validator
+
+ def validate_and_handle(self) -> None:
"""
Validate buffer and handle the accept action.
"""
- pass
+ valid = self.validate(set_cursor=True)
+
+ # When the validation succeeded, accept the input.
+ if valid:
+ if self.accept_handler:
+ keep_text = self.accept_handler(self)
+ else:
+ keep_text = False
+ self.append_to_history()
-_T = TypeVar('_T', bound=Callable[..., Coroutine[Any, Any, None]])
+ if not keep_text:
+ self.reset()
-def _only_one_at_a_time(coroutine: _T) ->_T:
+_T = TypeVar("_T", bound=Callable[..., Coroutine[Any, Any, None]])
+
+
+def _only_one_at_a_time(coroutine: _T) -> _T:
"""
Decorator that only starts the coroutine only if the previous call has
finished. (Used to make sure that we have only one autocompleter, auto
@@ -731,31 +1902,125 @@ def _only_one_at_a_time(coroutine: _T) ->_T:
When the coroutine raises `_Retry`, it is restarted.
"""
- pass
+ running = False
+
+ @wraps(coroutine)
+ async def new_coroutine(*a: Any, **kw: Any) -> Any:
+ nonlocal running
+
+ # Don't start a new function, if the previous is still in progress.
+ if running:
+ return
+
+ running = True
+
+ try:
+ while True:
+ try:
+ await coroutine(*a, **kw)
+ except _Retry:
+ continue
+ else:
+ return None
+ finally:
+ running = False
+
+ return cast(_T, new_coroutine)
class _Retry(Exception):
- """Retry in `_only_one_at_a_time`."""
+ "Retry in `_only_one_at_a_time`."
-def indent(buffer: Buffer, from_row: int, to_row: int, count: int=1) ->None:
+def indent(buffer: Buffer, from_row: int, to_row: int, count: int = 1) -> None:
"""
Indent text of a :class:`.Buffer` object.
"""
- pass
+ current_row = buffer.document.cursor_position_row
+ current_col = buffer.document.cursor_position_col
+ line_range = range(from_row, to_row)
+
+ # Apply transformation.
+ indent_content = " " * count
+ new_text = buffer.transform_lines(line_range, lambda l: indent_content + l)
+ buffer.document = Document(
+ new_text, Document(new_text).translate_row_col_to_index(current_row, 0)
+ )
+
+ # Place cursor in the same position in text after indenting
+ buffer.cursor_position += current_col + len(indent_content)
-def unindent(buffer: Buffer, from_row: int, to_row: int, count: int=1) ->None:
+def unindent(buffer: Buffer, from_row: int, to_row: int, count: int = 1) -> None:
"""
Unindent text of a :class:`.Buffer` object.
"""
- pass
+ current_row = buffer.document.cursor_position_row
+ current_col = buffer.document.cursor_position_col
+ line_range = range(from_row, to_row)
+
+ indent_content = " " * count
+
+ def transform(text: str) -> str:
+ remove = indent_content
+ if text.startswith(remove):
+ return text[len(remove) :]
+ else:
+ return text.lstrip()
+
+ # Apply transformation.
+ new_text = buffer.transform_lines(line_range, transform)
+ buffer.document = Document(
+ new_text, Document(new_text).translate_row_col_to_index(current_row, 0)
+ )
+
+ # Place cursor in the same position in text after dedent
+ buffer.cursor_position += current_col - len(indent_content)
-def reshape_text(buffer: Buffer, from_row: int, to_row: int) ->None:
+def reshape_text(buffer: Buffer, from_row: int, to_row: int) -> None:
"""
Reformat text, taking the width into account.
`to_row` is included.
(Vi 'gq' operator.)
"""
- pass
+ lines = buffer.text.splitlines(True)
+ lines_before = lines[:from_row]
+ lines_after = lines[to_row + 1 :]
+ lines_to_reformat = lines[from_row : to_row + 1]
+
+ if lines_to_reformat:
+ # Take indentation from the first line.
+ match = re.search(r"^\s*", lines_to_reformat[0])
+ length = match.end() if match else 0 # `match` can't be None, actually.
+
+ indent = lines_to_reformat[0][:length].replace("\n", "")
+
+ # Now, take all the 'words' from the lines to be reshaped.
+ words = "".join(lines_to_reformat).split()
+
+ # And reshape.
+ width = (buffer.text_width or 80) - len(indent)
+ reshaped_text = [indent]
+ current_width = 0
+ for w in words:
+ if current_width:
+ if len(w) + current_width + 1 > width:
+ reshaped_text.append("\n")
+ reshaped_text.append(indent)
+ current_width = 0
+ else:
+ reshaped_text.append(" ")
+ current_width += 1
+
+ reshaped_text.append(w)
+ current_width += len(w)
+
+ if reshaped_text[-1] != "\n":
+ reshaped_text.append("\n")
+
+ # Apply result.
+ buffer.document = Document(
+ text="".join(lines_before + reshaped_text + lines_after),
+ cursor_position=len("".join(lines_before + reshaped_text)),
+ )
diff --git a/src/prompt_toolkit/cache.py b/src/prompt_toolkit/cache.py
index bfff0829..01dd1f79 100644
--- a/src/prompt_toolkit/cache.py
+++ b/src/prompt_toolkit/cache.py
@@ -1,10 +1,17 @@
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')
+
+__all__ = [
+ "SimpleCache",
+ "FastDictCache",
+ "memoized",
+]
+
+_T = TypeVar("_T", bound=Hashable)
+_U = TypeVar("_U")
class SimpleCache(Generic[_T, _U]):
@@ -15,27 +22,44 @@ class SimpleCache(Generic[_T, _U]):
:param maxsize: Maximum size of the cache. (Don't make it too big.)
"""
- def __init__(self, maxsize: int=8) ->None:
+ 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:
+ 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.
"""
- pass
+ # 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)
- def clear(self) ->None:
- """Clear cache."""
- pass
+ # 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
-_K = TypeVar('_K', bound=Tuple[Hashable, ...])
-_V = TypeVar('_V')
+ 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]):
@@ -51,28 +75,53 @@ class FastDictCache(Dict[_K, _V]):
:param get_value: Callable that's called in case of a missing key.
"""
- def __init__(self, get_value: Callable[..., _V], size: int=1000000) ->None:
+ # 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:
+ 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])
+_F = TypeVar("_F", bound=Callable[..., object])
-def memoized(maxsize: int=1024) ->Callable[[_F], _F]:
+def memoized(maxsize: int = 1024) -> Callable[[_F], _F]:
"""
Memoization decorator for immutable classes and pure functions.
"""
- pass
+
+ 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
diff --git a/src/prompt_toolkit/clipboard/base.py b/src/prompt_toolkit/clipboard/base.py
index e09febd7..b05275ba 100644
--- a/src/prompt_toolkit/clipboard/base.py
+++ b/src/prompt_toolkit/clipboard/base.py
@@ -2,10 +2,18 @@
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']
+
+__all__ = [
+ "Clipboard",
+ "ClipboardData",
+ "DummyClipboard",
+ "DynamicClipboard",
+]
class ClipboardData:
@@ -16,8 +24,9 @@ class ClipboardData:
:param type: :class:`~prompt_toolkit.selection.SelectionType`
"""
- def __init__(self, text: str='', type: SelectionType=SelectionType.
- CHARACTERS) ->None:
+ def __init__(
+ self, text: str = "", type: SelectionType = SelectionType.CHARACTERS
+ ) -> None:
self.text = text
self.type = type
@@ -30,32 +39,29 @@ class Clipboard(metaclass=ABCMeta):
"""
@abstractmethod
- def set_data(self, data: ClipboardData) ->None:
+ def set_data(self, data: ClipboardData) -> None:
"""
Set data to the clipboard.
:param data: :class:`~.ClipboardData` instance.
"""
- pass
- def set_text(self, text: str) ->None:
+ def set_text(self, text: str) -> None: # Not abstract.
"""
Shortcut for setting plain text on clipboard.
"""
- pass
+ self.set_data(ClipboardData(text))
- def rotate(self) ->None:
+ def rotate(self) -> None:
"""
For Emacs mode, rotate the kill ring.
"""
- pass
@abstractmethod
- def get_data(self) ->ClipboardData:
+ def get_data(self) -> ClipboardData:
"""
Return clipboard data.
"""
- pass
class DummyClipboard(Clipboard):
@@ -63,6 +69,18 @@ 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):
"""
@@ -71,5 +89,20 @@ class DynamicClipboard(Clipboard):
:param get_clipboard: Callable that returns a :class:`.Clipboard` instance.
"""
- def __init__(self, get_clipboard: Callable[[], Clipboard | None]) ->None:
+ 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()
diff --git a/src/prompt_toolkit/clipboard/in_memory.py b/src/prompt_toolkit/clipboard/in_memory.py
index 8c6ea6b4..d9ae0817 100644
--- a/src/prompt_toolkit/clipboard/in_memory.py
+++ b/src/prompt_toolkit/clipboard/in_memory.py
@@ -1,7 +1,12 @@
from __future__ import annotations
+
from collections import deque
+
from .base import Clipboard, ClipboardData
-__all__ = ['InMemoryClipboard']
+
+__all__ = [
+ "InMemoryClipboard",
+]
class InMemoryClipboard(Clipboard):
@@ -12,10 +17,28 @@ class InMemoryClipboard(Clipboard):
This implements a kill-ring, for Emacs mode.
"""
- def __init__(self, data: (ClipboardData | None)=None, max_size: int=60
- ) ->None:
+ 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())
diff --git a/src/prompt_toolkit/clipboard/pyperclip.py b/src/prompt_toolkit/clipboard/pyperclip.py
index 3405ec25..66eb7117 100644
--- a/src/prompt_toolkit/clipboard/pyperclip.py
+++ b/src/prompt_toolkit/clipboard/pyperclip.py
@@ -1,8 +1,14 @@
from __future__ import annotations
+
import pyperclip
+
from prompt_toolkit.selection import SelectionType
+
from .base import Clipboard, ClipboardData
-__all__ = ['PyperclipClipboard']
+
+__all__ = [
+ "PyperclipClipboard",
+]
class PyperclipClipboard(Clipboard):
@@ -11,5 +17,26 @@ class PyperclipClipboard(Clipboard):
using the pyperclip module.
"""
- def __init__(self) ->None:
+ 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,
+ )
diff --git a/src/prompt_toolkit/completion/base.py b/src/prompt_toolkit/completion/base.py
index 62214619..04a712df 100644
--- a/src/prompt_toolkit/completion/base.py
+++ b/src/prompt_toolkit/completion/base.py
@@ -1,15 +1,26 @@
"""
"""
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']
+
+__all__ = [
+ "Completion",
+ "Completer",
+ "ThreadedCompleter",
+ "DummyCompleter",
+ "DynamicCompleter",
+ "CompleteEvent",
+ "ConditionalCompleter",
+ "merge_completers",
+ "get_common_complete_suffix",
+]
class Completion:
@@ -28,63 +39,94 @@ class 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:
+ 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:
+ def __repr__(self) -> str:
if isinstance(self.display, str) and self.display == self.text:
- return '{}(text={!r}, start_position={!r})'.format(self.
- __class__.__name__, self.text, self.start_position)
+ return "{}(text={!r}, start_position={!r})".format(
+ self.__class__.__name__,
+ self.text,
+ self.start_position,
+ )
else:
- return '{}(text={!r}, start_position={!r}, display={!r})'.format(
- self.__class__.__name__, self.text, self.start_position,
- self.display)
+ return "{}(text={!r}, start_position={!r}, display={!r})".format(
+ self.__class__.__name__,
+ self.text,
+ self.start_position,
+ self.display,
+ )
- def __eq__(self, other: object) ->bool:
+ 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)
+ 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))
+ 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."""
- pass
+ 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)."""
- pass
+ 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."""
- pass
+ def display_meta_text(self) -> str:
+ "The 'meta' field as plain text."
+ from prompt_toolkit.formatted_text import fragment_list_to_text
- def new_completion_from_position(self, position: int) ->Completion:
+ 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.
"""
- pass
+ 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:
@@ -102,15 +144,23 @@ class CompleteEvent:
`complete_while_typing`.)
"""
- def __init__(self, text_inserted: bool=False, completion_requested:
- bool=False) ->None:
+ def __init__(
+ self, text_inserted: bool = False, completion_requested: bool = False
+ ) -> None:
assert not (text_inserted and completion_requested)
+
+ #: Automatic completion while typing.
self.text_inserted = text_inserted
+
+ #: Used explicitly requested completion by pressing 'tab'.
self.completion_requested = completion_requested
- def __repr__(self) ->str:
- return '{}(text_inserted={!r}, completion_requested={!r})'.format(self
- .__class__.__name__, self.text_inserted, self.completion_requested)
+ def __repr__(self) -> str:
+ return "{}(text_inserted={!r}, completion_requested={!r})".format(
+ self.__class__.__name__,
+ self.text_inserted,
+ self.completion_requested,
+ )
class Completer(metaclass=ABCMeta):
@@ -119,8 +169,9 @@ class Completer(metaclass=ABCMeta):
"""
@abstractmethod
- def get_completions(self, document: Document, complete_event: CompleteEvent
- ) ->Iterable[Completion]:
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
"""
This should be a generator that yields :class:`.Completion` instances.
@@ -133,17 +184,20 @@ class Completer(metaclass=ABCMeta):
:param document: :class:`~prompt_toolkit.document.Document` instance.
:param complete_event: :class:`.CompleteEvent` instance.
"""
- pass
+ while False:
+ yield
- async def get_completions_async(self, document: Document,
- complete_event: CompleteEvent) ->AsyncGenerator[Completion, None]:
+ 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.
"""
- pass
+ for item in self.get_completions(document, complete_event):
+ yield item
class ThreadedCompleter(Completer):
@@ -157,18 +211,78 @@ class ThreadedCompleter(Completer):
can already select a completion, even if not all completions are displayed.
"""
- def __init__(self, completer: Completer) ->None:
+ def __init__(self, completer: Completer) -> None:
self.completer = completer
- async def get_completions_async(self, document: Document,
- complete_event: CompleteEvent) ->AsyncGenerator[Completion, None]:
+ 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.
"""
- pass
+ # 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})'
+ def __repr__(self) -> str:
+ return f"ThreadedCompleter({self.completer!r})"
class DummyCompleter(Completer):
@@ -176,8 +290,13 @@ class DummyCompleter(Completer):
A completer that doesn't return any completion.
"""
- def __repr__(self) ->str:
- return 'DummyCompleter()'
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ return []
+
+ def __repr__(self) -> str:
+ return "DummyCompleter()"
class DynamicCompleter(Completer):
@@ -187,13 +306,27 @@ class DynamicCompleter(Completer):
:param get_completer: Callable that returns a :class:`.Completer` instance.
"""
- def __init__(self, get_completer: Callable[[], Completer | None]) ->None:
+ def __init__(self, get_completer: Callable[[], Completer | None]) -> None:
self.get_completer = get_completer
- def __repr__(self) ->str:
- return (
- f'DynamicCompleter({self.get_completer!r} -> {self.get_completer()!r})'
- )
+ 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):
@@ -205,14 +338,30 @@ class ConditionalCompleter(Completer):
:param filter: :class:`.Filter` instance.
"""
- def __init__(self, completer: Completer, filter: FilterOrBool) ->None:
+ 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 __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):
@@ -220,12 +369,31 @@ class _MergedCompleter(Completer):
Combine several completers into one.
"""
- def __init__(self, completers: Sequence[Completer]) ->None:
+ def __init__(self, completers: Sequence[Completer]) -> None:
self.completers = completers
-
-def merge_completers(completers: Sequence[Completer], deduplicate: bool=False
- ) ->Completer:
+ 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.
@@ -233,12 +401,51 @@ def merge_completers(completers: Sequence[Completer], deduplicate: bool=False
so that completions that would result in the same text will be
deduplicated.
"""
- pass
+ if deduplicate:
+ from .deduplicate import DeduplicateCompleter
+
+ return DeduplicateCompleter(_MergedCompleter(completers))
+
+ return _MergedCompleter(completers)
-def get_common_complete_suffix(document: Document, completions: Sequence[
- Completion]) ->str:
+def get_common_complete_suffix(
+ document: Document, completions: Sequence[Completion]
+) -> str:
"""
Return the common prefix for all completions.
"""
- pass
+
+ # 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
diff --git a/src/prompt_toolkit/completion/deduplicate.py b/src/prompt_toolkit/completion/deduplicate.py
index 5860f486..c3d52566 100644
--- a/src/prompt_toolkit/completion/deduplicate.py
+++ b/src/prompt_toolkit/completion/deduplicate.py
@@ -1,8 +1,12 @@
from __future__ import annotations
+
from typing import Iterable
+
from prompt_toolkit.document import Document
+
from .base import CompleteEvent, Completer, Completion
-__all__ = ['DeduplicateCompleter']
+
+__all__ = ["DeduplicateCompleter"]
class DeduplicateCompleter(Completer):
@@ -14,5 +18,28 @@ class DeduplicateCompleter(Completer):
document text when they would be applied.
"""
- def __init__(self, completer: Completer) ->None:
+ 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
diff --git a/src/prompt_toolkit/completion/filesystem.py b/src/prompt_toolkit/completion/filesystem.py
index 729dddb2..8e7f87e0 100644
--- a/src/prompt_toolkit/completion/filesystem.py
+++ b/src/prompt_toolkit/completion/filesystem.py
@@ -1,9 +1,15 @@
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']
+
+__all__ = [
+ "PathCompleter",
+ "ExecutableCompleter",
+]
class PathCompleter(Completer):
@@ -18,22 +24,95 @@ class PathCompleter(Completer):
: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:
+ 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.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)
+ 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,
+ )
diff --git a/src/prompt_toolkit/completion/fuzzy_completer.py b/src/prompt_toolkit/completion/fuzzy_completer.py
index fc4b7e20..25ea8923 100644
--- a/src/prompt_toolkit/completion/fuzzy_completer.py
+++ b/src/prompt_toolkit/completion/fuzzy_completer.py
@@ -1,12 +1,19 @@
from __future__ import annotations
+
import re
from typing import Callable, Iterable, NamedTuple
+
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']
+
+__all__ = [
+ "FuzzyCompleter",
+ "FuzzyWordCompleter",
+]
class FuzzyCompleter(Completer):
@@ -38,21 +45,133 @@ class FuzzyCompleter(Completer):
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('^')
+ 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_display(self, fuzzy_match: _FuzzyMatch, word_before_cursor: str
- ) ->AnyFormattedText:
+ 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.
"""
- pass
+
+ 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):
@@ -66,15 +185,26 @@ class FuzzyWordCompleter(Completer):
:param WORD: When True, use WORD characters.
"""
- def __init__(self, words: (list[str] | Callable[[], list[str]]),
- meta_dict: (dict[str, str] | None)=None, WORD: bool=False) ->None:
+ def __init__(
+ self,
+ words: list[str] | Callable[[], list[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)
+
+ 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):
diff --git a/src/prompt_toolkit/completion/nested.py b/src/prompt_toolkit/completion/nested.py
index 130f3429..a1d211ab 100644
--- a/src/prompt_toolkit/completion/nested.py
+++ b/src/prompt_toolkit/completion/nested.py
@@ -2,11 +2,16 @@
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']
+
+__all__ = ["NestedCompleter"]
+
+# NestedDict = Mapping[str, Union['NestedDict', Set[str], None, Completer]]
NestedDict = Mapping[str, Union[Any, Set[str], None, Completer]]
@@ -22,18 +27,17 @@ class NestedCompleter(Completer):
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:
+ 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})'
- )
+ 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:
+ def from_nested_dict(cls, data: NestedDict) -> NestedCompleter:
"""
Create a `NestedCompleter`, starting from a nested dictionary data
structure, like this:
@@ -57,4 +61,48 @@ class NestedCompleter(Completer):
Values in this data structure can be a completers as well.
"""
- pass
+ 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({item: None for item in value})
+ else:
+ assert value is None
+ options[key] = None
+
+ return cls(options)
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ # Split document.
+ text = document.text_before_cursor.lstrip()
+ stripped_len = len(document.text_before_cursor) - len(text)
+
+ # If there is a space, check for the first term, and use a
+ # subcompleter.
+ if " " in text:
+ first_term = text.split()[0]
+ completer = self.options.get(first_term)
+
+ # If we have a sub completer, use this for the completions.
+ if completer is not None:
+ remaining_text = text[len(first_term) :].lstrip()
+ move_cursor = len(text) - len(remaining_text) + stripped_len
+
+ new_document = Document(
+ remaining_text,
+ cursor_position=document.cursor_position - move_cursor,
+ )
+
+ yield from completer.get_completions(new_document, complete_event)
+
+ # No space in the input: behave exactly like `WordCompleter`.
+ else:
+ completer = WordCompleter(
+ list(self.options.keys()), ignore_case=self.ignore_case
+ )
+ yield from completer.get_completions(document, complete_event)
diff --git a/src/prompt_toolkit/completion/word_completer.py b/src/prompt_toolkit/completion/word_completer.py
index 60a1da8f..6ef4031f 100644
--- a/src/prompt_toolkit/completion/word_completer.py
+++ b/src/prompt_toolkit/completion/word_completer.py
@@ -1,9 +1,14 @@
from __future__ import annotations
+
from typing import Callable, Iterable, Mapping, Pattern
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import AnyFormattedText
-__all__ = ['WordCompleter']
+
+__all__ = [
+ "WordCompleter",
+]
class WordCompleter(Completer):
@@ -26,13 +31,19 @@ class WordCompleter(Completer):
default one (see document._FIND_WORD_RE)
"""
- def __init__(self, words: (list[str] | Callable[[], list[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:
+ def __init__(
+ self,
+ words: list[str] | Callable[[], list[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 {}
@@ -41,3 +52,43 @@ class WordCompleter(Completer):
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,
+ )
diff --git a/src/prompt_toolkit/contrib/completers/system.py b/src/prompt_toolkit/contrib/completers/system.py
index d3409c9a..5d990e52 100644
--- a/src/prompt_toolkit/contrib/completers/system.py
+++ b/src/prompt_toolkit/contrib/completers/system.py
@@ -1,8 +1,12 @@
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']
+
+__all__ = [
+ "SystemCompleter",
+]
class SystemCompleter(GrammarCompleter):
@@ -10,35 +14,51 @@ class SystemCompleter(GrammarCompleter):
Completer for system commands.
"""
- def __init__(self) ->None:
+ def __init__(self) -> None:
+ # Compile grammar.
g = compile(
- """
+ r"""
# First we have an executable.
- (?P<executable>[^\\s]+)
+ (?P<executable>[^\s]+)
# Ignore literals in between.
(
- \\s+
+ \s+
("[^"]*" | '[^']*' | [^'"]+ )
)*
- \\s+
+ \s+
# Filename as parameters.
(
- (?P<filename>[^\\s]+) |
- "(?P<double_quoted_filename>[^\\s]+)" |
- '(?P<single_quoted_filename>[^\\s]+)'
+ (?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('\\"',
- '"'), 'single_quoted_filename': lambda string: string.replace(
- "\\'", "'")})
- 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)})
+ """,
+ 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
+ ),
+ },
+ )
diff --git a/src/prompt_toolkit/contrib/regular_languages/compiler.py b/src/prompt_toolkit/contrib/regular_languages/compiler.py
index b154d948..474f6cfd 100644
--- a/src/prompt_toolkit/contrib/regular_languages/compiler.py
+++ b/src/prompt_toolkit/contrib/regular_languages/compiler.py
@@ -1,10 +1,10 @@
-"""
+r"""
Compiler for a regular grammar.
Example usage::
# Create and compile grammar.
- p = compile('add \\s+ (?P<var1>[^\\s]+) \\s+ (?P<var2>[^\\s]+)')
+ p = compile('add \s+ (?P<var1>[^\s]+) \s+ (?P<var2>[^\s]+)')
# Match input string.
m = p.match('add 23 432')
@@ -19,10 +19,10 @@ 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]+)) |
+ ((?P<operator1>[^\s]+) \s+ (?P<var1>[^\s]+) \s+ (?P<var2>[^\s]+)) |
# Operators with only one arguments.
- ((?P<operator2>[^\\s]+) \\s+ (?P<var1>[^\\s]+))
+ ((?P<operator2>[^\s]+) \s+ (?P<var1>[^\s]+))
''')
# Match partial input string.
@@ -39,12 +39,33 @@ Partial matches are possible::
"""
from __future__ import annotations
+
import re
from typing import Callable, Dict, Iterable, Iterator, Pattern
from typing import Match as RegexMatch
-from .regex_parser import AnyNode, Lookahead, Node, NodeSequence, Regex, Repeat, Variable, parse_regex, tokenize_regex
-__all__ = ['compile']
-_INVALID_TRAILING_INPUT = 'invalid_trailing'
+
+from .regex_parser import (
+ AnyNode,
+ Lookahead,
+ Node,
+ NodeSequence,
+ Regex,
+ Repeat,
+ Variable,
+ parse_regex,
+ tokenize_regex,
+)
+
+__all__ = [
+ "compile",
+]
+
+
+# Name of the named group in the regex, matching trailing input.
+# (Trailing input is when the input contains characters after the end of the
+# expression has been matched.)
+_INVALID_TRAILING_INPUT = "invalid_trailing"
+
EscapeFuncDict = Dict[str, Callable[[str], str]]
@@ -58,46 +79,69 @@ class _CompiledGrammar:
: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:
+ 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 {}
- self._group_names_to_nodes: dict[str, str] = {}
+
+ #: Dictionary that will map the regex names to Node instances.
+ self._group_names_to_nodes: dict[
+ str, str
+ ] = {} # Maps regex group names to varnames.
counter = [0]
- def create_group_func(node: Variable) ->str:
- name = 'n%s' % counter[0]
+ def create_group_func(node: Variable) -> str:
+ name = "n%s" % counter[0]
self._group_names_to_nodes[name] = node.varname
counter[0] += 1
return name
- self._re_pattern = '^%s$' % self._transform(root_node,
- create_group_func)
- self._re_prefix_patterns = list(self._transform_prefix(root_node,
- create_group_func))
- flags = re.DOTALL
- self._re = re.compile(self._re_pattern, flags)
- self._re_prefix = [re.compile(t, flags) for t in self.
- _re_prefix_patterns]
- self._re_prefix_with_trailing_input = [re.compile(
- '(?:{})(?P<{}>.*?)$'.format(t.rstrip('$'),
- _INVALID_TRAILING_INPUT), flags) for t in self._re_prefix_patterns]
- def escape(self, varname: str, value: str) ->str:
+ # Compile regex strings.
+ self._re_pattern = "^%s$" % self._transform(root_node, create_group_func)
+ self._re_prefix_patterns = list(
+ self._transform_prefix(root_node, create_group_func)
+ )
+
+ # Compile the regex itself.
+ flags = re.DOTALL # Note that we don't need re.MULTILINE! (^ and $
+ # still represent the start and end of input text.)
+ self._re = re.compile(self._re_pattern, flags)
+ self._re_prefix = [re.compile(t, flags) for t in self._re_prefix_patterns]
+
+ # We compile one more set of regexes, similar to `_re_prefix`, but accept any trailing
+ # input. This will ensure that we can still highlight the input correctly, even when the
+ # input contains some additional characters at the end that don't match the grammar.)
+ self._re_prefix_with_trailing_input = [
+ re.compile(
+ r"(?:{})(?P<{}>.*?)$".format(t.rstrip("$"), _INVALID_TRAILING_INPUT),
+ flags,
+ )
+ for t in self._re_prefix_patterns
+ ]
+
+ def escape(self, varname: str, value: str) -> str:
"""
Escape `value` to fit in the place of this variable into the grammar.
"""
- pass
+ f = self.escape_funcs.get(varname)
+ return f(value) if f else value
- def unescape(self, varname: str, value: str) ->str:
+ def unescape(self, varname: str, value: str) -> str:
"""
Unescape `value`.
"""
- pass
+ 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:
+ def _transform(
+ cls, root_node: Node, create_group_func: Callable[[Variable], str]
+ ) -> str:
"""
Turn a :class:`Node` object into a regular expression.
@@ -105,11 +149,55 @@ class _CompiledGrammar:
:param create_group_func: A callable which takes a `Node` and returns the next
free name for this node.
"""
- pass
+
+ def transform(node: Node) -> str:
+ # Turn `AnyNode` into an OR.
+ if isinstance(node, AnyNode):
+ return "(?:%s)" % "|".join(transform(c) for c in node.children)
+
+ # Concatenate a `NodeSequence`
+ elif isinstance(node, NodeSequence):
+ return "".join(transform(c) for c in node.children)
+
+ # For Regex and Lookahead nodes, just insert them literally.
+ elif isinstance(node, Regex):
+ return node.regex
+
+ elif isinstance(node, Lookahead):
+ before = "(?!" if node.negative else "(="
+ return before + transform(node.childnode) + ")"
+
+ # A `Variable` wraps the children into a named group.
+ elif isinstance(node, Variable):
+ return 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]:
+ 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.
@@ -131,18 +219,157 @@ class _CompiledGrammar:
:param create_group_func: A callable which takes a `Node` and returns the next
free name for this node.
"""
- pass
- def match(self, string: str) ->(Match | None):
+ def contains_variable(node: Node) -> bool:
+ if isinstance(node, Regex):
+ return False
+ elif isinstance(node, Variable):
+ return True
+ elif isinstance(node, (Lookahead, Repeat)):
+ return contains_variable(node.childnode)
+ elif isinstance(node, (NodeSequence, AnyNode)):
+ return any(contains_variable(child) for child in node.children)
+
+ return False
+
+ def transform(node: Node) -> Iterable[str]:
+ # Generate separate pattern for all terms that contain variables
+ # within this OR. Terms that don't contain a variable can be merged
+ # together in one pattern.
+ if isinstance(node, AnyNode):
+ # If we have a definition like:
+ # (?P<name> .*) | (?P<city> .*)
+ # Then we want to be able to generate completions for both the
+ # name as well as the city. We do this by yielding two
+ # different regular expressions, because the engine won't
+ # follow multiple paths, if multiple are possible.
+ children_with_variable = []
+ children_without_variable = []
+ for c in node.children:
+ if contains_variable(c):
+ children_with_variable.append(c)
+ else:
+ children_without_variable.append(c)
+
+ for c in children_with_variable:
+ yield from transform(c)
+
+ # Merge options without variable together.
+ if children_without_variable:
+ yield "|".join(
+ r for c in children_without_variable for r in transform(c)
+ )
+
+ # For a sequence, generate a pattern for each prefix that ends with
+ # a variable + one pattern of the complete sequence.
+ # (This is because, for autocompletion, we match the text before
+ # the cursor, and completions are given for the variable that we
+ # match right before the cursor.)
+ elif isinstance(node, NodeSequence):
+ # For all components in the sequence, compute prefix patterns,
+ # as well as full patterns.
+ complete = [cls._transform(c, create_group_func) for c in node.children]
+ prefixes = [list(transform(c)) for c in node.children]
+ variable_nodes = [contains_variable(c) for c in node.children]
+
+ # If any child is contains a variable, we should yield a
+ # pattern up to that point, so that we are sure this will be
+ # matched.
+ for i in range(len(node.children)):
+ if variable_nodes[i]:
+ for c_str in prefixes[i]:
+ yield "".join(complete[:i]) + c_str
+
+ # If there are non-variable nodes, merge all the prefixes into
+ # one pattern. If the input is: "[part1] [part2] [part3]", then
+ # this gets compiled into:
+ # (complete1 + (complete2 + (complete3 | partial3) | partial2) | partial1 )
+ # For nodes that contain a variable, we skip the "|partial"
+ # part here, because thees are matched with the previous
+ # patterns.
+ if not all(variable_nodes):
+ result = []
+
+ # Start with complete patterns.
+ for i in range(len(node.children)):
+ result.append("(?:")
+ result.append(complete[i])
+
+ # Add prefix patterns.
+ for i in range(len(node.children) - 1, -1, -1):
+ if variable_nodes[i]:
+ # No need to yield a prefix for this one, we did
+ # the variable prefixes earlier.
+ result.append(")")
+ else:
+ result.append("|(?:")
+ # If this yields multiple, we should yield all combinations.
+ assert len(prefixes[i]) == 1
+ result.append(prefixes[i][0])
+ result.append("))")
+
+ yield "".join(result)
+
+ elif isinstance(node, Regex):
+ yield "(?:%s)?" % node.regex
+
+ elif isinstance(node, Lookahead):
+ if node.negative:
+ yield "(?!%s)" % cls._transform(node.childnode, create_group_func)
+ else:
+ # Not sure what the correct semantics are in this case.
+ # (Probably it's not worth implementing this.)
+ raise Exception("Positive lookahead not yet supported.")
+
+ elif isinstance(node, Variable):
+ # (Note that we should not append a '?' here. the 'transform'
+ # method will already recursively do that.)
+ for c_str in transform(node.childnode):
+ yield f"(?P<{create_group_func(node)}>{c_str})"
+
+ elif isinstance(node, Repeat):
+ # If we have a repetition of 8 times. That would mean that the
+ # current input could have for instance 7 times a complete
+ # match, followed by a partial match.
+ prefix = cls._transform(node.childnode, create_group_func)
+
+ if node.max_repeat == 1:
+ yield from transform(node.childnode)
+ else:
+ for c_str in transform(node.childnode):
+ if node.max_repeat:
+ repeat_sign = "{,%i}" % (node.max_repeat - 1)
+ else:
+ repeat_sign = "*"
+ yield "(?:{}){}{}{}".format(
+ prefix,
+ repeat_sign,
+ ("" if node.greedy else "?"),
+ c_str,
+ )
+
+ else:
+ raise TypeError("Got %r" % node)
+
+ for r in transform(root_node):
+ yield "^(?:%s)$" % r
+
+ def match(self, string: str) -> 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.
"""
- pass
+ 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):
+ 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
@@ -151,7 +378,18 @@ class _CompiledGrammar:
:param string: The input string.
"""
- pass
+ # 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:
@@ -161,61 +399,119 @@ class Match:
: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]]):
+ 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]]]:
+ def _nodes_to_regs(self) -> list[tuple[str, tuple[int, int]]]:
"""
Return a list of (varname, reg) tuples.
"""
- pass
- def _nodes_to_values(self) ->list[tuple[str, str, tuple[int, int]]]:
+ 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.
"""
- pass
- def variables(self) ->Variables:
+ 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.
"""
- pass
+ return Variables(
+ [(k, self._unescape(k, v), sl) for k, v, sl in self._nodes_to_values()]
+ )
- def trailing_input(self) ->(MatchVariable | None):
+ 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.
"""
- pass
-
- def end_nodes(self) ->Iterable[MatchVariable]:
+ 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.
"""
- pass
+ for varname, reg in self._nodes_to_regs():
+ # If this part goes until the end of the input string.
+ if reg[1] == len(self.string):
+ value = self._unescape(varname, self.string[reg[0] : reg[1]])
+ yield MatchVariable(varname, value, (reg[0], reg[1]))
class Variables:
-
- def __init__(self, tuples: list[tuple[str, str, tuple[int, int]]]) ->None:
+ def __init__(self, tuples: list[tuple[str, str, tuple[int, int]]]) -> None:
+ #: List of (varname, value, slice) tuples.
self._tuples = tuples
- def __repr__(self) ->str:
- return '{}({})'.format(self.__class__.__name__, ', '.join(
- f'{k}={v!r}' for k, v, _ in self._tuples))
+ def __repr__(self) -> str:
+ return "{}({})".format(
+ self.__class__.__name__,
+ ", ".join(f"{k}={v!r}" for k, v, _ in self._tuples),
+ )
+
+ def get(self, key: str, default: str | None = None) -> str | 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):
+ def __getitem__(self, key: str) -> str | None:
return self.get(key)
- def __iter__(self) ->Iterator[MatchVariable]:
+ def __iter__(self) -> Iterator[MatchVariable]:
"""
Yield `MatchVariable` instances.
"""
@@ -233,32 +529,43 @@ class MatchVariable:
in the input string.
"""
- def __init__(self, varname: str, value: str, slice: tuple[int, int]
- ) ->None:
+ 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 __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:
+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.
"""
- pass
-
-
-def _compile_from_parse_tree(root_node: Node, escape_funcs: (EscapeFuncDict |
- None)=None, unescape_funcs: (EscapeFuncDict | None)=None
- ) ->_CompiledGrammar:
+ 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.
"""
- pass
+ return _CompiledGrammar(
+ root_node, escape_funcs=escape_funcs, unescape_funcs=unescape_funcs
+ )
diff --git a/src/prompt_toolkit/contrib/regular_languages/completion.py b/src/prompt_toolkit/contrib/regular_languages/completion.py
index cf287636..2e353e8d 100644
--- a/src/prompt_toolkit/contrib/regular_languages/completion.py
+++ b/src/prompt_toolkit/contrib/regular_languages/completion.py
@@ -2,11 +2,17 @@
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']
+
+__all__ = [
+ "GrammarCompleter",
+]
class GrammarCompleter(Completer):
@@ -19,25 +25,70 @@ class GrammarCompleter(Completer):
`Completer` instances to be used for each variable.
"""
- def __init__(self, compiled_grammar: _CompiledGrammar, completers: dict
- [str, Completer]) ->None:
+ def __init__(
+ self, compiled_grammar: _CompiledGrammar, completers: dict[str, Completer]
+ ) -> None:
self.compiled_grammar = compiled_grammar
self.completers = completers
- def _get_completions_for_match(self, match: Match, complete_event:
- CompleteEvent) ->Iterable[Completion]:
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ m = self.compiled_grammar.match_prefix(document.text_before_cursor)
+
+ if m:
+ completions = self._remove_duplicates(
+ self._get_completions_for_match(m, complete_event)
+ )
+
+ yield from completions
+
+ def _get_completions_for_match(
+ self, match: Match, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
"""
Yield all the possible completions for this input string.
(The completer assumes that the cursor position was at the end of the
input string.)
"""
- pass
+ for match_variable in match.end_nodes():
+ varname = match_variable.varname
+ start = match_variable.start
+
+ completer = self.completers.get(varname)
+
+ if completer:
+ text = match_variable.value
+
+ # Unwrap text.
+ unwrapped_text = self.compiled_grammar.unescape(varname, text)
+
+ # Create a document, for the completions API (text/cursor_position)
+ document = Document(unwrapped_text, len(unwrapped_text))
+
+ # Call completer
+ for completion in completer.get_completions(document, complete_event):
+ new_text = (
+ unwrapped_text[: len(text) + completion.start_position]
+ + completion.text
+ )
+
+ # Wrap again.
+ yield Completion(
+ text=self.compiled_grammar.escape(varname, new_text),
+ start_position=start - len(match.string),
+ display=completion.display,
+ display_meta=completion.display_meta,
+ )
- def _remove_duplicates(self, items: Iterable[Completion]) ->list[Completion
- ]:
+ def _remove_duplicates(self, items: Iterable[Completion]) -> list[Completion]:
"""
Remove duplicates, while keeping the order.
(Sometimes we have duplicates, because the there several matches of the
same grammar, each yielding similar completions.)
"""
- pass
+ result: list[Completion] = []
+ for i in items:
+ if i not in result:
+ result.append(i)
+ return result
diff --git a/src/prompt_toolkit/contrib/regular_languages/lexer.py b/src/prompt_toolkit/contrib/regular_languages/lexer.py
index e69b634c..b0a4debe 100644
--- a/src/prompt_toolkit/contrib/regular_languages/lexer.py
+++ b/src/prompt_toolkit/contrib/regular_languages/lexer.py
@@ -3,13 +3,19 @@
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']
+
+__all__ = [
+ "GrammarLexer",
+]
class GrammarLexer(Lexer):
@@ -27,8 +33,61 @@ class GrammarLexer(Lexer):
`prompt_toolkit.lexers.SimpleLexer`.
"""
- def __init__(self, compiled_grammar: _CompiledGrammar, default_style:
- str='', lexers: (dict[str, Lexer] | None)=None) ->None:
+ 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
diff --git a/src/prompt_toolkit/contrib/regular_languages/regex_parser.py b/src/prompt_toolkit/contrib/regular_languages/regex_parser.py
index ba97c928..a365ba8e 100644
--- a/src/prompt_toolkit/contrib/regular_languages/regex_parser.py
+++ b/src/prompt_toolkit/contrib/regular_languages/regex_parser.py
@@ -15,9 +15,17 @@ Limitations:
- Lookahead is not supported.
"""
from __future__ import annotations
+
import re
-__all__ = ['Repeat', 'Variable', 'Regex', 'Lookahead', 'tokenize_regex',
- 'parse_regex']
+
+__all__ = [
+ "Repeat",
+ "Variable",
+ "Regex",
+ "Lookahead",
+ "tokenize_regex",
+ "parse_regex",
+]
class Node:
@@ -26,10 +34,10 @@ class Node:
(You don't initialize this one.)
"""
- def __add__(self, other_node: Node) ->NodeSequence:
+ def __add__(self, other_node: Node) -> NodeSequence:
return NodeSequence([self, other_node])
- def __or__(self, other_node: Node) ->AnyNode:
+ def __or__(self, other_node: Node) -> AnyNode:
return AnyNode([self, other_node])
@@ -40,14 +48,14 @@ class AnyNode(Node):
operation.
"""
- def __init__(self, children: list[Node]) ->None:
+ def __init__(self, children: list[Node]) -> None:
self.children = children
- def __or__(self, other_node: Node) ->AnyNode:
+ 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})'
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.children!r})"
class NodeSequence(Node):
@@ -56,14 +64,14 @@ class NodeSequence(Node):
yourself, but it's a result of a "Grammar1 + Grammar2" operation.
"""
- def __init__(self, children: list[Node]) ->None:
+ def __init__(self, children: list[Node]) -> None:
self.children = children
- def __add__(self, other_node: Node) ->NodeSequence:
+ 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})'
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.children!r})"
class Regex(Node):
@@ -71,12 +79,13 @@ class Regex(Node):
Regular expression.
"""
- def __init__(self, regex: str) ->None:
- re.compile(regex)
+ def __init__(self, regex: str) -> None:
+ re.compile(regex) # Validate
+
self.regex = regex
- def __repr__(self) ->str:
- return f'{self.__class__.__name__}(/{self.regex}/)'
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}(/{self.regex}/)"
class Lookahead(Node):
@@ -84,12 +93,12 @@ class Lookahead(Node):
Lookahead expression.
"""
- def __init__(self, childnode: Node, negative: bool=False) ->None:
+ 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})'
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.childnode!r})"
class Variable(Node):
@@ -101,29 +110,36 @@ class Variable(Node):
:param varname: String.
"""
- def __init__(self, childnode: Node, varname: str='') ->None:
+ def __init__(self, childnode: Node, varname: str = "") -> None:
self.childnode = childnode
self.varname = varname
- def __repr__(self) ->str:
- return '{}(childnode={!r}, varname={!r})'.format(self.__class__.
- __name__, self.childnode, self.varname)
+ def __repr__(self) -> str:
+ return "{}(childnode={!r}, varname={!r})".format(
+ self.__class__.__name__,
+ self.childnode,
+ self.varname,
+ )
class Repeat(Node):
-
- def __init__(self, childnode: Node, min_repeat: int=0, max_repeat: (int |
- None)=None, greedy: bool=True) ->None:
+ 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 __repr__(self) -> str:
+ return f"{self.__class__.__name__}(childnode={self.childnode!r})"
-def tokenize_regex(input: str) ->list[str]:
+def tokenize_regex(input: str) -> list[str]:
"""
Takes a string, representing a regular expression as input, and tokenizes
it.
@@ -131,11 +147,136 @@ def tokenize_regex(input: str) ->list[str]:
:param input: string, representing a regular expression.
:returns: List of tokens.
"""
- pass
-
-
-def parse_regex(regex_tokens: list[str]) ->Node:
+ # 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.
"""
- pass
+ # We add a closing brace because that represents the final pop of the stack.
+ tokens: list[str] = [")"] + regex_tokens[::-1]
+
+ def wrap(lst: list[Node]) -> Node:
+ """Turn list into sequence when it contains several items."""
+ if len(lst) == 1:
+ return lst[0]
+ else:
+ return NodeSequence(lst)
+
+ def _parse() -> Node:
+ or_list: list[list[Node]] = []
+ result: list[Node] = []
+
+ def wrapped_result() -> Node:
+ if or_list == []:
+ return wrap(result)
+ else:
+ or_list.append(result)
+ return AnyNode([wrap(i) for i in or_list])
+
+ while tokens:
+ t = tokens.pop()
+
+ if t.startswith("(?P<"):
+ variable = Variable(_parse(), varname=t[4:-1])
+ result.append(variable)
+
+ elif t in ("*", "*?"):
+ greedy = t == "*"
+ result[-1] = Repeat(result[-1], greedy=greedy)
+
+ elif t in ("+", "+?"):
+ greedy = t == "+"
+ result[-1] = Repeat(result[-1], min_repeat=1, greedy=greedy)
+
+ elif t in ("?", "??"):
+ if result == []:
+ raise Exception("Nothing to repeat." + repr(tokens))
+ else:
+ greedy = t == "?"
+ result[-1] = Repeat(
+ result[-1], min_repeat=0, max_repeat=1, greedy=greedy
+ )
+
+ elif t == "|":
+ or_list.append(result)
+ result = []
+
+ elif t in ("(", "(?:"):
+ result.append(_parse())
+
+ elif t == "(?!":
+ result.append(Lookahead(_parse(), negative=True))
+
+ elif t == "(?=":
+ result.append(Lookahead(_parse(), negative=False))
+
+ elif t == ")":
+ return wrapped_result()
+
+ elif t.startswith("#"):
+ pass
+
+ elif t.startswith("{"):
+ # TODO: implement!
+ raise Exception(f"{t}-style repetition not yet supported")
+
+ elif t.startswith("(?"):
+ raise Exception("%r not supported" % t)
+
+ elif t.isspace():
+ pass
+ else:
+ result.append(Regex(t))
+
+ raise Exception("Expecting ')' token")
+
+ result = _parse()
+
+ if len(tokens) != 0:
+ raise Exception("Unmatched parentheses.")
+ else:
+ return result
diff --git a/src/prompt_toolkit/contrib/regular_languages/validation.py b/src/prompt_toolkit/contrib/regular_languages/validation.py
index 8ce0aa3e..8e56e050 100644
--- a/src/prompt_toolkit/contrib/regular_languages/validation.py
+++ b/src/prompt_toolkit/contrib/regular_languages/validation.py
@@ -2,10 +2,15 @@
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']
+
+__all__ = [
+ "GrammarValidator",
+]
class GrammarValidator(Validator):
@@ -18,7 +23,37 @@ class GrammarValidator(Validator):
`Validator` instances to be used for each variable.
"""
- def __init__(self, compiled_grammar: _CompiledGrammar, validators: dict
- [str, Validator]) ->None:
+ 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"
+ )
diff --git a/src/prompt_toolkit/contrib/ssh/server.py b/src/prompt_toolkit/contrib/ssh/server.py
index 73ec9401..9a5d4022 100644
--- a/src/prompt_toolkit/contrib/ssh/server.py
+++ b/src/prompt_toolkit/contrib/ssh/server.py
@@ -2,57 +2,124 @@
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']
+__all__ = ["PromptToolkitSSHSession", "PromptToolkitSSHServer"]
-class PromptToolkitSSHSession(asyncssh.SSHServerSession):
- def __init__(self, interact: Callable[[PromptToolkitSSHSession],
- Coroutine[Any, Any, None]], *, enable_cpr: bool) ->None:
+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:
+ def write(s, data: str) -> None:
try:
if self._chan is not None:
- self._chan.write(data.replace('\n', '\r\n'))
+ self._chan.write(data.replace("\n", "\r\n"))
except BrokenPipeError:
- pass
+ pass # Channel not open for sending.
- def isatty(s) ->bool:
+ def isatty(s) -> bool:
return True
- def flush(s) ->None:
+ def flush(s) -> None:
pass
@property
- def encoding(s) ->str:
+ 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:
+ def _get_size(self) -> Size:
"""
Callable that returns the current `Size`, required by Vt100_Output.
"""
- pass
+ 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):
@@ -93,7 +160,18 @@ class PromptToolkitSSHServer(asyncssh.SSHServer):
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:
+ 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)
diff --git a/src/prompt_toolkit/contrib/telnet/log.py b/src/prompt_toolkit/contrib/telnet/log.py
index 3012a780..0fe84337 100644
--- a/src/prompt_toolkit/contrib/telnet/log.py
+++ b/src/prompt_toolkit/contrib/telnet/log.py
@@ -2,6 +2,11 @@
Python logger for the telnet server.
"""
from __future__ import annotations
+
import logging
+
logger = logging.getLogger(__package__)
-__all__ = ['logger']
+
+__all__ = [
+ "logger",
+]
diff --git a/src/prompt_toolkit/contrib/telnet/protocol.py b/src/prompt_toolkit/contrib/telnet/protocol.py
index f58280d4..4b90e985 100644
--- a/src/prompt_toolkit/contrib/telnet/protocol.py
+++ b/src/prompt_toolkit/contrib/telnet/protocol.py
@@ -5,12 +5,25 @@ 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']
+
+__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)
@@ -24,9 +37,11 @@ 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)
@@ -52,58 +67,142 @@ class TelnetProtocolParser:
p.feed(binary_data)
"""
- def __init__(self, data_received_callback: Callable[[bytes], None],
+ def __init__(
+ self,
+ data_received_callback: Callable[[bytes], None],
size_received_callback: Callable[[int, int], None],
- ttype_received_callback: Callable[[str], None]) ->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)
+ self._parser.send(None) # type: ignore
- def do_received(self, data: bytes) ->None:
+ def received_data(self, data: bytes) -> None:
+ self.data_received_callback(data)
+
+ def do_received(self, data: bytes) -> None:
"""Received telnet DO command."""
- pass
+ logger.info("DO %r", data)
- def dont_received(self, data: bytes) ->None:
+ def dont_received(self, data: bytes) -> None:
"""Received telnet DONT command."""
- pass
+ logger.info("DONT %r", data)
- def will_received(self, data: bytes) ->None:
+ def will_received(self, data: bytes) -> None:
"""Received telnet WILL command."""
- pass
+ logger.info("WILL %r", data)
- def wont_received(self, data: bytes) ->None:
+ def wont_received(self, data: bytes) -> None:
"""Received telnet WONT command."""
- pass
+ 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)
- def naws(self, data: bytes) ->None:
+ else:
+ logger.info("command received %r %r", command, data)
+
+ def naws(self, data: bytes) -> None:
"""
Received NAWS. (Window dimensions.)
"""
- pass
-
- def ttype(self, data: bytes) ->None:
+ 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.
"""
- pass
-
- def negotiate(self, data: bytes) ->None:
+ 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.
"""
- pass
+ command, payload = data[0:1], data[1:]
- def _parse_coroutine(self) ->Generator[None, bytes, None]:
+ 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.
"""
- pass
-
- def feed(self, data: bytes) ->None:
+ 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.
"""
- pass
+ for b in data:
+ self._parser.send(int2byte(b))
diff --git a/src/prompt_toolkit/contrib/telnet/server.py b/src/prompt_toolkit/contrib/telnet/server.py
index 4feaaadb..9ebe66c6 100644
--- a/src/prompt_toolkit/contrib/telnet/server.py
+++ b/src/prompt_toolkit/contrib/telnet/server.py
@@ -2,11 +2,13 @@
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
@@ -15,9 +17,60 @@ 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']
+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:
@@ -26,23 +79,58 @@ class _ConnectionStdout:
Vt100_Output output.
"""
- def __init__(self, connection: socket.socket, encoding: str) ->None:
+ def __init__(self, connection: socket.socket, encoding: str) -> None:
self._encoding = encoding
self._connection = connection
- self._errors = 'strict'
+ self._errors = "strict"
self._buffer: list[bytes] = []
self._closed = False
+ def write(self, data: str) -> None:
+ data = data.replace("\n", "\r\n")
+ self._buffer.append(data.encode(self._encoding, errors=self._errors))
+ self.flush()
+
+ def isatty(self) -> bool:
+ return True
+
+ def flush(self) -> None:
+ try:
+ if not self._closed:
+ self._connection.send(b"".join(self._buffer))
+ except OSError as e:
+ logger.warning("Couldn't send data over socket: %s" % e)
+
+ self._buffer = []
+
+ def close(self) -> None:
+ self._closed = True
+
+ @property
+ def encoding(self) -> str:
+ return self._encoding
+
+ @property
+ def errors(self) -> str:
+ return self._errors
+
class TelnetConnection:
"""
Class that represents one Telnet connection.
"""
- def __init__(self, conn: socket.socket, addr: tuple[str, int], interact:
- Callable[[TelnetConnection], Coroutine[Any, Any, None]], server:
- TelnetServer, encoding: str, style: (BaseStyle | None), vt100_input:
- PipeInput, enable_cpr: bool=True) ->None:
+ 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
@@ -54,68 +142,124 @@ class TelnetConnection:
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)
- def get_size() ->Size:
+ # Create output.
+ def get_size() -> Size:
return self.size
+
self.stdout = cast(TextIO, _ConnectionStdout(conn, encoding=encoding))
- def data_received(data: bytes) ->None:
+ def data_received(data: bytes) -> None:
"""TelnetProtocolParser 'data_received' callback"""
self.vt100_input.send_bytes(data)
- def size_received(rows: int, columns: int) ->None:
+ 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())
+ self.context.run(lambda: get_app()._on_resize())
- def ttype_received(ttype: str) ->None:
+ 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.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.parser = TelnetProtocolParser(data_received, size_received, ttype_received)
self.context: contextvars.Context | None = None
- async def run_application(self) ->None:
+ async def run_application(self) -> None:
"""
Run application.
"""
- pass
- def feed(self, data: bytes) ->None:
+ 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.)
"""
- pass
+ self.parser.feed(data)
- def close(self) ->None:
+ def close(self) -> None:
"""
Closed by client.
"""
- pass
+ 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:
+ def send(self, formatted_text: AnyFormattedText) -> None:
"""
Send text to the client.
"""
- pass
+ 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:
+ def send_above_prompt(self, formatted_text: AnyFormattedText) -> None:
"""
Send text to the client.
This is asynchronous, returns a `Future`.
"""
- pass
+ formatted_text = to_formatted_text(formatted_text)
+ return self._run_in_terminal(lambda: self.send(formatted_text))
- def erase_screen(self) ->None:
+ 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) # type: ignore
+ 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.
"""
- pass
+ 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:
@@ -128,56 +272,156 @@ class TelnetServer:
connection.send("Welcome")
session = PromptSession()
result = await session.prompt_async(message="Say something: ")
- connection.send(f"You said: {result}
-")
+ 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:
+ 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()
- async def run(self, ready_cb: (Callable[[], None] | None)=None) ->None:
+ @classmethod
+ def _create_socket(cls, host: str, port: int) -> socket.socket:
+ # Create and bind socket
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ s.bind((host, port))
+
+ s.listen(4)
+ return s
+
+ 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.
"""
- pass
+ 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:
+ def start(self) -> None:
"""
Deprecated: Use `.run()` instead.
Start the telnet server (stop by calling and awaiting `stop()`).
"""
- pass
+ if self._run_task is not None:
+ # Already running.
+ return
- async def stop(self) ->None:
+ 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.
"""
- pass
+ 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:
+ def _accept(self, listen_socket: socket.socket) -> None:
"""
Accept new incoming connection.
"""
- pass
+ 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("Got %s" % 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)
diff --git a/src/prompt_toolkit/cursor_shapes.py b/src/prompt_toolkit/cursor_shapes.py
index 076033d2..453b72c3 100644
--- a/src/prompt_toolkit/cursor_shapes.py
+++ b/src/prompt_toolkit/cursor_shapes.py
@@ -1,34 +1,50 @@
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']
+
+__all__ = [
+ "CursorShape",
+ "CursorShapeConfig",
+ "SimpleCursorShapeConfig",
+ "ModalCursorShapeConfig",
+ "DynamicCursorShapeConfig",
+ "to_cursor_shape_config",
+]
class CursorShape(Enum):
- _NEVER_CHANGE = '_NEVER_CHANGE'
- BLOCK = 'BLOCK'
- BEAM = 'BEAM'
- UNDERLINE = 'UNDERLINE'
- BLINKING_BLOCK = 'BLINKING_BLOCK'
- BLINKING_BEAM = 'BLINKING_BEAM'
- BLINKING_UNDERLINE = 'BLINKING_UNDERLINE'
+ # 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:
+ def get_cursor_shape(self, application: Application[Any]) -> CursorShape:
"""
Return the cursor shape to be used in the current state.
"""
- pass
AnyCursorShapeConfig = Union[CursorShape, CursorShapeConfig, None]
@@ -39,27 +55,50 @@ class SimpleCursorShapeConfig(CursorShapeConfig):
Always show the given cursor shape.
"""
- def __init__(self, cursor_shape: CursorShape=CursorShape._NEVER_CHANGE
- ) ->None:
+ def __init__(self, cursor_shape: CursorShape = CursorShape._NEVER_CHANGE) -> None:
self.cursor_shape = cursor_shape
+ def get_cursor_shape(self, application: Application[Any]) -> CursorShape:
+ return self.cursor_shape
+
class ModalCursorShapeConfig(CursorShapeConfig):
"""
Show cursor shape according to the current input mode.
"""
+ def get_cursor_shape(self, application: Application[Any]) -> CursorShape:
+ if application.editing_mode == EditingMode.VI:
+ if application.vi_state.input_mode == InputMode.INSERT:
+ return CursorShape.BEAM
+ if application.vi_state.input_mode == InputMode.REPLACE:
+ return CursorShape.UNDERLINE
+
+ # Default
+ return CursorShape.BLOCK
-class DynamicCursorShapeConfig(CursorShapeConfig):
- def __init__(self, get_cursor_shape_config: Callable[[],
- AnyCursorShapeConfig]) ->None:
+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:
+
+def to_cursor_shape_config(value: AnyCursorShapeConfig) -> CursorShapeConfig:
"""
Take a `CursorShape` instance or `CursorShapeConfig` and turn it into a
`CursorShapeConfig`.
"""
- pass
+ if value is None:
+ return SimpleCursorShapeConfig()
+
+ if isinstance(value, CursorShape):
+ return SimpleCursorShapeConfig(value)
+
+ return value
diff --git a/src/prompt_toolkit/data_structures.py b/src/prompt_toolkit/data_structures.py
index 8b413a31..27dd4585 100644
--- a/src/prompt_toolkit/data_structures.py
+++ b/src/prompt_toolkit/data_structures.py
@@ -1,6 +1,11 @@
from __future__ import annotations
+
from typing import NamedTuple
-__all__ = ['Point', 'Size']
+
+__all__ = [
+ "Point",
+ "Size",
+]
class Point(NamedTuple):
diff --git a/src/prompt_toolkit/document.py b/src/prompt_toolkit/document.py
index c246ba0d..74f4c13f 100644
--- a/src/prompt_toolkit/document.py
+++ b/src/prompt_toolkit/document.py
@@ -2,25 +2,46 @@
The `Document` that implements all the text operations/querying.
"""
from __future__ import annotations
+
import bisect
import re
import string
import weakref
from typing import Callable, Dict, Iterable, List, NoReturn, Pattern, cast
+
from .clipboard import ClipboardData
from .filters import vi_mode
from .selection import PasteMode, SelectionState, SelectionType
-__all__ = ['Document']
-_FIND_WORD_RE = re.compile('([a-zA-Z0-9_]+|[^a-zA-Z0-9_\\s]+)')
-_FIND_CURRENT_WORD_RE = re.compile('^([a-zA-Z0-9_]+|[^a-zA-Z0-9_\\s]+)')
+
+__all__ = [
+ "Document",
+]
+
+
+# Regex for finding "words" in documents. (We consider a group of alnum
+# characters a word, but also a group of special characters a word, as long as
+# it doesn't contain a space.)
+# (This is a 'word' in Vi.)
+_FIND_WORD_RE = re.compile(r"([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)")
+_FIND_CURRENT_WORD_RE = re.compile(r"^([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)")
_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(
- '^(([a-zA-Z0-9_]+|[^a-zA-Z0-9_\\s]+)\\s*)')
-_FIND_BIG_WORD_RE = re.compile('([^\\s]+)')
-_FIND_CURRENT_BIG_WORD_RE = re.compile('^([^\\s]+)')
-_FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(
- '^([^\\s]+\\s*)')
-_text_to_document_cache: dict[str, _DocumentCache] = cast(Dict[str,
- '_DocumentCache'], weakref.WeakValueDictionary())
+ r"^(([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)\s*)"
+)
+
+# Regex for finding "WORDS" in documents.
+# (This is a 'WORD in Vi.)
+_FIND_BIG_WORD_RE = re.compile(r"([^\s]+)")
+_FIND_CURRENT_BIG_WORD_RE = re.compile(r"^([^\s]+)")
+_FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(r"^([^\s]+\s*)")
+
+# Share the Document._cache between all Document instances.
+# (Document instances are considered immutable. That means that if another
+# `Document` is constructed with the same text, it should have the same
+# `_DocumentCache`.)
+_text_to_document_cache: dict[str, _DocumentCache] = cast(
+ Dict[str, "_DocumentCache"],
+ weakref.WeakValueDictionary(), # Maps document.text to DocumentCache instance.
+)
class _ImmutableLineList(List[str]):
@@ -28,7 +49,11 @@ class _ImmutableLineList(List[str]):
Some protection for our 'lines' list, which is assumed to be immutable in the cache.
(Useful for detecting obvious bugs.)
"""
- __setitem__ = _error
+
+ def _error(self, *a: object, **kw: object) -> NoReturn:
+ raise NotImplementedError("Attempt to modify an immutable list.")
+
+ __setitem__ = _error # type: ignore
append = _error
clear = _error
extend = _error
@@ -36,13 +61,15 @@ class _ImmutableLineList(List[str]):
pop = _error
remove = _error
reverse = _error
- sort = _error
+ sort = _error # type: ignore
class _DocumentCache:
-
- def __init__(self) ->None:
+ def __init__(self) -> None:
+ #: List of lines for the Document text.
self.lines: _ImmutableLineList | None = None
+
+ #: List of index positions, pointing to the start of all the lines.
self.line_indexes: list[int] | None = None
@@ -58,213 +85,358 @@ class Document:
:param cursor_position: int
:param selection: :class:`.SelectionState`
"""
- __slots__ = '_text', '_cursor_position', '_selection', '_cache'
- def __init__(self, text: str='', cursor_position: (int | None)=None,
- selection: (SelectionState | None)=None) ->None:
- assert cursor_position is None or cursor_position <= len(text
- ), AssertionError(
- f'cursor_position={cursor_position!r}, len_text={len(text)!r}')
+ __slots__ = ("_text", "_cursor_position", "_selection", "_cache")
+
+ def __init__(
+ self,
+ text: str = "",
+ cursor_position: int | None = None,
+ selection: SelectionState | None = None,
+ ) -> None:
+ # Check cursor position. It can also be right after the end. (Where we
+ # insert text.)
+ assert cursor_position is None or cursor_position <= len(text), AssertionError(
+ f"cursor_position={cursor_position!r}, len_text={len(text)!r}"
+ )
+
+ # By default, if no cursor position was given, make sure to put the
+ # cursor position is at the end of the document. This is what makes
+ # sense in most places.
if cursor_position is None:
cursor_position = len(text)
+
+ # Keep these attributes private. A `Document` really has to be
+ # considered to be immutable, because otherwise the caching will break
+ # things. Because of that, we wrap these into read-only properties.
self._text = text
self._cursor_position = cursor_position
self._selection = selection
+
+ # Cache for lines/indexes. (Shared with other Document instances that
+ # contain the same text.
try:
self._cache = _text_to_document_cache[self.text]
except KeyError:
self._cache = _DocumentCache()
_text_to_document_cache[self.text] = self._cache
- def __repr__(self) ->str:
- return (
- f'{self.__class__.__name__}({self.text!r}, {self.cursor_position!r})'
- )
+ # XX: For some reason, above, we can't use 'WeakValueDictionary.setdefault'.
+ # This fails in Pypy3. `self._cache` becomes None, because that's what
+ # 'setdefault' returns.
+ # self._cache = _text_to_document_cache.setdefault(self.text, _DocumentCache())
+ # assert self._cache
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.text!r}, {self.cursor_position!r})"
- def __eq__(self, other: object) ->bool:
+ def __eq__(self, other: object) -> bool:
if not isinstance(other, Document):
return False
- return (self.text == other.text and self.cursor_position == other.
- cursor_position and self.selection == other.selection)
+
+ return (
+ self.text == other.text
+ and self.cursor_position == other.cursor_position
+ and self.selection == other.selection
+ )
@property
- def text(self) ->str:
- """The document text."""
- pass
+ def text(self) -> str:
+ "The document text."
+ return self._text
@property
- def cursor_position(self) ->int:
- """The document cursor position."""
- pass
+ def cursor_position(self) -> int:
+ "The document cursor position."
+ return self._cursor_position
@property
- def selection(self) ->(SelectionState | None):
- """:class:`.SelectionState` object."""
- pass
+ def selection(self) -> SelectionState | None:
+ ":class:`.SelectionState` object."
+ return self._selection
@property
- def current_char(self) ->str:
+ def current_char(self) -> str:
"""Return character under cursor or an empty string."""
- pass
+ return self._get_char_relative_to_cursor(0) or ""
@property
- def char_before_cursor(self) ->str:
+ def char_before_cursor(self) -> str:
"""Return character before the cursor or an empty string."""
- pass
+ return self._get_char_relative_to_cursor(-1) or ""
@property
- def current_line_before_cursor(self) ->str:
+ def text_before_cursor(self) -> str:
+ return self.text[: self.cursor_position :]
+
+ @property
+ def text_after_cursor(self) -> str:
+ return self.text[self.cursor_position :]
+
+ @property
+ def current_line_before_cursor(self) -> str:
"""Text from the start of the line until the cursor."""
- pass
+ _, _, text = self.text_before_cursor.rpartition("\n")
+ return text
@property
- def current_line_after_cursor(self) ->str:
+ def current_line_after_cursor(self) -> str:
"""Text from the cursor until the end of the line."""
- pass
+ text, _, _ = self.text_after_cursor.partition("\n")
+ return text
@property
- def lines(self) ->list[str]:
+ def lines(self) -> list[str]:
"""
Array of all the lines.
"""
- pass
+ # Cache, because this one is reused very often.
+ if self._cache.lines is None:
+ self._cache.lines = _ImmutableLineList(self.text.split("\n"))
+
+ return self._cache.lines
@property
- def _line_start_indexes(self) ->list[int]:
+ def _line_start_indexes(self) -> list[int]:
"""
Array pointing to the start indexes of all the lines.
"""
- pass
+ # Cache, because this is often reused. (If it is used, it's often used
+ # many times. And this has to be fast for editing big documents!)
+ if self._cache.line_indexes is None:
+ # Create list of line lengths.
+ line_lengths = map(len, self.lines)
+
+ # Calculate cumulative sums.
+ indexes = [0]
+ append = indexes.append
+ pos = 0
+
+ for line_length in line_lengths:
+ pos += line_length + 1
+ append(pos)
+
+ # Remove the last item. (This is not a new line.)
+ if len(indexes) > 1:
+ indexes.pop()
+
+ self._cache.line_indexes = indexes
+
+ return self._cache.line_indexes
@property
- def lines_from_current(self) ->list[str]:
+ def lines_from_current(self) -> list[str]:
"""
Array of the lines starting from the current line, until the last line.
"""
- pass
+ return self.lines[self.cursor_position_row :]
@property
- def line_count(self) ->int:
- """Return the number of lines in this document. If the document ends
- with a trailing \\n, that counts as the beginning of a new line."""
- pass
+ def line_count(self) -> int:
+ r"""Return the number of lines in this document. If the document ends
+ with a trailing \n, that counts as the beginning of a new line."""
+ return len(self.lines)
@property
- def current_line(self) ->str:
+ def current_line(self) -> str:
"""Return the text on the line where the cursor is. (when the input
consists of just one line, it equals `text`."""
- pass
+ return self.current_line_before_cursor + self.current_line_after_cursor
@property
- def leading_whitespace_in_current_line(self) ->str:
+ def leading_whitespace_in_current_line(self) -> str:
"""The leading whitespace in the left margin of the current line."""
- pass
+ current_line = self.current_line
+ length = len(current_line) - len(current_line.lstrip())
+ return current_line[:length]
- def _get_char_relative_to_cursor(self, offset: int=0) ->str:
+ def _get_char_relative_to_cursor(self, offset: int = 0) -> str:
"""
Return character relative to cursor position, or empty string
"""
- pass
+ try:
+ return self.text[self.cursor_position + offset]
+ except IndexError:
+ return ""
@property
- def on_first_line(self) ->bool:
+ def on_first_line(self) -> bool:
"""
True when we are at the first line.
"""
- pass
+ return self.cursor_position_row == 0
@property
- def on_last_line(self) ->bool:
+ def on_last_line(self) -> bool:
"""
True when we are at the last line.
"""
- pass
+ return self.cursor_position_row == self.line_count - 1
@property
- def cursor_position_row(self) ->int:
+ def cursor_position_row(self) -> int:
"""
Current row. (0-based.)
"""
- pass
+ row, _ = self._find_line_start_index(self.cursor_position)
+ return row
@property
- def cursor_position_col(self) ->int:
+ def cursor_position_col(self) -> int:
"""
Current column. (0-based.)
"""
- pass
+ # (Don't use self.text_before_cursor to calculate this. Creating
+ # substrings and doing rsplit is too expensive for getting the cursor
+ # position.)
+ _, line_start_index = self._find_line_start_index(self.cursor_position)
+ return self.cursor_position - line_start_index
- def _find_line_start_index(self, index: int) ->tuple[int, int]:
+ def _find_line_start_index(self, index: int) -> tuple[int, int]:
"""
For the index of a character at a certain line, calculate the index of
the first character on that line.
Return (row, index) tuple.
"""
- pass
+ indexes = self._line_start_indexes
- def translate_index_to_position(self, index: int) ->tuple[int, int]:
+ pos = bisect.bisect_right(indexes, index) - 1
+ return pos, indexes[pos]
+
+ def translate_index_to_position(self, index: int) -> tuple[int, int]:
"""
Given an index for the text, return the corresponding (row, col) tuple.
(0-based. Returns (0, 0) for index=0.)
"""
- pass
+ # Find start of this line.
+ row, row_index = self._find_line_start_index(index)
+ col = index - row_index
+
+ return row, col
- def translate_row_col_to_index(self, row: int, col: int) ->int:
+ def translate_row_col_to_index(self, row: int, col: int) -> int:
"""
Given a (row, col) tuple, return the corresponding index.
(Row and col params are 0-based.)
Negative row/col values are turned into zero.
"""
- pass
+ try:
+ result = self._line_start_indexes[row]
+ line = self.lines[row]
+ except IndexError:
+ if row < 0:
+ result = self._line_start_indexes[0]
+ line = self.lines[0]
+ else:
+ result = self._line_start_indexes[-1]
+ line = self.lines[-1]
+
+ result += max(0, min(col, len(line)))
+
+ # Keep in range. (len(self.text) is included, because the cursor can be
+ # right after the end of the text as well.)
+ result = max(0, min(result, len(self.text)))
+ return result
@property
- def is_cursor_at_the_end(self) ->bool:
+ def is_cursor_at_the_end(self) -> bool:
"""True when the cursor is at the end of the text."""
- pass
+ return self.cursor_position == len(self.text)
@property
- def is_cursor_at_the_end_of_line(self) ->bool:
+ def is_cursor_at_the_end_of_line(self) -> bool:
"""True when the cursor is at the end of this line."""
- pass
+ return self.current_char in ("\n", "")
- def has_match_at_current_position(self, sub: str) ->bool:
+ def has_match_at_current_position(self, sub: str) -> bool:
"""
`True` when this substring is found at the cursor position.
"""
- pass
+ return self.text.find(sub, self.cursor_position) == self.cursor_position
- def find(self, sub: str, in_current_line: bool=False,
- include_current_position: bool=False, ignore_case: bool=False,
- count: int=1) ->(int | None):
+ def find(
+ self,
+ sub: str,
+ in_current_line: bool = False,
+ include_current_position: bool = False,
+ ignore_case: bool = False,
+ count: int = 1,
+ ) -> int | None:
"""
Find `text` after the cursor, return position relative to the cursor
position. Return `None` if nothing was found.
:param count: Find the n-th occurrence.
"""
- pass
+ assert isinstance(ignore_case, bool)
+
+ if in_current_line:
+ text = self.current_line_after_cursor
+ else:
+ text = self.text_after_cursor
+
+ if not include_current_position:
+ if len(text) == 0:
+ return None # (Otherwise, we always get a match for the empty string.)
+ else:
+ text = text[1:]
+
+ flags = re.IGNORECASE if ignore_case else 0
+ iterator = re.finditer(re.escape(sub), text, flags)
- def find_all(self, sub: str, ignore_case: bool=False) ->list[int]:
+ try:
+ for i, match in enumerate(iterator):
+ if i + 1 == count:
+ if include_current_position:
+ return match.start(0)
+ else:
+ return match.start(0) + 1
+ except StopIteration:
+ pass
+ return None
+
+ def find_all(self, sub: str, ignore_case: bool = False) -> list[int]:
"""
Find all occurrences of the substring. Return a list of absolute
positions in the document.
"""
- pass
+ flags = re.IGNORECASE if ignore_case else 0
+ return [a.start() for a in re.finditer(re.escape(sub), self.text, flags)]
- def find_backwards(self, sub: str, in_current_line: bool=False,
- ignore_case: bool=False, count: int=1) ->(int | None):
+ def find_backwards(
+ self,
+ sub: str,
+ in_current_line: bool = False,
+ ignore_case: bool = False,
+ count: int = 1,
+ ) -> int | None:
"""
Find `text` before the cursor, return position relative to the cursor
position. Return `None` if nothing was found.
:param count: Find the n-th occurrence.
"""
- pass
+ if in_current_line:
+ before_cursor = self.current_line_before_cursor[::-1]
+ else:
+ before_cursor = self.text_before_cursor[::-1]
+
+ flags = re.IGNORECASE if ignore_case else 0
+ iterator = re.finditer(re.escape(sub[::-1]), before_cursor, flags)
- def get_word_before_cursor(self, WORD: bool=False, pattern: (Pattern[
- str] | None)=None) ->str:
+ try:
+ for i, match in enumerate(iterator):
+ if i + 1 == count:
+ return -match.start(0) - len(sub)
+ except StopIteration:
+ pass
+ return None
+
+ def get_word_before_cursor(
+ self, WORD: bool = False, pattern: Pattern[str] | None = None
+ ) -> str:
"""
Give the word before the cursor.
If we have whitespace before the cursor this returns an empty string.
@@ -272,10 +444,28 @@ class Document:
:param pattern: (None or compiled regex). When given, use this regex
pattern.
"""
- pass
+ if self._is_word_before_cursor_complete(WORD=WORD, pattern=pattern):
+ # Space before the cursor or no text before cursor.
+ return ""
+
+ text_before_cursor = self.text_before_cursor
+ start = self.find_start_of_previous_word(WORD=WORD, pattern=pattern) or 0
- def find_start_of_previous_word(self, count: int=1, WORD: bool=False,
- pattern: (Pattern[str] | None)=None) ->(int | None):
+ return text_before_cursor[len(text_before_cursor) + start :]
+
+ def _is_word_before_cursor_complete(
+ self, WORD: bool = False, pattern: Pattern[str] | None = None
+ ) -> bool:
+ if pattern:
+ return self.find_start_of_previous_word(WORD=WORD, pattern=pattern) is None
+ else:
+ return (
+ self.text_before_cursor == "" or self.text_before_cursor[-1:].isspace()
+ )
+
+ def find_start_of_previous_word(
+ self, count: int = 1, WORD: bool = False, pattern: Pattern[str] | None = None
+ ) -> int | None:
"""
Return an index relative to the cursor position pointing to the start
of the previous word. Return `None` if nothing was found.
@@ -283,88 +473,244 @@ class Document:
:param pattern: (None or compiled regex). When given, use this regex
pattern.
"""
- pass
+ assert not (WORD and pattern)
+
+ # Reverse the text before the cursor, in order to do an efficient
+ # backwards search.
+ text_before_cursor = self.text_before_cursor[::-1]
- def find_boundaries_of_current_word(self, WORD: bool=False,
- include_leading_whitespace: bool=False, include_trailing_whitespace:
- bool=False) ->tuple[int, int]:
+ if pattern:
+ regex = pattern
+ elif WORD:
+ regex = _FIND_BIG_WORD_RE
+ else:
+ regex = _FIND_WORD_RE
+
+ iterator = regex.finditer(text_before_cursor)
+
+ try:
+ for i, match in enumerate(iterator):
+ if i + 1 == count:
+ return -match.end(0)
+ except StopIteration:
+ pass
+ return None
+
+ def find_boundaries_of_current_word(
+ self,
+ WORD: bool = False,
+ include_leading_whitespace: bool = False,
+ include_trailing_whitespace: bool = False,
+ ) -> tuple[int, int]:
"""
Return the relative boundaries (startpos, endpos) of the current word under the
cursor. (This is at the current line, because line boundaries obviously
don't belong to any word.)
If not on a word, this returns (0,0)
"""
- pass
+ text_before_cursor = self.current_line_before_cursor[::-1]
+ text_after_cursor = self.current_line_after_cursor
+
+ def get_regex(include_whitespace: bool) -> Pattern[str]:
+ return {
+ (False, False): _FIND_CURRENT_WORD_RE,
+ (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE,
+ (True, False): _FIND_CURRENT_BIG_WORD_RE,
+ (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE,
+ }[(WORD, include_whitespace)]
+
+ match_before = get_regex(include_leading_whitespace).search(text_before_cursor)
+ match_after = get_regex(include_trailing_whitespace).search(text_after_cursor)
+
+ # When there is a match before and after, and we're not looking for
+ # WORDs, make sure that both the part before and after the cursor are
+ # either in the [a-zA-Z_] alphabet or not. Otherwise, drop the part
+ # before the cursor.
+ if not WORD and match_before and match_after:
+ c1 = self.text[self.cursor_position - 1]
+ c2 = self.text[self.cursor_position]
+ alphabet = string.ascii_letters + "0123456789_"
+
+ if (c1 in alphabet) != (c2 in alphabet):
+ match_before = None
+
+ return (
+ -match_before.end(1) if match_before else 0,
+ match_after.end(1) if match_after else 0,
+ )
- def get_word_under_cursor(self, WORD: bool=False) ->str:
+ def get_word_under_cursor(self, WORD: bool = False) -> str:
"""
Return the word, currently below the cursor.
This returns an empty string when the cursor is on a whitespace region.
"""
- pass
+ start, end = self.find_boundaries_of_current_word(WORD=WORD)
+ return self.text[self.cursor_position + start : self.cursor_position + end]
- def find_next_word_beginning(self, count: int=1, WORD: bool=False) ->(int |
- None):
+ def find_next_word_beginning(
+ self, count: int = 1, WORD: bool = False
+ ) -> int | None:
"""
Return an index relative to the cursor position pointing to the start
of the next word. Return `None` if nothing was found.
"""
- pass
+ if count < 0:
+ return self.find_previous_word_beginning(count=-count, WORD=WORD)
- def find_next_word_ending(self, include_current_position: bool=False,
- count: int=1, WORD: bool=False) ->(int | None):
+ regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
+ iterator = regex.finditer(self.text_after_cursor)
+
+ try:
+ for i, match in enumerate(iterator):
+ # Take first match, unless it's the word on which we're right now.
+ if i == 0 and match.start(1) == 0:
+ count += 1
+
+ if i + 1 == count:
+ return match.start(1)
+ except StopIteration:
+ pass
+ return None
+
+ def find_next_word_ending(
+ self, include_current_position: bool = False, count: int = 1, WORD: bool = False
+ ) -> int | None:
"""
Return an index relative to the cursor position pointing to the end
of the next word. Return `None` if nothing was found.
"""
- pass
+ if count < 0:
+ return self.find_previous_word_ending(count=-count, WORD=WORD)
+
+ if include_current_position:
+ text = self.text_after_cursor
+ else:
+ text = self.text_after_cursor[1:]
+
+ regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
+ iterable = regex.finditer(text)
+
+ try:
+ for i, match in enumerate(iterable):
+ if i + 1 == count:
+ value = match.end(1)
+
+ if include_current_position:
+ return value
+ else:
+ return value + 1
- def find_previous_word_beginning(self, count: int=1, WORD: bool=False) ->(
- int | None):
+ except StopIteration:
+ pass
+ return None
+
+ def find_previous_word_beginning(
+ self, count: int = 1, WORD: bool = False
+ ) -> int | None:
"""
Return an index relative to the cursor position pointing to the start
of the previous word. Return `None` if nothing was found.
"""
- pass
+ if count < 0:
+ return self.find_next_word_beginning(count=-count, WORD=WORD)
+
+ regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
+ iterator = regex.finditer(self.text_before_cursor[::-1])
+
+ try:
+ for i, match in enumerate(iterator):
+ if i + 1 == count:
+ return -match.end(1)
+ except StopIteration:
+ pass
+ return None
- def find_previous_word_ending(self, count: int=1, WORD: bool=False) ->(int
- | None):
+ def find_previous_word_ending(
+ self, count: int = 1, WORD: bool = False
+ ) -> int | None:
"""
Return an index relative to the cursor position pointing to the end
of the previous word. Return `None` if nothing was found.
"""
- pass
+ if count < 0:
+ return self.find_next_word_ending(count=-count, WORD=WORD)
+
+ text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1]
+
+ regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
+ iterator = regex.finditer(text_before_cursor)
+
+ try:
+ for i, match in enumerate(iterator):
+ # Take first match, unless it's the word on which we're right now.
+ if i == 0 and match.start(1) == 0:
+ count += 1
- def find_next_matching_line(self, match_func: Callable[[str], bool],
- count: int=1) ->(int | None):
+ if i + 1 == count:
+ return -match.start(1) + 1
+ except StopIteration:
+ pass
+ return None
+
+ def find_next_matching_line(
+ self, match_func: Callable[[str], bool], count: int = 1
+ ) -> int | None:
"""
Look downwards for empty lines.
Return the line index, relative to the current line.
"""
- pass
+ result = None
+
+ for index, line in enumerate(self.lines[self.cursor_position_row + 1 :]):
+ if match_func(line):
+ result = 1 + index
+ count -= 1
+
+ if count == 0:
+ break
- def find_previous_matching_line(self, match_func: Callable[[str], bool],
- count: int=1) ->(int | None):
+ return result
+
+ def find_previous_matching_line(
+ self, match_func: Callable[[str], bool], count: int = 1
+ ) -> int | None:
"""
Look upwards for empty lines.
Return the line index, relative to the current line.
"""
- pass
+ result = None
+
+ for index, line in enumerate(self.lines[: self.cursor_position_row][::-1]):
+ if match_func(line):
+ result = -1 - index
+ count -= 1
- def get_cursor_left_position(self, count: int=1) ->int:
+ if count == 0:
+ break
+
+ return result
+
+ def get_cursor_left_position(self, count: int = 1) -> int:
"""
Relative position for cursor left.
"""
- pass
+ if count < 0:
+ return self.get_cursor_right_position(-count)
- def get_cursor_right_position(self, count: int=1) ->int:
+ return -min(self.cursor_position_col, count)
+
+ def get_cursor_right_position(self, count: int = 1) -> int:
"""
Relative position for cursor_right.
"""
- pass
+ if count < 0:
+ return self.get_cursor_left_position(-count)
+
+ return min(count, len(self.current_line_after_cursor))
- def get_cursor_up_position(self, count: int=1, preferred_column: (int |
- None)=None) ->int:
+ def get_cursor_up_position(
+ self, count: int = 1, preferred_column: int | None = None
+ ) -> int:
"""
Return the relative cursor position (character index) where we would be if the
user pressed the arrow-up button.
@@ -372,10 +718,21 @@ class Document:
:param preferred_column: When given, go to this column instead of
staying at the current column.
"""
- pass
+ assert count >= 1
+ column = (
+ self.cursor_position_col if preferred_column is None else preferred_column
+ )
- def get_cursor_down_position(self, count: int=1, preferred_column: (int |
- None)=None) ->int:
+ return (
+ self.translate_row_col_to_index(
+ max(0, self.cursor_position_row - count), column
+ )
+ - self.cursor_position
+ )
+
+ def get_cursor_down_position(
+ self, count: int = 1, preferred_column: int | None = None
+ ) -> int:
"""
Return the relative cursor position (character index) where we would be if the
user pressed the arrow-down button.
@@ -383,68 +740,149 @@ class Document:
:param preferred_column: When given, go to this column instead of
staying at the current column.
"""
- pass
+ assert count >= 1
+ column = (
+ self.cursor_position_col if preferred_column is None else preferred_column
+ )
+
+ return (
+ self.translate_row_col_to_index(self.cursor_position_row + count, column)
+ - self.cursor_position
+ )
- def find_enclosing_bracket_right(self, left_ch: str, right_ch: str,
- end_pos: (int | None)=None) ->(int | None):
+ def find_enclosing_bracket_right(
+ self, left_ch: str, right_ch: str, end_pos: int | None = None
+ ) -> int | None:
"""
Find the right bracket enclosing current position. Return the relative
position to the cursor position.
When `end_pos` is given, don't look past the position.
"""
- pass
+ if self.current_char == right_ch:
+ return 0
+
+ if end_pos is None:
+ end_pos = len(self.text)
+ else:
+ end_pos = min(len(self.text), end_pos)
+
+ stack = 1
+
+ # Look forward.
+ for i in range(self.cursor_position + 1, end_pos):
+ c = self.text[i]
+
+ if c == left_ch:
+ stack += 1
+ elif c == right_ch:
+ stack -= 1
- def find_enclosing_bracket_left(self, left_ch: str, right_ch: str,
- start_pos: (int | None)=None) ->(int | None):
+ if stack == 0:
+ return i - self.cursor_position
+
+ return None
+
+ def find_enclosing_bracket_left(
+ self, left_ch: str, right_ch: str, start_pos: int | None = None
+ ) -> int | None:
"""
Find the left bracket enclosing current position. Return the relative
position to the cursor position.
When `start_pos` is given, don't look past the position.
"""
- pass
+ if self.current_char == left_ch:
+ return 0
+
+ if start_pos is None:
+ start_pos = 0
+ else:
+ start_pos = max(0, start_pos)
+
+ stack = 1
+
+ # Look backward.
+ for i in range(self.cursor_position - 1, start_pos - 1, -1):
+ c = self.text[i]
- def find_matching_bracket_position(self, start_pos: (int | None)=None,
- end_pos: (int | None)=None) ->int:
+ if c == right_ch:
+ stack += 1
+ elif c == left_ch:
+ stack -= 1
+
+ if stack == 0:
+ return i - self.cursor_position
+
+ return None
+
+ def find_matching_bracket_position(
+ self, start_pos: int | None = None, end_pos: int | None = None
+ ) -> int:
"""
Return relative cursor position of matching [, (, { or < bracket.
When `start_pos` or `end_pos` are given. Don't look past the positions.
"""
- pass
- def get_start_of_document_position(self) ->int:
+ # Look for a match.
+ for pair in "()", "[]", "{}", "<>":
+ A = pair[0]
+ B = pair[1]
+ if self.current_char == A:
+ return self.find_enclosing_bracket_right(A, B, end_pos=end_pos) or 0
+ elif self.current_char == B:
+ return self.find_enclosing_bracket_left(A, B, start_pos=start_pos) or 0
+
+ return 0
+
+ def get_start_of_document_position(self) -> int:
"""Relative position for the start of the document."""
- pass
+ return -self.cursor_position
- def get_end_of_document_position(self) ->int:
+ def get_end_of_document_position(self) -> int:
"""Relative position for the end of the document."""
- pass
+ return len(self.text) - self.cursor_position
- def get_start_of_line_position(self, after_whitespace: bool=False) ->int:
+ def get_start_of_line_position(self, after_whitespace: bool = False) -> int:
"""Relative position for the start of this line."""
- pass
+ if after_whitespace:
+ current_line = self.current_line
+ return (
+ len(current_line)
+ - len(current_line.lstrip())
+ - self.cursor_position_col
+ )
+ else:
+ return -len(self.current_line_before_cursor)
- def get_end_of_line_position(self) ->int:
+ def get_end_of_line_position(self) -> int:
"""Relative position for the end of this line."""
- pass
+ return len(self.current_line_after_cursor)
- def last_non_blank_of_current_line_position(self) ->int:
+ def last_non_blank_of_current_line_position(self) -> int:
"""
Relative position for the last non blank character of this line.
"""
- pass
+ return len(self.current_line.rstrip()) - self.cursor_position_col - 1
- def get_column_cursor_position(self, column: int) ->int:
+ def get_column_cursor_position(self, column: int) -> int:
"""
Return the relative cursor position for this column at the current
line. (It will stay between the boundaries of the line in case of a
larger number.)
"""
- pass
+ line_length = len(self.current_line)
+ current_column = self.cursor_position_col
+ column = max(0, min(line_length, column))
- def selection_range(self) ->tuple[int, int]:
+ return column - current_column
+
+ def selection_range(
+ self,
+ ) -> tuple[
+ int, int
+ ]: # XXX: shouldn't this return `None` if there is no selection???
"""
Return (from, to) tuple of the selection.
start and end position are included.
@@ -452,9 +890,16 @@ class Document:
This doesn't take the selection type into account. Use
`selection_ranges` instead.
"""
- pass
+ if self.selection:
+ from_, to = sorted(
+ [self.cursor_position, self.selection.original_cursor_position]
+ )
+ else:
+ from_, to = self.cursor_position, self.cursor_position
- def selection_ranges(self) ->Iterable[tuple[int, int]]:
+ return from_, to
+
+ def selection_ranges(self) -> Iterable[tuple[int, int]]:
"""
Return a list of `(from, to)` tuples for the selection or none if
nothing was selected. The upper boundary is not included.
@@ -463,9 +908,48 @@ class Document:
This will return zero ranges, like (8,8) for empty lines in a block
selection.
"""
- pass
+ if self.selection:
+ from_, to = sorted(
+ [self.cursor_position, self.selection.original_cursor_position]
+ )
+
+ if self.selection.type == SelectionType.BLOCK:
+ from_line, from_column = self.translate_index_to_position(from_)
+ to_line, to_column = self.translate_index_to_position(to)
+ from_column, to_column = sorted([from_column, to_column])
+ lines = self.lines
+
+ if vi_mode():
+ to_column += 1
- def selection_range_at_line(self, row: int) ->(tuple[int, int] | None):
+ for l in range(from_line, to_line + 1):
+ line_length = len(lines[l])
+
+ if from_column <= line_length:
+ yield (
+ self.translate_row_col_to_index(l, from_column),
+ self.translate_row_col_to_index(
+ l, min(line_length, to_column)
+ ),
+ )
+ else:
+ # In case of a LINES selection, go to the start/end of the lines.
+ if self.selection.type == SelectionType.LINES:
+ from_ = max(0, self.text.rfind("\n", 0, from_) + 1)
+
+ if self.text.find("\n", to) >= 0:
+ to = self.text.find("\n", to)
+ else:
+ to = len(self.text) - 1
+
+ # In Vi mode, the upper boundary is always included. For Emacs,
+ # that's not the case.
+ if vi_mode():
+ to += 1
+
+ yield from_, to
+
+ def selection_range_at_line(self, row: int) -> tuple[int, int] | None:
"""
If the selection spans a portion of the given line, return a (from, to) tuple.
@@ -474,18 +958,89 @@ class Document:
Returns None if the selection doesn't cover this line at all.
"""
- pass
+ if self.selection:
+ line = self.lines[row]
+
+ row_start = self.translate_row_col_to_index(row, 0)
+ row_end = self.translate_row_col_to_index(row, len(line))
+
+ from_, to = sorted(
+ [self.cursor_position, self.selection.original_cursor_position]
+ )
+
+ # Take the intersection of the current line and the selection.
+ intersection_start = max(row_start, from_)
+ intersection_end = min(row_end, to)
- def cut_selection(self) ->tuple[Document, ClipboardData]:
+ if intersection_start <= intersection_end:
+ if self.selection.type == SelectionType.LINES:
+ intersection_start = row_start
+ intersection_end = row_end
+
+ elif self.selection.type == SelectionType.BLOCK:
+ _, col1 = self.translate_index_to_position(from_)
+ _, col2 = self.translate_index_to_position(to)
+ col1, col2 = sorted([col1, col2])
+
+ if col1 > len(line):
+ return None # Block selection doesn't cross this line.
+
+ intersection_start = self.translate_row_col_to_index(row, col1)
+ intersection_end = self.translate_row_col_to_index(row, col2)
+
+ _, from_column = self.translate_index_to_position(intersection_start)
+ _, to_column = self.translate_index_to_position(intersection_end)
+
+ # In Vi mode, the upper boundary is always included. For Emacs
+ # mode, that's not the case.
+ if vi_mode():
+ to_column += 1
+
+ return from_column, to_column
+ return None
+
+ def cut_selection(self) -> tuple[Document, ClipboardData]:
"""
Return a (:class:`.Document`, :class:`.ClipboardData`) tuple, where the
document represents the new document when the selection is cut, and the
clipboard data, represents whatever has to be put on the clipboard.
"""
- pass
+ if self.selection:
+ cut_parts = []
+ remaining_parts = []
+ new_cursor_position = self.cursor_position
+
+ last_to = 0
+ for from_, to in self.selection_ranges():
+ if last_to == 0:
+ new_cursor_position = from_
+
+ remaining_parts.append(self.text[last_to:from_])
+ cut_parts.append(self.text[from_:to])
+ last_to = to
- def paste_clipboard_data(self, data: ClipboardData, paste_mode:
- PasteMode=PasteMode.EMACS, count: int=1) ->Document:
+ remaining_parts.append(self.text[last_to:])
+
+ cut_text = "\n".join(cut_parts)
+ remaining_text = "".join(remaining_parts)
+
+ # In case of a LINES selection, don't include the trailing newline.
+ if self.selection.type == SelectionType.LINES and cut_text.endswith("\n"):
+ cut_text = cut_text[:-1]
+
+ return (
+ Document(text=remaining_text, cursor_position=new_cursor_position),
+ ClipboardData(cut_text, self.selection.type),
+ )
+ else:
+ return self, ClipboardData("")
+
+ def paste_clipboard_data(
+ self,
+ data: ClipboardData,
+ paste_mode: PasteMode = PasteMode.EMACS,
+ count: int = 1,
+ ) -> Document:
"""
Return a new :class:`.Document` instance which contains the result if
we would paste this data at the current cursor position.
@@ -493,36 +1048,134 @@ class Document:
:param paste_mode: Where to paste. (Before/after/emacs.)
:param count: When >1, Paste multiple times.
"""
- pass
-
- def empty_line_count_at_the_end(self) ->int:
+ before = paste_mode == PasteMode.VI_BEFORE
+ after = paste_mode == PasteMode.VI_AFTER
+
+ if data.type == SelectionType.CHARACTERS:
+ if after:
+ new_text = (
+ self.text[: self.cursor_position + 1]
+ + data.text * count
+ + self.text[self.cursor_position + 1 :]
+ )
+ else:
+ new_text = (
+ self.text_before_cursor + data.text * count + self.text_after_cursor
+ )
+
+ new_cursor_position = self.cursor_position + len(data.text) * count
+ if before:
+ new_cursor_position -= 1
+
+ elif data.type == SelectionType.LINES:
+ l = self.cursor_position_row
+ if before:
+ lines = self.lines[:l] + [data.text] * count + self.lines[l:]
+ new_text = "\n".join(lines)
+ new_cursor_position = len("".join(self.lines[:l])) + l
+ else:
+ lines = self.lines[: l + 1] + [data.text] * count + self.lines[l + 1 :]
+ new_cursor_position = len("".join(self.lines[: l + 1])) + l + 1
+ new_text = "\n".join(lines)
+
+ elif data.type == SelectionType.BLOCK:
+ lines = self.lines[:]
+ start_line = self.cursor_position_row
+ start_column = self.cursor_position_col + (0 if before else 1)
+
+ for i, line in enumerate(data.text.split("\n")):
+ index = i + start_line
+ if index >= len(lines):
+ lines.append("")
+
+ lines[index] = lines[index].ljust(start_column)
+ lines[index] = (
+ lines[index][:start_column]
+ + line * count
+ + lines[index][start_column:]
+ )
+
+ new_text = "\n".join(lines)
+ new_cursor_position = self.cursor_position + (0 if before else 1)
+
+ return Document(text=new_text, cursor_position=new_cursor_position)
+
+ def empty_line_count_at_the_end(self) -> int:
"""
Return number of empty lines at the end of the document.
"""
- pass
+ count = 0
+ for line in self.lines[::-1]:
+ if not line or line.isspace():
+ count += 1
+ else:
+ break
+
+ return count
- def start_of_paragraph(self, count: int=1, before: bool=False) ->int:
+ def start_of_paragraph(self, count: int = 1, before: bool = False) -> int:
"""
Return the start of the current paragraph. (Relative cursor position.)
"""
- pass
- def end_of_paragraph(self, count: int=1, after: bool=False) ->int:
+ def match_func(text: str) -> bool:
+ return not text or text.isspace()
+
+ line_index = self.find_previous_matching_line(
+ match_func=match_func, count=count
+ )
+
+ if line_index:
+ add = 0 if before else 1
+ return min(0, self.get_cursor_up_position(count=-line_index) + add)
+ else:
+ return -self.cursor_position
+
+ def end_of_paragraph(self, count: int = 1, after: bool = False) -> int:
"""
Return the end of the current paragraph. (Relative cursor position.)
"""
- pass
- def insert_after(self, text: str) ->Document:
+ def match_func(text: str) -> bool:
+ return not text or text.isspace()
+
+ line_index = self.find_next_matching_line(match_func=match_func, count=count)
+
+ if line_index:
+ add = 0 if after else 1
+ return max(0, self.get_cursor_down_position(count=line_index) - add)
+ else:
+ return len(self.text_after_cursor)
+
+ # Modifiers.
+
+ def insert_after(self, text: str) -> Document:
"""
Create a new document, with this text inserted after the buffer.
It keeps selection ranges and cursor position in sync.
"""
- pass
+ return Document(
+ text=self.text + text,
+ cursor_position=self.cursor_position,
+ selection=self.selection,
+ )
- def insert_before(self, text: str) ->Document:
+ def insert_before(self, text: str) -> Document:
"""
Create a new document, with this text inserted before the buffer.
It keeps selection ranges and cursor position in sync.
"""
- pass
+ selection_state = self.selection
+
+ if selection_state:
+ selection_state = SelectionState(
+ original_cursor_position=selection_state.original_cursor_position
+ + len(text),
+ type=selection_state.type,
+ )
+
+ return Document(
+ text=text + self.text,
+ cursor_position=self.cursor_position + len(text),
+ selection=selection_state,
+ )
diff --git a/src/prompt_toolkit/enums.py b/src/prompt_toolkit/enums.py
index 0a8e11fa..da036337 100644
--- a/src/prompt_toolkit/enums.py
+++ b/src/prompt_toolkit/enums.py
@@ -1,12 +1,19 @@
from __future__ import annotations
+
from enum import Enum
class EditingMode(Enum):
- VI = 'VI'
- EMACS = 'EMACS'
+ # 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"
-SEARCH_BUFFER = 'SEARCH_BUFFER'
-DEFAULT_BUFFER = 'DEFAULT_BUFFER'
-SYSTEM_BUFFER = 'SYSTEM_BUFFER'
+#: Name of the system buffer.
+SYSTEM_BUFFER = "SYSTEM_BUFFER"
diff --git a/src/prompt_toolkit/eventloop/async_generator.py b/src/prompt_toolkit/eventloop/async_generator.py
index 9c1d7e5a..5aee50a4 100644
--- a/src/prompt_toolkit/eventloop/async_generator.py
+++ b/src/prompt_toolkit/eventloop/async_generator.py
@@ -2,31 +2,57 @@
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])
+
+__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."""
- pass
+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')
+
+_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]:
+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.
@@ -37,4 +63,62 @@ async def generator_to_async_generator(get_iterable: Callable[[], Iterable[
:param buffer_size: Size of the queue between the async consumer and the
synchronous generator that produces items.
"""
- pass
+ 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
diff --git a/src/prompt_toolkit/eventloop/inputhook.py b/src/prompt_toolkit/eventloop/inputhook.py
index 33584d8e..a4c0eee6 100644
--- a/src/prompt_toolkit/eventloop/inputhook.py
+++ b/src/prompt_toolkit/eventloop/inputhook.py
@@ -23,6 +23,7 @@ stuff to do. There are two ways to detect when to return:
also want prompt-toolkit to gain control again in order to display that.
"""
from __future__ import annotations
+
import asyncio
import os
import select
@@ -32,11 +33,19 @@ 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']
+
+__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
@@ -45,28 +54,39 @@ class InputHookContext:
Given as a parameter to the inputhook.
"""
- def __init__(self, fileno: int, input_is_ready: Callable[[], bool]) ->None:
+ 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:
+def new_eventloop_with_inputhook(
+ inputhook: Callable[[InputHookContext], None],
+) -> AbstractEventLoop:
"""
Create a new event loop with the given inputhook.
"""
- pass
+ selector = InputHookSelector(selectors.DefaultSelector(), inputhook)
+ loop = asyncio.SelectorEventLoop(selector)
+ return loop
-def set_eventloop_with_inputhook(inputhook: Callable[[InputHookContext], None]
- ) ->AbstractEventLoop:
+def set_eventloop_with_inputhook(
+ inputhook: Callable[[InputHookContext], None],
+) -> AbstractEventLoop:
"""
Create a new event loop with the given inputhook, and activate it.
"""
- pass
+ # Deprecated!
+
+ loop = new_eventloop_with_inputhook(inputhook)
+ asyncio.set_event_loop(loop)
+ return loop
class InputHookSelector(BaseSelector):
@@ -78,14 +98,93 @@ class InputHookSelector(BaseSelector):
asyncio.set_event_loop(loop)
"""
- def __init__(self, selector: BaseSelector, inputhook: Callable[[
- InputHookContext], None]) ->None:
+ def __init__(
+ self, selector: BaseSelector, inputhook: Callable[[InputHookContext], None]
+ ) -> None:
self.selector = selector
self.inputhook = inputhook
self._r, self._w = os.pipe()
- def close(self) ->None:
+ 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.
"""
- pass
+ 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()
diff --git a/src/prompt_toolkit/eventloop/utils.py b/src/prompt_toolkit/eventloop/utils.py
index 4b6cc6db..31383613 100644
--- a/src/prompt_toolkit/eventloop/utils.py
+++ b/src/prompt_toolkit/eventloop/utils.py
@@ -1,4 +1,5 @@
from __future__ import annotations
+
import asyncio
import contextvars
import sys
@@ -6,24 +7,38 @@ 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')
+
+__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]:
+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
"""
- pass
+ 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:
+
+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`.
@@ -40,12 +55,47 @@ def call_soon_threadsafe(func: Callable[[], None], max_postpone_time: (
However, we want to set a deadline value, for when the rendering should
happen. (The UI should stay responsive).
"""
- pass
+ 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
-def get_traceback_from_context(context: dict[str, Any]) ->(TracebackType | None
- ):
+ # 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.
"""
- pass
+ 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
diff --git a/src/prompt_toolkit/eventloop/win32.py b/src/prompt_toolkit/eventloop/win32.py
index 55033702..56a0c7da 100644
--- a/src/prompt_toolkit/eventloop/win32.py
+++ b/src/prompt_toolkit/eventloop/win32.py
@@ -1,19 +1,30 @@
from __future__ import annotations
+
import sys
-assert sys.platform == 'win32'
+
+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 = 258
+
+__all__ = ["wait_for_handles", "create_win32_event"]
+
+
+WAIT_TIMEOUT = 0x00000102
INFINITE = -1
-def wait_for_handles(handles: list[HANDLE], timeout: int=INFINITE) ->(HANDLE |
- None):
+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.
@@ -33,12 +44,29 @@ def wait_for_handles(handles: list[HANDLE], timeout: int=INFINITE) ->(HANDLE |
This function returns either `None` or one of the given `HANDLE` objects.
(The return value can be tested with the `is` operator.)
"""
- pass
+ 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:
+def create_win32_event() -> HANDLE:
"""
Creates a Win32 unnamed Event .
http://msdn.microsoft.com/en-us/library/windows/desktop/ms682396(v=vs.85).aspx
"""
- pass
+ return HANDLE(
+ windll.kernel32.CreateEventA(
+ pointer(SECURITY_ATTRIBUTES()),
+ BOOL(True), # Manual reset event.
+ BOOL(False), # Initial state.
+ None, # Unnamed event object.
+ )
+ )
diff --git a/src/prompt_toolkit/filters/app.py b/src/prompt_toolkit/filters/app.py
index 1cf7bf99..aacb2284 100644
--- a/src/prompt_toolkit/filters/app.py
+++ b/src/prompt_toolkit/filters/app.py
@@ -2,110 +2,190 @@
Filters that accept a `Application` as argument.
"""
from __future__ import annotations
+
from typing import TYPE_CHECKING, cast
+
from prompt_toolkit.application.current import get_app
from prompt_toolkit.cache import memoized
from prompt_toolkit.enums import EditingMode
+
from .base import Condition
+
if TYPE_CHECKING:
from prompt_toolkit.layout.layout import FocusableElement
-__all__ = ['has_arg', 'has_completions', 'completion_is_selected',
- 'has_focus', 'buffer_has_focus', 'has_selection', 'has_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']
-def has_focus(value: FocusableElement) ->Condition:
+__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.
"""
- pass
+ from prompt_toolkit.buffer import Buffer
+ from prompt_toolkit.layout import walk
+ from prompt_toolkit.layout.containers import Container, Window, to_container
+ from prompt_toolkit.layout.controls import UIControl
+
+ if isinstance(value, str):
+
+ def test() -> bool:
+ return get_app().current_buffer.name == value
+
+ elif isinstance(value, Buffer):
+
+ def test() -> bool:
+ return get_app().current_buffer == value
+
+ elif isinstance(value, UIControl):
+
+ def test() -> bool:
+ return get_app().layout.current_control == value
+
+ else:
+ value = to_container(value)
+
+ if isinstance(value, Window):
+
+ def test() -> bool:
+ return get_app().layout.current_window == value
+
+ else:
+
+ def test() -> bool:
+ # Consider focused when any window inside this container is
+ # focused.
+ current_window = get_app().layout.current_window
+
+ for c in walk(cast(Container, value)):
+ if isinstance(c, Window) and c == current_window:
+ return True
+ return False
+
+ @Condition
+ def has_focus_filter() -> bool:
+ return test()
+
+ return has_focus_filter
@Condition
-def buffer_has_focus() ->bool:
+def buffer_has_focus() -> bool:
"""
Enabled when the currently focused control is a `BufferControl`.
"""
- pass
+ return get_app().layout.buffer_has_focus
@Condition
-def has_selection() ->bool:
+def has_selection() -> bool:
"""
Enable when the current buffer has a selection.
"""
- pass
+ return bool(get_app().current_buffer.selection_state)
@Condition
-def has_suggestion() ->bool:
+def has_suggestion() -> bool:
"""
Enable when the current buffer has a suggestion.
"""
- pass
+ buffer = get_app().current_buffer
+ return buffer.suggestion is not None and buffer.suggestion.text != ""
@Condition
-def has_completions() ->bool:
+def has_completions() -> bool:
"""
Enable when the current buffer has completions.
"""
- pass
+ state = get_app().current_buffer.complete_state
+ return state is not None and len(state.completions) > 0
@Condition
-def completion_is_selected() ->bool:
+def completion_is_selected() -> bool:
"""
True when the user selected a completion.
"""
- pass
+ 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:
+def is_read_only() -> bool:
"""
True when the current buffer is read only.
"""
- pass
+ return get_app().current_buffer.read_only()
@Condition
-def is_multiline() ->bool:
+def is_multiline() -> bool:
"""
True when the current buffer has been marked as multiline.
"""
- pass
+ return get_app().current_buffer.multiline()
@Condition
-def has_validation_error() ->bool:
- """Current buffer has validation error."""
- pass
+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'."""
- pass
+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:
+def is_done() -> bool:
"""
True when the CLI is returning, aborting or exiting.
"""
- pass
+ return get_app().is_done
@Condition
-def renderer_height_is_known() ->bool:
+def renderer_height_is_known() -> bool:
"""
Only True when the renderer knows it's real height.
@@ -115,50 +195,224 @@ def renderer_height_is_known() ->bool:
until we receive the height, in order to avoid flickering -- first drawing
somewhere in the middle, and then again at the bottom.)
"""
- pass
+ return get_app().renderer.height_is_known
@memoized()
-def in_editing_mode(editing_mode: EditingMode) ->Condition:
+def in_editing_mode(editing_mode: EditingMode) -> Condition:
"""
Check whether a given editing mode is active. (Vi or Emacs.)
"""
- pass
+
+ @Condition
+ def in_editing_mode_filter() -> bool:
+ return get_app().editing_mode == editing_mode
+
+ return in_editing_mode_filter
@Condition
-def vi_navigation_mode() ->bool:
+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.
"""
- pass
+ 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_recording_macro() ->bool:
- """When recording a Vi macro."""
- pass
+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 emacs_mode() ->bool:
- """When the Emacs bindings are active."""
- pass
+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 is_searching() ->bool:
- """When we are searching."""
- pass
+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 control_is_searchable() ->bool:
- """When the current UIControl is searchable."""
- pass
+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."""
- pass
+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()
diff --git a/src/prompt_toolkit/filters/base.py b/src/prompt_toolkit/filters/base.py
index 66696022..afce6dc5 100644
--- a/src/prompt_toolkit/filters/base.py
+++ b/src/prompt_toolkit/filters/base.py
@@ -1,7 +1,9 @@
from __future__ import annotations
+
from abc import ABCMeta, abstractmethod
from typing import Callable, Iterable, Union
-__all__ = ['Filter', 'Never', 'Always', 'Condition', 'FilterOrBool']
+
+__all__ = ["Filter", "Never", "Always", "Condition", "FilterOrBool"]
class Filter(metaclass=ABCMeta):
@@ -12,57 +14,64 @@ class Filter(metaclass=ABCMeta):
The return value of ``__call__`` will tell if the feature should be active.
"""
- def __init__(self) ->None:
+ 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:
+ def __call__(self) -> bool:
"""
The actual call to evaluate the filter.
"""
return True
- def __and__(self, other: Filter) ->Filter:
+ def __and__(self, other: Filter) -> Filter:
"""
Chaining of filters using the & operator.
"""
- assert isinstance(other, Filter), 'Expecting filter, got %r' % other
+ assert isinstance(other, Filter), "Expecting filter, got %r" % other
+
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:
+ def __or__(self, other: Filter) -> Filter:
"""
Chaining of filters using the | operator.
"""
- assert isinstance(other, Filter), 'Expecting filter, got %r' % other
+ assert isinstance(other, Filter), "Expecting filter, got %r" % other
+
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:
+ 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:
+ def __bool__(self) -> None:
"""
By purpose, we don't allow bool(...) operations directly on a filter,
because the meaning is ambiguous.
@@ -72,8 +81,17 @@ class Filter(metaclass=ABCMeta):
instead of for instance ``filter1 or Always()``.
"""
raise ValueError(
- 'The truth value of a Filter is ambiguous. Instead, call it as a function.'
- )
+ "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):
@@ -81,25 +99,42 @@ class _AndList(Filter):
Result of &-operation between several filters.
"""
- def __init__(self, filters: list[Filter]) ->None:
+ def __init__(self, filters: list[Filter]) -> None:
super().__init__()
self.filters = filters
@classmethod
- def create(cls, filters: Iterable[Filter]) ->Filter:
+ 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`.
"""
- pass
+ 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:
+ 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)
+ def __repr__(self) -> str:
+ return "&".join(repr(f) for f in self.filters)
class _OrList(Filter):
@@ -107,25 +142,42 @@ class _OrList(Filter):
Result of |-operation between several filters.
"""
- def __init__(self, filters: list[Filter]) ->None:
+ def __init__(self, filters: list[Filter]) -> None:
super().__init__()
self.filters = filters
@classmethod
- def create(cls, filters: Iterable[Filter]) ->Filter:
+ 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`.
"""
- pass
+ 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:
+ 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)
+ def __repr__(self) -> str:
+ return "|".join(repr(f) for f in self.filters)
class _Invert(Filter):
@@ -133,15 +185,15 @@ class _Invert(Filter):
Negation of another filter.
"""
- def __init__(self, filter: Filter) ->None:
+ def __init__(self, filter: Filter) -> None:
super().__init__()
self.filter = filter
- def __call__(self) ->bool:
+ def __call__(self) -> bool:
return not self.filter()
- def __repr__(self) ->str:
- return '~%r' % self.filter
+ def __repr__(self) -> str:
+ return "~%r" % self.filter
class Always(Filter):
@@ -149,13 +201,13 @@ class Always(Filter):
Always enable feature.
"""
- def __call__(self) ->bool:
+ def __call__(self) -> bool:
return True
- def __or__(self, other: Filter) ->Filter:
+ def __or__(self, other: Filter) -> Filter:
return self
- def __invert__(self) ->Never:
+ def __invert__(self) -> Never:
return Never()
@@ -164,13 +216,13 @@ class Never(Filter):
Never enable feature.
"""
- def __call__(self) ->bool:
+ def __call__(self) -> bool:
return False
- def __and__(self, other: Filter) ->Filter:
+ def __and__(self, other: Filter) -> Filter:
return self
- def __invert__(self) ->Always:
+ def __invert__(self) -> Always:
return Always()
@@ -188,15 +240,16 @@ class Condition(Filter):
:param func: Callable which takes no inputs and returns a boolean.
"""
- def __init__(self, func: Callable[[], bool]) ->None:
+ def __init__(self, func: Callable[[], bool]) -> None:
super().__init__()
self.func = func
- def __call__(self) ->bool:
+ def __call__(self) -> bool:
return self.func()
- def __repr__(self) ->str:
- return 'Condition(%r)' % self.func
+ def __repr__(self) -> str:
+ return "Condition(%r)" % self.func
+# Often used as type annotation.
FilterOrBool = Union[Filter, bool]
diff --git a/src/prompt_toolkit/filters/cli.py b/src/prompt_toolkit/filters/cli.py
index bc535e54..c95080a9 100644
--- a/src/prompt_toolkit/filters/cli.py
+++ b/src/prompt_toolkit/filters/cli.py
@@ -3,36 +3,62 @@ 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__ = ['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']
-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
+
+__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
diff --git a/src/prompt_toolkit/filters/utils.py b/src/prompt_toolkit/filters/utils.py
index 4234ca63..bac85bab 100644
--- a/src/prompt_toolkit/filters/utils.py
+++ b/src/prompt_toolkit/filters/utils.py
@@ -1,23 +1,41 @@
from __future__ import annotations
+
from .base import Always, Filter, FilterOrBool, Never
-__all__ = ['to_filter', 'is_true']
+
+__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:
+_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.
"""
- pass
+ if isinstance(bool_or_filter, bool):
+ return _bool_to_filter[bool_or_filter]
+
+ if isinstance(bool_or_filter, Filter):
+ return bool_or_filter
+
+ raise TypeError("Expecting a bool or a Filter instance. Got %r" % bool_or_filter)
-def is_true(value: FilterOrBool) ->bool:
+def is_true(value: FilterOrBool) -> bool:
"""
Test whether `value` is True. In case of a Filter, call it.
:param value: Boolean or `Filter` instance.
"""
- pass
+ return to_filter(value)()
diff --git a/src/prompt_toolkit/formatted_text/ansi.py b/src/prompt_toolkit/formatted_text/ansi.py
index 35ad0c63..08ec0b32 100644
--- a/src/prompt_toolkit/formatted_text/ansi.py
+++ b/src/prompt_toolkit/formatted_text/ansi.py
@@ -1,10 +1,17 @@
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']
+
+__all__ = [
+ "ANSI",
+ "ansi_escape",
+]
class ANSI:
@@ -22,9 +29,11 @@ class ANSI:
be translated into a prompt_toolkit '[ZeroWidthEscape]' fragment.
"""
- def __init__(self, value: str) ->None:
+ 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
@@ -34,68 +43,257 @@ class ANSI:
self._blink = False
self._reverse = False
self._hidden = False
+
+ # Process received text.
parser = self._parse_corot()
- parser.send(None)
+ parser.send(None) # type: ignore
for c in value:
parser.send(c)
- def _parse_corot(self) ->Generator[None, str, None]:
+ def _parse_corot(self) -> Generator[None, str, None]:
"""
Coroutine that parses the ANSI escape sequences.
"""
- pass
+ 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))
- def _select_graphic_rendition(self, attrs: list[int]) ->None:
+ # 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.
"""
- pass
+ if not attrs:
+ attrs = [0]
+ else:
+ attrs = list(attrs[::-1])
+
+ while attrs:
+ attr = attrs.pop()
+
+ if attr in _fg_colors:
+ self._color = _fg_colors[attr]
+ elif attr in _bg_colors:
+ self._bgcolor = _bg_colors[attr]
+ elif attr == 1:
+ self._bold = True
+ # elif attr == 2:
+ # self._faint = True
+ elif attr == 3:
+ self._italic = True
+ elif attr == 4:
+ self._underline = True
+ elif attr == 5:
+ self._blink = True # Slow blink
+ elif attr == 6:
+ self._blink = True # Fast blink
+ elif attr == 7:
+ self._reverse = True
+ elif attr == 8:
+ self._hidden = True
+ elif attr == 9:
+ self._strike = True
+ elif attr == 22:
+ self._bold = False # Normal intensity
+ elif attr == 23:
+ self._italic = False
+ elif attr == 24:
+ self._underline = False
+ elif attr == 25:
+ self._blink = False
+ elif attr == 27:
+ self._reverse = False
+ elif attr == 28:
+ self._hidden = False
+ elif attr == 29:
+ self._strike = False
+ elif not attr:
+ # Reset all style attributes
+ self._color = None
+ self._bgcolor = None
+ self._bold = False
+ self._underline = False
+ self._strike = False
+ self._italic = False
+ self._blink = False
+ self._reverse = False
+ self._hidden = False
+
+ elif attr in (38, 48) and len(attrs) > 1:
+ n = attrs.pop()
- def _create_style_string(self) ->str:
+ # 256 colors.
+ if n == 5 and len(attrs) >= 1:
+ if attr == 38:
+ m = attrs.pop()
+ self._color = _256_colors.get(m)
+ elif attr == 48:
+ m = attrs.pop()
+ self._bgcolor = _256_colors.get(m)
+
+ # True colors.
+ if n == 2 and len(attrs) >= 3:
+ try:
+ color_str = "#{:02x}{:02x}{:02x}".format(
+ attrs.pop(),
+ attrs.pop(),
+ attrs.pop(),
+ )
+ except IndexError:
+ pass
+ else:
+ if attr == 38:
+ self._color = color_str
+ elif attr == 48:
+ self._bgcolor = color_str
+
+ def _create_style_string(self) -> str:
"""
Turn current style flags into a string for usage in a formatted text.
"""
- pass
+ result = []
+ if self._color:
+ result.append(self._color)
+ if self._bgcolor:
+ result.append("bg:" + self._bgcolor)
+ if self._bold:
+ result.append("bold")
+ if self._underline:
+ result.append("underline")
+ if self._strike:
+ result.append("strike")
+ if self._italic:
+ result.append("italic")
+ if self._blink:
+ result.append("blink")
+ if self._reverse:
+ result.append("reverse")
+ if self._hidden:
+ result.append("hidden")
+
+ return " ".join(result)
- def __repr__(self) ->str:
- return f'ANSI({self.value!r})'
+ def __repr__(self) -> str:
+ return f"ANSI({self.value!r})"
- def __pt_formatted_text__(self) ->StyleAndTextTuples:
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
return self._formatted_text
- def format(self, *args: str, **kwargs: str) ->ANSI:
+ 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.)
"""
- pass
+ return ANSI(FORMATTER.vformat(self.value, args, kwargs))
- def __mod__(self, value: object) ->ANSI:
+ def __mod__(self, value: object) -> ANSI:
"""
ANSI('<b>%s</b>') % value
"""
if not isinstance(value, tuple):
- value = value,
+ 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}'
+ _256_colors[i] = f"#{r:02x}{g:02x}{b:02x}"
-def ansi_escape(text: object) ->str:
+def ansi_escape(text: object) -> str:
"""
Replace characters with a special meaning.
"""
- pass
+ return str(text).replace("\x1b", "?").replace("\b", "?")
class ANSIFormatter(Formatter):
- pass
+ def format_field(self, value: object, format_spec: str) -> str:
+ return ansi_escape(format(value, format_spec))
FORMATTER = ANSIFormatter()
diff --git a/src/prompt_toolkit/formatted_text/base.py b/src/prompt_toolkit/formatted_text/base.py
index 41ecbc3b..92de3535 100644
--- a/src/prompt_toolkit/formatted_text/base.py
+++ b/src/prompt_toolkit/formatted_text/base.py
@@ -1,33 +1,60 @@
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']]]
+
+__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:
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
...
-AnyFormattedText = Union[str, 'MagicFormattedText', StyleAndTextTuples,
- Callable[[], Any], None]
-def to_formatted_text(value: AnyFormattedText, style: str='', auto_convert:
- bool=False) ->FormattedText:
+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
@@ -42,16 +69,55 @@ def to_formatted_text(value: AnyFormattedText, style: str='', auto_convert:
:param auto_convert: If `True`, also accept other types, and convert them
to a string first.
"""
- pass
-
-
-def is_formatted_text(value: object) ->TypeGuard[AnyFormattedText]:
+ 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.
"""
- pass
+ 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):
@@ -62,11 +128,11 @@ class FormattedText(StyleAndTextTuples):
tuples.)
"""
- def __pt_formatted_text__(self) ->StyleAndTextTuples:
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
return self
- def __repr__(self) ->str:
- return 'FormattedText(%s)' % super().__repr__()
+ def __repr__(self) -> str:
+ return "FormattedText(%s)" % super().__repr__()
class Template:
@@ -80,13 +146,35 @@ class Template:
:param text: Plain text.
"""
- def __init__(self, text: str) ->None:
- assert '{0}' not in 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
-def merge_formatted_text(items: Iterable[AnyFormattedText]) ->AnyFormattedText:
+ return get_result
+
+
+def merge_formatted_text(items: Iterable[AnyFormattedText]) -> AnyFormattedText:
"""
Merge (Concatenate) several pieces of formatted text together.
"""
- pass
+
+ def _merge_formatted_text() -> AnyFormattedText:
+ result = FormattedText()
+ for i in items:
+ result.extend(to_formatted_text(i))
+ return result
+
+ return _merge_formatted_text
diff --git a/src/prompt_toolkit/formatted_text/html.py b/src/prompt_toolkit/formatted_text/html.py
index 25f82c7d..a940ac8f 100644
--- a/src/prompt_toolkit/formatted_text/html.py
+++ b/src/prompt_toolkit/formatted_text/html.py
@@ -1,9 +1,12 @@
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']
+
+__all__ = ["HTML"]
class HTML:
@@ -27,86 +30,116 @@ class HTML:
``username``.
"""
- def __init__(self, value: str) ->None:
+ def __init__(self, value: str) -> None:
self.value = value
- document = minidom.parseString(f'<html-root>{value}</html-root>')
+ 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."""
+ def get_current_style() -> str:
+ "Build style string for current node."
parts = []
if name_stack:
- parts.append('class:' + ','.join(name_stack))
+ parts.append("class:" + ",".join(name_stack))
+
if fg_stack:
- parts.append('fg:' + fg_stack[-1])
+ parts.append("fg:" + fg_stack[-1])
if bg_stack:
- parts.append('bg:' + bg_stack[-1])
- return ' '.join(parts)
+ parts.append("bg:" + bg_stack[-1])
+ return " ".join(parts)
- def process_node(node: Any) ->None:
- """Process node recursively."""
+ 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 = ''
+ add_to_name_stack = child.nodeName not in (
+ "#document",
+ "html-root",
+ "style",
+ )
+ fg = bg = ""
+
for k, v in child.attributes.items():
- if k == 'fg':
+ if k == "fg":
fg = v
- if k == 'bg':
+ if k == "bg":
bg = v
- if k == 'color':
- fg = v
- if ' ' in fg:
+ 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:
+ 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 __repr__(self) -> str:
+ return f"HTML({self.value!r})"
- def __pt_formatted_text__(self) ->StyleAndTextTuples:
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
return self.formatted_text
- def format(self, *args: object, **kwargs: object) ->HTML:
+ def format(self, *args: object, **kwargs: object) -> HTML:
"""
Like `str.format`, but make sure that the arguments are properly
escaped.
"""
- pass
+ return HTML(FORMATTER.vformat(self.value, args, kwargs))
- def __mod__(self, value: object) ->HTML:
+ def __mod__(self, value: object) -> HTML:
"""
HTML('<b>%s</b>') % value
"""
if not isinstance(value, tuple):
- value = value,
+ value = (value,)
+
value = tuple(html_escape(i) for i in value)
return HTML(self.value % value)
class HTMLFormatter(Formatter):
- pass
+ 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()
diff --git a/src/prompt_toolkit/formatted_text/pygments.py b/src/prompt_toolkit/formatted_text/pygments.py
index e0c34f89..d4ef3ad8 100644
--- a/src/prompt_toolkit/formatted_text/pygments.py
+++ b/src/prompt_toolkit/formatted_text/pygments.py
@@ -1,10 +1,17 @@
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']
+
+__all__ = [
+ "PygmentsTokens",
+]
class PygmentsTokens:
@@ -13,12 +20,13 @@ class PygmentsTokens:
(``(style_str, text)`` tuples).
"""
- def __init__(self, token_list: list[tuple[Token, str]]) ->None:
+ def __init__(self, token_list: list[tuple[Token, str]]) -> None:
self.token_list = token_list
- def __pt_formatted_text__(self) ->StyleAndTextTuples:
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
result: StyleAndTextTuples = []
+
for token, text in self.token_list:
- result.append(('class:' + pygments_token_to_classname(token), text)
- )
+ result.append(("class:" + pygments_token_to_classname(token), text))
+
return result
diff --git a/src/prompt_toolkit/formatted_text/utils.py b/src/prompt_toolkit/formatted_text/utils.py
index 1e4f49b4..c8c37e09 100644
--- a/src/prompt_toolkit/formatted_text/utils.py
+++ b/src/prompt_toolkit/formatted_text/utils.py
@@ -5,31 +5,46 @@ 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']
+
+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:
+def to_plain_text(value: AnyFormattedText) -> str:
"""
Turn any kind of formatted text back into plain text.
"""
- pass
+ return fragment_list_to_text(to_formatted_text(value))
-def fragment_list_len(fragments: StyleAndTextTuples) ->int:
+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.
"""
- pass
+ ZeroWidthEscape = "[ZeroWidthEscape]"
+ return sum(len(item[1]) for item in fragments if ZeroWidthEscape not in item[0])
-def fragment_list_width(fragments: StyleAndTextTuples) ->int:
+def fragment_list_width(fragments: StyleAndTextTuples) -> int:
"""
Return the character width of this text fragment list.
(Take double width characters into account.)
@@ -37,21 +52,29 @@ def fragment_list_width(fragments: StyleAndTextTuples) ->int:
:param fragments: List of ``(style_str, text)`` or
``(style_str, text, mouse_handler)`` tuples.
"""
- pass
+ 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:
+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.
"""
- pass
+ ZeroWidthEscape = "[ZeroWidthEscape]"
+ return "".join(item[1] for item in fragments if ZeroWidthEscape not in item[0])
-def split_lines(fragments: Iterable[OneStyleAndTextTuple]) ->Iterable[
- StyleAndTextTuples]:
+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.
@@ -59,4 +82,21 @@ def split_lines(fragments: Iterable[OneStyleAndTextTuple]) ->Iterable[
:param fragments: Iterable of ``(style_str, text)`` or
``(style_str, text, mouse_handler)`` tuples.
"""
- pass
+ line: StyleAndTextTuples = []
+
+ for style, string, *mouse_handler in fragments:
+ parts = string.split("\n")
+
+ for part in parts[:-1]:
+ if part:
+ line.append(cast(OneStyleAndTextTuple, (style, part, *mouse_handler)))
+ yield line
+ line = []
+
+ line.append(cast(OneStyleAndTextTuple, (style, parts[-1], *mouse_handler)))
+
+ # Always yield the last line, even when this is an empty line. This ensures
+ # that when `fragments` ends with a newline character, an additional empty
+ # line is yielded. (Otherwise, there's no way to differentiate between the
+ # cases where `fragments` does and doesn't end with a newline.)
+ yield line
diff --git a/src/prompt_toolkit/history.py b/src/prompt_toolkit/history.py
index de643197..553918e5 100644
--- a/src/prompt_toolkit/history.py
+++ b/src/prompt_toolkit/history.py
@@ -8,14 +8,21 @@ NOTE: There is no `DynamicHistory`:
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
-__all__ = ['History', 'ThreadedHistory', 'DummyHistory', 'FileHistory',
- 'InMemoryHistory']
+
+__all__ = [
+ "History",
+ "ThreadedHistory",
+ "DummyHistory",
+ "FileHistory",
+ "InMemoryHistory",
+]
class History(metaclass=ABCMeta):
@@ -25,11 +32,19 @@ class History(metaclass=ABCMeta):
This also includes abstract methods for loading/storing history.
"""
- def __init__(self) ->None:
+ 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] = []
- async def load(self) ->AsyncGenerator[str, None]:
+ #
+ # 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).
@@ -40,21 +55,31 @@ class History(metaclass=ABCMeta):
were were appended to the history will be incorporated next time this
method is called.
"""
- pass
+ 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]:
+ def get_strings(self) -> list[str]:
"""
Get the strings from the history that are loaded so far.
(In order. Oldest item first.)
"""
- pass
+ return self._loaded_strings[::-1]
- def append_string(self, string: str) ->None:
- """Add string to the history."""
- pass
+ 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]:
+ def load_history_strings(self) -> Iterable[str]:
"""
This should be a generator that yields `str` instances.
@@ -62,14 +87,14 @@ class History(metaclass=ABCMeta):
important. (The history can already be used, even when it's only
partially loaded.)
"""
- pass
+ while False:
+ yield
@abstractmethod
- def store_string(self, string: str) ->None:
+ def store_string(self, string: str) -> None:
"""
Store the string in persistent storage.
"""
- pass
class ThreadedHistory(History):
@@ -82,22 +107,112 @@ class ThreadedHistory(History):
wait for everything to be loaded.
"""
- def __init__(self, history: History) ->None:
+ 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]:
+ async def load(self) -> AsyncGenerator[str, None]:
"""
Like `History.load(), but call `self.load_history_strings()` in a
background thread.
"""
- pass
+ # 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 __repr__(self) ->str:
- return f'ThreadedHistory({self.history!r})'
+ 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):
@@ -108,25 +223,80 @@ class InMemoryHistory(History):
`append_string` for all items or pass a list of strings to `__init__` here.
"""
- def __init__(self, history_strings: (Sequence[str] | None)=None) ->None:
+ 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
+
class FileHistory(History):
"""
:class:`.History` class that stores all strings in a file.
"""
- def __init__(self, filename: str) ->None:
+ def __init__(self, filename: str) -> None:
self.filename = filename
super().__init__()
+
+ def load_history_strings(self) -> Iterable[str]:
+ strings: list[str] = []
+ lines: list[str] = []
+
+ def add() -> None:
+ if lines:
+ # Join and drop trailing newline.
+ string = "".join(lines)[:-1]
+
+ strings.append(string)
+
+ if os.path.exists(self.filename):
+ with open(self.filename, "rb") as f:
+ for line_bytes in f:
+ line = line_bytes.decode("utf-8", errors="replace")
+
+ if line.startswith("+"):
+ lines.append(line[1:])
+ else:
+ add()
+ lines = []
+
+ add()
+
+ # Reverse the order, because newest items have to go first.
+ return reversed(strings)
+
+ def store_string(self, string: str) -> None:
+ # Save to file.
+ with open(self.filename, "ab") as f:
+
+ def write(t: str) -> None:
+ f.write(t.encode("utf-8"))
+
+ write("\n# %s\n" % datetime.datetime.now())
+ for line in string.split("\n"):
+ write("+%s\n" % line)
diff --git a/src/prompt_toolkit/input/ansi_escape_sequences.py b/src/prompt_toolkit/input/ansi_escape_sequences.py
index e1df88bf..5648c664 100644
--- a/src/prompt_toolkit/input/ansi_escape_sequences.py
+++ b/src/prompt_toolkit/input/ansi_escape_sequences.py
@@ -11,129 +11,333 @@ 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']
-ANSI_SEQUENCES: dict[str, Keys | tuple[Keys, ...]] = {'\x00': Keys.
- ControlAt, '\x01': Keys.ControlA, '\x02': Keys.ControlB, '\x03': Keys.
- ControlC, '\x04': Keys.ControlD, '\x05': Keys.ControlE, '\x06': Keys.
- ControlF, '\x07': Keys.ControlG, '\x08': Keys.ControlH, '\t': Keys.
- ControlI, '\n': Keys.ControlJ, '\x0b': Keys.ControlK, '\x0c': Keys.
- ControlL, '\r': Keys.ControlM, '\x0e': Keys.ControlN, '\x0f': Keys.
- ControlO, '\x10': Keys.ControlP, '\x11': Keys.ControlQ, '\x12': Keys.
- ControlR, '\x13': Keys.ControlS, '\x14': Keys.ControlT, '\x15': Keys.
- ControlU, '\x16': Keys.ControlV, '\x17': Keys.ControlW, '\x18': Keys.
- ControlX, '\x19': Keys.ControlY, '\x1a': Keys.ControlZ, '\x1b': Keys.
- Escape, '\x9b': Keys.ShiftEscape, '\x1c': Keys.ControlBackslash, '\x1d':
- Keys.ControlSquareClose, '\x1e': Keys.ControlCircumflex, '\x1f': Keys.
- ControlUnderscore, '\x7f': Keys.ControlH, '\x1b[1~': Keys.Home,
- '\x1b[2~': Keys.Insert, '\x1b[3~': Keys.Delete, '\x1b[4~': Keys.End,
- '\x1b[5~': Keys.PageUp, '\x1b[6~': Keys.PageDown, '\x1b[7~': Keys.Home,
- '\x1b[8~': Keys.End, '\x1b[Z': Keys.BackTab, '\x1b\t': Keys.BackTab,
- '\x1b[~': Keys.BackTab, '\x1bOP': Keys.F1, '\x1bOQ': Keys.F2, '\x1bOR':
- Keys.F3, '\x1bOS': Keys.F4, '\x1b[[A': Keys.F1, '\x1b[[B': Keys.F2,
- '\x1b[[C': Keys.F3, '\x1b[[D': Keys.F4, '\x1b[[E': Keys.F5, '\x1b[11~':
- Keys.F1, '\x1b[12~': Keys.F2, '\x1b[13~': Keys.F3, '\x1b[14~': Keys.F4,
- '\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, '\x1b[1;2P': Keys.F13, '\x1b[1;2Q': Keys.F14,
- '\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,
- '\x1b[27;2;13~': Keys.ControlM, '\x1b[27;5;13~': Keys.ControlM,
- '\x1b[27;6;13~': Keys.ControlM, '\x1b[1;5P': Keys.ControlF1,
- '\x1b[1;5Q': Keys.ControlF2, '\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;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,
- '\x1b[62~': Keys.ScrollUp, '\x1b[63~': Keys.ScrollDown, '\x1b[200~':
- Keys.BracketedPaste, '\x1b[E': Keys.Ignore, '\x1b[G': Keys.Ignore,
- '\x1b[3;2~': Keys.ShiftDelete, '\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,
- '\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), '\x1b[A': Keys.Up, '\x1b[B': Keys.Down, '\x1b[C':
- Keys.Right, '\x1b[D': Keys.Left, '\x1b[H': Keys.Home, '\x1b[F': Keys.
- End, '\x1bOA': Keys.Up, '\x1bOB': Keys.Down, '\x1bOC': Keys.Right,
- '\x1bOD': Keys.Left, '\x1bOF': Keys.End, '\x1bOH': Keys.Home,
- '\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, '\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), '\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), '\x1b[1;5A': Keys.ControlUp, '\x1b[1;5B':
- Keys.ControlDown, '\x1b[1;5C': Keys.ControlRight, '\x1b[1;5D': Keys.
- ControlLeft, '\x1b[1;5F': Keys.ControlEnd, '\x1b[1;5H': Keys.
- ControlHome, '\x1b[5A': Keys.ControlUp, '\x1b[5B': Keys.ControlDown,
- '\x1b[5C': Keys.ControlRight, '\x1b[5D': Keys.ControlLeft, '\x1bOc':
- Keys.ControlRight, '\x1bOd': Keys.ControlLeft, '\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, '\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), '\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), '\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), '\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)
- }
+
+__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]:
+def _get_reverse_ansi_sequences() -> dict[Keys, str]:
"""
Create a dictionary that maps prompt_toolkit keys back to the VT100 escape
sequences.
"""
- pass
+ 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()
diff --git a/src/prompt_toolkit/input/base.py b/src/prompt_toolkit/input/base.py
index 208e9d1f..fd1429df 100644
--- a/src/prompt_toolkit/input/base.py
+++ b/src/prompt_toolkit/input/base.py
@@ -2,11 +2,18 @@
Abstraction of CLI Input.
"""
from __future__ import annotations
+
from abc import ABCMeta, abstractmethod, abstractproperty
from contextlib import contextmanager
from typing import Callable, ContextManager, Generator
+
from prompt_toolkit.key_binding import KeyPress
-__all__ = ['Input', 'PipeInput', 'DummyInput']
+
+__all__ = [
+ "Input",
+ "PipeInput",
+ "DummyInput",
+]
class Input(metaclass=ABCMeta):
@@ -19,75 +26,67 @@ class Input(metaclass=ABCMeta):
"""
@abstractmethod
- def fileno(self) ->int:
+ def fileno(self) -> int:
"""
Fileno for putting this in an event loop.
"""
- pass
@abstractmethod
- def typeahead_hash(self) ->str:
+ def typeahead_hash(self) -> str:
"""
Identifier for storing type ahead key presses.
"""
- pass
@abstractmethod
- def read_keys(self) ->list[KeyPress]:
+ def read_keys(self) -> list[KeyPress]:
"""
Return a list of Key objects which are read/parsed from the input.
"""
- pass
- def flush_keys(self) ->list[KeyPress]:
+ def flush_keys(self) -> list[KeyPress]:
"""
Flush the underlying parser. and return the pending keys.
(Used for vt100 input.)
"""
- pass
+ return []
- def flush(self) ->None:
- """The event loop can call this when the input has to be flushed."""
+ def flush(self) -> None:
+ "The event loop can call this when the input has to be flushed."
pass
@abstractproperty
- def closed(self) ->bool:
- """Should be true when the input stream is closed."""
- pass
+ def closed(self) -> bool:
+ "Should be true when the input stream is closed."
+ return False
@abstractmethod
- def raw_mode(self) ->ContextManager[None]:
+ def raw_mode(self) -> ContextManager[None]:
"""
Context manager that turns the input into raw mode.
"""
- pass
@abstractmethod
- def cooked_mode(self) ->ContextManager[None]:
+ def cooked_mode(self) -> ContextManager[None]:
"""
Context manager that turns the input into cooked mode.
"""
- pass
@abstractmethod
- def attach(self, input_ready_callback: Callable[[], None]
- ) ->ContextManager[None]:
+ def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
"""
Return a context manager that makes this input active in the current
event loop.
"""
- pass
@abstractmethod
- def detach(self) ->ContextManager[None]:
+ def detach(self) -> ContextManager[None]:
"""
Return a context manager that makes sure that this input is not active
in the current event loop.
"""
- pass
- def close(self) ->None:
- """Close input."""
+ def close(self) -> None:
+ "Close input."
pass
@@ -97,14 +96,12 @@ class PipeInput(Input):
"""
@abstractmethod
- def send_bytes(self, data: bytes) ->None:
+ def send_bytes(self, data: bytes) -> None:
"""Feed byte string into the pipe"""
- pass
@abstractmethod
- def send_text(self, data: str) ->None:
+ def send_text(self, data: str) -> None:
"""Feed a text string into the pipe"""
- pass
class DummyInput(Input):
@@ -114,3 +111,42 @@ class DummyInput(Input):
If used in an actual application, it will make the application render
itself once and exit immediately, due to an `EOFError`.
"""
+
+ def fileno(self) -> int:
+ raise NotImplementedError
+
+ def typeahead_hash(self) -> str:
+ return "dummy-%s" % id(self)
+
+ def read_keys(self) -> list[KeyPress]:
+ return []
+
+ @property
+ def closed(self) -> bool:
+ # This needs to be true, so that the dummy input will trigger an
+ # `EOFError` immediately in the application.
+ return True
+
+ def raw_mode(self) -> ContextManager[None]:
+ return _dummy_context_manager()
+
+ def cooked_mode(self) -> ContextManager[None]:
+ return _dummy_context_manager()
+
+ def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
+ # Call the callback immediately once after attaching.
+ # This tells the callback to call `read_keys` and check the
+ # `input.closed` flag, after which it won't receive any keys, but knows
+ # that `EOFError` should be raised. This unblocks `read_from_input` in
+ # `application.py`.
+ input_ready_callback()
+
+ return _dummy_context_manager()
+
+ def detach(self) -> ContextManager[None]:
+ return _dummy_context_manager()
+
+
+@contextmanager
+def _dummy_context_manager() -> Generator[None, None, None]:
+ yield
diff --git a/src/prompt_toolkit/input/defaults.py b/src/prompt_toolkit/input/defaults.py
index 43cd9c53..483eeb20 100644
--- a/src/prompt_toolkit/input/defaults.py
+++ b/src/prompt_toolkit/input/defaults.py
@@ -1,13 +1,18 @@
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']
+
+__all__ = [
+ "create_input",
+ "create_pipe_input",
+]
-def create_input(stdin: (TextIO | None)=None, always_prefer_tty: bool=False
- ) ->Input:
+def create_input(stdin: TextIO | None = None, always_prefer_tty: bool = False) -> Input:
"""
Create the appropriate `Input` object for the current os/environment.
@@ -17,10 +22,41 @@ def create_input(stdin: (TextIO | None)=None, always_prefer_tty: bool=False
`sys.stdin`. (We can open `stdout` or `stderr` for reading, this is how
a `$PAGER` works.)
"""
- pass
+ 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
-def create_pipe_input() ->ContextManager[PipeInput]:
+ # 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.
@@ -33,4 +69,11 @@ def create_pipe_input() ->ContextManager[PipeInput]:
Breaking change: In prompt_toolkit 3.0.28 and earlier, this was returning
the `PipeInput` directly, rather than through a context manager.
"""
- pass
+ if sys.platform == "win32":
+ from .win32_pipe import Win32PipeInput
+
+ return Win32PipeInput.create()
+ else:
+ from .posix_pipe import PosixPipeInput
+
+ return PosixPipeInput.create()
diff --git a/src/prompt_toolkit/input/posix_pipe.py b/src/prompt_toolkit/input/posix_pipe.py
index 33950c1e..c131fb81 100644
--- a/src/prompt_toolkit/input/posix_pipe.py
+++ b/src/prompt_toolkit/input/posix_pipe.py
@@ -1,34 +1,50 @@
from __future__ import annotations
+
import sys
-assert sys.platform != 'win32'
+
+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']
+
+__all__ = [
+ "PosixPipeInput",
+]
class _Pipe:
- """Wrapper around os.pipe, that ensures we don't double close any end."""
+ "Wrapper around os.pipe, that ensures we don't double close any end."
- def __init__(self) ->None:
+ 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."""
- pass
+ 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
- def close_write(self) ->None:
- """Close write-end if not yet closed."""
- pass
+ os.close(self.write_fd)
+ self._write_closed = True
- def close(self) ->None:
- """Close both read and write ends."""
- pass
+ def close(self) -> None:
+ "Close both read and write ends."
+ self.close_read()
+ self.close_write()
class PosixPipeInput(Vt100Input, PipeInput):
@@ -42,35 +58,61 @@ class PosixPipeInput(Vt100Input, PipeInput):
with PosixPipeInput.create() as input:
input.send_text('inputdata')
"""
+
_id = 0
- def __init__(self, _pipe: _Pipe, _text: str='') ->None:
+ 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'
+ encoding = "utf-8"
- def isatty(stdin) ->bool:
+ def isatty(stdin) -> bool:
return True
- def fileno(stdin) ->int:
+ 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
- def send_text(self, data: str) ->None:
- """Send text to the input."""
- pass
-
- def close(self) ->None:
- """Close pipe fds."""
- pass
-
- def typeahead_hash(self) ->str:
+ @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`.
"""
- pass
+ return f"pipe-input-{self._id}"
diff --git a/src/prompt_toolkit/input/posix_utils.py b/src/prompt_toolkit/input/posix_utils.py
index 3e1d3ca4..4a78dc42 100644
--- a/src/prompt_toolkit/input/posix_utils.py
+++ b/src/prompt_toolkit/input/posix_utils.py
@@ -1,8 +1,12 @@
from __future__ import annotations
+
import os
import select
from codecs import getincrementaldecoder
-__all__ = ['PosixStdinReader']
+
+__all__ = [
+ "PosixStdinReader",
+]
class PosixStdinReader:
@@ -27,15 +31,30 @@ class PosixStdinReader:
can be any possible byte.
"""
- def __init__(self, stdin_fd: int, errors: str='surrogateescape',
- encoding: str='utf-8') ->None:
+ # 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:
+ 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.
@@ -43,4 +62,36 @@ class PosixStdinReader:
the input stream was not yet closed. This means that something went
wrong during the decoding.
"""
- pass
+ 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)
diff --git a/src/prompt_toolkit/input/typeahead.py b/src/prompt_toolkit/input/typeahead.py
index 1d5e4fca..a45e7cf5 100644
--- a/src/prompt_toolkit/input/typeahead.py
+++ b/src/prompt_toolkit/input/typeahead.py
@@ -1,4 +1,4 @@
-"""
+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
@@ -23,7 +23,7 @@ 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
+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.
@@ -32,29 +32,46 @@ 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']
+
+__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:
+def store_typeahead(input_obj: Input, key_presses: list[KeyPress]) -> None:
"""
Insert typeahead key presses for the given input.
"""
- pass
+ global _buffer
+ key = input_obj.typeahead_hash()
+ _buffer[key].extend(key_presses)
-def get_typeahead(input_obj: Input) ->list[KeyPress]:
+def get_typeahead(input_obj: Input) -> list[KeyPress]:
"""
Retrieve typeahead and reset the buffer for this input.
"""
- pass
+ global _buffer
+
+ key = input_obj.typeahead_hash()
+ result = _buffer[key]
+ _buffer[key] = []
+ return result
-def clear_typeahead(input_obj: Input) ->None:
+def clear_typeahead(input_obj: Input) -> None:
"""
Clear typeahead buffer.
"""
- pass
+ global _buffer
+ key = input_obj.typeahead_hash()
+ _buffer[key] = []
diff --git a/src/prompt_toolkit/input/vt100.py b/src/prompt_toolkit/input/vt100.py
index 2e980418..c1660de9 100644
--- a/src/prompt_toolkit/input/vt100.py
+++ b/src/prompt_toolkit/input/vt100.py
@@ -1,17 +1,26 @@
from __future__ import annotations
+
import sys
-assert sys.platform != 'win32'
+
+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']
+
+__all__ = [
+ "Vt100Input",
+ "raw_mode",
+ "cooked_mode",
+]
class Vt100Input(Input):
@@ -19,75 +28,181 @@ 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:
+ 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:
+ if "idlelib.run" in sys.modules:
raise io.UnsupportedOperation(
- 'Stdin is not a terminal. Running from Idle is not supported.'
- ) from e
+ "Stdin is not a terminal. Running from Idle is not supported."
+ ) from e
else:
- raise io.UnsupportedOperation('Stdin is not a terminal.'
- ) from e
+ 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'
+ 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] = []
- 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]:
+
+ 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.
"""
- pass
+ return _attached_input(self, input_ready_callback)
- def detach(self) ->ContextManager[None]:
+ def detach(self) -> ContextManager[None]:
"""
Return a context manager that makes sure that this input is not active
in the current event loop.
"""
- pass
+ return _detached_input(self)
+
+ def read_keys(self) -> list[KeyPress]:
+ "Read list of KeyPress."
+ # Read text from stdin.
+ data = self.stdin_reader.read()
- def read_keys(self) ->list[KeyPress]:
- """Read list of KeyPress."""
- pass
+ # Pass it through our vt100 parser.
+ self.vt100_parser.feed(data)
- def flush_keys(self) ->list[KeyPress]:
+ # 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.)
"""
- pass
+ # 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] = {}
+_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]:
+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.
"""
- pass
+ 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:
@@ -100,15 +215,31 @@ class raw_mode:
We ignore errors when executing `tcgetattr` fails.
"""
- def __init__(self, fileno: int) ->None:
+ # 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:
+ 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:
@@ -116,17 +247,45 @@ class raw_mode:
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)
- def __exit__(self, *a: object) ->None:
+ @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)
+ 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):
"""
@@ -136,3 +295,15 @@ class cooked_mode(raw_mode):
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
diff --git a/src/prompt_toolkit/input/vt100_parser.py b/src/prompt_toolkit/input/vt100_parser.py
index e2623b5d..99e2d99c 100644
--- a/src/prompt_toolkit/input/vt100_parser.py
+++ b/src/prompt_toolkit/input/vt100_parser.py
@@ -2,22 +2,39 @@
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']
-_cpr_response_re = re.compile('^' + re.escape('\x1b[') + '\\d+;\\d+R\\Z')
-_mouse_event_re = re.compile('^' + re.escape('\x1b[') +
- '(<?[\\d;]+[mM]|M...)\\Z')
-_cpr_response_prefix_re = re.compile('^' + re.escape('\x1b[') + '[\\d;]*\\Z')
-_mouse_event_prefix_re = re.compile('^' + re.escape('\x1b[') +
- '(<?[\\d;]*|M.{0,2})\\Z')
+
+__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
@@ -27,13 +44,21 @@ class _IsPrefixOfLongerMatchCache(Dict[str, bool]):
any key that start with this characters.
"""
- def __missing__(self, prefix: str) ->bool:
- if _cpr_response_prefix_re.match(prefix
- ) or _mouse_event_prefix_re.match(prefix):
+ 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:
- result = any(v for k, v in ANSI_SEQUENCES.items() if k.
- startswith(prefix) and k != prefix)
+ # 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
@@ -52,49 +77,157 @@ class Vt100Parser:
def callback(key):
pass
i = Vt100Parser(callback)
- i.feed('data...')
+ i.feed('data\x01...')
:attr feed_key_callback: Function that will be called when a key is parsed.
"""
- def __init__(self, feed_key_callback: Callable[[KeyPress], None]) ->None:
+ # 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 _start_parser(self) ->None:
+ def reset(self, request: bool = False) -> None:
+ self._in_bracketed_paste = False
+ self._start_parser()
+
+ def _start_parser(self) -> None:
"""
Start the parser coroutine.
"""
- pass
+ self._input_parser = self._input_parser_generator()
+ self._input_parser.send(None) # type: ignore
- def _get_match(self, prefix: str) ->(None | Keys | tuple[Keys, ...]):
+ def _get_match(self, prefix: str) -> None | Keys | tuple[Keys, ...]:
"""
Return the key (or keys) that maps to this prefix.
"""
- pass
-
- def _input_parser_generator(self) ->Generator[None, str | _Flush, None]:
+ # (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.
"""
- pass
-
- def _call_handler(self, key: (str | Keys | tuple[Keys, ...]),
- insert_text: str) ->None:
+ 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.
"""
- pass
+ 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:
+ def feed(self, data: str) -> None:
"""
Feed the input stream.
:param data: Input string (unicode).
"""
- pass
+ # 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)
- def flush(self) ->None:
+ # 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.
@@ -106,10 +239,11 @@ class Vt100Parser:
timeout, and processes everything that's still in the buffer as-is, so
without assuming any characters will follow.
"""
- pass
+ self._input_parser.send(_Flush())
- def feed_and_flush(self, data: str) ->None:
+ def feed_and_flush(self, data: str) -> None:
"""
Wrapper around ``feed`` and ``flush``.
"""
- pass
+ self.feed(data)
+ self.flush()
diff --git a/src/prompt_toolkit/input/win32.py b/src/prompt_toolkit/input/win32.py
index 8d89a53c..35e8948d 100644
--- a/src/prompt_toolkit/input/win32.py
+++ b/src/prompt_toolkit/input/win32.py
@@ -1,31 +1,56 @@
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'
+
+assert sys.platform == "win32"
+
+# Do not import win32-specific stuff when generating documentation.
+# Otherwise RTD would be unable to generate docs for this module.
if not SPHINX_AUTODOC_RUNNING:
import msvcrt
from ctypes import windll
+
from ctypes import Array, pointer
from ctypes.wintypes import DWORD, HANDLE
from typing import Callable, ContextManager, 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 prompt_toolkit.win32_types import (
+ INPUT_RECORD,
+ KEY_EVENT_RECORD,
+ MOUSE_EVENT_RECORD,
+ STD_INPUT_HANDLE,
+ EventTypes,
+)
+
from .ansi_escape_sequences import REVERSE_ANSI_SEQUENCES
from .base import Input
-__all__ = ['Win32Input', 'ConsoleInputReader', 'raw_mode', 'cooked_mode',
- 'attach_win32_input', 'detach_win32_input']
-FROM_LEFT_1ST_BUTTON_PRESSED = 1
-RIGHTMOST_BUTTON_PRESSED = 2
-MOUSE_MOVED = 1
-MOUSE_WHEELED = 4
+
+__all__ = [
+ "Win32Input",
+ "ConsoleInputReader",
+ "raw_mode",
+ "cooked_mode",
+ "attach_win32_input",
+ "detach_win32_input",
+]
+
+# Win32 Constants for MOUSE_EVENT_RECORD.
+# See: https://docs.microsoft.com/en-us/windows/console/mouse-event-record-str
+FROM_LEFT_1ST_BUTTON_PRESSED = 0x1
+RIGHTMOST_BUTTON_PRESSED = 0x2
+MOUSE_MOVED = 0x0001
+MOUSE_WHEELED = 0x0004
class _Win32InputBase(Input):
@@ -33,114 +58,303 @@ class _Win32InputBase(Input):
Base class for `Win32Input` and `Win32PipeInput`.
"""
- def __init__(self) ->None:
+ 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:
+ def __init__(self, stdin: TextIO | None = None) -> None:
super().__init__()
self.console_input_reader = ConsoleInputReader()
- def attach(self, input_ready_callback: Callable[[], None]
- ) ->ContextManager[None]:
+ def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
"""
Return a context manager that makes this input active in the current
event loop.
"""
- pass
+ return attach_win32_input(self, input_ready_callback)
- def detach(self) ->ContextManager[None]:
+ def detach(self) -> ContextManager[None]:
"""
Return a context manager that makes sure that this input is not active
in the current event loop.
"""
+ return detach_win32_input(self)
+
+ def read_keys(self) -> list[KeyPress]:
+ return list(self.console_input_reader.read())
+
+ def flush(self) -> None:
pass
+ @property
+ def closed(self) -> bool:
+ return False
+
+ def raw_mode(self) -> ContextManager[None]:
+ return raw_mode()
+
+ def cooked_mode(self) -> ContextManager[None]:
+ return cooked_mode()
+
+ def fileno(self) -> int:
+ # The windows console doesn't depend on the file handle, so
+ # this is not used for the event loop (which uses the
+ # handle instead). But it's used in `Application.run_system_command`
+ # which opens a subprocess with a given stdin/stdout.
+ return sys.stdin.fileno()
+
+ def typeahead_hash(self) -> str:
+ return "win32-input"
+
+ def close(self) -> None:
+ self.console_input_reader.close()
+
+ @property
+ def handle(self) -> HANDLE:
+ return self.console_input_reader.handle
+
class ConsoleInputReader:
"""
:param recognize_paste: When True, try to discover paste actions and turn
the event into a BracketedPaste.
"""
- mappings = {b'\x1b': Keys.Escape, b'\x00': Keys.ControlSpace, b'\x01':
- Keys.ControlA, b'\x02': Keys.ControlB, b'\x03': Keys.ControlC,
- b'\x04': Keys.ControlD, b'\x05': Keys.ControlE, b'\x06': Keys.
- ControlF, b'\x07': Keys.ControlG, b'\x08': Keys.ControlH, b'\t':
- Keys.ControlI, b'\n': Keys.ControlJ, b'\x0b': Keys.ControlK,
- b'\x0c': Keys.ControlL, b'\r': Keys.ControlM, b'\x0e': Keys.
- ControlN, b'\x0f': Keys.ControlO, b'\x10': Keys.ControlP, b'\x11':
- Keys.ControlQ, b'\x12': Keys.ControlR, b'\x13': Keys.ControlS,
- b'\x14': Keys.ControlT, b'\x15': Keys.ControlU, b'\x16': Keys.
- ControlV, b'\x17': Keys.ControlW, b'\x18': Keys.ControlX, b'\x19':
- Keys.ControlY, b'\x1a': Keys.ControlZ, b'\x1c': Keys.
- ControlBackslash, b'\x1d': Keys.ControlSquareClose, b'\x1e': Keys.
- ControlCircumflex, b'\x1f': Keys.ControlUnderscore, b'\x7f': Keys.
- Backspace}
- keycodes = {(33): Keys.PageUp, (34): Keys.PageDown, (35): Keys.End, (36
- ): Keys.Home, (37): Keys.Left, (38): Keys.Up, (39): Keys.Right, (40
- ): Keys.Down, (45): Keys.Insert, (46): Keys.Delete, (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 = 2
- RIGHT_ALT_PRESSED = 1
- SHIFT_PRESSED = 16
- LEFT_CTRL_PRESSED = 8
- RIGHT_CTRL_PRESSED = 4
-
- def __init__(self, recognize_paste: bool=True) ->None:
+
+ # 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)
- )
+ self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
else:
- self._fdcon = os.open('CONIN$', os.O_RDWR | os.O_BINARY)
+ 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."""
- pass
+ def close(self) -> None:
+ "Close fdcon."
+ if self._fdcon is not None:
+ os.close(self._fdcon)
- def read(self) ->Iterable[KeyPress]:
+ 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
"""
- pass
+ 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 _insert_key_data(self, key_press: KeyPress) ->KeyPress:
+ def _insert_key_data(self, key_press: KeyPress) -> KeyPress:
"""
Insert KeyPress data, for vt100 compatibility.
"""
- pass
+ if key_press.data:
+ return key_press
+
+ if isinstance(key_press.key, Keys):
+ data = REVERSE_ANSI_SEQUENCES.get(key_press.key, "")
+ else:
+ data = ""
- def _get_keys(self, read: DWORD, input_records: Array[INPUT_RECORD]
- ) ->Iterator[KeyPress]:
+ 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.
"""
- pass
+ 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]:
+ def _merge_paired_surrogates(key_presses: list[KeyPress]) -> Iterator[KeyPress]:
"""
Combines consecutive KeyPresses with high and low surrogates into
single characters
"""
- pass
+ 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:
+ 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
@@ -148,19 +362,197 @@ class ConsoleInputReader:
the best possible way to detect pasting of text and handle that
correctly.)
"""
- pass
+ # 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]:
+ def _event_to_key_presses(self, ev: KEY_EVENT_RECORD) -> list[KeyPress]:
"""
For this `KEY_EVENT_RECORD`, return a list of `KeyPress` instances.
"""
- pass
+ 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.
- def _handle_mouse(self, ev: MOUSE_EVENT_RECORD) ->list[KeyPress]:
+ # 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.
"""
- pass
+ 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 _Win32Handles:
@@ -179,36 +571,122 @@ class _Win32Handles:
mechanism (used by IPython), only works with the `SelectorEventLoop`.
"""
- def __init__(self) ->None:
+ 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:
+ def add_win32_handle(self, handle: HANDLE, callback: Callable[[], None]) -> None:
"""
Add a Win32 handle to the event loop.
"""
- pass
-
- def remove_win32_handle(self, handle: HANDLE) ->(Callable[[], None] | None
- ):
+ 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`.
"""
- pass
+ 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]:
+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.
"""
- pass
+ 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:
@@ -222,16 +700,31 @@ class raw_mode:
`raw_input` method of `.vt100_input`.
"""
- def __init__(self, fileno: (int | None)=None) ->None:
+ def __init__(self, fileno: int | None = None) -> None:
self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
- def __enter__(self) ->None:
+ 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 __exit__(self, *a: object) ->None:
+ def _patch(self) -> None:
+ # Set raw
+ ENABLE_ECHO_INPUT = 0x0004
+ ENABLE_LINE_INPUT = 0x0002
+ ENABLE_PROCESSED_INPUT = 0x0001
+
+ windll.kernel32.SetConsoleMode(
+ self.handle,
+ self.original_mode.value
+ & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT),
+ )
+
+ def __exit__(self, *a: object) -> None:
+ # Restore original mode
windll.kernel32.SetConsoleMode(self.handle, self.original_mode)
@@ -242,3 +735,15 @@ 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),
+ )
diff --git a/src/prompt_toolkit/input/win32_pipe.py b/src/prompt_toolkit/input/win32_pipe.py
index 740ea177..0bafa49e 100644
--- a/src/prompt_toolkit/input/win32_pipe.py
+++ b/src/prompt_toolkit/input/win32_pipe.py
@@ -1,17 +1,23 @@
from __future__ import annotations
+
import sys
-assert sys.platform == 'win32'
+
+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']
+
+__all__ = ["Win32PipeInput"]
class Win32PipeInput(_Win32InputBase, PipeInput):
@@ -29,68 +35,122 @@ class Win32PipeInput(_Win32InputBase, PipeInput):
input = Win32PipeInput()
input.send_text('inputdata')
"""
+
_id = 0
- def __init__(self, _event: HANDLE) ->None:
+ 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
- self._buffer: list[KeyPress] = []
+
+ # 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
- def fileno(self) ->int:
+ @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.
"""
- pass
+ raise NotImplementedError
@property
- def handle(self) ->HANDLE:
- """The handle used for registering this pipe in the event loop."""
- pass
+ 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]:
+ def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
"""
Return a context manager that makes this input active in the current
event loop.
"""
- pass
+ return attach_win32_input(self, input_ready_callback)
- def detach(self) ->ContextManager[None]:
+ def detach(self) -> ContextManager[None]:
"""
Return a context manager that makes sure that this input is not active
in the current event loop.
"""
- pass
+ 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)
- def read_keys(self) ->list[KeyPress]:
- """Read list of KeyPress."""
- pass
+ return result
- def flush_keys(self) ->list[KeyPress]:
+ def flush_keys(self) -> list[KeyPress]:
"""
Flush pending keys and return them.
(Used for flushing the 'escape' key.)
"""
- pass
+ # 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 send_bytes(self, data: bytes) ->None:
- """Send bytes to the input."""
- pass
+ def raw_mode(self) -> ContextManager[None]:
+ return DummyContext()
- def send_text(self, text: str) ->None:
- """Send text to the input."""
- pass
+ def cooked_mode(self) -> ContextManager[None]:
+ return DummyContext()
- def close(self) ->None:
- """Close write-end of the pipe."""
- pass
+ def close(self) -> None:
+ "Close write-end of the pipe."
+ self._closed = True
+ windll.kernel32.SetEvent(self._event)
- def typeahead_hash(self) ->str:
+ def typeahead_hash(self) -> str:
"""
This needs to be unique for every `PipeInput`.
"""
- pass
+ return f"pipe-input-{self._id}"
diff --git a/src/prompt_toolkit/key_binding/bindings/auto_suggest.py b/src/prompt_toolkit/key_binding/bindings/auto_suggest.py
index 67735983..3d8a843d 100644
--- a/src/prompt_toolkit/key_binding/bindings/auto_suggest.py
+++ b/src/prompt_toolkit/key_binding/bindings/auto_suggest.py
@@ -2,16 +2,22 @@
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']
+
+__all__ = [
+ "load_auto_suggest_bindings",
+]
+
E = KeyPressEvent
-def load_auto_suggest_bindings() ->KeyBindings:
+def load_auto_suggest_bindings() -> KeyBindings:
"""
Key bindings for accepting auto suggestion text.
@@ -19,4 +25,41 @@ def load_auto_suggest_bindings() ->KeyBindings:
implementation for the "right arrow", but we really want the suggestion
binding when a suggestion is available.)
"""
- pass
+ 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
diff --git a/src/prompt_toolkit/key_binding/bindings/basic.py b/src/prompt_toolkit/key_binding/bindings/basic.py
index 9c3870ef..084548d6 100644
--- a/src/prompt_toolkit/key_binding/bindings/basic.py
+++ b/src/prompt_toolkit/key_binding/bindings/basic.py
@@ -1,15 +1,255 @@
+# 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.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']
+
+__all__ = [
+ "load_basic_bindings",
+]
+
E = KeyPressEvent
-def if_no_repeat(event: E) ->bool:
+def if_no_repeat(event: E) -> bool:
"""Callable that returns True when the previous event was delivered to
another handler."""
- pass
+ return not event.is_repeat
+
+
+def load_basic_bindings() -> KeyBindings:
+ key_bindings = KeyBindings()
+ insert_mode = vi_insert_mode | emacs_insert_mode
+ handle = key_bindings.add
+
+ @handle("c-a")
+ @handle("c-b")
+ @handle("c-c")
+ @handle("c-d")
+ @handle("c-e")
+ @handle("c-f")
+ @handle("c-g")
+ @handle("c-h")
+ @handle("c-i")
+ @handle("c-j")
+ @handle("c-k")
+ @handle("c-l")
+ @handle("c-m")
+ @handle("c-n")
+ @handle("c-o")
+ @handle("c-p")
+ @handle("c-q")
+ @handle("c-r")
+ @handle("c-s")
+ @handle("c-t")
+ @handle("c-u")
+ @handle("c-v")
+ @handle("c-w")
+ @handle("c-x")
+ @handle("c-y")
+ @handle("c-z")
+ @handle("f1")
+ @handle("f2")
+ @handle("f3")
+ @handle("f4")
+ @handle("f5")
+ @handle("f6")
+ @handle("f7")
+ @handle("f8")
+ @handle("f9")
+ @handle("f10")
+ @handle("f11")
+ @handle("f12")
+ @handle("f13")
+ @handle("f14")
+ @handle("f15")
+ @handle("f16")
+ @handle("f17")
+ @handle("f18")
+ @handle("f19")
+ @handle("f20")
+ @handle("f21")
+ @handle("f22")
+ @handle("f23")
+ @handle("f24")
+ @handle("c-@") # Also c-space.
+ @handle("c-\\")
+ @handle("c-]")
+ @handle("c-^")
+ @handle("c-_")
+ @handle("backspace")
+ @handle("up")
+ @handle("down")
+ @handle("right")
+ @handle("left")
+ @handle("s-up")
+ @handle("s-down")
+ @handle("s-right")
+ @handle("s-left")
+ @handle("home")
+ @handle("end")
+ @handle("s-home")
+ @handle("s-end")
+ @handle("delete")
+ @handle("s-delete")
+ @handle("c-delete")
+ @handle("pageup")
+ @handle("pagedown")
+ @handle("s-tab")
+ @handle("tab")
+ @handle("c-s-left")
+ @handle("c-s-right")
+ @handle("c-s-home")
+ @handle("c-s-end")
+ @handle("c-left")
+ @handle("c-right")
+ @handle("c-up")
+ @handle("c-down")
+ @handle("c-home")
+ @handle("c-end")
+ @handle("insert")
+ @handle("s-insert")
+ @handle("c-insert")
+ @handle("<sigint>")
+ @handle(Keys.Ignore)
+ def _ignore(event: E) -> None:
+ """
+ First, for any of these keys, Don't do anything by default. Also don't
+ catch them in the 'Any' handler which will insert them as data.
+
+ If people want to insert these characters as a literal, they can always
+ do by doing a quoted insert. (ControlQ in emacs mode, ControlV in Vi
+ mode.)
+ """
+ pass
+
+ # Readline-style bindings.
+ handle("home")(get_by_name("beginning-of-line"))
+ handle("end")(get_by_name("end-of-line"))
+ handle("left")(get_by_name("backward-char"))
+ handle("right")(get_by_name("forward-char"))
+ handle("c-up")(get_by_name("previous-history"))
+ handle("c-down")(get_by_name("next-history"))
+ handle("c-l")(get_by_name("clear-screen"))
+
+ handle("c-k", filter=insert_mode)(get_by_name("kill-line"))
+ handle("c-u", filter=insert_mode)(get_by_name("unix-line-discard"))
+ handle("backspace", filter=insert_mode, save_before=if_no_repeat)(
+ get_by_name("backward-delete-char")
+ )
+ handle("delete", filter=insert_mode, save_before=if_no_repeat)(
+ get_by_name("delete-char")
+ )
+ handle("c-delete", filter=insert_mode, save_before=if_no_repeat)(
+ get_by_name("delete-char")
+ )
+ handle(Keys.Any, filter=insert_mode, save_before=if_no_repeat)(
+ get_by_name("self-insert")
+ )
+ handle("c-t", filter=insert_mode)(get_by_name("transpose-chars"))
+ handle("c-i", filter=insert_mode)(get_by_name("menu-complete"))
+ handle("s-tab", filter=insert_mode)(get_by_name("menu-complete-backward"))
+
+ # Control-W should delete, using whitespace as separator, while M-Del
+ # should delete using [^a-zA-Z0-9] as a boundary.
+ handle("c-w", filter=insert_mode)(get_by_name("unix-word-rubout"))
+
+ handle("pageup", filter=~has_selection)(get_by_name("previous-history"))
+ handle("pagedown", filter=~has_selection)(get_by_name("next-history"))
+
+ # CTRL keys.
+
+ @Condition
+ def has_text_before_cursor() -> bool:
+ return bool(get_app().current_buffer.text)
+
+ handle("c-d", filter=has_text_before_cursor & insert_mode)(
+ get_by_name("delete-char")
+ )
+
+ @handle("enter", filter=insert_mode & is_multiline)
+ def _newline(event: E) -> None:
+ """
+ Newline (in case of multiline input.
+ """
+ event.current_buffer.newline(copy_margin=not in_paste_mode())
+
+ @handle("c-j")
+ def _newline2(event: E) -> None:
+ r"""
+ By default, handle \n as if it were a \r (enter).
+ (It appears that some terminals send \n instead of \r when pressing
+ enter. - at least the Linux subsystem for Windows.)
+ """
+ event.key_processor.feed(KeyPress(Keys.ControlM, "\r"), first=True)
+
+ # Delete the word before the cursor.
+
+ @handle("up")
+ def _go_up(event: E) -> None:
+ event.current_buffer.auto_up(count=event.arg)
+
+ @handle("down")
+ def _go_down(event: E) -> None:
+ event.current_buffer.auto_down(count=event.arg)
+
+ @handle("delete", filter=has_selection)
+ def _cut(event: E) -> None:
+ data = event.current_buffer.cut_selection()
+ event.app.clipboard.set_data(data)
+
+ # Global bindings.
+
+ @handle("c-z")
+ def _insert_ctrl_z(event: E) -> None:
+ """
+ By default, control-Z should literally insert Ctrl-Z.
+ (Ansi Ctrl-Z, code 26 in MSDOS means End-Of-File.
+ In a Python REPL for instance, it's possible to type
+ Control-Z followed by enter to quit.)
+
+ When the system bindings are loaded and suspend-to-background is
+ supported, that will override this binding.
+ """
+ event.current_buffer.insert_text(event.data)
+
+ @handle(Keys.BracketedPaste)
+ def _paste(event: E) -> None:
+ """
+ Pasting from clipboard.
+ """
+ data = event.data
+
+ # Be sure to use \n as line ending.
+ # Some terminals (Like iTerm2) seem to paste \r\n line endings in a
+ # bracketed paste. See: https://github.com/ipython/ipython/issues/9737
+ data = data.replace("\r\n", "\n")
+ data = data.replace("\r", "\n")
+
+ event.current_buffer.insert_text(data)
+
+ @Condition
+ def in_quoted_insert() -> bool:
+ return get_app().quoted_insert
+
+ @handle(Keys.Any, filter=in_quoted_insert, eager=True)
+ def _insert_text(event: E) -> None:
+ """
+ Handle quoted insert.
+ """
+ event.current_buffer.insert_text(event.data, overwrite=False)
+ event.app.quoted_insert = False
+
+ return key_bindings
diff --git a/src/prompt_toolkit/key_binding/bindings/completion.py b/src/prompt_toolkit/key_binding/bindings/completion.py
index e88dca52..016821f4 100644
--- a/src/prompt_toolkit/key_binding/bindings/completion.py
+++ b/src/prompt_toolkit/key_binding/bindings/completion.py
@@ -2,32 +2,50 @@
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.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']
+
+__all__ = [
+ "generate_completions",
+ "display_completions_like_readline",
+]
+
E = KeyPressEvent
-def generate_completions(event: E) ->None:
- """
+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.
"""
- pass
+ 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:
+
+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
@@ -41,21 +59,147 @@ def display_completions_like_readline(event: E) ->None:
# Call this handler when 'Tab' has been pressed.
key_bindings.add(Keys.ControlI)(display_completions_like_readline)
"""
- pass
+ # 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]:
+
+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.
"""
- pass
+ 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))
-def _create_more_session(message: str='--MORE--') ->PromptSession[bool]:
+ # 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--".
"""
- pass
+ 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)
diff --git a/src/prompt_toolkit/key_binding/bindings/cpr.py b/src/prompt_toolkit/key_binding/bindings/cpr.py
index 904f58e7..cd9df0a6 100644
--- a/src/prompt_toolkit/key_binding/bindings/cpr.py
+++ b/src/prompt_toolkit/key_binding/bindings/cpr.py
@@ -1,6 +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']
+
+__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
diff --git a/src/prompt_toolkit/key_binding/bindings/emacs.py b/src/prompt_toolkit/key_binding/bindings/emacs.py
index c03fc0ea..80a66fd2 100644
--- a/src/prompt_toolkit/key_binding/bindings/emacs.py
+++ b/src/prompt_toolkit/key_binding/bindings/emacs.py
@@ -1,28 +1,557 @@
+# 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.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']
+
+__all__ = [
+ "load_emacs_bindings",
+ "load_emacs_search_bindings",
+ "load_emacs_shift_selection_bindings",
+]
+
E = KeyPressEvent
-def load_emacs_bindings() ->KeyBindingsBase:
+def load_emacs_bindings() -> KeyBindingsBase:
"""
Some e-macs extensions.
"""
- pass
+ # Overview of Readline emacs commands:
+ # http://www.catonmat.net/download/readline-emacs-editing-mode-cheat-sheet.pdf
+ key_bindings = KeyBindings()
+ handle = key_bindings.add
+
+ insert_mode = emacs_insert_mode
+
+ @handle("escape")
+ def _esc(event: E) -> None:
+ """
+ By default, ignore escape key.
+
+ (If we don't put this here, and Esc is followed by a key which sequence
+ is not handled, we'll insert an Escape character in the input stream.
+ Something we don't want and happens to easily in emacs mode.
+ Further, people can always use ControlQ to do a quoted insert.)
+ """
+ pass
+
+ handle("c-a")(get_by_name("beginning-of-line"))
+ handle("c-b")(get_by_name("backward-char"))
+ handle("c-delete", filter=insert_mode)(get_by_name("kill-word"))
+ handle("c-e")(get_by_name("end-of-line"))
+ handle("c-f")(get_by_name("forward-char"))
+ handle("c-left")(get_by_name("backward-word"))
+ handle("c-right")(get_by_name("forward-word"))
+ handle("c-x", "r", "y", filter=insert_mode)(get_by_name("yank"))
+ handle("c-y", filter=insert_mode)(get_by_name("yank"))
+ handle("escape", "b")(get_by_name("backward-word"))
+ handle("escape", "c", filter=insert_mode)(get_by_name("capitalize-word"))
+ handle("escape", "d", filter=insert_mode)(get_by_name("kill-word"))
+ handle("escape", "f")(get_by_name("forward-word"))
+ handle("escape", "l", filter=insert_mode)(get_by_name("downcase-word"))
+ handle("escape", "u", filter=insert_mode)(get_by_name("uppercase-word"))
+ handle("escape", "y", filter=insert_mode)(get_by_name("yank-pop"))
+ handle("escape", "backspace", filter=insert_mode)(get_by_name("backward-kill-word"))
+ handle("escape", "\\", filter=insert_mode)(get_by_name("delete-horizontal-space"))
+
+ handle("c-home")(get_by_name("beginning-of-buffer"))
+ handle("c-end")(get_by_name("end-of-buffer"))
+
+ handle("c-_", save_before=(lambda e: False), filter=insert_mode)(
+ get_by_name("undo")
+ )
+
+ handle("c-x", "c-u", save_before=(lambda e: False), filter=insert_mode)(
+ get_by_name("undo")
+ )
+
+ handle("escape", "<", filter=~has_selection)(get_by_name("beginning-of-history"))
+ handle("escape", ">", filter=~has_selection)(get_by_name("end-of-history"))
+
+ handle("escape", ".", filter=insert_mode)(get_by_name("yank-last-arg"))
+ handle("escape", "_", filter=insert_mode)(get_by_name("yank-last-arg"))
+ handle("escape", "c-y", filter=insert_mode)(get_by_name("yank-nth-arg"))
+ handle("escape", "#", filter=insert_mode)(get_by_name("insert-comment"))
+ handle("c-o")(get_by_name("operate-and-get-next"))
+
+ # ControlQ does a quoted insert. Not that for vt100 terminals, you have to
+ # disable flow control by running ``stty -ixon``, otherwise Ctrl-Q and
+ # Ctrl-S are captured by the terminal.
+ handle("c-q", filter=~has_selection)(get_by_name("quoted-insert"))
+
+ handle("c-x", "(")(get_by_name("start-kbd-macro"))
+ handle("c-x", ")")(get_by_name("end-kbd-macro"))
+ handle("c-x", "e")(get_by_name("call-last-kbd-macro"))
+
+ @handle("c-n")
+ def _next(event: E) -> None:
+ "Next line."
+ event.current_buffer.auto_down()
+
+ @handle("c-p")
+ def _prev(event: E) -> None:
+ "Previous line."
+ event.current_buffer.auto_up(count=event.arg)
+
+ def handle_digit(c: str) -> None:
+ """
+ Handle input of arguments.
+ The first number needs to be preceded by escape.
+ """
+
+ @handle(c, filter=has_arg)
+ @handle("escape", c)
+ def _(event: E) -> None:
+ event.append_to_arg_count(c)
+
+ for c in "0123456789":
+ handle_digit(c)
+
+ @handle("escape", "-", filter=~has_arg)
+ def _meta_dash(event: E) -> None:
+ """"""
+ if event._arg is None:
+ event.append_to_arg_count("-")
+
+ @handle("-", filter=Condition(lambda: get_app().key_processor.arg == "-"))
+ def _dash(event: E) -> None:
+ """
+ When '-' is typed again, after exactly '-' has been given as an
+ argument, ignore this.
+ """
+ event.app.key_processor.arg = "-"
+
+ @Condition
+ def is_returnable() -> bool:
+ return get_app().current_buffer.is_returnable
+
+ # Meta + Enter: always accept input.
+ handle("escape", "enter", filter=insert_mode & is_returnable)(
+ get_by_name("accept-line")
+ )
+
+ # Enter: accept input in single line mode.
+ handle("enter", filter=insert_mode & is_returnable & ~is_multiline)(
+ get_by_name("accept-line")
+ )
+
+ def character_search(buff: Buffer, char: str, count: int) -> None:
+ if count < 0:
+ match = buff.document.find_backwards(
+ char, in_current_line=True, count=-count
+ )
+ else:
+ match = buff.document.find(char, in_current_line=True, count=count)
+
+ if match is not None:
+ buff.cursor_position += match
+
+ @handle("c-]", Keys.Any)
+ def _goto_char(event: E) -> None:
+ "When Ctl-] + a character is pressed. go to that character."
+ # Also named 'character-search'
+ character_search(event.current_buffer, event.data, event.arg)
+
+ @handle("escape", "c-]", Keys.Any)
+ def _goto_char_backwards(event: E) -> None:
+ "Like Ctl-], but backwards."
+ # Also named 'character-search-backward'
+ character_search(event.current_buffer, event.data, -event.arg)
+
+ @handle("escape", "a")
+ def _prev_sentence(event: E) -> None:
+ "Previous sentence."
+ # TODO:
+
+ @handle("escape", "e")
+ def _end_of_sentence(event: E) -> None:
+ "Move to end of sentence."
+ # TODO:
+
+ @handle("escape", "t", filter=insert_mode)
+ def _swap_characters(event: E) -> None:
+ """
+ Swap the last two words before the cursor.
+ """
+ # TODO
+
+ @handle("escape", "*", filter=insert_mode)
+ def _insert_all_completions(event: E) -> None:
+ """
+ `meta-*`: Insert all possible completions of the preceding text.
+ """
+ buff = event.current_buffer
+
+ # List all completions.
+ complete_event = CompleteEvent(text_inserted=False, completion_requested=True)
+ completions = list(
+ buff.completer.get_completions(buff.document, complete_event)
+ )
+
+ # Insert them.
+ text_to_insert = " ".join(c.text for c in completions)
+ buff.insert_text(text_to_insert)
+
+ @handle("c-x", "c-x")
+ def _toggle_start_end(event: E) -> None:
+ """
+ Move cursor back and forth between the start and end of the current
+ line.
+ """
+ buffer = event.current_buffer
+
+ if buffer.document.is_cursor_at_the_end_of_line:
+ buffer.cursor_position += buffer.document.get_start_of_line_position(
+ after_whitespace=False
+ )
+ else:
+ buffer.cursor_position += buffer.document.get_end_of_line_position()
+
+ @handle("c-@") # Control-space or Control-@
+ def _start_selection(event: E) -> None:
+ """
+ Start of the selection (if the current buffer is not empty).
+ """
+ # Take the current cursor position as the start of this selection.
+ buff = event.current_buffer
+ if buff.text:
+ buff.start_selection(selection_type=SelectionType.CHARACTERS)
+
+ @handle("c-g", filter=~has_selection)
+ def _cancel(event: E) -> None:
+ """
+ Control + G: Cancel completion menu and validation state.
+ """
+ event.current_buffer.complete_state = None
+ event.current_buffer.validation_error = None
+
+ @handle("c-g", filter=has_selection)
+ def _cancel_selection(event: E) -> None:
+ """
+ Cancel selection.
+ """
+ event.current_buffer.exit_selection()
+
+ @handle("c-w", filter=has_selection)
+ @handle("c-x", "r", "k", filter=has_selection)
+ def _cut(event: E) -> None:
+ """
+ Cut selected text.
+ """
+ data = event.current_buffer.cut_selection()
+ event.app.clipboard.set_data(data)
+ @handle("escape", "w", filter=has_selection)
+ def _copy(event: E) -> None:
+ """
+ Copy selected text.
+ """
+ data = event.current_buffer.copy_selection()
+ event.app.clipboard.set_data(data)
-def load_emacs_shift_selection_bindings() ->KeyBindingsBase:
+ @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
"""
- pass
+
+ 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)
diff --git a/src/prompt_toolkit/key_binding/bindings/focus.py b/src/prompt_toolkit/key_binding/bindings/focus.py
index 9d636a30..24aa3ce3 100644
--- a/src/prompt_toolkit/key_binding/bindings/focus.py
+++ b/src/prompt_toolkit/key_binding/bindings/focus.py
@@ -1,20 +1,26 @@
from __future__ import annotations
+
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
-__all__ = ['focus_next', 'focus_previous']
+
+__all__ = [
+ "focus_next",
+ "focus_previous",
+]
+
E = KeyPressEvent
-def focus_next(event: E) ->None:
+def focus_next(event: E) -> None:
"""
Focus the next visible Window.
(Often bound to the `Tab` key.)
"""
- pass
+ event.app.layout.focus_next()
-def focus_previous(event: E) ->None:
+def focus_previous(event: E) -> None:
"""
Focus the previous visible Window.
(Often bound to the `BackTab` key.)
"""
- pass
+ event.app.layout.focus_previous()
diff --git a/src/prompt_toolkit/key_binding/bindings/mouse.py b/src/prompt_toolkit/key_binding/bindings/mouse.py
index 03ff5d6f..cb426ce7 100644
--- a/src/prompt_toolkit/key_binding/bindings/mouse.py
+++ b/src/prompt_toolkit/key_binding/bindings/mouse.py
@@ -1,116 +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 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']
+
+__all__ = [
+ "load_mouse_bindings",
+]
+
E = KeyPressEvent
-SCROLL_UP = MouseEventType.SCROLL_UP
+
+# 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
+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), (4, 'm'):
- (LEFT, MOUSE_UP, SHIFT), (8, 'm'): (LEFT, MOUSE_UP, ALT), (12, 'm'): (
- LEFT, MOUSE_UP, SHIFT_ALT), (16, 'm'): (LEFT, MOUSE_UP, CONTROL), (20,
- 'm'): (LEFT, MOUSE_UP, SHIFT_CONTROL), (24, 'm'): (LEFT, MOUSE_UP,
- ALT_CONTROL), (28, 'm'): (LEFT, MOUSE_UP, SHIFT_ALT_CONTROL), (1, 'm'):
- (MIDDLE, MOUSE_UP, NO_MODIFIER), (5, 'm'): (MIDDLE, MOUSE_UP, SHIFT), (
- 9, 'm'): (MIDDLE, MOUSE_UP, ALT), (13, 'm'): (MIDDLE, MOUSE_UP,
- SHIFT_ALT), (17, 'm'): (MIDDLE, MOUSE_UP, CONTROL), (21, 'm'): (MIDDLE,
- MOUSE_UP, SHIFT_CONTROL), (25, 'm'): (MIDDLE, MOUSE_UP, ALT_CONTROL), (
- 29, 'm'): (MIDDLE, MOUSE_UP, SHIFT_ALT_CONTROL), (2, 'm'): (RIGHT,
- MOUSE_UP, NO_MODIFIER), (6, 'm'): (RIGHT, MOUSE_UP, SHIFT), (10, 'm'):
- (RIGHT, MOUSE_UP, ALT), (14, 'm'): (RIGHT, MOUSE_UP, SHIFT_ALT), (18,
- 'm'): (RIGHT, MOUSE_UP, CONTROL), (22, 'm'): (RIGHT, MOUSE_UP,
- SHIFT_CONTROL), (26, 'm'): (RIGHT, MOUSE_UP, ALT_CONTROL), (30, 'm'): (
- RIGHT, MOUSE_UP, SHIFT_ALT_CONTROL), (0, 'M'): (LEFT, MOUSE_DOWN,
- NO_MODIFIER), (4, 'M'): (LEFT, MOUSE_DOWN, SHIFT), (8, 'M'): (LEFT,
- MOUSE_DOWN, ALT), (12, 'M'): (LEFT, MOUSE_DOWN, SHIFT_ALT), (16, 'M'):
- (LEFT, MOUSE_DOWN, CONTROL), (20, 'M'): (LEFT, MOUSE_DOWN,
- SHIFT_CONTROL), (24, 'M'): (LEFT, MOUSE_DOWN, ALT_CONTROL), (28, 'M'):
- (LEFT, MOUSE_DOWN, SHIFT_ALT_CONTROL), (1, 'M'): (MIDDLE, MOUSE_DOWN,
- NO_MODIFIER), (5, 'M'): (MIDDLE, MOUSE_DOWN, SHIFT), (9, 'M'): (MIDDLE,
- MOUSE_DOWN, ALT), (13, 'M'): (MIDDLE, MOUSE_DOWN, SHIFT_ALT), (17, 'M'):
- (MIDDLE, MOUSE_DOWN, CONTROL), (21, 'M'): (MIDDLE, MOUSE_DOWN,
- SHIFT_CONTROL), (25, 'M'): (MIDDLE, MOUSE_DOWN, ALT_CONTROL), (29, 'M'):
- (MIDDLE, MOUSE_DOWN, SHIFT_ALT_CONTROL), (2, 'M'): (RIGHT, MOUSE_DOWN,
- NO_MODIFIER), (6, 'M'): (RIGHT, MOUSE_DOWN, SHIFT), (10, 'M'): (RIGHT,
- MOUSE_DOWN, ALT), (14, 'M'): (RIGHT, MOUSE_DOWN, SHIFT_ALT), (18, 'M'):
- (RIGHT, MOUSE_DOWN, CONTROL), (22, 'M'): (RIGHT, MOUSE_DOWN,
- SHIFT_CONTROL), (26, 'M'): (RIGHT, MOUSE_DOWN, ALT_CONTROL), (30, 'M'):
- (RIGHT, MOUSE_DOWN, SHIFT_ALT_CONTROL), (32, 'M'): (LEFT, MOUSE_MOVE,
- NO_MODIFIER), (36, 'M'): (LEFT, MOUSE_MOVE, SHIFT), (40, 'M'): (LEFT,
- MOUSE_MOVE, ALT), (44, 'M'): (LEFT, MOUSE_MOVE, SHIFT_ALT), (48, 'M'):
- (LEFT, MOUSE_MOVE, CONTROL), (52, 'M'): (LEFT, MOUSE_MOVE,
- SHIFT_CONTROL), (56, 'M'): (LEFT, MOUSE_MOVE, ALT_CONTROL), (60, 'M'):
- (LEFT, MOUSE_MOVE, SHIFT_ALT_CONTROL), (33, 'M'): (MIDDLE, MOUSE_MOVE,
- NO_MODIFIER), (37, 'M'): (MIDDLE, MOUSE_MOVE, SHIFT), (41, 'M'): (
- MIDDLE, MOUSE_MOVE, ALT), (45, 'M'): (MIDDLE, MOUSE_MOVE, SHIFT_ALT), (
- 49, 'M'): (MIDDLE, MOUSE_MOVE, CONTROL), (53, 'M'): (MIDDLE, MOUSE_MOVE,
- SHIFT_CONTROL), (57, 'M'): (MIDDLE, MOUSE_MOVE, ALT_CONTROL), (61, 'M'):
- (MIDDLE, MOUSE_MOVE, SHIFT_ALT_CONTROL), (34, 'M'): (RIGHT, MOUSE_MOVE,
- NO_MODIFIER), (38, 'M'): (RIGHT, MOUSE_MOVE, SHIFT), (42, 'M'): (RIGHT,
- MOUSE_MOVE, ALT), (46, 'M'): (RIGHT, MOUSE_MOVE, SHIFT_ALT), (50, 'M'):
- (RIGHT, MOUSE_MOVE, CONTROL), (54, 'M'): (RIGHT, MOUSE_MOVE,
- SHIFT_CONTROL), (58, 'M'): (RIGHT, MOUSE_MOVE, ALT_CONTROL), (62, 'M'):
- (RIGHT, MOUSE_MOVE, SHIFT_ALT_CONTROL), (35, 'M'): (NO_BUTTON,
- MOUSE_MOVE, NO_MODIFIER), (39, 'M'): (NO_BUTTON, MOUSE_MOVE, SHIFT), (
- 43, 'M'): (NO_BUTTON, MOUSE_MOVE, ALT), (47, 'M'): (NO_BUTTON,
- MOUSE_MOVE, SHIFT_ALT), (51, 'M'): (NO_BUTTON, MOUSE_MOVE, CONTROL), (
- 55, 'M'): (NO_BUTTON, MOUSE_MOVE, SHIFT_CONTROL), (59, 'M'): (NO_BUTTON,
- MOUSE_MOVE, ALT_CONTROL), (63, 'M'): (NO_BUTTON, MOUSE_MOVE,
- SHIFT_ALT_CONTROL), (64, 'M'): (NO_BUTTON, SCROLL_UP, NO_MODIFIER), (68,
- 'M'): (NO_BUTTON, SCROLL_UP, SHIFT), (72, 'M'): (NO_BUTTON, SCROLL_UP,
- ALT), (76, 'M'): (NO_BUTTON, SCROLL_UP, SHIFT_ALT), (80, 'M'): (
- NO_BUTTON, SCROLL_UP, CONTROL), (84, 'M'): (NO_BUTTON, SCROLL_UP,
- SHIFT_CONTROL), (88, 'M'): (NO_BUTTON, SCROLL_UP, ALT_CONTROL), (92,
- 'M'): (NO_BUTTON, SCROLL_UP, SHIFT_ALT_CONTROL), (65, 'M'): (NO_BUTTON,
- SCROLL_DOWN, NO_MODIFIER), (69, 'M'): (NO_BUTTON, SCROLL_DOWN, SHIFT),
- (73, 'M'): (NO_BUTTON, SCROLL_DOWN, ALT), (77, 'M'): (NO_BUTTON,
- SCROLL_DOWN, SHIFT_ALT), (81, 'M'): (NO_BUTTON, SCROLL_DOWN, CONTROL),
- (85, 'M'): (NO_BUTTON, SCROLL_DOWN, SHIFT_CONTROL), (89, 'M'): (
- NO_BUTTON, SCROLL_DOWN, ALT_CONTROL), (93, 'M'): (NO_BUTTON,
- SCROLL_DOWN, SHIFT_ALT_CONTROL)}
-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)}
-
-
-def load_mouse_bindings() ->KeyBindings:
+
+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.)
"""
- pass
+ 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
diff --git a/src/prompt_toolkit/key_binding/bindings/named_commands.py b/src/prompt_toolkit/key_binding/bindings/named_commands.py
index 5b59883f..d8361169 100644
--- a/src/prompt_toolkit/key_binding/bindings/named_commands.py
+++ b/src/prompt_toolkit/key_binding/bindings/named_commands.py
@@ -4,7 +4,9 @@ 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
@@ -13,103 +15,148 @@ 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']
+
+__all__ = [
+ "get_by_name",
+]
+
+
+# Typing.
_Handler = Callable[[KeyPressEvent], None]
_HandlerOrBinding = Union[_Handler, Binding]
-_T = TypeVar('_T', bound=_HandlerOrBinding)
+_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]:
+def register(name: str) -> Callable[[_T], _T]:
"""
Store handler in the `_readline_commands` dictionary.
"""
- pass
+ 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:
+
+def get_by_name(name: str) -> Binding:
"""
Return the handler for the (Readline) command with the given name.
"""
- pass
+ try:
+ return _readline_commands[name]
+ except KeyError as e:
+ raise KeyError("Unknown Readline command: %r" % name) from e
+
+#
+# Commands for moving
+# See: http://www.delorie.com/gnu/docs/readline/rlman_14.html
+#
-@register('beginning-of-buffer')
-def beginning_of_buffer(event: E) ->None:
+
+@register("beginning-of-buffer")
+def beginning_of_buffer(event: E) -> None:
"""
Move to the start of the buffer.
"""
- pass
+ buff = event.current_buffer
+ buff.cursor_position = 0
-@register('end-of-buffer')
-def end_of_buffer(event: E) ->None:
+@register("end-of-buffer")
+def end_of_buffer(event: E) -> None:
"""
Move to the end of the buffer.
"""
- pass
+ buff = event.current_buffer
+ buff.cursor_position = len(buff.text)
-@register('beginning-of-line')
-def beginning_of_line(event: E) ->None:
+@register("beginning-of-line")
+def beginning_of_line(event: E) -> None:
"""
Move to the start of the current line.
"""
- pass
+ 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:
+@register("end-of-line")
+def end_of_line(event: E) -> None:
"""
Move to the end of the line.
"""
- pass
+ buff = event.current_buffer
+ buff.cursor_position += buff.document.get_end_of_line_position()
-@register('forward-char')
-def forward_char(event: E) ->None:
+@register("forward-char")
+def forward_char(event: E) -> None:
"""
Move forward a character.
"""
- pass
+ 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."""
- pass
+@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:
+@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.
"""
- pass
+ 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:
+@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.
"""
- pass
+ 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:
+
+@register("clear-screen")
+def clear_screen(event: E) -> None:
"""
Clear the screen and redraw everything at the top of the screen.
"""
- pass
+ event.app.renderer.clear()
-@register('redraw-current-line')
-def redraw_current_line(event: E) ->None:
+@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.)
@@ -117,133 +164,188 @@ def redraw_current_line(event: E) ->None:
pass
-@register('accept-line')
-def accept_line(event: E) ->None:
+#
+# 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.
"""
- pass
+ event.current_buffer.validate_and_handle()
-@register('previous-history')
-def previous_history(event: E) ->None:
+@register("previous-history")
+def previous_history(event: E) -> None:
"""
Move `back` through the history list, fetching the previous command.
"""
- pass
+ event.current_buffer.history_backward(count=event.arg)
-@register('next-history')
-def next_history(event: E) ->None:
+@register("next-history")
+def next_history(event: E) -> None:
"""
Move `forward` through the history list, fetching the next command.
"""
- pass
+ event.current_buffer.history_forward(count=event.arg)
-@register('beginning-of-history')
-def beginning_of_history(event: E) ->None:
+@register("beginning-of-history")
+def beginning_of_history(event: E) -> None:
"""
Move to the first line in the history.
"""
- pass
+ event.current_buffer.go_to_history(0)
-@register('end-of-history')
-def end_of_history(event: E) ->None:
+@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.
"""
- pass
+ 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:
+@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.
"""
- pass
+ 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
-@register('end-of-file')
-def end_of_file(event: E) ->None:
+
+#
+# Commands for changing text
+#
+
+
+@register("end-of-file")
+def end_of_file(event: E) -> None:
"""
Exit.
"""
- pass
+ event.app.exit()
-@register('delete-char')
-def delete_char(event: E) ->None:
+@register("delete-char")
+def delete_char(event: E) -> None:
"""
Delete character before the cursor.
"""
- pass
+ 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:
+@register("backward-delete-char")
+def backward_delete_char(event: E) -> None:
"""
Delete the character behind the cursor.
"""
- pass
+ 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:
+
+@register("self-insert")
+def self_insert(event: E) -> None:
"""
Insert yourself.
"""
- pass
+ event.current_buffer.insert_text(event.data * event.arg)
-@register('transpose-chars')
-def transpose_chars(event: E) ->None:
+@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.
"""
- pass
+ 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:
+@register("uppercase-word")
+def uppercase_word(event: E) -> None:
"""
Uppercase the current (or following) word.
"""
- pass
+ 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:
+
+@register("downcase-word")
+def downcase_word(event: E) -> None:
"""
Lowercase the current (or following) word.
"""
- pass
+ 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:
+@register("capitalize-word")
+def capitalize_word(event: E) -> None:
"""
Capitalize the current (or following) word.
"""
- pass
+ 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:
+@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.
"""
- pass
+ event.app.quoted_insert = True
-@register('kill-line')
-def kill_line(event: E) ->None:
+#
+# Killing and yanking.
+#
+
+
+@register("kill-line")
+def kill_line(event: E) -> None:
"""
Kill the text from the cursor to the end of the line.
@@ -251,132 +353,208 @@ def kill_line(event: E) ->None:
(That way, it is possible to delete multiple lines by executing this
command multiple times.)
"""
- pass
+ 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:
+@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.
"""
- pass
+ 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:
+@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.
"""
- pass
+ 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
-@register('backward-kill-word')
-def backward_kill_word(event: E) ->None:
+ 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.
"""
- pass
+ unix_word_rubout(event, WORD=False)
-@register('delete-horizontal-space')
-def delete_horizontal_space(event: E) ->None:
+@register("delete-horizontal-space")
+def delete_horizontal_space(event: E) -> None:
"""
Delete all spaces and tabs around point.
"""
- pass
+ 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 "))
-@register('unix-line-discard')
-def unix_line_discard(event: E) ->None:
+ 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.
"""
- pass
+ 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:
+
+@register("yank")
+def yank(event: E) -> None:
"""
Paste before cursor.
"""
- pass
+ 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:
+@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).
"""
- pass
+ 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:
+@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.
"""
- pass
+ 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:
+@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.
"""
- pass
+ 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:
+
+@register("complete")
+def complete(event: E) -> None:
"""
Attempt to perform completion.
"""
- pass
+ display_completions_like_readline(event)
-@register('menu-complete')
-def menu_complete(event: E) ->None:
+@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.)
"""
- pass
+ generate_completions(event)
-@register('menu-complete-backward')
-def menu_complete_backward(event: E) ->None:
+@register("menu-complete-backward")
+def menu_complete_backward(event: E) -> None:
"""
Move backward through the list of possible completions.
"""
- pass
+ event.current_buffer.complete_previous()
+
+
+#
+# Keyboard macros.
+#
-@register('start-kbd-macro')
-def start_kbd_macro(event: E) ->None:
+@register("start-kbd-macro")
+def start_kbd_macro(event: E) -> None:
"""
Begin saving the characters typed into the current keyboard macro.
"""
- pass
+ event.app.emacs_state.start_macro()
-@register('end-kbd-macro')
-def end_kbd_macro(event: E) ->None:
+@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.
"""
- pass
+ event.app.emacs_state.end_macro()
-@register('call-last-kbd-macro')
+@register("call-last-kbd-macro")
@key_binding(record_in_macro=False)
-def call_last_kbd_macro(event: E) ->None:
+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.
@@ -386,53 +564,90 @@ def call_last_kbd_macro(event: E) ->None:
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`.
"""
- pass
+ # 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:
+@register("print-last-kbd-macro")
+def print_last_kbd_macro(event: E) -> None:
"""
Print the last keyboard macro.
"""
- pass
+ # 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)
-@register('undo')
-def undo(event: E) ->None:
+
+#
+# Miscellaneous Commands.
+#
+
+
+@register("undo")
+def undo(event: E) -> None:
"""
Incremental undo.
"""
- pass
+ event.current_buffer.undo()
-@register('insert-comment')
-def insert_comment(event: E) ->None:
+@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.
"""
- pass
+ buff = event.current_buffer
+
+ # Transform all lines.
+ if event.arg != 1:
+ def change(line: str) -> str:
+ return line[1:] if line.startswith("#") else line
-@register('vi-editing-mode')
-def vi_editing_mode(event: E) ->None:
+ 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.
"""
- pass
+ event.app.editing_mode = EditingMode.VI
-@register('emacs-editing-mode')
-def emacs_editing_mode(event: E) ->None:
+@register("emacs-editing-mode")
+def emacs_editing_mode(event: E) -> None:
"""
Switch to Emacs editing mode.
"""
- pass
+ event.app.editing_mode = EditingMode.EMACS
-@register('prefix-meta')
-def prefix_meta(event: E) ->None:
+@register("prefix-meta")
+def prefix_meta(event: E) -> None:
"""
Metafy the next character typed. This is for keyboards without a meta key.
@@ -440,21 +655,36 @@ def prefix_meta(event: E) ->None:
key_bindings.add_key_binding('j', 'j', filter=ViInsertMode())(prefix_meta)
"""
- pass
+ # ('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:
+@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.
"""
- pass
+ 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
-@register('edit-and-execute-command')
-def edit_and_execute(event: E) ->None:
+ 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.
"""
- pass
+ buff = event.current_buffer
+ buff.open_in_editor(validate_and_handle=True)
diff --git a/src/prompt_toolkit/key_binding/bindings/open_in_editor.py b/src/prompt_toolkit/key_binding/bindings/open_in_editor.py
index 6199de8b..d156424f 100644
--- a/src/prompt_toolkit/key_binding/bindings/open_in_editor.py
+++ b/src/prompt_toolkit/key_binding/bindings/open_in_editor.py
@@ -2,29 +2,50 @@
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']
+
+__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:
+def load_open_in_editor_bindings() -> KeyBindingsBase:
"""
Load both the Vi and emacs key bindings for handling edit-and-execute-command.
"""
- pass
+ return merge_key_bindings(
+ [
+ load_emacs_open_in_editor_bindings(),
+ load_vi_open_in_editor_bindings(),
+ ]
+ )
-def load_emacs_open_in_editor_bindings() ->KeyBindings:
+def load_emacs_open_in_editor_bindings() -> KeyBindings:
"""
Pressing C-X C-E will open the buffer in an external editor.
"""
- pass
+ 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:
+def load_vi_open_in_editor_bindings() -> KeyBindings:
"""
Pressing 'v' in navigation mode will open the buffer in an external editor.
"""
- pass
+ key_bindings = KeyBindings()
+ key_bindings.add("v", filter=vi_navigation_mode)(
+ get_by_name("edit-and-execute-command")
+ )
+ return key_bindings
diff --git a/src/prompt_toolkit/key_binding/bindings/page_navigation.py b/src/prompt_toolkit/key_binding/bindings/page_navigation.py
index 302f662b..3918e141 100644
--- a/src/prompt_toolkit/key_binding/bindings/page_navigation.py
+++ b/src/prompt_toolkit/key_binding/bindings/page_navigation.py
@@ -3,31 +3,82 @@ 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']
+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:
+
+def load_page_navigation_bindings() -> KeyBindingsBase:
"""
Load both the Vi and Emacs bindings for page navigation.
"""
- pass
+ # 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:
+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.
"""
- pass
+ 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:
+
+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.
"""
- pass
+ 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)
diff --git a/src/prompt_toolkit/key_binding/bindings/scroll.py b/src/prompt_toolkit/key_binding/bindings/scroll.py
index c1d1ada6..83a4be1f 100644
--- a/src/prompt_toolkit/key_binding/bindings/scroll.py
+++ b/src/prompt_toolkit/key_binding/bindings/scroll.py
@@ -6,63 +6,184 @@ 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']
+
+__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:
+def scroll_forward(event: E, half: bool = False) -> None:
"""
Scroll window down.
"""
- pass
+ 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
-def scroll_backward(event: E, half: bool=False) ->None:
+ b.cursor_position = b.document.translate_row_col_to_index(y, 0)
+
+
+def scroll_backward(event: E, half: bool = False) -> None:
"""
Scroll window up.
"""
- pass
+ 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:
+
+def scroll_half_page_down(event: E) -> None:
"""
Same as ControlF, but only scroll half a page.
"""
- pass
+ scroll_forward(event, half=True)
-def scroll_half_page_up(event: E) ->None:
+def scroll_half_page_up(event: E) -> None:
"""
Same as ControlB, but only scroll half a page.
"""
- pass
+ scroll_backward(event, half=True)
-def scroll_one_line_down(event: E) ->None:
+def scroll_one_line_down(event: E) -> None:
"""
scroll_offset += 1
"""
- pass
+ 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:
+
+def scroll_one_line_up(event: E) -> None:
"""
scroll_offset -= 1
"""
- pass
+ 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
+ )
-def scroll_page_down(event: E) ->None:
+ # 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.)
"""
- pass
+ 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:
+def scroll_page_up(event: E) -> None:
"""
Scroll page up. (Prefer the cursor at the bottom of the page, after scrolling.)
"""
- pass
+ 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
diff --git a/src/prompt_toolkit/key_binding/bindings/search.py b/src/prompt_toolkit/key_binding/bindings/search.py
index 3df6124e..ba5e117f 100644
--- a/src/prompt_toolkit/key_binding/bindings/search.py
+++ b/src/prompt_toolkit/key_binding/bindings/search.py
@@ -2,83 +2,94 @@
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']
+
+__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:
+def abort_search(event: E) -> None:
"""
Abort an incremental search and restore the original
line.
(Usually bound to ControlG/ControlC.)
"""
- pass
+ search.stop_search()
@key_binding(filter=is_searching)
-def accept_search(event: E) ->None:
+def accept_search(event: E) -> None:
"""
When enter pressed in isearch, quit isearch mode. (Multiline
isearch would be too complicated.)
(Usually bound to Enter.)
"""
- pass
+ search.accept_search()
@key_binding(filter=control_is_searchable)
-def start_reverse_incremental_search(event: E) ->None:
+def start_reverse_incremental_search(event: E) -> None:
"""
Enter reverse incremental search.
(Usually ControlR.)
"""
- pass
+ search.start_search(direction=search.SearchDirection.BACKWARD)
@key_binding(filter=control_is_searchable)
-def start_forward_incremental_search(event: E) ->None:
+def start_forward_incremental_search(event: E) -> None:
"""
Enter forward incremental search.
(Usually ControlS.)
"""
- pass
+ search.start_search(direction=search.SearchDirection.FORWARD)
@key_binding(filter=is_searching)
-def reverse_incremental_search(event: E) ->None:
+def reverse_incremental_search(event: E) -> None:
"""
Apply reverse incremental search, but keep search buffer focused.
"""
- pass
+ search.do_incremental_search(search.SearchDirection.BACKWARD, count=event.arg)
@key_binding(filter=is_searching)
-def forward_incremental_search(event: E) ->None:
+def forward_incremental_search(event: E) -> None:
"""
Apply forward incremental search, but keep search buffer focused.
"""
- pass
+ search.do_incremental_search(search.SearchDirection.FORWARD, count=event.arg)
@Condition
-def _previous_buffer_is_returnable() ->bool:
+def _previous_buffer_is_returnable() -> bool:
"""
True if the previously focused buffer has a return handler.
"""
- pass
+ 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:
+def accept_search_and_accept_input(event: E) -> None:
"""
Accept the search operation first, then accept the input.
"""
- pass
+ search.accept_search()
+ event.current_buffer.validate_and_handle()
diff --git a/src/prompt_toolkit/key_binding/bindings/vi.py b/src/prompt_toolkit/key_binding/bindings/vi.py
index bdf5ffe0..5cc74b4f 100644
--- a/src/prompt_toolkit/key_binding/bindings/vi.py
+++ b/src/prompt_toolkit/key_binding/bindings/vi.py
@@ -1,15 +1,39 @@
+# pylint: disable=function-redefined
from __future__ import annotations
+
import codecs
import string
from enum import Enum
from itertools import accumulate
from typing import Callable, Iterable, Tuple, TypeVar
+
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import Buffer, indent, reshape_text, unindent
from prompt_toolkit.clipboard import ClipboardData
from prompt_toolkit.document import Document
-from prompt_toolkit.filters import Always, Condition, Filter, has_arg, is_read_only, is_searching
-from prompt_toolkit.filters.app import in_paste_mode, is_multiline, vi_digraph_mode, vi_insert_mode, vi_insert_multiple_mode, vi_mode, vi_navigation_mode, vi_recording_macro, vi_replace_mode, vi_replace_single_mode, vi_search_direction_reversed, vi_selection_mode, vi_waiting_for_text_object_mode
+from prompt_toolkit.filters import (
+ Always,
+ Condition,
+ Filter,
+ has_arg,
+ is_read_only,
+ is_searching,
+)
+from prompt_toolkit.filters.app import (
+ in_paste_mode,
+ is_multiline,
+ vi_digraph_mode,
+ vi_insert_mode,
+ vi_insert_multiple_mode,
+ vi_mode,
+ vi_navigation_mode,
+ vi_recording_macro,
+ vi_replace_mode,
+ vi_replace_single_mode,
+ vi_search_direction_reversed,
+ vi_selection_mode,
+ vi_waiting_for_text_object_mode,
+)
from prompt_toolkit.input.vt100_parser import Vt100Parser
from prompt_toolkit.key_binding.digraphs import DIGRAPHS
from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
@@ -17,19 +41,27 @@ from prompt_toolkit.key_binding.vi_state import CharacterFind, InputMode
from prompt_toolkit.keys import Keys
from prompt_toolkit.search import SearchDirection
from prompt_toolkit.selection import PasteMode, SelectionState, SelectionType
+
from ..key_bindings import ConditionalKeyBindings, KeyBindings, KeyBindingsBase
from .named_commands import get_by_name
-__all__ = ['load_vi_bindings', 'load_vi_search_bindings']
+
+__all__ = [
+ "load_vi_bindings",
+ "load_vi_search_bindings",
+]
+
E = KeyPressEvent
+
ascii_lowercase = string.ascii_lowercase
-vi_register_names = ascii_lowercase + '0123456789'
+
+vi_register_names = ascii_lowercase + "0123456789"
class TextObjectType(Enum):
- EXCLUSIVE = 'EXCLUSIVE'
- INCLUSIVE = 'INCLUSIVE'
- LINEWISE = 'LINEWISE'
- BLOCK = 'BLOCK'
+ EXCLUSIVE = "EXCLUSIVE"
+ INCLUSIVE = "INCLUSIVE"
+ LINEWISE = "LINEWISE"
+ BLOCK = "BLOCK"
class TextObject:
@@ -38,19 +70,32 @@ class TextObject:
Both `start` and `end` are relative to the current cursor position.
"""
- def __init__(self, start: int, end: int=0, type: TextObjectType=
- TextObjectType.EXCLUSIVE):
+ def __init__(
+ self, start: int, end: int = 0, type: TextObjectType = TextObjectType.EXCLUSIVE
+ ):
self.start = start
self.end = end
self.type = type
- def sorted(self) ->tuple[int, int]:
+ @property
+ def selection_type(self) -> SelectionType:
+ if self.type == TextObjectType.LINEWISE:
+ return SelectionType.LINES
+ if self.type == TextObjectType.BLOCK:
+ return SelectionType.BLOCK
+ else:
+ return SelectionType.CHARACTERS
+
+ def sorted(self) -> tuple[int, int]:
"""
Return a (start, end) tuple where start <= end.
"""
- pass
+ if self.start < self.end:
+ return self.start, self.end
+ else:
+ return self.end, self.start
- def operator_range(self, document: Document) ->tuple[int, int]:
+ def operator_range(self, document: Document) -> tuple[int, int]:
"""
Return a (start, end) tuple with start <= end that indicates the range
operators should operate on.
@@ -59,50 +104,2121 @@ class TextObject:
This should return something that can be used in a slice, so the `end`
position is *not* included.
"""
- pass
+ start, end = self.sorted()
+ doc = document
- def get_line_numbers(self, buffer: Buffer) ->tuple[int, int]:
+ if (
+ self.type == TextObjectType.EXCLUSIVE
+ and doc.translate_index_to_position(end + doc.cursor_position)[1] == 0
+ ):
+ # If the motion is exclusive and the end of motion is on the first
+ # column, the end position becomes end of previous line.
+ end -= 1
+ if self.type == TextObjectType.INCLUSIVE:
+ end += 1
+ if self.type == TextObjectType.LINEWISE:
+ # Select whole lines
+ row, col = doc.translate_index_to_position(start + doc.cursor_position)
+ start = doc.translate_row_col_to_index(row, 0) - doc.cursor_position
+ row, col = doc.translate_index_to_position(end + doc.cursor_position)
+ end = (
+ doc.translate_row_col_to_index(row, len(doc.lines[row]))
+ - doc.cursor_position
+ )
+ return start, end
+
+ def get_line_numbers(self, buffer: Buffer) -> tuple[int, int]:
"""
Return a (start_line, end_line) pair.
"""
- pass
+ # Get absolute cursor positions from the text object.
+ from_, to = self.operator_range(buffer.document)
+ from_ += buffer.cursor_position
+ to += buffer.cursor_position
- def cut(self, buffer: Buffer) ->tuple[Document, ClipboardData]:
+ # Take the start of the lines.
+ from_, _ = buffer.document.translate_index_to_position(from_)
+ to, _ = buffer.document.translate_index_to_position(to)
+
+ return from_, to
+
+ def cut(self, buffer: Buffer) -> tuple[Document, ClipboardData]:
"""
Turn text object into `ClipboardData` instance.
"""
- pass
+ from_, to = self.operator_range(buffer.document)
+
+ from_ += buffer.cursor_position
+ to += buffer.cursor_position
+ # For Vi mode, the SelectionState does include the upper position,
+ # while `self.operator_range` does not. So, go one to the left, unless
+ # we're in the line mode, then we don't want to risk going to the
+ # previous line, and missing one line in the selection.
+ if self.type != TextObjectType.LINEWISE:
+ to -= 1
+ document = Document(
+ buffer.text,
+ to,
+ SelectionState(original_cursor_position=from_, type=self.selection_type),
+ )
+
+ new_document, clipboard_data = document.cut_selection()
+ return new_document, clipboard_data
+
+
+# Typevar for any text object function:
TextObjectFunction = Callable[[E], TextObject]
-_TOF = TypeVar('_TOF', bound=TextObjectFunction)
+_TOF = TypeVar("_TOF", bound=TextObjectFunction)
-def create_text_object_decorator(key_bindings: KeyBindings) ->Callable[...,
- Callable[[_TOF], _TOF]]:
+def create_text_object_decorator(
+ key_bindings: KeyBindings,
+) -> Callable[..., Callable[[_TOF], _TOF]]:
"""
Create a decorator that can be used to register Vi text object implementations.
"""
- pass
+ def text_object_decorator(
+ *keys: Keys | str,
+ filter: Filter = Always(),
+ no_move_handler: bool = False,
+ no_selection_handler: bool = False,
+ eager: bool = False,
+ ) -> Callable[[_TOF], _TOF]:
+ """
+ Register a text object function.
+
+ Usage::
+
+ @text_object('w', filter=..., no_move_handler=False)
+ def handler(event):
+ # Return a text object for this key.
+ return TextObject(...)
+
+ :param no_move_handler: Disable the move handler in navigation mode.
+ (It's still active in selection mode.)
+ """
+
+ def decorator(text_object_func: _TOF) -> _TOF:
+ @key_bindings.add(
+ *keys, filter=vi_waiting_for_text_object_mode & filter, eager=eager
+ )
+ def _apply_operator_to_text_object(event: E) -> None:
+ # Arguments are multiplied.
+ vi_state = event.app.vi_state
+ event._arg = str((vi_state.operator_arg or 1) * (event.arg or 1))
+
+ # Call the text object handler.
+ text_obj = text_object_func(event)
+
+ # Get the operator function.
+ # (Should never be None here, given the
+ # `vi_waiting_for_text_object_mode` filter state.)
+ operator_func = vi_state.operator_func
+
+ if text_obj is not None and operator_func is not None:
+ # Call the operator function with the text object.
+ operator_func(event, text_obj)
+
+ # Clear operator.
+ event.app.vi_state.operator_func = None
+ event.app.vi_state.operator_arg = None
+
+ # Register a move operation. (Doesn't need an operator.)
+ if not no_move_handler:
+
+ @key_bindings.add(
+ *keys,
+ filter=~vi_waiting_for_text_object_mode
+ & filter
+ & vi_navigation_mode,
+ eager=eager,
+ )
+ def _move_in_navigation_mode(event: E) -> None:
+ """
+ Move handler for navigation mode.
+ """
+ text_object = text_object_func(event)
+ event.current_buffer.cursor_position += text_object.start
+
+ # Register a move selection operation.
+ if not no_selection_handler:
+ @key_bindings.add(
+ *keys,
+ filter=~vi_waiting_for_text_object_mode
+ & filter
+ & vi_selection_mode,
+ eager=eager,
+ )
+ def _move_in_selection_mode(event: E) -> None:
+ """
+ Move handler for selection mode.
+ """
+ text_object = text_object_func(event)
+ buff = event.current_buffer
+ selection_state = buff.selection_state
+
+ if selection_state is None:
+ return # Should not happen, because of the `vi_selection_mode` filter.
+
+ # When the text object has both a start and end position, like 'i(' or 'iw',
+ # Turn this into a selection, otherwise the cursor.
+ if text_object.end:
+ # Take selection positions from text object.
+ start, end = text_object.operator_range(buff.document)
+ start += buff.cursor_position
+ end += buff.cursor_position
+
+ selection_state.original_cursor_position = start
+ buff.cursor_position = end
+
+ # Take selection type from text object.
+ if text_object.type == TextObjectType.LINEWISE:
+ selection_state.type = SelectionType.LINES
+ else:
+ selection_state.type = SelectionType.CHARACTERS
+ else:
+ event.current_buffer.cursor_position += text_object.start
+
+ # Make it possible to chain @text_object decorators.
+ return text_object_func
+
+ return decorator
+
+ return text_object_decorator
+
+
+# Typevar for any operator function:
OperatorFunction = Callable[[E, TextObject], None]
-_OF = TypeVar('_OF', bound=OperatorFunction)
+_OF = TypeVar("_OF", bound=OperatorFunction)
-def create_operator_decorator(key_bindings: KeyBindings) ->Callable[...,
- Callable[[_OF], _OF]]:
+def create_operator_decorator(
+ key_bindings: KeyBindings,
+) -> Callable[..., Callable[[_OF], _OF]]:
"""
Create a decorator that can be used for registering Vi operators.
"""
- pass
+ def operator_decorator(
+ *keys: Keys | str, filter: Filter = Always(), eager: bool = False
+ ) -> Callable[[_OF], _OF]:
+ """
+ Register a Vi operator.
+
+ Usage::
+
+ @operator('d', filter=...)
+ def handler(event, text_object):
+ # Do something with the text object here.
+ """
+
+ def decorator(operator_func: _OF) -> _OF:
+ @key_bindings.add(
+ *keys,
+ filter=~vi_waiting_for_text_object_mode & filter & vi_navigation_mode,
+ eager=eager,
+ )
+ def _operator_in_navigation(event: E) -> None:
+ """
+ Handle operator in navigation mode.
+ """
+ # When this key binding is matched, only set the operator
+ # function in the ViState. We should execute it after a text
+ # object has been received.
+ event.app.vi_state.operator_func = operator_func
+ event.app.vi_state.operator_arg = event.arg
+
+ @key_bindings.add(
+ *keys,
+ filter=~vi_waiting_for_text_object_mode & filter & vi_selection_mode,
+ eager=eager,
+ )
+ def _operator_in_selection(event: E) -> None:
+ """
+ Handle operator in selection mode.
+ """
+ buff = event.current_buffer
+ selection_state = buff.selection_state
+
+ if selection_state is not None:
+ # Create text object from selection.
+ if selection_state.type == SelectionType.LINES:
+ text_obj_type = TextObjectType.LINEWISE
+ elif selection_state.type == SelectionType.BLOCK:
+ text_obj_type = TextObjectType.BLOCK
+ else:
+ text_obj_type = TextObjectType.INCLUSIVE
-def load_vi_bindings() ->KeyBindingsBase:
+ text_object = TextObject(
+ selection_state.original_cursor_position - buff.cursor_position,
+ type=text_obj_type,
+ )
+
+ # Execute operator.
+ operator_func(event, text_object)
+
+ # Quit selection mode.
+ buff.selection_state = None
+
+ return operator_func
+
+ return decorator
+
+ return operator_decorator
+
+
+def load_vi_bindings() -> KeyBindingsBase:
"""
Vi extensions.
# Overview of Readline Vi commands:
# http://www.catonmat.net/download/bash-vi-editing-mode-cheat-sheet.pdf
"""
- pass
+ # Note: Some key bindings have the "~IsReadOnly()" filter added. This
+ # prevents the handler to be executed when the focus is on a
+ # read-only buffer.
+ # This is however only required for those that change the ViState to
+ # INSERT mode. The `Buffer` class itself throws the
+ # `EditReadOnlyBuffer` exception for any text operations which is
+ # handled correctly. There is no need to add "~IsReadOnly" to all key
+ # bindings that do text manipulation.
+
+ key_bindings = KeyBindings()
+ handle = key_bindings.add
+
+ # (Note: Always take the navigation bindings in read-only mode, even when
+ # ViState says different.)
+
+ TransformFunction = Tuple[Tuple[str, ...], Filter, Callable[[str], str]]
+
+ vi_transform_functions: list[TransformFunction] = [
+ # Rot 13 transformation
+ (
+ ("g", "?"),
+ Always(),
+ lambda string: codecs.encode(string, "rot_13"),
+ ),
+ # To lowercase
+ (("g", "u"), Always(), lambda string: string.lower()),
+ # To uppercase.
+ (("g", "U"), Always(), lambda string: string.upper()),
+ # Swap case.
+ (("g", "~"), Always(), lambda string: string.swapcase()),
+ (
+ ("~",),
+ Condition(lambda: get_app().vi_state.tilde_operator),
+ lambda string: string.swapcase(),
+ ),
+ ]
+
+ # Insert a character literally (quoted insert).
+ handle("c-v", filter=vi_insert_mode)(get_by_name("quoted-insert"))
+
+ @handle("escape")
+ def _back_to_navigation(event: E) -> None:
+ """
+ Escape goes to vi navigation mode.
+ """
+ buffer = event.current_buffer
+ vi_state = event.app.vi_state
+
+ if vi_state.input_mode in (InputMode.INSERT, InputMode.REPLACE):
+ buffer.cursor_position += buffer.document.get_cursor_left_position()
+
+ vi_state.input_mode = InputMode.NAVIGATION
+
+ if bool(buffer.selection_state):
+ buffer.exit_selection()
+
+ @handle("k", filter=vi_selection_mode)
+ def _up_in_selection(event: E) -> None:
+ """
+ Arrow up in selection mode.
+ """
+ event.current_buffer.cursor_up(count=event.arg)
+
+ @handle("j", filter=vi_selection_mode)
+ def _down_in_selection(event: E) -> None:
+ """
+ Arrow down in selection mode.
+ """
+ event.current_buffer.cursor_down(count=event.arg)
+
+ @handle("up", filter=vi_navigation_mode)
+ @handle("c-p", filter=vi_navigation_mode)
+ def _up_in_navigation(event: E) -> None:
+ """
+ Arrow up and ControlP in navigation mode go up.
+ """
+ event.current_buffer.auto_up(count=event.arg)
+
+ @handle("k", filter=vi_navigation_mode)
+ def _go_up(event: E) -> None:
+ """
+ Go up, but if we enter a new history entry, move to the start of the
+ line.
+ """
+ event.current_buffer.auto_up(
+ count=event.arg, go_to_start_of_line_if_history_changes=True
+ )
+
+ @handle("down", filter=vi_navigation_mode)
+ @handle("c-n", filter=vi_navigation_mode)
+ def _go_down(event: E) -> None:
+ """
+ Arrow down and Control-N in navigation mode.
+ """
+ event.current_buffer.auto_down(count=event.arg)
+
+ @handle("j", filter=vi_navigation_mode)
+ def _go_down2(event: E) -> None:
+ """
+ Go down, but if we enter a new history entry, go to the start of the line.
+ """
+ event.current_buffer.auto_down(
+ count=event.arg, go_to_start_of_line_if_history_changes=True
+ )
+
+ @handle("backspace", filter=vi_navigation_mode)
+ def _go_left(event: E) -> None:
+ """
+ In navigation-mode, move cursor.
+ """
+ event.current_buffer.cursor_position += (
+ event.current_buffer.document.get_cursor_left_position(count=event.arg)
+ )
+
+ @handle("c-n", filter=vi_insert_mode)
+ def _complete_next(event: E) -> None:
+ b = event.current_buffer
+
+ if b.complete_state:
+ b.complete_next()
+ else:
+ b.start_completion(select_first=True)
+
+ @handle("c-p", filter=vi_insert_mode)
+ def _complete_prev(event: E) -> None:
+ """
+ Control-P: To previous completion.
+ """
+ b = event.current_buffer
+
+ if b.complete_state:
+ b.complete_previous()
+ else:
+ b.start_completion(select_last=True)
+
+ @handle("c-g", filter=vi_insert_mode)
+ @handle("c-y", filter=vi_insert_mode)
+ def _accept_completion(event: E) -> None:
+ """
+ Accept current completion.
+ """
+ event.current_buffer.complete_state = None
+
+ @handle("c-e", filter=vi_insert_mode)
+ def _cancel_completion(event: E) -> None:
+ """
+ Cancel completion. Go back to originally typed text.
+ """
+ event.current_buffer.cancel_completion()
+
+ @Condition
+ def is_returnable() -> bool:
+ return get_app().current_buffer.is_returnable
+
+ # In navigation mode, pressing enter will always return the input.
+ handle("enter", filter=vi_navigation_mode & is_returnable)(
+ get_by_name("accept-line")
+ )
+
+ # In insert mode, also accept input when enter is pressed, and the buffer
+ # has been marked as single line.
+ handle("enter", filter=is_returnable & ~is_multiline)(get_by_name("accept-line"))
+
+ @handle("enter", filter=~is_returnable & vi_navigation_mode)
+ def _start_of_next_line(event: E) -> None:
+ """
+ Go to the beginning of next line.
+ """
+ b = event.current_buffer
+ b.cursor_down(count=event.arg)
+ b.cursor_position += b.document.get_start_of_line_position(
+ after_whitespace=True
+ )
+
+ # ** In navigation mode **
+
+ # List of navigation commands: http://hea-www.harvard.edu/~fine/Tech/vi.html
+
+ @handle("insert", filter=vi_navigation_mode)
+ def _insert_mode(event: E) -> None:
+ """
+ Pressing the Insert key.
+ """
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ @handle("insert", filter=vi_insert_mode)
+ def _navigation_mode(event: E) -> None:
+ """
+ Pressing the Insert key.
+ """
+ event.app.vi_state.input_mode = InputMode.NAVIGATION
+
+ @handle("a", filter=vi_navigation_mode & ~is_read_only)
+ # ~IsReadOnly, because we want to stay in navigation mode for
+ # read-only buffers.
+ def _a(event: E) -> None:
+ event.current_buffer.cursor_position += (
+ event.current_buffer.document.get_cursor_right_position()
+ )
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ @handle("A", filter=vi_navigation_mode & ~is_read_only)
+ def _A(event: E) -> None:
+ event.current_buffer.cursor_position += (
+ event.current_buffer.document.get_end_of_line_position()
+ )
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ @handle("C", filter=vi_navigation_mode & ~is_read_only)
+ def _change_until_end_of_line(event: E) -> None:
+ """
+ Change to end of line.
+ Same as 'c$' (which is implemented elsewhere.)
+ """
+ buffer = event.current_buffer
+
+ deleted = buffer.delete(count=buffer.document.get_end_of_line_position())
+ event.app.clipboard.set_text(deleted)
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ @handle("c", "c", filter=vi_navigation_mode & ~is_read_only)
+ @handle("S", filter=vi_navigation_mode & ~is_read_only)
+ def _change_current_line(event: E) -> None: # TODO: implement 'arg'
+ """
+ Change current line
+ """
+ buffer = event.current_buffer
+
+ # We copy the whole line.
+ data = ClipboardData(buffer.document.current_line, SelectionType.LINES)
+ event.app.clipboard.set_data(data)
+
+ # But we delete after the whitespace
+ buffer.cursor_position += buffer.document.get_start_of_line_position(
+ after_whitespace=True
+ )
+ buffer.delete(count=buffer.document.get_end_of_line_position())
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ @handle("D", filter=vi_navigation_mode)
+ def _delete_until_end_of_line(event: E) -> None:
+ """
+ Delete from cursor position until the end of the line.
+ """
+ buffer = event.current_buffer
+ deleted = buffer.delete(count=buffer.document.get_end_of_line_position())
+ event.app.clipboard.set_text(deleted)
+
+ @handle("d", "d", filter=vi_navigation_mode)
+ def _delete_line(event: E) -> None:
+ """
+ Delete line. (Or the following 'n' lines.)
+ """
+ buffer = event.current_buffer
+
+ # Split string in before/deleted/after text.
+ lines = buffer.document.lines
+
+ before = "\n".join(lines[: buffer.document.cursor_position_row])
+ deleted = "\n".join(
+ lines[
+ buffer.document.cursor_position_row : buffer.document.cursor_position_row
+ + event.arg
+ ]
+ )
+ after = "\n".join(lines[buffer.document.cursor_position_row + event.arg :])
+
+ # Set new text.
+ if before and after:
+ before = before + "\n"
+
+ # Set text and cursor position.
+ buffer.document = Document(
+ text=before + after,
+ # Cursor At the start of the first 'after' line, after the leading whitespace.
+ cursor_position=len(before) + len(after) - len(after.lstrip(" ")),
+ )
+
+ # Set clipboard data
+ event.app.clipboard.set_data(ClipboardData(deleted, SelectionType.LINES))
+
+ @handle("x", filter=vi_selection_mode)
+ def _cut(event: E) -> None:
+ """
+ Cut selection.
+ ('x' is not an operator.)
+ """
+ clipboard_data = event.current_buffer.cut_selection()
+ event.app.clipboard.set_data(clipboard_data)
+
+ @handle("i", filter=vi_navigation_mode & ~is_read_only)
+ def _i(event: E) -> None:
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ @handle("I", filter=vi_navigation_mode & ~is_read_only)
+ def _I(event: E) -> None:
+ event.app.vi_state.input_mode = InputMode.INSERT
+ event.current_buffer.cursor_position += (
+ event.current_buffer.document.get_start_of_line_position(
+ after_whitespace=True
+ )
+ )
+
+ @Condition
+ def in_block_selection() -> bool:
+ buff = get_app().current_buffer
+ return bool(
+ buff.selection_state and buff.selection_state.type == SelectionType.BLOCK
+ )
+
+ @handle("I", filter=in_block_selection & ~is_read_only)
+ def insert_in_block_selection(event: E, after: bool = False) -> None:
+ """
+ Insert in block selection mode.
+ """
+ buff = event.current_buffer
+
+ # Store all cursor positions.
+ positions = []
+
+ if after:
+
+ def get_pos(from_to: tuple[int, int]) -> int:
+ return from_to[1]
+
+ else:
+
+ def get_pos(from_to: tuple[int, int]) -> int:
+ return from_to[0]
+
+ for i, from_to in enumerate(buff.document.selection_ranges()):
+ positions.append(get_pos(from_to))
+ if i == 0:
+ buff.cursor_position = get_pos(from_to)
+
+ buff.multiple_cursor_positions = positions
+
+ # Go to 'INSERT_MULTIPLE' mode.
+ event.app.vi_state.input_mode = InputMode.INSERT_MULTIPLE
+ buff.exit_selection()
+
+ @handle("A", filter=in_block_selection & ~is_read_only)
+ def _append_after_block(event: E) -> None:
+ insert_in_block_selection(event, after=True)
+
+ @handle("J", filter=vi_navigation_mode & ~is_read_only)
+ def _join(event: E) -> None:
+ """
+ Join lines.
+ """
+ for i in range(event.arg):
+ event.current_buffer.join_next_line()
+
+ @handle("g", "J", filter=vi_navigation_mode & ~is_read_only)
+ def _join_nospace(event: E) -> None:
+ """
+ Join lines without space.
+ """
+ for i in range(event.arg):
+ event.current_buffer.join_next_line(separator="")
+
+ @handle("J", filter=vi_selection_mode & ~is_read_only)
+ def _join_selection(event: E) -> None:
+ """
+ Join selected lines.
+ """
+ event.current_buffer.join_selected_lines()
+
+ @handle("g", "J", filter=vi_selection_mode & ~is_read_only)
+ def _join_selection_nospace(event: E) -> None:
+ """
+ Join selected lines without space.
+ """
+ event.current_buffer.join_selected_lines(separator="")
+
+ @handle("p", filter=vi_navigation_mode)
+ def _paste(event: E) -> None:
+ """
+ Paste after
+ """
+ event.current_buffer.paste_clipboard_data(
+ event.app.clipboard.get_data(),
+ count=event.arg,
+ paste_mode=PasteMode.VI_AFTER,
+ )
+
+ @handle("P", filter=vi_navigation_mode)
+ def _paste_before(event: E) -> None:
+ """
+ Paste before
+ """
+ event.current_buffer.paste_clipboard_data(
+ event.app.clipboard.get_data(),
+ count=event.arg,
+ paste_mode=PasteMode.VI_BEFORE,
+ )
+
+ @handle('"', Keys.Any, "p", filter=vi_navigation_mode)
+ def _paste_register(event: E) -> None:
+ """
+ Paste from named register.
+ """
+ c = event.key_sequence[1].data
+ if c in vi_register_names:
+ data = event.app.vi_state.named_registers.get(c)
+ if data:
+ event.current_buffer.paste_clipboard_data(
+ data, count=event.arg, paste_mode=PasteMode.VI_AFTER
+ )
+
+ @handle('"', Keys.Any, "P", filter=vi_navigation_mode)
+ def _paste_register_before(event: E) -> None:
+ """
+ Paste (before) from named register.
+ """
+ c = event.key_sequence[1].data
+ if c in vi_register_names:
+ data = event.app.vi_state.named_registers.get(c)
+ if data:
+ event.current_buffer.paste_clipboard_data(
+ data, count=event.arg, paste_mode=PasteMode.VI_BEFORE
+ )
+
+ @handle("r", filter=vi_navigation_mode)
+ def _replace(event: E) -> None:
+ """
+ Go to 'replace-single'-mode.
+ """
+ event.app.vi_state.input_mode = InputMode.REPLACE_SINGLE
+
+ @handle("R", filter=vi_navigation_mode)
+ def _replace_mode(event: E) -> None:
+ """
+ Go to 'replace'-mode.
+ """
+ event.app.vi_state.input_mode = InputMode.REPLACE
+
+ @handle("s", filter=vi_navigation_mode & ~is_read_only)
+ def _substitute(event: E) -> None:
+ """
+ Substitute with new text
+ (Delete character(s) and go to insert mode.)
+ """
+ text = event.current_buffer.delete(count=event.arg)
+ event.app.clipboard.set_text(text)
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ @handle("u", filter=vi_navigation_mode, save_before=(lambda e: False))
+ def _undo(event: E) -> None:
+ for i in range(event.arg):
+ event.current_buffer.undo()
+
+ @handle("V", filter=vi_navigation_mode)
+ def _visual_line(event: E) -> None:
+ """
+ Start lines selection.
+ """
+ event.current_buffer.start_selection(selection_type=SelectionType.LINES)
+
+ @handle("c-v", filter=vi_navigation_mode)
+ def _visual_block(event: E) -> None:
+ """
+ Enter block selection mode.
+ """
+ event.current_buffer.start_selection(selection_type=SelectionType.BLOCK)
+
+ @handle("V", filter=vi_selection_mode)
+ def _visual_line2(event: E) -> None:
+ """
+ Exit line selection mode, or go from non line selection mode to line
+ selection mode.
+ """
+ selection_state = event.current_buffer.selection_state
+
+ if selection_state is not None:
+ if selection_state.type != SelectionType.LINES:
+ selection_state.type = SelectionType.LINES
+ else:
+ event.current_buffer.exit_selection()
+
+ @handle("v", filter=vi_navigation_mode)
+ def _visual(event: E) -> None:
+ """
+ Enter character selection mode.
+ """
+ event.current_buffer.start_selection(selection_type=SelectionType.CHARACTERS)
+
+ @handle("v", filter=vi_selection_mode)
+ def _visual2(event: E) -> None:
+ """
+ Exit character selection mode, or go from non-character-selection mode
+ to character selection mode.
+ """
+ selection_state = event.current_buffer.selection_state
+
+ if selection_state is not None:
+ if selection_state.type != SelectionType.CHARACTERS:
+ selection_state.type = SelectionType.CHARACTERS
+ else:
+ event.current_buffer.exit_selection()
+
+ @handle("c-v", filter=vi_selection_mode)
+ def _visual_block2(event: E) -> None:
+ """
+ Exit block selection mode, or go from non block selection mode to block
+ selection mode.
+ """
+ selection_state = event.current_buffer.selection_state
+
+ if selection_state is not None:
+ if selection_state.type != SelectionType.BLOCK:
+ selection_state.type = SelectionType.BLOCK
+ else:
+ event.current_buffer.exit_selection()
+
+ @handle("a", "w", filter=vi_selection_mode)
+ @handle("a", "W", filter=vi_selection_mode)
+ def _visual_auto_word(event: E) -> None:
+ """
+ Switch from visual linewise mode to visual characterwise mode.
+ """
+ buffer = event.current_buffer
+
+ if (
+ buffer.selection_state
+ and buffer.selection_state.type == SelectionType.LINES
+ ):
+ buffer.selection_state.type = SelectionType.CHARACTERS
+
+ @handle("x", filter=vi_navigation_mode)
+ def _delete(event: E) -> None:
+ """
+ Delete character.
+ """
+ buff = event.current_buffer
+ count = min(event.arg, len(buff.document.current_line_after_cursor))
+ if count:
+ text = event.current_buffer.delete(count=count)
+ event.app.clipboard.set_text(text)
+
+ @handle("X", filter=vi_navigation_mode)
+ def _delete_before_cursor(event: E) -> None:
+ buff = event.current_buffer
+ count = min(event.arg, len(buff.document.current_line_before_cursor))
+ if count:
+ text = event.current_buffer.delete_before_cursor(count=count)
+ event.app.clipboard.set_text(text)
+
+ @handle("y", "y", filter=vi_navigation_mode)
+ @handle("Y", filter=vi_navigation_mode)
+ def _yank_line(event: E) -> None:
+ """
+ Yank the whole line.
+ """
+ text = "\n".join(event.current_buffer.document.lines_from_current[: event.arg])
+ event.app.clipboard.set_data(ClipboardData(text, SelectionType.LINES))
+
+ @handle("+", filter=vi_navigation_mode)
+ def _next_line(event: E) -> None:
+ """
+ Move to first non whitespace of next line
+ """
+ buffer = event.current_buffer
+ buffer.cursor_position += buffer.document.get_cursor_down_position(
+ count=event.arg
+ )
+ buffer.cursor_position += buffer.document.get_start_of_line_position(
+ after_whitespace=True
+ )
+
+ @handle("-", filter=vi_navigation_mode)
+ def _prev_line(event: E) -> None:
+ """
+ Move to first non whitespace of previous line
+ """
+ buffer = event.current_buffer
+ buffer.cursor_position += buffer.document.get_cursor_up_position(
+ count=event.arg
+ )
+ buffer.cursor_position += buffer.document.get_start_of_line_position(
+ after_whitespace=True
+ )
+
+ @handle(">", ">", filter=vi_navigation_mode)
+ @handle("c-t", filter=vi_insert_mode)
+ def _indent(event: E) -> None:
+ """
+ Indent lines.
+ """
+ buffer = event.current_buffer
+ current_row = buffer.document.cursor_position_row
+ indent(buffer, current_row, current_row + event.arg)
+
+ @handle("<", "<", filter=vi_navigation_mode)
+ @handle("c-d", filter=vi_insert_mode)
+ def _unindent(event: E) -> None:
+ """
+ Unindent lines.
+ """
+ current_row = event.current_buffer.document.cursor_position_row
+ unindent(event.current_buffer, current_row, current_row + event.arg)
+
+ @handle("O", filter=vi_navigation_mode & ~is_read_only)
+ def _open_above(event: E) -> None:
+ """
+ Open line above and enter insertion mode
+ """
+ event.current_buffer.insert_line_above(copy_margin=not in_paste_mode())
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ @handle("o", filter=vi_navigation_mode & ~is_read_only)
+ def _open_below(event: E) -> None:
+ """
+ Open line below and enter insertion mode
+ """
+ event.current_buffer.insert_line_below(copy_margin=not in_paste_mode())
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ @handle("~", filter=vi_navigation_mode)
+ def _reverse_case(event: E) -> None:
+ """
+ Reverse case of current character and move cursor forward.
+ """
+ buffer = event.current_buffer
+ c = buffer.document.current_char
+
+ if c is not None and c != "\n":
+ buffer.insert_text(c.swapcase(), overwrite=True)
+
+ @handle("g", "u", "u", filter=vi_navigation_mode & ~is_read_only)
+ def _lowercase_line(event: E) -> None:
+ """
+ Lowercase current line.
+ """
+ buff = event.current_buffer
+ buff.transform_current_line(lambda s: s.lower())
+
+ @handle("g", "U", "U", filter=vi_navigation_mode & ~is_read_only)
+ def _uppercase_line(event: E) -> None:
+ """
+ Uppercase current line.
+ """
+ buff = event.current_buffer
+ buff.transform_current_line(lambda s: s.upper())
+
+ @handle("g", "~", "~", filter=vi_navigation_mode & ~is_read_only)
+ def _swapcase_line(event: E) -> None:
+ """
+ Swap case of the current line.
+ """
+ buff = event.current_buffer
+ buff.transform_current_line(lambda s: s.swapcase())
+
+ @handle("#", filter=vi_navigation_mode)
+ def _prev_occurrence(event: E) -> None:
+ """
+ Go to previous occurrence of this word.
+ """
+ b = event.current_buffer
+ search_state = event.app.current_search_state
+
+ search_state.text = b.document.get_word_under_cursor()
+ search_state.direction = SearchDirection.BACKWARD
+
+ b.apply_search(search_state, count=event.arg, include_current_position=False)
+
+ @handle("*", filter=vi_navigation_mode)
+ def _next_occurrence(event: E) -> None:
+ """
+ Go to next occurrence of this word.
+ """
+ b = event.current_buffer
+ search_state = event.app.current_search_state
+
+ search_state.text = b.document.get_word_under_cursor()
+ search_state.direction = SearchDirection.FORWARD
+
+ b.apply_search(search_state, count=event.arg, include_current_position=False)
+
+ @handle("(", filter=vi_navigation_mode)
+ def _begin_of_sentence(event: E) -> None:
+ # TODO: go to begin of sentence.
+ # XXX: should become text_object.
+ pass
+
+ @handle(")", filter=vi_navigation_mode)
+ def _end_of_sentence(event: E) -> None:
+ # TODO: go to end of sentence.
+ # XXX: should become text_object.
+ pass
+
+ operator = create_operator_decorator(key_bindings)
+ text_object = create_text_object_decorator(key_bindings)
+
+ @handle(Keys.Any, filter=vi_waiting_for_text_object_mode)
+ def _unknown_text_object(event: E) -> None:
+ """
+ Unknown key binding while waiting for a text object.
+ """
+ event.app.output.bell()
+
+ #
+ # *** Operators ***
+ #
+
+ def create_delete_and_change_operators(
+ delete_only: bool, with_register: bool = False
+ ) -> None:
+ """
+ Delete and change operators.
+
+ :param delete_only: Create an operator that deletes, but doesn't go to insert mode.
+ :param with_register: Copy the deleted text to this named register instead of the clipboard.
+ """
+ handler_keys: Iterable[str]
+ if with_register:
+ handler_keys = ('"', Keys.Any, "cd"[delete_only])
+ else:
+ handler_keys = "cd"[delete_only]
+
+ @operator(*handler_keys, filter=~is_read_only)
+ def delete_or_change_operator(event: E, text_object: TextObject) -> None:
+ clipboard_data = None
+ buff = event.current_buffer
+
+ if text_object:
+ new_document, clipboard_data = text_object.cut(buff)
+ buff.document = new_document
+
+ # Set deleted/changed text to clipboard or named register.
+ if clipboard_data and clipboard_data.text:
+ if with_register:
+ reg_name = event.key_sequence[1].data
+ if reg_name in vi_register_names:
+ event.app.vi_state.named_registers[reg_name] = clipboard_data
+ else:
+ event.app.clipboard.set_data(clipboard_data)
+
+ # Only go back to insert mode in case of 'change'.
+ if not delete_only:
+ event.app.vi_state.input_mode = InputMode.INSERT
+
+ create_delete_and_change_operators(False, False)
+ create_delete_and_change_operators(False, True)
+ create_delete_and_change_operators(True, False)
+ create_delete_and_change_operators(True, True)
+
+ def create_transform_handler(
+ filter: Filter, transform_func: Callable[[str], str], *a: str
+ ) -> None:
+ @operator(*a, filter=filter & ~is_read_only)
+ def _(event: E, text_object: TextObject) -> None:
+ """
+ Apply transformation (uppercase, lowercase, rot13, swap case).
+ """
+ buff = event.current_buffer
+ start, end = text_object.operator_range(buff.document)
+
+ if start < end:
+ # Transform.
+ buff.transform_region(
+ buff.cursor_position + start,
+ buff.cursor_position + end,
+ transform_func,
+ )
+
+ # Move cursor
+ buff.cursor_position += text_object.end or text_object.start
+
+ for k, f, func in vi_transform_functions:
+ create_transform_handler(f, func, *k)
+
+ @operator("y")
+ def _yank(event: E, text_object: TextObject) -> None:
+ """
+ Yank operator. (Copy text.)
+ """
+ _, clipboard_data = text_object.cut(event.current_buffer)
+ if clipboard_data.text:
+ event.app.clipboard.set_data(clipboard_data)
+
+ @operator('"', Keys.Any, "y")
+ def _yank_to_register(event: E, text_object: TextObject) -> None:
+ """
+ Yank selection to named register.
+ """
+ c = event.key_sequence[1].data
+ if c in vi_register_names:
+ _, clipboard_data = text_object.cut(event.current_buffer)
+ event.app.vi_state.named_registers[c] = clipboard_data
+
+ @operator(">")
+ def _indent_text_object(event: E, text_object: TextObject) -> None:
+ """
+ Indent.
+ """
+ buff = event.current_buffer
+ from_, to = text_object.get_line_numbers(buff)
+ indent(buff, from_, to + 1, count=event.arg)
+
+ @operator("<")
+ def _unindent_text_object(event: E, text_object: TextObject) -> None:
+ """
+ Unindent.
+ """
+ buff = event.current_buffer
+ from_, to = text_object.get_line_numbers(buff)
+ unindent(buff, from_, to + 1, count=event.arg)
+
+ @operator("g", "q")
+ def _reshape(event: E, text_object: TextObject) -> None:
+ """
+ Reshape text.
+ """
+ buff = event.current_buffer
+ from_, to = text_object.get_line_numbers(buff)
+ reshape_text(buff, from_, to)
+
+ #
+ # *** Text objects ***
+ #
+
+ @text_object("b")
+ def _b(event: E) -> TextObject:
+ """
+ Move one word or token left.
+ """
+ return TextObject(
+ event.current_buffer.document.find_start_of_previous_word(count=event.arg)
+ or 0
+ )
+
+ @text_object("B")
+ def _B(event: E) -> TextObject:
+ """
+ Move one non-blank word left
+ """
+ return TextObject(
+ event.current_buffer.document.find_start_of_previous_word(
+ count=event.arg, WORD=True
+ )
+ or 0
+ )
+
+ @text_object("$")
+ def _dollar(event: E) -> TextObject:
+ """
+ 'c$', 'd$' and '$': Delete/change/move until end of line.
+ """
+ return TextObject(event.current_buffer.document.get_end_of_line_position())
+
+ @text_object("w")
+ def _word_forward(event: E) -> TextObject:
+ """
+ 'word' forward. 'cw', 'dw', 'w': Delete/change/move one word.
+ """
+ return TextObject(
+ event.current_buffer.document.find_next_word_beginning(count=event.arg)
+ or event.current_buffer.document.get_end_of_document_position()
+ )
+
+ @text_object("W")
+ def _WORD_forward(event: E) -> TextObject:
+ """
+ 'WORD' forward. 'cW', 'dW', 'W': Delete/change/move one WORD.
+ """
+ return TextObject(
+ event.current_buffer.document.find_next_word_beginning(
+ count=event.arg, WORD=True
+ )
+ or event.current_buffer.document.get_end_of_document_position()
+ )
+
+ @text_object("e")
+ def _end_of_word(event: E) -> TextObject:
+ """
+ End of 'word': 'ce', 'de', 'e'
+ """
+ end = event.current_buffer.document.find_next_word_ending(count=event.arg)
+ return TextObject(end - 1 if end else 0, type=TextObjectType.INCLUSIVE)
+
+ @text_object("E")
+ def _end_of_WORD(event: E) -> TextObject:
+ """
+ End of 'WORD': 'cE', 'dE', 'E'
+ """
+ end = event.current_buffer.document.find_next_word_ending(
+ count=event.arg, WORD=True
+ )
+ return TextObject(end - 1 if end else 0, type=TextObjectType.INCLUSIVE)
+
+ @text_object("i", "w", no_move_handler=True)
+ def _inner_word(event: E) -> TextObject:
+ """
+ Inner 'word': ciw and diw
+ """
+ start, end = event.current_buffer.document.find_boundaries_of_current_word()
+ return TextObject(start, end)
+
+ @text_object("a", "w", no_move_handler=True)
+ def _a_word(event: E) -> TextObject:
+ """
+ A 'word': caw and daw
+ """
+ start, end = event.current_buffer.document.find_boundaries_of_current_word(
+ include_trailing_whitespace=True
+ )
+ return TextObject(start, end)
+
+ @text_object("i", "W", no_move_handler=True)
+ def _inner_WORD(event: E) -> TextObject:
+ """
+ Inner 'WORD': ciW and diW
+ """
+ start, end = event.current_buffer.document.find_boundaries_of_current_word(
+ WORD=True
+ )
+ return TextObject(start, end)
+
+ @text_object("a", "W", no_move_handler=True)
+ def _a_WORD(event: E) -> TextObject:
+ """
+ A 'WORD': caw and daw
+ """
+ start, end = event.current_buffer.document.find_boundaries_of_current_word(
+ WORD=True, include_trailing_whitespace=True
+ )
+ return TextObject(start, end)
+
+ @text_object("a", "p", no_move_handler=True)
+ def _paragraph(event: E) -> TextObject:
+ """
+ Auto paragraph.
+ """
+ start = event.current_buffer.document.start_of_paragraph()
+ end = event.current_buffer.document.end_of_paragraph(count=event.arg)
+ return TextObject(start, end)
+
+ @text_object("^")
+ def _start_of_line(event: E) -> TextObject:
+ """'c^', 'd^' and '^': Soft start of line, after whitespace."""
+ return TextObject(
+ event.current_buffer.document.get_start_of_line_position(
+ after_whitespace=True
+ )
+ )
+
+ @text_object("0")
+ def _hard_start_of_line(event: E) -> TextObject:
+ """
+ 'c0', 'd0': Hard start of line, before whitespace.
+ (The move '0' key is implemented elsewhere, because a '0' could also change the `arg`.)
+ """
+ return TextObject(
+ event.current_buffer.document.get_start_of_line_position(
+ after_whitespace=False
+ )
+ )
+
+ def create_ci_ca_handles(
+ ci_start: str, ci_end: str, inner: bool, key: str | None = None
+ ) -> None:
+ # TODO: 'dat', 'dit', (tags (like xml)
+ """
+ Delete/Change string between this start and stop character. But keep these characters.
+ This implements all the ci", ci<, ci{, ci(, di", di<, ca", ca<, ... combinations.
+ """
+
+ def handler(event: E) -> TextObject:
+ if ci_start == ci_end:
+ # Quotes
+ start = event.current_buffer.document.find_backwards(
+ ci_start, in_current_line=False
+ )
+ end = event.current_buffer.document.find(ci_end, in_current_line=False)
+ else:
+ # Brackets
+ start = event.current_buffer.document.find_enclosing_bracket_left(
+ ci_start, ci_end
+ )
+ end = event.current_buffer.document.find_enclosing_bracket_right(
+ ci_start, ci_end
+ )
+
+ if start is not None and end is not None:
+ offset = 0 if inner else 1
+ return TextObject(start + 1 - offset, end + offset)
+ else:
+ # Nothing found.
+ return TextObject(0)
+
+ if key is None:
+ text_object("ai"[inner], ci_start, no_move_handler=True)(handler)
+ text_object("ai"[inner], ci_end, no_move_handler=True)(handler)
+ else:
+ text_object("ai"[inner], key, no_move_handler=True)(handler)
+
+ for inner in (False, True):
+ for ci_start, ci_end in [
+ ('"', '"'),
+ ("'", "'"),
+ ("`", "`"),
+ ("[", "]"),
+ ("<", ">"),
+ ("{", "}"),
+ ("(", ")"),
+ ]:
+ create_ci_ca_handles(ci_start, ci_end, inner)
+
+ create_ci_ca_handles("(", ")", inner, "b") # 'dab', 'dib'
+ create_ci_ca_handles("{", "}", inner, "B") # 'daB', 'diB'
+
+ @text_object("{")
+ def _previous_section(event: E) -> TextObject:
+ """
+ Move to previous blank-line separated section.
+ Implements '{', 'c{', 'd{', 'y{'
+ """
+ index = event.current_buffer.document.start_of_paragraph(
+ count=event.arg, before=True
+ )
+ return TextObject(index)
+
+ @text_object("}")
+ def _next_section(event: E) -> TextObject:
+ """
+ Move to next blank-line separated section.
+ Implements '}', 'c}', 'd}', 'y}'
+ """
+ index = event.current_buffer.document.end_of_paragraph(
+ count=event.arg, after=True
+ )
+ return TextObject(index)
+
+ @text_object("f", Keys.Any)
+ def _find_next_occurrence(event: E) -> TextObject:
+ """
+ Go to next occurrence of character. Typing 'fx' will move the
+ cursor to the next occurrence of character. 'x'.
+ """
+ event.app.vi_state.last_character_find = CharacterFind(event.data, False)
+ match = event.current_buffer.document.find(
+ event.data, in_current_line=True, count=event.arg
+ )
+ if match:
+ return TextObject(match, type=TextObjectType.INCLUSIVE)
+ else:
+ return TextObject(0)
+
+ @text_object("F", Keys.Any)
+ def _find_previous_occurrence(event: E) -> TextObject:
+ """
+ Go to previous occurrence of character. Typing 'Fx' will move the
+ cursor to the previous occurrence of character. 'x'.
+ """
+ event.app.vi_state.last_character_find = CharacterFind(event.data, True)
+ return TextObject(
+ event.current_buffer.document.find_backwards(
+ event.data, in_current_line=True, count=event.arg
+ )
+ or 0
+ )
+
+ @text_object("t", Keys.Any)
+ def _t(event: E) -> TextObject:
+ """
+ Move right to the next occurrence of c, then one char backward.
+ """
+ event.app.vi_state.last_character_find = CharacterFind(event.data, False)
+ match = event.current_buffer.document.find(
+ event.data, in_current_line=True, count=event.arg
+ )
+ if match:
+ return TextObject(match - 1, type=TextObjectType.INCLUSIVE)
+ else:
+ return TextObject(0)
+
+ @text_object("T", Keys.Any)
+ def _T(event: E) -> TextObject:
+ """
+ Move left to the previous occurrence of c, then one char forward.
+ """
+ event.app.vi_state.last_character_find = CharacterFind(event.data, True)
+ match = event.current_buffer.document.find_backwards(
+ event.data, in_current_line=True, count=event.arg
+ )
+ return TextObject(match + 1 if match else 0)
+
+ def repeat(reverse: bool) -> None:
+ """
+ Create ',' and ';' commands.
+ """
+
+ @text_object("," if reverse else ";")
+ def _(event: E) -> TextObject:
+ """
+ Repeat the last 'f'/'F'/'t'/'T' command.
+ """
+ pos: int | None = 0
+ vi_state = event.app.vi_state
+
+ type = TextObjectType.EXCLUSIVE
+
+ if vi_state.last_character_find:
+ char = vi_state.last_character_find.character
+ backwards = vi_state.last_character_find.backwards
+
+ if reverse:
+ backwards = not backwards
+
+ if backwards:
+ pos = event.current_buffer.document.find_backwards(
+ char, in_current_line=True, count=event.arg
+ )
+ else:
+ pos = event.current_buffer.document.find(
+ char, in_current_line=True, count=event.arg
+ )
+ type = TextObjectType.INCLUSIVE
+ if pos:
+ return TextObject(pos, type=type)
+ else:
+ return TextObject(0)
+
+ repeat(True)
+ repeat(False)
+
+ @text_object("h")
+ @text_object("left")
+ def _left(event: E) -> TextObject:
+ """
+ Implements 'ch', 'dh', 'h': Cursor left.
+ """
+ return TextObject(
+ event.current_buffer.document.get_cursor_left_position(count=event.arg)
+ )
+
+ @text_object("j", no_move_handler=True, no_selection_handler=True)
+ # Note: We also need `no_selection_handler`, because we in
+ # selection mode, we prefer the other 'j' binding that keeps
+ # `buffer.preferred_column`.
+ def _down(event: E) -> TextObject:
+ """
+ Implements 'cj', 'dj', 'j', ... Cursor up.
+ """
+ return TextObject(
+ event.current_buffer.document.get_cursor_down_position(count=event.arg),
+ type=TextObjectType.LINEWISE,
+ )
+
+ @text_object("k", no_move_handler=True, no_selection_handler=True)
+ def _up(event: E) -> TextObject:
+ """
+ Implements 'ck', 'dk', 'k', ... Cursor up.
+ """
+ return TextObject(
+ event.current_buffer.document.get_cursor_up_position(count=event.arg),
+ type=TextObjectType.LINEWISE,
+ )
+
+ @text_object("l")
+ @text_object(" ")
+ @text_object("right")
+ def _right(event: E) -> TextObject:
+ """
+ Implements 'cl', 'dl', 'l', 'c ', 'd ', ' '. Cursor right.
+ """
+ return TextObject(
+ event.current_buffer.document.get_cursor_right_position(count=event.arg)
+ )
+
+ @text_object("H")
+ def _top_of_screen(event: E) -> TextObject:
+ """
+ Moves to the start of the visible region. (Below the scroll offset.)
+ Implements 'cH', 'dH', 'H'.
+ """
+ w = event.app.layout.current_window
+ b = event.current_buffer
+
+ if w and w.render_info:
+ # When we find a Window that has BufferControl showing this window,
+ # move to the start of the visible area.
+ pos = (
+ b.document.translate_row_col_to_index(
+ w.render_info.first_visible_line(after_scroll_offset=True), 0
+ )
+ - b.cursor_position
+ )
+
+ else:
+ # Otherwise, move to the start of the input.
+ pos = -len(b.document.text_before_cursor)
+ return TextObject(pos, type=TextObjectType.LINEWISE)
+
+ @text_object("M")
+ def _middle_of_screen(event: E) -> TextObject:
+ """
+ Moves cursor to the vertical center of the visible region.
+ Implements 'cM', 'dM', 'M'.
+ """
+ w = event.app.layout.current_window
+ b = event.current_buffer
+
+ if w and w.render_info:
+ # When we find a Window that has BufferControl showing this window,
+ # move to the center of the visible area.
+ pos = (
+ b.document.translate_row_col_to_index(
+ w.render_info.center_visible_line(), 0
+ )
+ - b.cursor_position
+ )
+
+ else:
+ # Otherwise, move to the start of the input.
+ pos = -len(b.document.text_before_cursor)
+ return TextObject(pos, type=TextObjectType.LINEWISE)
+
+ @text_object("L")
+ def _end_of_screen(event: E) -> TextObject:
+ """
+ Moves to the end of the visible region. (Above the scroll offset.)
+ """
+ w = event.app.layout.current_window
+ b = event.current_buffer
+
+ if w and w.render_info:
+ # When we find a Window that has BufferControl showing this window,
+ # move to the end of the visible area.
+ pos = (
+ b.document.translate_row_col_to_index(
+ w.render_info.last_visible_line(before_scroll_offset=True), 0
+ )
+ - b.cursor_position
+ )
+
+ else:
+ # Otherwise, move to the end of the input.
+ pos = len(b.document.text_after_cursor)
+ return TextObject(pos, type=TextObjectType.LINEWISE)
+
+ @text_object("n", no_move_handler=True)
+ def _search_next(event: E) -> TextObject:
+ """
+ Search next.
+ """
+ buff = event.current_buffer
+ search_state = event.app.current_search_state
+
+ cursor_position = buff.get_search_position(
+ search_state, include_current_position=False, count=event.arg
+ )
+ return TextObject(cursor_position - buff.cursor_position)
+
+ @handle("n", filter=vi_navigation_mode)
+ def _search_next2(event: E) -> None:
+ """
+ Search next in navigation mode. (This goes through the history.)
+ """
+ search_state = event.app.current_search_state
+
+ event.current_buffer.apply_search(
+ search_state, include_current_position=False, count=event.arg
+ )
+
+ @text_object("N", no_move_handler=True)
+ def _search_previous(event: E) -> TextObject:
+ """
+ Search previous.
+ """
+ buff = event.current_buffer
+ search_state = event.app.current_search_state
+
+ cursor_position = buff.get_search_position(
+ ~search_state, include_current_position=False, count=event.arg
+ )
+ return TextObject(cursor_position - buff.cursor_position)
+
+ @handle("N", filter=vi_navigation_mode)
+ def _search_previous2(event: E) -> None:
+ """
+ Search previous in navigation mode. (This goes through the history.)
+ """
+ search_state = event.app.current_search_state
+
+ event.current_buffer.apply_search(
+ ~search_state, include_current_position=False, count=event.arg
+ )
+
+ @handle("z", "+", filter=vi_navigation_mode | vi_selection_mode)
+ @handle("z", "t", filter=vi_navigation_mode | vi_selection_mode)
+ @handle("z", "enter", filter=vi_navigation_mode | vi_selection_mode)
+ def _scroll_top(event: E) -> None:
+ """
+ Scrolls the window to makes the current line the first line in the visible region.
+ """
+ b = event.current_buffer
+ event.app.layout.current_window.vertical_scroll = b.document.cursor_position_row
+
+ @handle("z", "-", filter=vi_navigation_mode | vi_selection_mode)
+ @handle("z", "b", filter=vi_navigation_mode | vi_selection_mode)
+ def _scroll_bottom(event: E) -> None:
+ """
+ Scrolls the window to makes the current line the last line in the visible region.
+ """
+ # We can safely set the scroll offset to zero; the Window will make
+ # sure that it scrolls at least enough to make the cursor visible
+ # again.
+ event.app.layout.current_window.vertical_scroll = 0
+
+ @handle("z", "z", filter=vi_navigation_mode | vi_selection_mode)
+ def _scroll_center(event: E) -> None:
+ """
+ Center Window vertically around cursor.
+ """
+ w = event.app.layout.current_window
+ b = event.current_buffer
+
+ if w and w.render_info:
+ info = w.render_info
+
+ # Calculate the offset that we need in order to position the row
+ # containing the cursor in the center.
+ scroll_height = info.window_height // 2
+
+ 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
+
+ w.vertical_scroll = y
+
+ @text_object("%")
+ def _goto_corresponding_bracket(event: E) -> TextObject:
+ """
+ Implements 'c%', 'd%', '%, 'y%' (Move to corresponding bracket.)
+ If an 'arg' has been given, go this this % position in the file.
+ """
+ buffer = event.current_buffer
+
+ if event._arg:
+ # If 'arg' has been given, the meaning of % is to go to the 'x%'
+ # row in the file.
+ if 0 < event.arg <= 100:
+ absolute_index = buffer.document.translate_row_col_to_index(
+ int((event.arg * buffer.document.line_count - 1) / 100), 0
+ )
+ return TextObject(
+ absolute_index - buffer.document.cursor_position,
+ type=TextObjectType.LINEWISE,
+ )
+ else:
+ return TextObject(0) # Do nothing.
+
+ else:
+ # Move to the corresponding opening/closing bracket (()'s, []'s and {}'s).
+ match = buffer.document.find_matching_bracket_position()
+ if match:
+ return TextObject(match, type=TextObjectType.INCLUSIVE)
+ else:
+ return TextObject(0)
+
+ @text_object("|")
+ def _to_column(event: E) -> TextObject:
+ """
+ Move to the n-th column (you may specify the argument n by typing it on
+ number keys, for example, 20|).
+ """
+ return TextObject(
+ event.current_buffer.document.get_column_cursor_position(event.arg - 1)
+ )
+
+ @text_object("g", "g")
+ def _goto_first_line(event: E) -> TextObject:
+ """
+ Go to the start of the very first line.
+ Implements 'gg', 'cgg', 'ygg'
+ """
+ d = event.current_buffer.document
+
+ if event._arg:
+ # Move to the given line.
+ return TextObject(
+ d.translate_row_col_to_index(event.arg - 1, 0) - d.cursor_position,
+ type=TextObjectType.LINEWISE,
+ )
+ else:
+ # Move to the top of the input.
+ return TextObject(
+ d.get_start_of_document_position(), type=TextObjectType.LINEWISE
+ )
+
+ @text_object("g", "_")
+ def _goto_last_line(event: E) -> TextObject:
+ """
+ Go to last non-blank of line.
+ 'g_', 'cg_', 'yg_', etc..
+ """
+ return TextObject(
+ event.current_buffer.document.last_non_blank_of_current_line_position(),
+ type=TextObjectType.INCLUSIVE,
+ )
+
+ @text_object("g", "e")
+ def _ge(event: E) -> TextObject:
+ """
+ Go to last character of previous word.
+ 'ge', 'cge', 'yge', etc..
+ """
+ prev_end = event.current_buffer.document.find_previous_word_ending(
+ count=event.arg
+ )
+ return TextObject(
+ prev_end - 1 if prev_end is not None else 0, type=TextObjectType.INCLUSIVE
+ )
+
+ @text_object("g", "E")
+ def _gE(event: E) -> TextObject:
+ """
+ Go to last character of previous WORD.
+ 'gE', 'cgE', 'ygE', etc..
+ """
+ prev_end = event.current_buffer.document.find_previous_word_ending(
+ count=event.arg, WORD=True
+ )
+ return TextObject(
+ prev_end - 1 if prev_end is not None else 0, type=TextObjectType.INCLUSIVE
+ )
+
+ @text_object("g", "m")
+ def _gm(event: E) -> TextObject:
+ """
+ Like g0, but half a screenwidth to the right. (Or as much as possible.)
+ """
+ w = event.app.layout.current_window
+ buff = event.current_buffer
+
+ if w and w.render_info:
+ width = w.render_info.window_width
+ start = buff.document.get_start_of_line_position(after_whitespace=False)
+ start += int(min(width / 2, len(buff.document.current_line)))
+
+ return TextObject(start, type=TextObjectType.INCLUSIVE)
+ return TextObject(0)
+
+ @text_object("G")
+ def _last_line(event: E) -> TextObject:
+ """
+ Go to the end of the document. (If no arg has been given.)
+ """
+ buf = event.current_buffer
+ return TextObject(
+ buf.document.translate_row_col_to_index(buf.document.line_count - 1, 0)
+ - buf.cursor_position,
+ type=TextObjectType.LINEWISE,
+ )
+
+ #
+ # *** Other ***
+ #
+
+ @handle("G", filter=has_arg)
+ def _to_nth_history_line(event: E) -> None:
+ """
+ If an argument is given, move to this line in the history. (for
+ example, 15G)
+ """
+ event.current_buffer.go_to_history(event.arg - 1)
+
+ for n in "123456789":
+
+ @handle(
+ n,
+ filter=vi_navigation_mode
+ | vi_selection_mode
+ | vi_waiting_for_text_object_mode,
+ )
+ def _arg(event: E) -> None:
+ """
+ Always handle numerics in navigation mode as arg.
+ """
+ event.append_to_arg_count(event.data)
+
+ @handle(
+ "0",
+ filter=(
+ vi_navigation_mode | vi_selection_mode | vi_waiting_for_text_object_mode
+ )
+ & has_arg,
+ )
+ def _0_arg(event: E) -> None:
+ """
+ Zero when an argument was already give.
+ """
+ event.append_to_arg_count(event.data)
+
+ @handle(Keys.Any, filter=vi_replace_mode)
+ def _insert_text(event: E) -> None:
+ """
+ Insert data at cursor position.
+ """
+ event.current_buffer.insert_text(event.data, overwrite=True)
+
+ @handle(Keys.Any, filter=vi_replace_single_mode)
+ def _replace_single(event: E) -> None:
+ """
+ Replace single character at cursor position.
+ """
+ event.current_buffer.insert_text(event.data, overwrite=True)
+ event.current_buffer.cursor_position -= 1
+ event.app.vi_state.input_mode = InputMode.NAVIGATION
+
+ @handle(
+ Keys.Any,
+ filter=vi_insert_multiple_mode,
+ save_before=(lambda e: not e.is_repeat),
+ )
+ def _insert_text_multiple_cursors(event: E) -> None:
+ """
+ Insert data at multiple cursor positions at once.
+ (Usually a result of pressing 'I' or 'A' in block-selection mode.)
+ """
+ buff = event.current_buffer
+ original_text = buff.text
+
+ # Construct new text.
+ text = []
+ p = 0
+
+ for p2 in buff.multiple_cursor_positions:
+ text.append(original_text[p:p2])
+ text.append(event.data)
+ p = p2
+
+ text.append(original_text[p:])
+
+ # Shift all cursor positions.
+ new_cursor_positions = [
+ pos + i + 1 for i, pos in enumerate(buff.multiple_cursor_positions)
+ ]
+
+ # Set result.
+ buff.text = "".join(text)
+ buff.multiple_cursor_positions = new_cursor_positions
+ buff.cursor_position += 1
+
+ @handle("backspace", filter=vi_insert_multiple_mode)
+ def _delete_before_multiple_cursors(event: E) -> None:
+ """
+ Backspace, using multiple cursors.
+ """
+ buff = event.current_buffer
+ original_text = buff.text
+
+ # Construct new text.
+ deleted_something = False
+ text = []
+ p = 0
+
+ for p2 in buff.multiple_cursor_positions:
+ if p2 > 0 and original_text[p2 - 1] != "\n": # Don't delete across lines.
+ text.append(original_text[p : p2 - 1])
+ deleted_something = True
+ else:
+ text.append(original_text[p:p2])
+ p = p2
+
+ text.append(original_text[p:])
+
+ if deleted_something:
+ # Shift all cursor positions.
+ lengths = [len(part) for part in text[:-1]]
+ new_cursor_positions = list(accumulate(lengths))
+
+ # Set result.
+ buff.text = "".join(text)
+ buff.multiple_cursor_positions = new_cursor_positions
+ buff.cursor_position -= 1
+ else:
+ event.app.output.bell()
+
+ @handle("delete", filter=vi_insert_multiple_mode)
+ def _delete_after_multiple_cursors(event: E) -> None:
+ """
+ Delete, using multiple cursors.
+ """
+ buff = event.current_buffer
+ original_text = buff.text
+
+ # Construct new text.
+ deleted_something = False
+ text = []
+ new_cursor_positions = []
+ p = 0
+
+ for p2 in buff.multiple_cursor_positions:
+ text.append(original_text[p:p2])
+ if p2 >= len(original_text) or original_text[p2] == "\n":
+ # Don't delete across lines.
+ p = p2
+ else:
+ p = p2 + 1
+ deleted_something = True
+
+ text.append(original_text[p:])
+
+ if deleted_something:
+ # Shift all cursor positions.
+ lengths = [len(part) for part in text[:-1]]
+ new_cursor_positions = list(accumulate(lengths))
+
+ # Set result.
+ buff.text = "".join(text)
+ buff.multiple_cursor_positions = new_cursor_positions
+ else:
+ event.app.output.bell()
+
+ @handle("left", filter=vi_insert_multiple_mode)
+ def _left_multiple(event: E) -> None:
+ """
+ Move all cursors to the left.
+ (But keep all cursors on the same line.)
+ """
+ buff = event.current_buffer
+ new_positions = []
+
+ for p in buff.multiple_cursor_positions:
+ if buff.document.translate_index_to_position(p)[1] > 0:
+ p -= 1
+ new_positions.append(p)
+
+ buff.multiple_cursor_positions = new_positions
+
+ if buff.document.cursor_position_col > 0:
+ buff.cursor_position -= 1
+
+ @handle("right", filter=vi_insert_multiple_mode)
+ def _right_multiple(event: E) -> None:
+ """
+ Move all cursors to the right.
+ (But keep all cursors on the same line.)
+ """
+ buff = event.current_buffer
+ new_positions = []
+
+ for p in buff.multiple_cursor_positions:
+ row, column = buff.document.translate_index_to_position(p)
+ if column < len(buff.document.lines[row]):
+ p += 1
+ new_positions.append(p)
+
+ buff.multiple_cursor_positions = new_positions
+
+ if not buff.document.is_cursor_at_the_end_of_line:
+ buff.cursor_position += 1
+
+ @handle("up", filter=vi_insert_multiple_mode)
+ @handle("down", filter=vi_insert_multiple_mode)
+ def _updown_multiple(event: E) -> None:
+ """
+ Ignore all up/down key presses when in multiple cursor mode.
+ """
+
+ @handle("c-x", "c-l", filter=vi_insert_mode)
+ def _complete_line(event: E) -> None:
+ """
+ Pressing the ControlX - ControlL sequence in Vi mode does line
+ completion based on the other lines in the document and the history.
+ """
+ event.current_buffer.start_history_lines_completion()
+
+ @handle("c-x", "c-f", filter=vi_insert_mode)
+ def _complete_filename(event: E) -> None:
+ """
+ Complete file names.
+ """
+ # TODO
+ pass
+
+ @handle("c-k", filter=vi_insert_mode | vi_replace_mode)
+ def _digraph(event: E) -> None:
+ """
+ Go into digraph mode.
+ """
+ event.app.vi_state.waiting_for_digraph = True
+
+ @Condition
+ def digraph_symbol_1_given() -> bool:
+ return get_app().vi_state.digraph_symbol1 is not None
+
+ @handle(Keys.Any, filter=vi_digraph_mode & ~digraph_symbol_1_given)
+ def _digraph1(event: E) -> None:
+ """
+ First digraph symbol.
+ """
+ event.app.vi_state.digraph_symbol1 = event.data
+
+ @handle(Keys.Any, filter=vi_digraph_mode & digraph_symbol_1_given)
+ def _create_digraph(event: E) -> None:
+ """
+ Insert digraph.
+ """
+ try:
+ # Lookup.
+ code: tuple[str, str] = (
+ event.app.vi_state.digraph_symbol1 or "",
+ event.data,
+ )
+ if code not in DIGRAPHS:
+ code = code[::-1] # Try reversing.
+ symbol = DIGRAPHS[code]
+ except KeyError:
+ # Unknown digraph.
+ event.app.output.bell()
+ else:
+ # Insert digraph.
+ overwrite = event.app.vi_state.input_mode == InputMode.REPLACE
+ event.current_buffer.insert_text(chr(symbol), overwrite=overwrite)
+ event.app.vi_state.waiting_for_digraph = False
+ finally:
+ event.app.vi_state.waiting_for_digraph = False
+ event.app.vi_state.digraph_symbol1 = None
+
+ @handle("c-o", filter=vi_insert_mode | vi_replace_mode)
+ def _quick_normal_mode(event: E) -> None:
+ """
+ Go into normal mode for one single action.
+ """
+ event.app.vi_state.temporary_navigation_mode = True
+
+ @handle("q", Keys.Any, filter=vi_navigation_mode & ~vi_recording_macro)
+ def _start_macro(event: E) -> None:
+ """
+ Start recording macro.
+ """
+ c = event.key_sequence[1].data
+ if c in vi_register_names:
+ vi_state = event.app.vi_state
+
+ vi_state.recording_register = c
+ vi_state.current_recording = ""
+
+ @handle("q", filter=vi_navigation_mode & vi_recording_macro)
+ def _stop_macro(event: E) -> None:
+ """
+ Stop recording macro.
+ """
+ vi_state = event.app.vi_state
+
+ # Store and stop recording.
+ if vi_state.recording_register:
+ vi_state.named_registers[vi_state.recording_register] = ClipboardData(
+ vi_state.current_recording
+ )
+ vi_state.recording_register = None
+ vi_state.current_recording = ""
+
+ @handle("@", Keys.Any, filter=vi_navigation_mode, record_in_macro=False)
+ def _execute_macro(event: E) -> None:
+ """
+ Execute macro.
+
+ Notice that we pass `record_in_macro=False`. This ensures that the `@x`
+ keys don'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`.
+ """
+ # Retrieve macro.
+ c = event.key_sequence[1].data
+ try:
+ macro = event.app.vi_state.named_registers[c]
+ except KeyError:
+ return
+
+ # Expand macro (which is a string in the register), in individual keys.
+ # Use vt100 parser for this.
+ keys: list[KeyPress] = []
+
+ parser = Vt100Parser(keys.append)
+ parser.feed(macro.text)
+ parser.flush()
+
+ # Now feed keys back to the input processor.
+ for _ in range(event.arg):
+ event.app.key_processor.feed_multiple(keys, first=True)
+
+ return ConditionalKeyBindings(key_bindings, vi_mode)
+
+
+def load_vi_search_bindings() -> KeyBindingsBase:
+ key_bindings = KeyBindings()
+ handle = key_bindings.add
+ from . import search
+
+ @Condition
+ def search_buffer_is_empty() -> bool:
+ "Returns True when the search buffer is empty."
+ return get_app().current_buffer.text == ""
+
+ # Vi-style forward search.
+ handle(
+ "/",
+ filter=(vi_navigation_mode | vi_selection_mode) & ~vi_search_direction_reversed,
+ )(search.start_forward_incremental_search)
+ handle(
+ "?",
+ filter=(vi_navigation_mode | vi_selection_mode) & vi_search_direction_reversed,
+ )(search.start_forward_incremental_search)
+ handle("c-s")(search.start_forward_incremental_search)
+
+ # Vi-style backward search.
+ handle(
+ "?",
+ filter=(vi_navigation_mode | vi_selection_mode) & ~vi_search_direction_reversed,
+ )(search.start_reverse_incremental_search)
+ handle(
+ "/",
+ filter=(vi_navigation_mode | vi_selection_mode) & vi_search_direction_reversed,
+ )(search.start_reverse_incremental_search)
+ handle("c-r")(search.start_reverse_incremental_search)
+
+ # Apply the search. (At the / or ? prompt.)
+ handle("enter", filter=is_searching)(search.accept_search)
+
+ handle("c-r", filter=is_searching)(search.reverse_incremental_search)
+ handle("c-s", filter=is_searching)(search.forward_incremental_search)
+
+ handle("c-c")(search.abort_search)
+ handle("c-g")(search.abort_search)
+ handle("backspace", filter=search_buffer_is_empty)(search.abort_search)
+
+ # Handle escape. This should accept the search, just like readline.
+ # `abort_search` would be a meaningful alternative.
+ handle("escape")(search.accept_search)
+
+ return ConditionalKeyBindings(key_bindings, vi_mode)
diff --git a/src/prompt_toolkit/key_binding/defaults.py b/src/prompt_toolkit/key_binding/defaults.py
index 020b28c1..166da8d4 100644
--- a/src/prompt_toolkit/key_binding/defaults.py
+++ b/src/prompt_toolkit/key_binding/defaults.py
@@ -5,18 +5,58 @@ Default 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.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']
+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:
+def load_key_bindings() -> KeyBindingsBase:
"""
Create a KeyBindings object that contains the default key bindings.
"""
- pass
+ 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(),
+ ]
+ )
diff --git a/src/prompt_toolkit/key_binding/digraphs.py b/src/prompt_toolkit/key_binding/digraphs.py
index 3658dd15..1e8a4326 100644
--- a/src/prompt_toolkit/key_binding/digraphs.py
+++ b/src/prompt_toolkit/key_binding/digraphs.py
@@ -7,344 +7,1371 @@ Taken from Neovim and translated to Python:
https://raw.githubusercontent.com/neovim/neovim/master/src/nvim/digraph.c
"""
from __future__ import annotations
-__all__ = ['DIGRAPHS']
-DIGRAPHS: dict[tuple[str, str], int] = {('N', 'U'): 0, ('S', 'H'): 1, ('S',
- 'X'): 2, ('E', 'X'): 3, ('E', 'T'): 4, ('E', 'Q'): 5, ('A', 'K'): 6, (
- 'B', 'L'): 7, ('B', 'S'): 8, ('H', 'T'): 9, ('L', 'F'): 10, ('V', 'T'):
- 11, ('F', 'F'): 12, ('C', 'R'): 13, ('S', 'O'): 14, ('S', 'I'): 15, (
- 'D', 'L'): 16, ('D', '1'): 17, ('D', '2'): 18, ('D', '3'): 19, ('D',
- '4'): 20, ('N', 'K'): 21, ('S', 'Y'): 22, ('E', 'B'): 23, ('C', 'N'):
- 24, ('E', 'M'): 25, ('S', 'B'): 26, ('E', 'C'): 27, ('F', 'S'): 28, (
- 'G', 'S'): 29, ('R', 'S'): 30, ('U', 'S'): 31, ('S', 'P'): 32, ('N',
- 'b'): 35, ('D', 'O'): 36, ('A', 't'): 64, ('<', '('): 91, ('/', '/'):
- 92, (')', '>'): 93, ("'", '>'): 94, ("'", '!'): 96, ('(', '!'): 123, (
- '!', '!'): 124, ('!', ')'): 125, ("'", '?'): 126, ('D', 'T'): 127, ('P',
- 'A'): 128, ('H', 'O'): 129, ('B', 'H'): 130, ('N', 'H'): 131, ('I', 'N'
- ): 132, ('N', 'L'): 133, ('S', 'A'): 134, ('E', 'S'): 135, ('H', 'S'):
- 136, ('H', 'J'): 137, ('V', 'S'): 138, ('P', 'D'): 139, ('P', 'U'): 140,
- ('R', 'I'): 141, ('S', '2'): 142, ('S', '3'): 143, ('D', 'C'): 144, (
- 'P', '1'): 145, ('P', '2'): 146, ('T', 'S'): 147, ('C', 'C'): 148, ('M',
- 'W'): 149, ('S', 'G'): 150, ('E', 'G'): 151, ('S', 'S'): 152, ('G', 'C'
- ): 153, ('S', 'C'): 154, ('C', 'I'): 155, ('S', 'T'): 156, ('O', 'C'):
- 157, ('P', 'M'): 158, ('A', 'C'): 159, ('N', 'S'): 160, ('!', 'I'): 161,
- ('C', 't'): 162, ('P', 'd'): 163, ('C', 'u'): 164, ('Y', 'e'): 165, (
- 'B', 'B'): 166, ('S', 'E'): 167, ("'", ':'): 168, ('C', 'o'): 169, ('-',
- 'a'): 170, ('<', '<'): 171, ('N', 'O'): 172, ('-', '-'): 173, ('R', 'g'
- ): 174, ("'", 'm'): 175, ('D', 'G'): 176, ('+', '-'): 177, ('2', 'S'):
- 178, ('3', 'S'): 179, ("'", "'"): 180, ('M', 'y'): 181, ('P', 'I'): 182,
- ('.', 'M'): 183, ("'", ','): 184, ('1', 'S'): 185, ('-', 'o'): 186, (
- '>', '>'): 187, ('1', '4'): 188, ('1', '2'): 189, ('3', '4'): 190, ('?',
- 'I'): 191, ('A', '!'): 192, ('A', "'"): 193, ('A', '>'): 194, ('A', '?'
- ): 195, ('A', ':'): 196, ('A', 'A'): 197, ('A', 'E'): 198, ('C', ','):
- 199, ('E', '!'): 200, ('E', "'"): 201, ('E', '>'): 202, ('E', ':'): 203,
- ('I', '!'): 204, ('I', "'"): 205, ('I', '>'): 206, ('I', ':'): 207, (
- 'D', '-'): 208, ('N', '?'): 209, ('O', '!'): 210, ('O', "'"): 211, ('O',
- '>'): 212, ('O', '?'): 213, ('O', ':'): 214, ('*', 'X'): 215, ('O', '/'
- ): 216, ('U', '!'): 217, ('U', "'"): 218, ('U', '>'): 219, ('U', ':'):
- 220, ('Y', "'"): 221, ('T', 'H'): 222, ('s', 's'): 223, ('a', '!'): 224,
- ('a', "'"): 225, ('a', '>'): 226, ('a', '?'): 227, ('a', ':'): 228, (
- 'a', 'a'): 229, ('a', 'e'): 230, ('c', ','): 231, ('e', '!'): 232, ('e',
- "'"): 233, ('e', '>'): 234, ('e', ':'): 235, ('i', '!'): 236, ('i', "'"
- ): 237, ('i', '>'): 238, ('i', ':'): 239, ('d', '-'): 240, ('n', '?'):
- 241, ('o', '!'): 242, ('o', "'"): 243, ('o', '>'): 244, ('o', '?'): 245,
- ('o', ':'): 246, ('-', ':'): 247, ('o', '/'): 248, ('u', '!'): 249, (
- 'u', "'"): 250, ('u', '>'): 251, ('u', ':'): 252, ('y', "'"): 253, ('t',
- 'h'): 254, ('y', ':'): 255, ('A', '-'): 256, ('a', '-'): 257, ('A', '('
- ): 258, ('a', '('): 259, ('A', ';'): 260, ('a', ';'): 261, ('C', "'"):
- 262, ('c', "'"): 263, ('C', '>'): 264, ('c', '>'): 265, ('C', '.'): 266,
- ('c', '.'): 267, ('C', '<'): 268, ('c', '<'): 269, ('D', '<'): 270, (
- 'd', '<'): 271, ('D', '/'): 272, ('d', '/'): 273, ('E', '-'): 274, ('e',
- '-'): 275, ('E', '('): 276, ('e', '('): 277, ('E', '.'): 278, ('e', '.'
- ): 279, ('E', ';'): 280, ('e', ';'): 281, ('E', '<'): 282, ('e', '<'):
- 283, ('G', '>'): 284, ('g', '>'): 285, ('G', '('): 286, ('g', '('): 287,
- ('G', '.'): 288, ('g', '.'): 289, ('G', ','): 290, ('g', ','): 291, (
- 'H', '>'): 292, ('h', '>'): 293, ('H', '/'): 294, ('h', '/'): 295, ('I',
- '?'): 296, ('i', '?'): 297, ('I', '-'): 298, ('i', '-'): 299, ('I', '('
- ): 300, ('i', '('): 301, ('I', ';'): 302, ('i', ';'): 303, ('I', '.'):
- 304, ('i', '.'): 305, ('I', 'J'): 306, ('i', 'j'): 307, ('J', '>'): 308,
- ('j', '>'): 309, ('K', ','): 310, ('k', ','): 311, ('k', 'k'): 312, (
- 'L', "'"): 313, ('l', "'"): 314, ('L', ','): 315, ('l', ','): 316, ('L',
- '<'): 317, ('l', '<'): 318, ('L', '.'): 319, ('l', '.'): 320, ('L', '/'
- ): 321, ('l', '/'): 322, ('N', "'"): 323, ('n', "'"): 324, ('N', ','):
- 325, ('n', ','): 326, ('N', '<'): 327, ('n', '<'): 328, ("'", 'n'): 329,
- ('N', 'G'): 330, ('n', 'g'): 331, ('O', '-'): 332, ('o', '-'): 333, (
- 'O', '('): 334, ('o', '('): 335, ('O', '"'): 336, ('o', '"'): 337, ('O',
- 'E'): 338, ('o', 'e'): 339, ('R', "'"): 340, ('r', "'"): 341, ('R', ','
- ): 342, ('r', ','): 343, ('R', '<'): 344, ('r', '<'): 345, ('S', "'"):
- 346, ('s', "'"): 347, ('S', '>'): 348, ('s', '>'): 349, ('S', ','): 350,
- ('s', ','): 351, ('S', '<'): 352, ('s', '<'): 353, ('T', ','): 354, (
- 't', ','): 355, ('T', '<'): 356, ('t', '<'): 357, ('T', '/'): 358, ('t',
- '/'): 359, ('U', '?'): 360, ('u', '?'): 361, ('U', '-'): 362, ('u', '-'
- ): 363, ('U', '('): 364, ('u', '('): 365, ('U', '0'): 366, ('u', '0'):
- 367, ('U', '"'): 368, ('u', '"'): 369, ('U', ';'): 370, ('u', ';'): 371,
- ('W', '>'): 372, ('w', '>'): 373, ('Y', '>'): 374, ('y', '>'): 375, (
- 'Y', ':'): 376, ('Z', "'"): 377, ('z', "'"): 378, ('Z', '.'): 379, ('z',
- '.'): 380, ('Z', '<'): 381, ('z', '<'): 382, ('O', '9'): 416, ('o', '9'
- ): 417, ('O', 'I'): 418, ('o', 'i'): 419, ('y', 'r'): 422, ('U', '9'):
- 431, ('u', '9'): 432, ('Z', '/'): 437, ('z', '/'): 438, ('E', 'D'): 439,
- ('A', '<'): 461, ('a', '<'): 462, ('I', '<'): 463, ('i', '<'): 464, (
- 'O', '<'): 465, ('o', '<'): 466, ('U', '<'): 467, ('u', '<'): 468, ('A',
- '1'): 478, ('a', '1'): 479, ('A', '7'): 480, ('a', '7'): 481, ('A', '3'
- ): 482, ('a', '3'): 483, ('G', '/'): 484, ('g', '/'): 485, ('G', '<'):
- 486, ('g', '<'): 487, ('K', '<'): 488, ('k', '<'): 489, ('O', ';'): 490,
- ('o', ';'): 491, ('O', '1'): 492, ('o', '1'): 493, ('E', 'Z'): 494, (
- 'e', 'z'): 495, ('j', '<'): 496, ('G', "'"): 500, ('g', "'"): 501, (';',
- 'S'): 703, ("'", '<'): 711, ("'", '('): 728, ("'", '.'): 729, ("'", '0'
- ): 730, ("'", ';'): 731, ("'", '"'): 733, ('A', '%'): 902, ('E', '%'):
- 904, ('Y', '%'): 905, ('I', '%'): 906, ('O', '%'): 908, ('U', '%'): 910,
- ('W', '%'): 911, ('i', '3'): 912, ('A', '*'): 913, ('B', '*'): 914, (
- 'G', '*'): 915, ('D', '*'): 916, ('E', '*'): 917, ('Z', '*'): 918, ('Y',
- '*'): 919, ('H', '*'): 920, ('I', '*'): 921, ('K', '*'): 922, ('L', '*'
- ): 923, ('M', '*'): 924, ('N', '*'): 925, ('C', '*'): 926, ('O', '*'):
- 927, ('P', '*'): 928, ('R', '*'): 929, ('S', '*'): 931, ('T', '*'): 932,
- ('U', '*'): 933, ('F', '*'): 934, ('X', '*'): 935, ('Q', '*'): 936, (
- 'W', '*'): 937, ('J', '*'): 938, ('V', '*'): 939, ('a', '%'): 940, ('e',
- '%'): 941, ('y', '%'): 942, ('i', '%'): 943, ('u', '3'): 944, ('a', '*'
- ): 945, ('b', '*'): 946, ('g', '*'): 947, ('d', '*'): 948, ('e', '*'):
- 949, ('z', '*'): 950, ('y', '*'): 951, ('h', '*'): 952, ('i', '*'): 953,
- ('k', '*'): 954, ('l', '*'): 955, ('m', '*'): 956, ('n', '*'): 957, (
- 'c', '*'): 958, ('o', '*'): 959, ('p', '*'): 960, ('r', '*'): 961, ('*',
- 's'): 962, ('s', '*'): 963, ('t', '*'): 964, ('u', '*'): 965, ('f', '*'
- ): 966, ('x', '*'): 967, ('q', '*'): 968, ('w', '*'): 969, ('j', '*'):
- 970, ('v', '*'): 971, ('o', '%'): 972, ('u', '%'): 973, ('w', '%'): 974,
- ("'", 'G'): 984, (',', 'G'): 985, ('T', '3'): 986, ('t', '3'): 987, (
- 'M', '3'): 988, ('m', '3'): 989, ('K', '3'): 990, ('k', '3'): 991, ('P',
- '3'): 992, ('p', '3'): 993, ("'", '%'): 1012, ('j', '3'): 1013, ('I',
- 'O'): 1025, ('D', '%'): 1026, ('G', '%'): 1027, ('I', 'E'): 1028, ('D',
- 'S'): 1029, ('I', 'I'): 1030, ('Y', 'I'): 1031, ('J', '%'): 1032, ('L',
- 'J'): 1033, ('N', 'J'): 1034, ('T', 's'): 1035, ('K', 'J'): 1036, ('V',
- '%'): 1038, ('D', 'Z'): 1039, ('A', '='): 1040, ('B', '='): 1041, ('V',
- '='): 1042, ('G', '='): 1043, ('D', '='): 1044, ('E', '='): 1045, ('Z',
- '%'): 1046, ('Z', '='): 1047, ('I', '='): 1048, ('J', '='): 1049, ('K',
- '='): 1050, ('L', '='): 1051, ('M', '='): 1052, ('N', '='): 1053, ('O',
- '='): 1054, ('P', '='): 1055, ('R', '='): 1056, ('S', '='): 1057, ('T',
- '='): 1058, ('U', '='): 1059, ('F', '='): 1060, ('H', '='): 1061, ('C',
- '='): 1062, ('C', '%'): 1063, ('S', '%'): 1064, ('S', 'c'): 1065, ('=',
- '"'): 1066, ('Y', '='): 1067, ('%', '"'): 1068, ('J', 'E'): 1069, ('J',
- 'U'): 1070, ('J', 'A'): 1071, ('a', '='): 1072, ('b', '='): 1073, ('v',
- '='): 1074, ('g', '='): 1075, ('d', '='): 1076, ('e', '='): 1077, ('z',
- '%'): 1078, ('z', '='): 1079, ('i', '='): 1080, ('j', '='): 1081, ('k',
- '='): 1082, ('l', '='): 1083, ('m', '='): 1084, ('n', '='): 1085, ('o',
- '='): 1086, ('p', '='): 1087, ('r', '='): 1088, ('s', '='): 1089, ('t',
- '='): 1090, ('u', '='): 1091, ('f', '='): 1092, ('h', '='): 1093, ('c',
- '='): 1094, ('c', '%'): 1095, ('s', '%'): 1096, ('s', 'c'): 1097, ('=',
- "'"): 1098, ('y', '='): 1099, ('%', "'"): 1100, ('j', 'e'): 1101, ('j',
- 'u'): 1102, ('j', 'a'): 1103, ('i', 'o'): 1105, ('d', '%'): 1106, ('g',
- '%'): 1107, ('i', 'e'): 1108, ('d', 's'): 1109, ('i', 'i'): 1110, ('y',
- 'i'): 1111, ('j', '%'): 1112, ('l', 'j'): 1113, ('n', 'j'): 1114, ('t',
- 's'): 1115, ('k', 'j'): 1116, ('v', '%'): 1118, ('d', 'z'): 1119, ('Y',
- '3'): 1122, ('y', '3'): 1123, ('O', '3'): 1130, ('o', '3'): 1131, ('F',
- '3'): 1138, ('f', '3'): 1139, ('V', '3'): 1140, ('v', '3'): 1141, ('C',
- '3'): 1152, ('c', '3'): 1153, ('G', '3'): 1168, ('g', '3'): 1169, ('A',
- '+'): 1488, ('B', '+'): 1489, ('G', '+'): 1490, ('D', '+'): 1491, ('H',
- '+'): 1492, ('W', '+'): 1493, ('Z', '+'): 1494, ('X', '+'): 1495, ('T',
- 'j'): 1496, ('J', '+'): 1497, ('K', '%'): 1498, ('K', '+'): 1499, ('L',
- '+'): 1500, ('M', '%'): 1501, ('M', '+'): 1502, ('N', '%'): 1503, ('N',
- '+'): 1504, ('S', '+'): 1505, ('E', '+'): 1506, ('P', '%'): 1507, ('P',
- '+'): 1508, ('Z', 'j'): 1509, ('Z', 'J'): 1510, ('Q', '+'): 1511, ('R',
- '+'): 1512, ('S', 'h'): 1513, ('T', '+'): 1514, (',', '+'): 1548, (';',
- '+'): 1563, ('?', '+'): 1567, ('H', "'"): 1569, ('a', 'M'): 1570, ('a',
- 'H'): 1571, ('w', 'H'): 1572, ('a', 'h'): 1573, ('y', 'H'): 1574, ('a',
- '+'): 1575, ('b', '+'): 1576, ('t', 'm'): 1577, ('t', '+'): 1578, ('t',
- 'k'): 1579, ('g', '+'): 1580, ('h', 'k'): 1581, ('x', '+'): 1582, ('d',
- '+'): 1583, ('d', 'k'): 1584, ('r', '+'): 1585, ('z', '+'): 1586, ('s',
- '+'): 1587, ('s', 'n'): 1588, ('c', '+'): 1589, ('d', 'd'): 1590, ('t',
- 'j'): 1591, ('z', 'H'): 1592, ('e', '+'): 1593, ('i', '+'): 1594, ('+',
- '+'): 1600, ('f', '+'): 1601, ('q', '+'): 1602, ('k', '+'): 1603, ('l',
- '+'): 1604, ('m', '+'): 1605, ('n', '+'): 1606, ('h', '+'): 1607, ('w',
- '+'): 1608, ('j', '+'): 1609, ('y', '+'): 1610, (':', '+'): 1611, ('"',
- '+'): 1612, ('=', '+'): 1613, ('/', '+'): 1614, ("'", '+'): 1615, ('1',
- '+'): 1616, ('3', '+'): 1617, ('0', '+'): 1618, ('a', 'S'): 1648, ('p',
- '+'): 1662, ('v', '+'): 1700, ('g', 'f'): 1711, ('0', 'a'): 1776, ('1',
- 'a'): 1777, ('2', 'a'): 1778, ('3', 'a'): 1779, ('4', 'a'): 1780, ('5',
- 'a'): 1781, ('6', 'a'): 1782, ('7', 'a'): 1783, ('8', 'a'): 1784, ('9',
- 'a'): 1785, ('B', '.'): 7682, ('b', '.'): 7683, ('B', '_'): 7686, ('b',
- '_'): 7687, ('D', '.'): 7690, ('d', '.'): 7691, ('D', '_'): 7694, ('d',
- '_'): 7695, ('D', ','): 7696, ('d', ','): 7697, ('F', '.'): 7710, ('f',
- '.'): 7711, ('G', '-'): 7712, ('g', '-'): 7713, ('H', '.'): 7714, ('h',
- '.'): 7715, ('H', ':'): 7718, ('h', ':'): 7719, ('H', ','): 7720, ('h',
- ','): 7721, ('K', "'"): 7728, ('k', "'"): 7729, ('K', '_'): 7732, ('k',
- '_'): 7733, ('L', '_'): 7738, ('l', '_'): 7739, ('M', "'"): 7742, ('m',
- "'"): 7743, ('M', '.'): 7744, ('m', '.'): 7745, ('N', '.'): 7748, ('n',
- '.'): 7749, ('N', '_'): 7752, ('n', '_'): 7753, ('P', "'"): 7764, ('p',
- "'"): 7765, ('P', '.'): 7766, ('p', '.'): 7767, ('R', '.'): 7768, ('r',
- '.'): 7769, ('R', '_'): 7774, ('r', '_'): 7775, ('S', '.'): 7776, ('s',
- '.'): 7777, ('T', '.'): 7786, ('t', '.'): 7787, ('T', '_'): 7790, ('t',
- '_'): 7791, ('V', '?'): 7804, ('v', '?'): 7805, ('W', '!'): 7808, ('w',
- '!'): 7809, ('W', "'"): 7810, ('w', "'"): 7811, ('W', ':'): 7812, ('w',
- ':'): 7813, ('W', '.'): 7814, ('w', '.'): 7815, ('X', '.'): 7818, ('x',
- '.'): 7819, ('X', ':'): 7820, ('x', ':'): 7821, ('Y', '.'): 7822, ('y',
- '.'): 7823, ('Z', '>'): 7824, ('z', '>'): 7825, ('Z', '_'): 7828, ('z',
- '_'): 7829, ('h', '_'): 7830, ('t', ':'): 7831, ('w', '0'): 7832, ('y',
- '0'): 7833, ('A', '2'): 7842, ('a', '2'): 7843, ('E', '2'): 7866, ('e',
- '2'): 7867, ('E', '?'): 7868, ('e', '?'): 7869, ('I', '2'): 7880, ('i',
- '2'): 7881, ('O', '2'): 7886, ('o', '2'): 7887, ('U', '2'): 7910, ('u',
- '2'): 7911, ('Y', '!'): 7922, ('y', '!'): 7923, ('Y', '2'): 7926, ('y',
- '2'): 7927, ('Y', '?'): 7928, ('y', '?'): 7929, (';', "'"): 7936, (',',
- "'"): 7937, (';', '!'): 7938, (',', '!'): 7939, ('?', ';'): 7940, ('?',
- ','): 7941, ('!', ':'): 7942, ('?', ':'): 7943, ('1', 'N'): 8194, ('1',
- 'M'): 8195, ('3', 'M'): 8196, ('4', 'M'): 8197, ('6', 'M'): 8198, ('1',
- 'T'): 8201, ('1', 'H'): 8202, ('-', '1'): 8208, ('-', 'N'): 8211, ('-',
- 'M'): 8212, ('-', '3'): 8213, ('!', '2'): 8214, ('=', '2'): 8215, ("'",
- '6'): 8216, ("'", '9'): 8217, ('.', '9'): 8218, ('9', "'"): 8219, ('"',
- '6'): 8220, ('"', '9'): 8221, (':', '9'): 8222, ('9', '"'): 8223, ('/',
- '-'): 8224, ('/', '='): 8225, ('.', '.'): 8229, ('%', '0'): 8240, ('1',
- "'"): 8242, ('2', "'"): 8243, ('3', "'"): 8244, ('1', '"'): 8245, ('2',
- '"'): 8246, ('3', '"'): 8247, ('C', 'a'): 8248, ('<', '1'): 8249, ('>',
- '1'): 8250, (':', 'X'): 8251, ("'", '-'): 8254, ('/', 'f'): 8260, ('0',
- 'S'): 8304, ('4', 'S'): 8308, ('5', 'S'): 8309, ('6', 'S'): 8310, ('7',
- 'S'): 8311, ('8', 'S'): 8312, ('9', 'S'): 8313, ('+', 'S'): 8314, ('-',
- 'S'): 8315, ('=', 'S'): 8316, ('(', 'S'): 8317, (')', 'S'): 8318, ('n',
- 'S'): 8319, ('0', 's'): 8320, ('1', 's'): 8321, ('2', 's'): 8322, ('3',
- 's'): 8323, ('4', 's'): 8324, ('5', 's'): 8325, ('6', 's'): 8326, ('7',
- 's'): 8327, ('8', 's'): 8328, ('9', 's'): 8329, ('+', 's'): 8330, ('-',
- 's'): 8331, ('=', 's'): 8332, ('(', 's'): 8333, (')', 's'): 8334, ('L',
- 'i'): 8356, ('P', 't'): 8359, ('W', '='): 8361, ('=', 'e'): 8364, ('E',
- 'u'): 8364, ('=', 'R'): 8381, ('=', 'P'): 8381, ('o', 'C'): 8451, ('c',
- 'o'): 8453, ('o', 'F'): 8457, ('N', '0'): 8470, ('P', 'O'): 8471, ('R',
- 'x'): 8478, ('S', 'M'): 8480, ('T', 'M'): 8482, ('O', 'm'): 8486, ('A',
- 'O'): 8491, ('1', '3'): 8531, ('2', '3'): 8532, ('1', '5'): 8533, ('2',
- '5'): 8534, ('3', '5'): 8535, ('4', '5'): 8536, ('1', '6'): 8537, ('5',
- '6'): 8538, ('1', '8'): 8539, ('3', '8'): 8540, ('5', '8'): 8541, ('7',
- '8'): 8542, ('1', 'R'): 8544, ('2', 'R'): 8545, ('3', 'R'): 8546, ('4',
- 'R'): 8547, ('5', 'R'): 8548, ('6', 'R'): 8549, ('7', 'R'): 8550, ('8',
- 'R'): 8551, ('9', 'R'): 8552, ('a', 'R'): 8553, ('b', 'R'): 8554, ('c',
- 'R'): 8555, ('1', 'r'): 8560, ('2', 'r'): 8561, ('3', 'r'): 8562, ('4',
- 'r'): 8563, ('5', 'r'): 8564, ('6', 'r'): 8565, ('7', 'r'): 8566, ('8',
- 'r'): 8567, ('9', 'r'): 8568, ('a', 'r'): 8569, ('b', 'r'): 8570, ('c',
- 'r'): 8571, ('<', '-'): 8592, ('-', '!'): 8593, ('-', '>'): 8594, ('-',
- 'v'): 8595, ('<', '>'): 8596, ('U', 'D'): 8597, ('<', '='): 8656, ('=',
- '>'): 8658, ('=', '='): 8660, ('F', 'A'): 8704, ('d', 'P'): 8706, ('T',
- 'E'): 8707, ('/', '0'): 8709, ('D', 'E'): 8710, ('N', 'B'): 8711, ('(',
- '-'): 8712, ('-', ')'): 8715, ('*', 'P'): 8719, ('+', 'Z'): 8721, ('-',
- '2'): 8722, ('-', '+'): 8723, ('*', '-'): 8727, ('O', 'b'): 8728, ('S',
- 'b'): 8729, ('R', 'T'): 8730, ('0', '('): 8733, ('0', '0'): 8734, ('-',
- 'L'): 8735, ('-', 'V'): 8736, ('P', 'P'): 8741, ('A', 'N'): 8743, ('O',
- 'R'): 8744, ('(', 'U'): 8745, (')', 'U'): 8746, ('I', 'n'): 8747, ('D',
- 'I'): 8748, ('I', 'o'): 8750, ('.', ':'): 8756, (':', '.'): 8757, (':',
- 'R'): 8758, (':', ':'): 8759, ('?', '1'): 8764, ('C', 'G'): 8766, ('?',
- '-'): 8771, ('?', '='): 8773, ('?', '2'): 8776, ('=', '?'): 8780, ('H',
- 'I'): 8787, ('!', '='): 8800, ('=', '3'): 8801, ('=', '<'): 8804, ('>',
- '='): 8805, ('<', '*'): 8810, ('*', '>'): 8811, ('!', '<'): 8814, ('!',
- '>'): 8815, ('(', 'C'): 8834, (')', 'C'): 8835, ('(', '_'): 8838, (')',
- '_'): 8839, ('0', '.'): 8857, ('0', '2'): 8858, ('-', 'T'): 8869, ('.',
- 'P'): 8901, (':', '3'): 8942, ('.', '3'): 8943, ('E', 'h'): 8962, ('<',
- '7'): 8968, ('>', '7'): 8969, ('7', '<'): 8970, ('7', '>'): 8971, ('N',
- 'I'): 8976, ('(', 'A'): 8978, ('T', 'R'): 8981, ('I', 'u'): 8992, ('I',
- 'l'): 8993, ('<', '/'): 9001, ('/', '>'): 9002, ('V', 's'): 9251, ('1',
- 'h'): 9280, ('3', 'h'): 9281, ('2', 'h'): 9282, ('4', 'h'): 9283, ('1',
- 'j'): 9286, ('2', 'j'): 9287, ('3', 'j'): 9288, ('4', 'j'): 9289, ('1',
- '.'): 9352, ('2', '.'): 9353, ('3', '.'): 9354, ('4', '.'): 9355, ('5',
- '.'): 9356, ('6', '.'): 9357, ('7', '.'): 9358, ('8', '.'): 9359, ('9',
- '.'): 9360, ('h', 'h'): 9472, ('H', 'H'): 9473, ('v', 'v'): 9474, ('V',
- 'V'): 9475, ('3', '-'): 9476, ('3', '_'): 9477, ('3', '!'): 9478, ('3',
- '/'): 9479, ('4', '-'): 9480, ('4', '_'): 9481, ('4', '!'): 9482, ('4',
- '/'): 9483, ('d', 'r'): 9484, ('d', 'R'): 9485, ('D', 'r'): 9486, ('D',
- 'R'): 9487, ('d', 'l'): 9488, ('d', 'L'): 9489, ('D', 'l'): 9490, ('L',
- 'D'): 9491, ('u', 'r'): 9492, ('u', 'R'): 9493, ('U', 'r'): 9494, ('U',
- 'R'): 9495, ('u', 'l'): 9496, ('u', 'L'): 9497, ('U', 'l'): 9498, ('U',
- 'L'): 9499, ('v', 'r'): 9500, ('v', 'R'): 9501, ('V', 'r'): 9504, ('V',
- 'R'): 9507, ('v', 'l'): 9508, ('v', 'L'): 9509, ('V', 'l'): 9512, ('V',
- 'L'): 9515, ('d', 'h'): 9516, ('d', 'H'): 9519, ('D', 'h'): 9520, ('D',
- 'H'): 9523, ('u', 'h'): 9524, ('u', 'H'): 9527, ('U', 'h'): 9528, ('U',
- 'H'): 9531, ('v', 'h'): 9532, ('v', 'H'): 9535, ('V', 'h'): 9538, ('V',
- 'H'): 9547, ('F', 'D'): 9585, ('B', 'D'): 9586, ('T', 'B'): 9600, ('L',
- 'B'): 9604, ('F', 'B'): 9608, ('l', 'B'): 9612, ('R', 'B'): 9616, ('.',
- 'S'): 9617, (':', 'S'): 9618, ('?', 'S'): 9619, ('f', 'S'): 9632, ('O',
- 'S'): 9633, ('R', 'O'): 9634, ('R', 'r'): 9635, ('R', 'F'): 9636, ('R',
- 'Y'): 9637, ('R', 'H'): 9638, ('R', 'Z'): 9639, ('R', 'K'): 9640, ('R',
- 'X'): 9641, ('s', 'B'): 9642, ('S', 'R'): 9644, ('O', 'r'): 9645, ('U',
- 'T'): 9650, ('u', 'T'): 9651, ('P', 'R'): 9654, ('T', 'r'): 9655, ('D',
- 't'): 9660, ('d', 'T'): 9661, ('P', 'L'): 9664, ('T', 'l'): 9665, ('D',
- 'b'): 9670, ('D', 'w'): 9671, ('L', 'Z'): 9674, ('0', 'm'): 9675, ('0',
- 'o'): 9678, ('0', 'M'): 9679, ('0', 'L'): 9680, ('0', 'R'): 9681, ('S',
- 'n'): 9688, ('I', 'c'): 9689, ('F', 'd'): 9698, ('B', 'd'): 9699, ('*',
- '2'): 9733, ('*', '1'): 9734, ('<', 'H'): 9756, ('>', 'H'): 9758, ('0',
- 'u'): 9786, ('0', 'U'): 9787, ('S', 'U'): 9788, ('F', 'm'): 9792, ('M',
- 'l'): 9794, ('c', 'S'): 9824, ('c', 'H'): 9825, ('c', 'D'): 9826, ('c',
- 'C'): 9827, ('M', 'd'): 9833, ('M', '8'): 9834, ('M', '2'): 9835, ('M',
- 'b'): 9837, ('M', 'x'): 9838, ('M', 'X'): 9839, ('O', 'K'): 10003, ('X',
- 'X'): 10007, ('-', 'X'): 10016, ('I', 'S'): 12288, (',', '_'): 12289, (
- '.', '_'): 12290, ('+', '"'): 12291, ('+', '_'): 12292, ('*', '_'):
- 12293, (';', '_'): 12294, ('0', '_'): 12295, ('<', '+'): 12298, ('>',
- '+'): 12299, ('<', "'"): 12300, ('>', "'"): 12301, ('<', '"'): 12302, (
- '>', '"'): 12303, ('(', '"'): 12304, (')', '"'): 12305, ('=', 'T'):
- 12306, ('=', '_'): 12307, ('(', "'"): 12308, (')', "'"): 12309, ('(',
- 'I'): 12310, (')', 'I'): 12311, ('-', '?'): 12316, ('A', '5'): 12353, (
- 'a', '5'): 12354, ('I', '5'): 12355, ('i', '5'): 12356, ('U', '5'):
- 12357, ('u', '5'): 12358, ('E', '5'): 12359, ('e', '5'): 12360, ('O',
- '5'): 12361, ('o', '5'): 12362, ('k', 'a'): 12363, ('g', 'a'): 12364, (
- 'k', 'i'): 12365, ('g', 'i'): 12366, ('k', 'u'): 12367, ('g', 'u'):
- 12368, ('k', 'e'): 12369, ('g', 'e'): 12370, ('k', 'o'): 12371, ('g',
- 'o'): 12372, ('s', 'a'): 12373, ('z', 'a'): 12374, ('s', 'i'): 12375, (
- 'z', 'i'): 12376, ('s', 'u'): 12377, ('z', 'u'): 12378, ('s', 'e'):
- 12379, ('z', 'e'): 12380, ('s', 'o'): 12381, ('z', 'o'): 12382, ('t',
- 'a'): 12383, ('d', 'a'): 12384, ('t', 'i'): 12385, ('d', 'i'): 12386, (
- 't', 'U'): 12387, ('t', 'u'): 12388, ('d', 'u'): 12389, ('t', 'e'):
- 12390, ('d', 'e'): 12391, ('t', 'o'): 12392, ('d', 'o'): 12393, ('n',
- 'a'): 12394, ('n', 'i'): 12395, ('n', 'u'): 12396, ('n', 'e'): 12397, (
- 'n', 'o'): 12398, ('h', 'a'): 12399, ('b', 'a'): 12400, ('p', 'a'):
- 12401, ('h', 'i'): 12402, ('b', 'i'): 12403, ('p', 'i'): 12404, ('h',
- 'u'): 12405, ('b', 'u'): 12406, ('p', 'u'): 12407, ('h', 'e'): 12408, (
- 'b', 'e'): 12409, ('p', 'e'): 12410, ('h', 'o'): 12411, ('b', 'o'):
- 12412, ('p', 'o'): 12413, ('m', 'a'): 12414, ('m', 'i'): 12415, ('m',
- 'u'): 12416, ('m', 'e'): 12417, ('m', 'o'): 12418, ('y', 'A'): 12419, (
- 'y', 'a'): 12420, ('y', 'U'): 12421, ('y', 'u'): 12422, ('y', 'O'):
- 12423, ('y', 'o'): 12424, ('r', 'a'): 12425, ('r', 'i'): 12426, ('r',
- 'u'): 12427, ('r', 'e'): 12428, ('r', 'o'): 12429, ('w', 'A'): 12430, (
- 'w', 'a'): 12431, ('w', 'i'): 12432, ('w', 'e'): 12433, ('w', 'o'):
- 12434, ('n', '5'): 12435, ('v', 'u'): 12436, ('"', '5'): 12443, ('0',
- '5'): 12444, ('*', '5'): 12445, ('+', '5'): 12446, ('a', '6'): 12449, (
- 'A', '6'): 12450, ('i', '6'): 12451, ('I', '6'): 12452, ('u', '6'):
- 12453, ('U', '6'): 12454, ('e', '6'): 12455, ('E', '6'): 12456, ('o',
- '6'): 12457, ('O', '6'): 12458, ('K', 'a'): 12459, ('G', 'a'): 12460, (
- 'K', 'i'): 12461, ('G', 'i'): 12462, ('K', 'u'): 12463, ('G', 'u'):
- 12464, ('K', 'e'): 12465, ('G', 'e'): 12466, ('K', 'o'): 12467, ('G',
- 'o'): 12468, ('S', 'a'): 12469, ('Z', 'a'): 12470, ('S', 'i'): 12471, (
- 'Z', 'i'): 12472, ('S', 'u'): 12473, ('Z', 'u'): 12474, ('S', 'e'):
- 12475, ('Z', 'e'): 12476, ('S', 'o'): 12477, ('Z', 'o'): 12478, ('T',
- 'a'): 12479, ('D', 'a'): 12480, ('T', 'i'): 12481, ('D', 'i'): 12482, (
- 'T', 'U'): 12483, ('T', 'u'): 12484, ('D', 'u'): 12485, ('T', 'e'):
- 12486, ('D', 'e'): 12487, ('T', 'o'): 12488, ('D', 'o'): 12489, ('N',
- 'a'): 12490, ('N', 'i'): 12491, ('N', 'u'): 12492, ('N', 'e'): 12493, (
- 'N', 'o'): 12494, ('H', 'a'): 12495, ('B', 'a'): 12496, ('P', 'a'):
- 12497, ('H', 'i'): 12498, ('B', 'i'): 12499, ('P', 'i'): 12500, ('H',
- 'u'): 12501, ('B', 'u'): 12502, ('P', 'u'): 12503, ('H', 'e'): 12504, (
- 'B', 'e'): 12505, ('P', 'e'): 12506, ('H', 'o'): 12507, ('B', 'o'):
- 12508, ('P', 'o'): 12509, ('M', 'a'): 12510, ('M', 'i'): 12511, ('M',
- 'u'): 12512, ('M', 'e'): 12513, ('M', 'o'): 12514, ('Y', 'A'): 12515, (
- 'Y', 'a'): 12516, ('Y', 'U'): 12517, ('Y', 'u'): 12518, ('Y', 'O'):
- 12519, ('Y', 'o'): 12520, ('R', 'a'): 12521, ('R', 'i'): 12522, ('R',
- 'u'): 12523, ('R', 'e'): 12524, ('R', 'o'): 12525, ('W', 'A'): 12526, (
- 'W', 'a'): 12527, ('W', 'i'): 12528, ('W', 'e'): 12529, ('W', 'o'):
- 12530, ('N', '6'): 12531, ('V', 'u'): 12532, ('K', 'A'): 12533, ('K',
- 'E'): 12534, ('V', 'a'): 12535, ('V', 'i'): 12536, ('V', 'e'): 12537, (
- 'V', 'o'): 12538, ('.', '6'): 12539, ('-', '6'): 12540, ('*', '6'):
- 12541, ('+', '6'): 12542, ('b', '4'): 12549, ('p', '4'): 12550, ('m',
- '4'): 12551, ('f', '4'): 12552, ('d', '4'): 12553, ('t', '4'): 12554, (
- 'n', '4'): 12555, ('l', '4'): 12556, ('g', '4'): 12557, ('k', '4'):
- 12558, ('h', '4'): 12559, ('j', '4'): 12560, ('q', '4'): 12561, ('x',
- '4'): 12562, ('z', 'h'): 12563, ('c', 'h'): 12564, ('s', 'h'): 12565, (
- 'r', '4'): 12566, ('z', '4'): 12567, ('c', '4'): 12568, ('s', '4'):
- 12569, ('a', '4'): 12570, ('o', '4'): 12571, ('e', '4'): 12572, ('a',
- 'i'): 12574, ('e', 'i'): 12575, ('a', 'u'): 12576, ('o', 'u'): 12577, (
- 'a', 'n'): 12578, ('e', 'n'): 12579, ('a', 'N'): 12580, ('e', 'N'):
- 12581, ('e', 'r'): 12582, ('i', '4'): 12583, ('u', '4'): 12584, ('i',
- 'u'): 12585, ('v', '4'): 12586, ('n', 'G'): 12587, ('g', 'n'): 12588, (
- '1', 'c'): 12832, ('2', 'c'): 12833, ('3', 'c'): 12834, ('4', 'c'):
- 12835, ('5', 'c'): 12836, ('6', 'c'): 12837, ('7', 'c'): 12838, ('8',
- 'c'): 12839, ('9', 'c'): 12840, ('f', 'f'): 64256, ('f', 'i'): 64257, (
- 'f', 'l'): 64258, ('f', 't'): 64261, ('s', 't'): 64262, ('~', '!'): 161,
- ('c', '|'): 162, ('$', '$'): 163, ('o', 'x'): 164, ('Y', '-'): 165, (
- '|', '|'): 166, ('c', 'O'): 169, ('-', ','): 172, ('-', '='): 175, ('~',
- 'o'): 176, ('2', '2'): 178, ('3', '3'): 179, ('p', 'p'): 182, ('~', '.'
- ): 183, ('1', '1'): 185, ('~', '?'): 191, ('A', '`'): 192, ('A', '^'):
- 194, ('A', '~'): 195, ('A', '"'): 196, ('A', '@'): 197, ('E', '`'): 200,
- ('E', '^'): 202, ('E', '"'): 203, ('I', '`'): 204, ('I', '^'): 206, (
- 'I', '"'): 207, ('N', '~'): 209, ('O', '`'): 210, ('O', '^'): 212, ('O',
- '~'): 213, ('/', '\\'): 215, ('U', '`'): 217, ('U', '^'): 219, ('I',
- 'p'): 222, ('a', '`'): 224, ('a', '^'): 226, ('a', '~'): 227, ('a', '"'
- ): 228, ('a', '@'): 229, ('e', '`'): 232, ('e', '^'): 234, ('e', '"'):
- 235, ('i', '`'): 236, ('i', '^'): 238, ('n', '~'): 241, ('o', '`'): 242,
- ('o', '^'): 244, ('o', '~'): 245, ('u', '`'): 249, ('u', '^'): 251, (
- 'y', '"'): 255}
+
+__all__ = [
+ "DIGRAPHS",
+]
+
+# digraphs for Unicode from RFC1345
+# (also work for ISO-8859-1 aka latin1)
+DIGRAPHS: dict[tuple[str, str], int] = {
+ ("N", "U"): 0x00,
+ ("S", "H"): 0x01,
+ ("S", "X"): 0x02,
+ ("E", "X"): 0x03,
+ ("E", "T"): 0x04,
+ ("E", "Q"): 0x05,
+ ("A", "K"): 0x06,
+ ("B", "L"): 0x07,
+ ("B", "S"): 0x08,
+ ("H", "T"): 0x09,
+ ("L", "F"): 0x0A,
+ ("V", "T"): 0x0B,
+ ("F", "F"): 0x0C,
+ ("C", "R"): 0x0D,
+ ("S", "O"): 0x0E,
+ ("S", "I"): 0x0F,
+ ("D", "L"): 0x10,
+ ("D", "1"): 0x11,
+ ("D", "2"): 0x12,
+ ("D", "3"): 0x13,
+ ("D", "4"): 0x14,
+ ("N", "K"): 0x15,
+ ("S", "Y"): 0x16,
+ ("E", "B"): 0x17,
+ ("C", "N"): 0x18,
+ ("E", "M"): 0x19,
+ ("S", "B"): 0x1A,
+ ("E", "C"): 0x1B,
+ ("F", "S"): 0x1C,
+ ("G", "S"): 0x1D,
+ ("R", "S"): 0x1E,
+ ("U", "S"): 0x1F,
+ ("S", "P"): 0x20,
+ ("N", "b"): 0x23,
+ ("D", "O"): 0x24,
+ ("A", "t"): 0x40,
+ ("<", "("): 0x5B,
+ ("/", "/"): 0x5C,
+ (")", ">"): 0x5D,
+ ("'", ">"): 0x5E,
+ ("'", "!"): 0x60,
+ ("(", "!"): 0x7B,
+ ("!", "!"): 0x7C,
+ ("!", ")"): 0x7D,
+ ("'", "?"): 0x7E,
+ ("D", "T"): 0x7F,
+ ("P", "A"): 0x80,
+ ("H", "O"): 0x81,
+ ("B", "H"): 0x82,
+ ("N", "H"): 0x83,
+ ("I", "N"): 0x84,
+ ("N", "L"): 0x85,
+ ("S", "A"): 0x86,
+ ("E", "S"): 0x87,
+ ("H", "S"): 0x88,
+ ("H", "J"): 0x89,
+ ("V", "S"): 0x8A,
+ ("P", "D"): 0x8B,
+ ("P", "U"): 0x8C,
+ ("R", "I"): 0x8D,
+ ("S", "2"): 0x8E,
+ ("S", "3"): 0x8F,
+ ("D", "C"): 0x90,
+ ("P", "1"): 0x91,
+ ("P", "2"): 0x92,
+ ("T", "S"): 0x93,
+ ("C", "C"): 0x94,
+ ("M", "W"): 0x95,
+ ("S", "G"): 0x96,
+ ("E", "G"): 0x97,
+ ("S", "S"): 0x98,
+ ("G", "C"): 0x99,
+ ("S", "C"): 0x9A,
+ ("C", "I"): 0x9B,
+ ("S", "T"): 0x9C,
+ ("O", "C"): 0x9D,
+ ("P", "M"): 0x9E,
+ ("A", "C"): 0x9F,
+ ("N", "S"): 0xA0,
+ ("!", "I"): 0xA1,
+ ("C", "t"): 0xA2,
+ ("P", "d"): 0xA3,
+ ("C", "u"): 0xA4,
+ ("Y", "e"): 0xA5,
+ ("B", "B"): 0xA6,
+ ("S", "E"): 0xA7,
+ ("'", ":"): 0xA8,
+ ("C", "o"): 0xA9,
+ ("-", "a"): 0xAA,
+ ("<", "<"): 0xAB,
+ ("N", "O"): 0xAC,
+ ("-", "-"): 0xAD,
+ ("R", "g"): 0xAE,
+ ("'", "m"): 0xAF,
+ ("D", "G"): 0xB0,
+ ("+", "-"): 0xB1,
+ ("2", "S"): 0xB2,
+ ("3", "S"): 0xB3,
+ ("'", "'"): 0xB4,
+ ("M", "y"): 0xB5,
+ ("P", "I"): 0xB6,
+ (".", "M"): 0xB7,
+ ("'", ","): 0xB8,
+ ("1", "S"): 0xB9,
+ ("-", "o"): 0xBA,
+ (">", ">"): 0xBB,
+ ("1", "4"): 0xBC,
+ ("1", "2"): 0xBD,
+ ("3", "4"): 0xBE,
+ ("?", "I"): 0xBF,
+ ("A", "!"): 0xC0,
+ ("A", "'"): 0xC1,
+ ("A", ">"): 0xC2,
+ ("A", "?"): 0xC3,
+ ("A", ":"): 0xC4,
+ ("A", "A"): 0xC5,
+ ("A", "E"): 0xC6,
+ ("C", ","): 0xC7,
+ ("E", "!"): 0xC8,
+ ("E", "'"): 0xC9,
+ ("E", ">"): 0xCA,
+ ("E", ":"): 0xCB,
+ ("I", "!"): 0xCC,
+ ("I", "'"): 0xCD,
+ ("I", ">"): 0xCE,
+ ("I", ":"): 0xCF,
+ ("D", "-"): 0xD0,
+ ("N", "?"): 0xD1,
+ ("O", "!"): 0xD2,
+ ("O", "'"): 0xD3,
+ ("O", ">"): 0xD4,
+ ("O", "?"): 0xD5,
+ ("O", ":"): 0xD6,
+ ("*", "X"): 0xD7,
+ ("O", "/"): 0xD8,
+ ("U", "!"): 0xD9,
+ ("U", "'"): 0xDA,
+ ("U", ">"): 0xDB,
+ ("U", ":"): 0xDC,
+ ("Y", "'"): 0xDD,
+ ("T", "H"): 0xDE,
+ ("s", "s"): 0xDF,
+ ("a", "!"): 0xE0,
+ ("a", "'"): 0xE1,
+ ("a", ">"): 0xE2,
+ ("a", "?"): 0xE3,
+ ("a", ":"): 0xE4,
+ ("a", "a"): 0xE5,
+ ("a", "e"): 0xE6,
+ ("c", ","): 0xE7,
+ ("e", "!"): 0xE8,
+ ("e", "'"): 0xE9,
+ ("e", ">"): 0xEA,
+ ("e", ":"): 0xEB,
+ ("i", "!"): 0xEC,
+ ("i", "'"): 0xED,
+ ("i", ">"): 0xEE,
+ ("i", ":"): 0xEF,
+ ("d", "-"): 0xF0,
+ ("n", "?"): 0xF1,
+ ("o", "!"): 0xF2,
+ ("o", "'"): 0xF3,
+ ("o", ">"): 0xF4,
+ ("o", "?"): 0xF5,
+ ("o", ":"): 0xF6,
+ ("-", ":"): 0xF7,
+ ("o", "/"): 0xF8,
+ ("u", "!"): 0xF9,
+ ("u", "'"): 0xFA,
+ ("u", ">"): 0xFB,
+ ("u", ":"): 0xFC,
+ ("y", "'"): 0xFD,
+ ("t", "h"): 0xFE,
+ ("y", ":"): 0xFF,
+ ("A", "-"): 0x0100,
+ ("a", "-"): 0x0101,
+ ("A", "("): 0x0102,
+ ("a", "("): 0x0103,
+ ("A", ";"): 0x0104,
+ ("a", ";"): 0x0105,
+ ("C", "'"): 0x0106,
+ ("c", "'"): 0x0107,
+ ("C", ">"): 0x0108,
+ ("c", ">"): 0x0109,
+ ("C", "."): 0x010A,
+ ("c", "."): 0x010B,
+ ("C", "<"): 0x010C,
+ ("c", "<"): 0x010D,
+ ("D", "<"): 0x010E,
+ ("d", "<"): 0x010F,
+ ("D", "/"): 0x0110,
+ ("d", "/"): 0x0111,
+ ("E", "-"): 0x0112,
+ ("e", "-"): 0x0113,
+ ("E", "("): 0x0114,
+ ("e", "("): 0x0115,
+ ("E", "."): 0x0116,
+ ("e", "."): 0x0117,
+ ("E", ";"): 0x0118,
+ ("e", ";"): 0x0119,
+ ("E", "<"): 0x011A,
+ ("e", "<"): 0x011B,
+ ("G", ">"): 0x011C,
+ ("g", ">"): 0x011D,
+ ("G", "("): 0x011E,
+ ("g", "("): 0x011F,
+ ("G", "."): 0x0120,
+ ("g", "."): 0x0121,
+ ("G", ","): 0x0122,
+ ("g", ","): 0x0123,
+ ("H", ">"): 0x0124,
+ ("h", ">"): 0x0125,
+ ("H", "/"): 0x0126,
+ ("h", "/"): 0x0127,
+ ("I", "?"): 0x0128,
+ ("i", "?"): 0x0129,
+ ("I", "-"): 0x012A,
+ ("i", "-"): 0x012B,
+ ("I", "("): 0x012C,
+ ("i", "("): 0x012D,
+ ("I", ";"): 0x012E,
+ ("i", ";"): 0x012F,
+ ("I", "."): 0x0130,
+ ("i", "."): 0x0131,
+ ("I", "J"): 0x0132,
+ ("i", "j"): 0x0133,
+ ("J", ">"): 0x0134,
+ ("j", ">"): 0x0135,
+ ("K", ","): 0x0136,
+ ("k", ","): 0x0137,
+ ("k", "k"): 0x0138,
+ ("L", "'"): 0x0139,
+ ("l", "'"): 0x013A,
+ ("L", ","): 0x013B,
+ ("l", ","): 0x013C,
+ ("L", "<"): 0x013D,
+ ("l", "<"): 0x013E,
+ ("L", "."): 0x013F,
+ ("l", "."): 0x0140,
+ ("L", "/"): 0x0141,
+ ("l", "/"): 0x0142,
+ ("N", "'"): 0x0143,
+ ("n", "'"): 0x0144,
+ ("N", ","): 0x0145,
+ ("n", ","): 0x0146,
+ ("N", "<"): 0x0147,
+ ("n", "<"): 0x0148,
+ ("'", "n"): 0x0149,
+ ("N", "G"): 0x014A,
+ ("n", "g"): 0x014B,
+ ("O", "-"): 0x014C,
+ ("o", "-"): 0x014D,
+ ("O", "("): 0x014E,
+ ("o", "("): 0x014F,
+ ("O", '"'): 0x0150,
+ ("o", '"'): 0x0151,
+ ("O", "E"): 0x0152,
+ ("o", "e"): 0x0153,
+ ("R", "'"): 0x0154,
+ ("r", "'"): 0x0155,
+ ("R", ","): 0x0156,
+ ("r", ","): 0x0157,
+ ("R", "<"): 0x0158,
+ ("r", "<"): 0x0159,
+ ("S", "'"): 0x015A,
+ ("s", "'"): 0x015B,
+ ("S", ">"): 0x015C,
+ ("s", ">"): 0x015D,
+ ("S", ","): 0x015E,
+ ("s", ","): 0x015F,
+ ("S", "<"): 0x0160,
+ ("s", "<"): 0x0161,
+ ("T", ","): 0x0162,
+ ("t", ","): 0x0163,
+ ("T", "<"): 0x0164,
+ ("t", "<"): 0x0165,
+ ("T", "/"): 0x0166,
+ ("t", "/"): 0x0167,
+ ("U", "?"): 0x0168,
+ ("u", "?"): 0x0169,
+ ("U", "-"): 0x016A,
+ ("u", "-"): 0x016B,
+ ("U", "("): 0x016C,
+ ("u", "("): 0x016D,
+ ("U", "0"): 0x016E,
+ ("u", "0"): 0x016F,
+ ("U", '"'): 0x0170,
+ ("u", '"'): 0x0171,
+ ("U", ";"): 0x0172,
+ ("u", ";"): 0x0173,
+ ("W", ">"): 0x0174,
+ ("w", ">"): 0x0175,
+ ("Y", ">"): 0x0176,
+ ("y", ">"): 0x0177,
+ ("Y", ":"): 0x0178,
+ ("Z", "'"): 0x0179,
+ ("z", "'"): 0x017A,
+ ("Z", "."): 0x017B,
+ ("z", "."): 0x017C,
+ ("Z", "<"): 0x017D,
+ ("z", "<"): 0x017E,
+ ("O", "9"): 0x01A0,
+ ("o", "9"): 0x01A1,
+ ("O", "I"): 0x01A2,
+ ("o", "i"): 0x01A3,
+ ("y", "r"): 0x01A6,
+ ("U", "9"): 0x01AF,
+ ("u", "9"): 0x01B0,
+ ("Z", "/"): 0x01B5,
+ ("z", "/"): 0x01B6,
+ ("E", "D"): 0x01B7,
+ ("A", "<"): 0x01CD,
+ ("a", "<"): 0x01CE,
+ ("I", "<"): 0x01CF,
+ ("i", "<"): 0x01D0,
+ ("O", "<"): 0x01D1,
+ ("o", "<"): 0x01D2,
+ ("U", "<"): 0x01D3,
+ ("u", "<"): 0x01D4,
+ ("A", "1"): 0x01DE,
+ ("a", "1"): 0x01DF,
+ ("A", "7"): 0x01E0,
+ ("a", "7"): 0x01E1,
+ ("A", "3"): 0x01E2,
+ ("a", "3"): 0x01E3,
+ ("G", "/"): 0x01E4,
+ ("g", "/"): 0x01E5,
+ ("G", "<"): 0x01E6,
+ ("g", "<"): 0x01E7,
+ ("K", "<"): 0x01E8,
+ ("k", "<"): 0x01E9,
+ ("O", ";"): 0x01EA,
+ ("o", ";"): 0x01EB,
+ ("O", "1"): 0x01EC,
+ ("o", "1"): 0x01ED,
+ ("E", "Z"): 0x01EE,
+ ("e", "z"): 0x01EF,
+ ("j", "<"): 0x01F0,
+ ("G", "'"): 0x01F4,
+ ("g", "'"): 0x01F5,
+ (";", "S"): 0x02BF,
+ ("'", "<"): 0x02C7,
+ ("'", "("): 0x02D8,
+ ("'", "."): 0x02D9,
+ ("'", "0"): 0x02DA,
+ ("'", ";"): 0x02DB,
+ ("'", '"'): 0x02DD,
+ ("A", "%"): 0x0386,
+ ("E", "%"): 0x0388,
+ ("Y", "%"): 0x0389,
+ ("I", "%"): 0x038A,
+ ("O", "%"): 0x038C,
+ ("U", "%"): 0x038E,
+ ("W", "%"): 0x038F,
+ ("i", "3"): 0x0390,
+ ("A", "*"): 0x0391,
+ ("B", "*"): 0x0392,
+ ("G", "*"): 0x0393,
+ ("D", "*"): 0x0394,
+ ("E", "*"): 0x0395,
+ ("Z", "*"): 0x0396,
+ ("Y", "*"): 0x0397,
+ ("H", "*"): 0x0398,
+ ("I", "*"): 0x0399,
+ ("K", "*"): 0x039A,
+ ("L", "*"): 0x039B,
+ ("M", "*"): 0x039C,
+ ("N", "*"): 0x039D,
+ ("C", "*"): 0x039E,
+ ("O", "*"): 0x039F,
+ ("P", "*"): 0x03A0,
+ ("R", "*"): 0x03A1,
+ ("S", "*"): 0x03A3,
+ ("T", "*"): 0x03A4,
+ ("U", "*"): 0x03A5,
+ ("F", "*"): 0x03A6,
+ ("X", "*"): 0x03A7,
+ ("Q", "*"): 0x03A8,
+ ("W", "*"): 0x03A9,
+ ("J", "*"): 0x03AA,
+ ("V", "*"): 0x03AB,
+ ("a", "%"): 0x03AC,
+ ("e", "%"): 0x03AD,
+ ("y", "%"): 0x03AE,
+ ("i", "%"): 0x03AF,
+ ("u", "3"): 0x03B0,
+ ("a", "*"): 0x03B1,
+ ("b", "*"): 0x03B2,
+ ("g", "*"): 0x03B3,
+ ("d", "*"): 0x03B4,
+ ("e", "*"): 0x03B5,
+ ("z", "*"): 0x03B6,
+ ("y", "*"): 0x03B7,
+ ("h", "*"): 0x03B8,
+ ("i", "*"): 0x03B9,
+ ("k", "*"): 0x03BA,
+ ("l", "*"): 0x03BB,
+ ("m", "*"): 0x03BC,
+ ("n", "*"): 0x03BD,
+ ("c", "*"): 0x03BE,
+ ("o", "*"): 0x03BF,
+ ("p", "*"): 0x03C0,
+ ("r", "*"): 0x03C1,
+ ("*", "s"): 0x03C2,
+ ("s", "*"): 0x03C3,
+ ("t", "*"): 0x03C4,
+ ("u", "*"): 0x03C5,
+ ("f", "*"): 0x03C6,
+ ("x", "*"): 0x03C7,
+ ("q", "*"): 0x03C8,
+ ("w", "*"): 0x03C9,
+ ("j", "*"): 0x03CA,
+ ("v", "*"): 0x03CB,
+ ("o", "%"): 0x03CC,
+ ("u", "%"): 0x03CD,
+ ("w", "%"): 0x03CE,
+ ("'", "G"): 0x03D8,
+ (",", "G"): 0x03D9,
+ ("T", "3"): 0x03DA,
+ ("t", "3"): 0x03DB,
+ ("M", "3"): 0x03DC,
+ ("m", "3"): 0x03DD,
+ ("K", "3"): 0x03DE,
+ ("k", "3"): 0x03DF,
+ ("P", "3"): 0x03E0,
+ ("p", "3"): 0x03E1,
+ ("'", "%"): 0x03F4,
+ ("j", "3"): 0x03F5,
+ ("I", "O"): 0x0401,
+ ("D", "%"): 0x0402,
+ ("G", "%"): 0x0403,
+ ("I", "E"): 0x0404,
+ ("D", "S"): 0x0405,
+ ("I", "I"): 0x0406,
+ ("Y", "I"): 0x0407,
+ ("J", "%"): 0x0408,
+ ("L", "J"): 0x0409,
+ ("N", "J"): 0x040A,
+ ("T", "s"): 0x040B,
+ ("K", "J"): 0x040C,
+ ("V", "%"): 0x040E,
+ ("D", "Z"): 0x040F,
+ ("A", "="): 0x0410,
+ ("B", "="): 0x0411,
+ ("V", "="): 0x0412,
+ ("G", "="): 0x0413,
+ ("D", "="): 0x0414,
+ ("E", "="): 0x0415,
+ ("Z", "%"): 0x0416,
+ ("Z", "="): 0x0417,
+ ("I", "="): 0x0418,
+ ("J", "="): 0x0419,
+ ("K", "="): 0x041A,
+ ("L", "="): 0x041B,
+ ("M", "="): 0x041C,
+ ("N", "="): 0x041D,
+ ("O", "="): 0x041E,
+ ("P", "="): 0x041F,
+ ("R", "="): 0x0420,
+ ("S", "="): 0x0421,
+ ("T", "="): 0x0422,
+ ("U", "="): 0x0423,
+ ("F", "="): 0x0424,
+ ("H", "="): 0x0425,
+ ("C", "="): 0x0426,
+ ("C", "%"): 0x0427,
+ ("S", "%"): 0x0428,
+ ("S", "c"): 0x0429,
+ ("=", '"'): 0x042A,
+ ("Y", "="): 0x042B,
+ ("%", '"'): 0x042C,
+ ("J", "E"): 0x042D,
+ ("J", "U"): 0x042E,
+ ("J", "A"): 0x042F,
+ ("a", "="): 0x0430,
+ ("b", "="): 0x0431,
+ ("v", "="): 0x0432,
+ ("g", "="): 0x0433,
+ ("d", "="): 0x0434,
+ ("e", "="): 0x0435,
+ ("z", "%"): 0x0436,
+ ("z", "="): 0x0437,
+ ("i", "="): 0x0438,
+ ("j", "="): 0x0439,
+ ("k", "="): 0x043A,
+ ("l", "="): 0x043B,
+ ("m", "="): 0x043C,
+ ("n", "="): 0x043D,
+ ("o", "="): 0x043E,
+ ("p", "="): 0x043F,
+ ("r", "="): 0x0440,
+ ("s", "="): 0x0441,
+ ("t", "="): 0x0442,
+ ("u", "="): 0x0443,
+ ("f", "="): 0x0444,
+ ("h", "="): 0x0445,
+ ("c", "="): 0x0446,
+ ("c", "%"): 0x0447,
+ ("s", "%"): 0x0448,
+ ("s", "c"): 0x0449,
+ ("=", "'"): 0x044A,
+ ("y", "="): 0x044B,
+ ("%", "'"): 0x044C,
+ ("j", "e"): 0x044D,
+ ("j", "u"): 0x044E,
+ ("j", "a"): 0x044F,
+ ("i", "o"): 0x0451,
+ ("d", "%"): 0x0452,
+ ("g", "%"): 0x0453,
+ ("i", "e"): 0x0454,
+ ("d", "s"): 0x0455,
+ ("i", "i"): 0x0456,
+ ("y", "i"): 0x0457,
+ ("j", "%"): 0x0458,
+ ("l", "j"): 0x0459,
+ ("n", "j"): 0x045A,
+ ("t", "s"): 0x045B,
+ ("k", "j"): 0x045C,
+ ("v", "%"): 0x045E,
+ ("d", "z"): 0x045F,
+ ("Y", "3"): 0x0462,
+ ("y", "3"): 0x0463,
+ ("O", "3"): 0x046A,
+ ("o", "3"): 0x046B,
+ ("F", "3"): 0x0472,
+ ("f", "3"): 0x0473,
+ ("V", "3"): 0x0474,
+ ("v", "3"): 0x0475,
+ ("C", "3"): 0x0480,
+ ("c", "3"): 0x0481,
+ ("G", "3"): 0x0490,
+ ("g", "3"): 0x0491,
+ ("A", "+"): 0x05D0,
+ ("B", "+"): 0x05D1,
+ ("G", "+"): 0x05D2,
+ ("D", "+"): 0x05D3,
+ ("H", "+"): 0x05D4,
+ ("W", "+"): 0x05D5,
+ ("Z", "+"): 0x05D6,
+ ("X", "+"): 0x05D7,
+ ("T", "j"): 0x05D8,
+ ("J", "+"): 0x05D9,
+ ("K", "%"): 0x05DA,
+ ("K", "+"): 0x05DB,
+ ("L", "+"): 0x05DC,
+ ("M", "%"): 0x05DD,
+ ("M", "+"): 0x05DE,
+ ("N", "%"): 0x05DF,
+ ("N", "+"): 0x05E0,
+ ("S", "+"): 0x05E1,
+ ("E", "+"): 0x05E2,
+ ("P", "%"): 0x05E3,
+ ("P", "+"): 0x05E4,
+ ("Z", "j"): 0x05E5,
+ ("Z", "J"): 0x05E6,
+ ("Q", "+"): 0x05E7,
+ ("R", "+"): 0x05E8,
+ ("S", "h"): 0x05E9,
+ ("T", "+"): 0x05EA,
+ (",", "+"): 0x060C,
+ (";", "+"): 0x061B,
+ ("?", "+"): 0x061F,
+ ("H", "'"): 0x0621,
+ ("a", "M"): 0x0622,
+ ("a", "H"): 0x0623,
+ ("w", "H"): 0x0624,
+ ("a", "h"): 0x0625,
+ ("y", "H"): 0x0626,
+ ("a", "+"): 0x0627,
+ ("b", "+"): 0x0628,
+ ("t", "m"): 0x0629,
+ ("t", "+"): 0x062A,
+ ("t", "k"): 0x062B,
+ ("g", "+"): 0x062C,
+ ("h", "k"): 0x062D,
+ ("x", "+"): 0x062E,
+ ("d", "+"): 0x062F,
+ ("d", "k"): 0x0630,
+ ("r", "+"): 0x0631,
+ ("z", "+"): 0x0632,
+ ("s", "+"): 0x0633,
+ ("s", "n"): 0x0634,
+ ("c", "+"): 0x0635,
+ ("d", "d"): 0x0636,
+ ("t", "j"): 0x0637,
+ ("z", "H"): 0x0638,
+ ("e", "+"): 0x0639,
+ ("i", "+"): 0x063A,
+ ("+", "+"): 0x0640,
+ ("f", "+"): 0x0641,
+ ("q", "+"): 0x0642,
+ ("k", "+"): 0x0643,
+ ("l", "+"): 0x0644,
+ ("m", "+"): 0x0645,
+ ("n", "+"): 0x0646,
+ ("h", "+"): 0x0647,
+ ("w", "+"): 0x0648,
+ ("j", "+"): 0x0649,
+ ("y", "+"): 0x064A,
+ (":", "+"): 0x064B,
+ ('"', "+"): 0x064C,
+ ("=", "+"): 0x064D,
+ ("/", "+"): 0x064E,
+ ("'", "+"): 0x064F,
+ ("1", "+"): 0x0650,
+ ("3", "+"): 0x0651,
+ ("0", "+"): 0x0652,
+ ("a", "S"): 0x0670,
+ ("p", "+"): 0x067E,
+ ("v", "+"): 0x06A4,
+ ("g", "f"): 0x06AF,
+ ("0", "a"): 0x06F0,
+ ("1", "a"): 0x06F1,
+ ("2", "a"): 0x06F2,
+ ("3", "a"): 0x06F3,
+ ("4", "a"): 0x06F4,
+ ("5", "a"): 0x06F5,
+ ("6", "a"): 0x06F6,
+ ("7", "a"): 0x06F7,
+ ("8", "a"): 0x06F8,
+ ("9", "a"): 0x06F9,
+ ("B", "."): 0x1E02,
+ ("b", "."): 0x1E03,
+ ("B", "_"): 0x1E06,
+ ("b", "_"): 0x1E07,
+ ("D", "."): 0x1E0A,
+ ("d", "."): 0x1E0B,
+ ("D", "_"): 0x1E0E,
+ ("d", "_"): 0x1E0F,
+ ("D", ","): 0x1E10,
+ ("d", ","): 0x1E11,
+ ("F", "."): 0x1E1E,
+ ("f", "."): 0x1E1F,
+ ("G", "-"): 0x1E20,
+ ("g", "-"): 0x1E21,
+ ("H", "."): 0x1E22,
+ ("h", "."): 0x1E23,
+ ("H", ":"): 0x1E26,
+ ("h", ":"): 0x1E27,
+ ("H", ","): 0x1E28,
+ ("h", ","): 0x1E29,
+ ("K", "'"): 0x1E30,
+ ("k", "'"): 0x1E31,
+ ("K", "_"): 0x1E34,
+ ("k", "_"): 0x1E35,
+ ("L", "_"): 0x1E3A,
+ ("l", "_"): 0x1E3B,
+ ("M", "'"): 0x1E3E,
+ ("m", "'"): 0x1E3F,
+ ("M", "."): 0x1E40,
+ ("m", "."): 0x1E41,
+ ("N", "."): 0x1E44,
+ ("n", "."): 0x1E45,
+ ("N", "_"): 0x1E48,
+ ("n", "_"): 0x1E49,
+ ("P", "'"): 0x1E54,
+ ("p", "'"): 0x1E55,
+ ("P", "."): 0x1E56,
+ ("p", "."): 0x1E57,
+ ("R", "."): 0x1E58,
+ ("r", "."): 0x1E59,
+ ("R", "_"): 0x1E5E,
+ ("r", "_"): 0x1E5F,
+ ("S", "."): 0x1E60,
+ ("s", "."): 0x1E61,
+ ("T", "."): 0x1E6A,
+ ("t", "."): 0x1E6B,
+ ("T", "_"): 0x1E6E,
+ ("t", "_"): 0x1E6F,
+ ("V", "?"): 0x1E7C,
+ ("v", "?"): 0x1E7D,
+ ("W", "!"): 0x1E80,
+ ("w", "!"): 0x1E81,
+ ("W", "'"): 0x1E82,
+ ("w", "'"): 0x1E83,
+ ("W", ":"): 0x1E84,
+ ("w", ":"): 0x1E85,
+ ("W", "."): 0x1E86,
+ ("w", "."): 0x1E87,
+ ("X", "."): 0x1E8A,
+ ("x", "."): 0x1E8B,
+ ("X", ":"): 0x1E8C,
+ ("x", ":"): 0x1E8D,
+ ("Y", "."): 0x1E8E,
+ ("y", "."): 0x1E8F,
+ ("Z", ">"): 0x1E90,
+ ("z", ">"): 0x1E91,
+ ("Z", "_"): 0x1E94,
+ ("z", "_"): 0x1E95,
+ ("h", "_"): 0x1E96,
+ ("t", ":"): 0x1E97,
+ ("w", "0"): 0x1E98,
+ ("y", "0"): 0x1E99,
+ ("A", "2"): 0x1EA2,
+ ("a", "2"): 0x1EA3,
+ ("E", "2"): 0x1EBA,
+ ("e", "2"): 0x1EBB,
+ ("E", "?"): 0x1EBC,
+ ("e", "?"): 0x1EBD,
+ ("I", "2"): 0x1EC8,
+ ("i", "2"): 0x1EC9,
+ ("O", "2"): 0x1ECE,
+ ("o", "2"): 0x1ECF,
+ ("U", "2"): 0x1EE6,
+ ("u", "2"): 0x1EE7,
+ ("Y", "!"): 0x1EF2,
+ ("y", "!"): 0x1EF3,
+ ("Y", "2"): 0x1EF6,
+ ("y", "2"): 0x1EF7,
+ ("Y", "?"): 0x1EF8,
+ ("y", "?"): 0x1EF9,
+ (";", "'"): 0x1F00,
+ (",", "'"): 0x1F01,
+ (";", "!"): 0x1F02,
+ (",", "!"): 0x1F03,
+ ("?", ";"): 0x1F04,
+ ("?", ","): 0x1F05,
+ ("!", ":"): 0x1F06,
+ ("?", ":"): 0x1F07,
+ ("1", "N"): 0x2002,
+ ("1", "M"): 0x2003,
+ ("3", "M"): 0x2004,
+ ("4", "M"): 0x2005,
+ ("6", "M"): 0x2006,
+ ("1", "T"): 0x2009,
+ ("1", "H"): 0x200A,
+ ("-", "1"): 0x2010,
+ ("-", "N"): 0x2013,
+ ("-", "M"): 0x2014,
+ ("-", "3"): 0x2015,
+ ("!", "2"): 0x2016,
+ ("=", "2"): 0x2017,
+ ("'", "6"): 0x2018,
+ ("'", "9"): 0x2019,
+ (".", "9"): 0x201A,
+ ("9", "'"): 0x201B,
+ ('"', "6"): 0x201C,
+ ('"', "9"): 0x201D,
+ (":", "9"): 0x201E,
+ ("9", '"'): 0x201F,
+ ("/", "-"): 0x2020,
+ ("/", "="): 0x2021,
+ (".", "."): 0x2025,
+ ("%", "0"): 0x2030,
+ ("1", "'"): 0x2032,
+ ("2", "'"): 0x2033,
+ ("3", "'"): 0x2034,
+ ("1", '"'): 0x2035,
+ ("2", '"'): 0x2036,
+ ("3", '"'): 0x2037,
+ ("C", "a"): 0x2038,
+ ("<", "1"): 0x2039,
+ (">", "1"): 0x203A,
+ (":", "X"): 0x203B,
+ ("'", "-"): 0x203E,
+ ("/", "f"): 0x2044,
+ ("0", "S"): 0x2070,
+ ("4", "S"): 0x2074,
+ ("5", "S"): 0x2075,
+ ("6", "S"): 0x2076,
+ ("7", "S"): 0x2077,
+ ("8", "S"): 0x2078,
+ ("9", "S"): 0x2079,
+ ("+", "S"): 0x207A,
+ ("-", "S"): 0x207B,
+ ("=", "S"): 0x207C,
+ ("(", "S"): 0x207D,
+ (")", "S"): 0x207E,
+ ("n", "S"): 0x207F,
+ ("0", "s"): 0x2080,
+ ("1", "s"): 0x2081,
+ ("2", "s"): 0x2082,
+ ("3", "s"): 0x2083,
+ ("4", "s"): 0x2084,
+ ("5", "s"): 0x2085,
+ ("6", "s"): 0x2086,
+ ("7", "s"): 0x2087,
+ ("8", "s"): 0x2088,
+ ("9", "s"): 0x2089,
+ ("+", "s"): 0x208A,
+ ("-", "s"): 0x208B,
+ ("=", "s"): 0x208C,
+ ("(", "s"): 0x208D,
+ (")", "s"): 0x208E,
+ ("L", "i"): 0x20A4,
+ ("P", "t"): 0x20A7,
+ ("W", "="): 0x20A9,
+ ("=", "e"): 0x20AC, # euro
+ ("E", "u"): 0x20AC, # euro
+ ("=", "R"): 0x20BD, # rouble
+ ("=", "P"): 0x20BD, # rouble
+ ("o", "C"): 0x2103,
+ ("c", "o"): 0x2105,
+ ("o", "F"): 0x2109,
+ ("N", "0"): 0x2116,
+ ("P", "O"): 0x2117,
+ ("R", "x"): 0x211E,
+ ("S", "M"): 0x2120,
+ ("T", "M"): 0x2122,
+ ("O", "m"): 0x2126,
+ ("A", "O"): 0x212B,
+ ("1", "3"): 0x2153,
+ ("2", "3"): 0x2154,
+ ("1", "5"): 0x2155,
+ ("2", "5"): 0x2156,
+ ("3", "5"): 0x2157,
+ ("4", "5"): 0x2158,
+ ("1", "6"): 0x2159,
+ ("5", "6"): 0x215A,
+ ("1", "8"): 0x215B,
+ ("3", "8"): 0x215C,
+ ("5", "8"): 0x215D,
+ ("7", "8"): 0x215E,
+ ("1", "R"): 0x2160,
+ ("2", "R"): 0x2161,
+ ("3", "R"): 0x2162,
+ ("4", "R"): 0x2163,
+ ("5", "R"): 0x2164,
+ ("6", "R"): 0x2165,
+ ("7", "R"): 0x2166,
+ ("8", "R"): 0x2167,
+ ("9", "R"): 0x2168,
+ ("a", "R"): 0x2169,
+ ("b", "R"): 0x216A,
+ ("c", "R"): 0x216B,
+ ("1", "r"): 0x2170,
+ ("2", "r"): 0x2171,
+ ("3", "r"): 0x2172,
+ ("4", "r"): 0x2173,
+ ("5", "r"): 0x2174,
+ ("6", "r"): 0x2175,
+ ("7", "r"): 0x2176,
+ ("8", "r"): 0x2177,
+ ("9", "r"): 0x2178,
+ ("a", "r"): 0x2179,
+ ("b", "r"): 0x217A,
+ ("c", "r"): 0x217B,
+ ("<", "-"): 0x2190,
+ ("-", "!"): 0x2191,
+ ("-", ">"): 0x2192,
+ ("-", "v"): 0x2193,
+ ("<", ">"): 0x2194,
+ ("U", "D"): 0x2195,
+ ("<", "="): 0x21D0,
+ ("=", ">"): 0x21D2,
+ ("=", "="): 0x21D4,
+ ("F", "A"): 0x2200,
+ ("d", "P"): 0x2202,
+ ("T", "E"): 0x2203,
+ ("/", "0"): 0x2205,
+ ("D", "E"): 0x2206,
+ ("N", "B"): 0x2207,
+ ("(", "-"): 0x2208,
+ ("-", ")"): 0x220B,
+ ("*", "P"): 0x220F,
+ ("+", "Z"): 0x2211,
+ ("-", "2"): 0x2212,
+ ("-", "+"): 0x2213,
+ ("*", "-"): 0x2217,
+ ("O", "b"): 0x2218,
+ ("S", "b"): 0x2219,
+ ("R", "T"): 0x221A,
+ ("0", "("): 0x221D,
+ ("0", "0"): 0x221E,
+ ("-", "L"): 0x221F,
+ ("-", "V"): 0x2220,
+ ("P", "P"): 0x2225,
+ ("A", "N"): 0x2227,
+ ("O", "R"): 0x2228,
+ ("(", "U"): 0x2229,
+ (")", "U"): 0x222A,
+ ("I", "n"): 0x222B,
+ ("D", "I"): 0x222C,
+ ("I", "o"): 0x222E,
+ (".", ":"): 0x2234,
+ (":", "."): 0x2235,
+ (":", "R"): 0x2236,
+ (":", ":"): 0x2237,
+ ("?", "1"): 0x223C,
+ ("C", "G"): 0x223E,
+ ("?", "-"): 0x2243,
+ ("?", "="): 0x2245,
+ ("?", "2"): 0x2248,
+ ("=", "?"): 0x224C,
+ ("H", "I"): 0x2253,
+ ("!", "="): 0x2260,
+ ("=", "3"): 0x2261,
+ ("=", "<"): 0x2264,
+ (">", "="): 0x2265,
+ ("<", "*"): 0x226A,
+ ("*", ">"): 0x226B,
+ ("!", "<"): 0x226E,
+ ("!", ">"): 0x226F,
+ ("(", "C"): 0x2282,
+ (")", "C"): 0x2283,
+ ("(", "_"): 0x2286,
+ (")", "_"): 0x2287,
+ ("0", "."): 0x2299,
+ ("0", "2"): 0x229A,
+ ("-", "T"): 0x22A5,
+ (".", "P"): 0x22C5,
+ (":", "3"): 0x22EE,
+ (".", "3"): 0x22EF,
+ ("E", "h"): 0x2302,
+ ("<", "7"): 0x2308,
+ (">", "7"): 0x2309,
+ ("7", "<"): 0x230A,
+ ("7", ">"): 0x230B,
+ ("N", "I"): 0x2310,
+ ("(", "A"): 0x2312,
+ ("T", "R"): 0x2315,
+ ("I", "u"): 0x2320,
+ ("I", "l"): 0x2321,
+ ("<", "/"): 0x2329,
+ ("/", ">"): 0x232A,
+ ("V", "s"): 0x2423,
+ ("1", "h"): 0x2440,
+ ("3", "h"): 0x2441,
+ ("2", "h"): 0x2442,
+ ("4", "h"): 0x2443,
+ ("1", "j"): 0x2446,
+ ("2", "j"): 0x2447,
+ ("3", "j"): 0x2448,
+ ("4", "j"): 0x2449,
+ ("1", "."): 0x2488,
+ ("2", "."): 0x2489,
+ ("3", "."): 0x248A,
+ ("4", "."): 0x248B,
+ ("5", "."): 0x248C,
+ ("6", "."): 0x248D,
+ ("7", "."): 0x248E,
+ ("8", "."): 0x248F,
+ ("9", "."): 0x2490,
+ ("h", "h"): 0x2500,
+ ("H", "H"): 0x2501,
+ ("v", "v"): 0x2502,
+ ("V", "V"): 0x2503,
+ ("3", "-"): 0x2504,
+ ("3", "_"): 0x2505,
+ ("3", "!"): 0x2506,
+ ("3", "/"): 0x2507,
+ ("4", "-"): 0x2508,
+ ("4", "_"): 0x2509,
+ ("4", "!"): 0x250A,
+ ("4", "/"): 0x250B,
+ ("d", "r"): 0x250C,
+ ("d", "R"): 0x250D,
+ ("D", "r"): 0x250E,
+ ("D", "R"): 0x250F,
+ ("d", "l"): 0x2510,
+ ("d", "L"): 0x2511,
+ ("D", "l"): 0x2512,
+ ("L", "D"): 0x2513,
+ ("u", "r"): 0x2514,
+ ("u", "R"): 0x2515,
+ ("U", "r"): 0x2516,
+ ("U", "R"): 0x2517,
+ ("u", "l"): 0x2518,
+ ("u", "L"): 0x2519,
+ ("U", "l"): 0x251A,
+ ("U", "L"): 0x251B,
+ ("v", "r"): 0x251C,
+ ("v", "R"): 0x251D,
+ ("V", "r"): 0x2520,
+ ("V", "R"): 0x2523,
+ ("v", "l"): 0x2524,
+ ("v", "L"): 0x2525,
+ ("V", "l"): 0x2528,
+ ("V", "L"): 0x252B,
+ ("d", "h"): 0x252C,
+ ("d", "H"): 0x252F,
+ ("D", "h"): 0x2530,
+ ("D", "H"): 0x2533,
+ ("u", "h"): 0x2534,
+ ("u", "H"): 0x2537,
+ ("U", "h"): 0x2538,
+ ("U", "H"): 0x253B,
+ ("v", "h"): 0x253C,
+ ("v", "H"): 0x253F,
+ ("V", "h"): 0x2542,
+ ("V", "H"): 0x254B,
+ ("F", "D"): 0x2571,
+ ("B", "D"): 0x2572,
+ ("T", "B"): 0x2580,
+ ("L", "B"): 0x2584,
+ ("F", "B"): 0x2588,
+ ("l", "B"): 0x258C,
+ ("R", "B"): 0x2590,
+ (".", "S"): 0x2591,
+ (":", "S"): 0x2592,
+ ("?", "S"): 0x2593,
+ ("f", "S"): 0x25A0,
+ ("O", "S"): 0x25A1,
+ ("R", "O"): 0x25A2,
+ ("R", "r"): 0x25A3,
+ ("R", "F"): 0x25A4,
+ ("R", "Y"): 0x25A5,
+ ("R", "H"): 0x25A6,
+ ("R", "Z"): 0x25A7,
+ ("R", "K"): 0x25A8,
+ ("R", "X"): 0x25A9,
+ ("s", "B"): 0x25AA,
+ ("S", "R"): 0x25AC,
+ ("O", "r"): 0x25AD,
+ ("U", "T"): 0x25B2,
+ ("u", "T"): 0x25B3,
+ ("P", "R"): 0x25B6,
+ ("T", "r"): 0x25B7,
+ ("D", "t"): 0x25BC,
+ ("d", "T"): 0x25BD,
+ ("P", "L"): 0x25C0,
+ ("T", "l"): 0x25C1,
+ ("D", "b"): 0x25C6,
+ ("D", "w"): 0x25C7,
+ ("L", "Z"): 0x25CA,
+ ("0", "m"): 0x25CB,
+ ("0", "o"): 0x25CE,
+ ("0", "M"): 0x25CF,
+ ("0", "L"): 0x25D0,
+ ("0", "R"): 0x25D1,
+ ("S", "n"): 0x25D8,
+ ("I", "c"): 0x25D9,
+ ("F", "d"): 0x25E2,
+ ("B", "d"): 0x25E3,
+ ("*", "2"): 0x2605,
+ ("*", "1"): 0x2606,
+ ("<", "H"): 0x261C,
+ (">", "H"): 0x261E,
+ ("0", "u"): 0x263A,
+ ("0", "U"): 0x263B,
+ ("S", "U"): 0x263C,
+ ("F", "m"): 0x2640,
+ ("M", "l"): 0x2642,
+ ("c", "S"): 0x2660,
+ ("c", "H"): 0x2661,
+ ("c", "D"): 0x2662,
+ ("c", "C"): 0x2663,
+ ("M", "d"): 0x2669,
+ ("M", "8"): 0x266A,
+ ("M", "2"): 0x266B,
+ ("M", "b"): 0x266D,
+ ("M", "x"): 0x266E,
+ ("M", "X"): 0x266F,
+ ("O", "K"): 0x2713,
+ ("X", "X"): 0x2717,
+ ("-", "X"): 0x2720,
+ ("I", "S"): 0x3000,
+ (",", "_"): 0x3001,
+ (".", "_"): 0x3002,
+ ("+", '"'): 0x3003,
+ ("+", "_"): 0x3004,
+ ("*", "_"): 0x3005,
+ (";", "_"): 0x3006,
+ ("0", "_"): 0x3007,
+ ("<", "+"): 0x300A,
+ (">", "+"): 0x300B,
+ ("<", "'"): 0x300C,
+ (">", "'"): 0x300D,
+ ("<", '"'): 0x300E,
+ (">", '"'): 0x300F,
+ ("(", '"'): 0x3010,
+ (")", '"'): 0x3011,
+ ("=", "T"): 0x3012,
+ ("=", "_"): 0x3013,
+ ("(", "'"): 0x3014,
+ (")", "'"): 0x3015,
+ ("(", "I"): 0x3016,
+ (")", "I"): 0x3017,
+ ("-", "?"): 0x301C,
+ ("A", "5"): 0x3041,
+ ("a", "5"): 0x3042,
+ ("I", "5"): 0x3043,
+ ("i", "5"): 0x3044,
+ ("U", "5"): 0x3045,
+ ("u", "5"): 0x3046,
+ ("E", "5"): 0x3047,
+ ("e", "5"): 0x3048,
+ ("O", "5"): 0x3049,
+ ("o", "5"): 0x304A,
+ ("k", "a"): 0x304B,
+ ("g", "a"): 0x304C,
+ ("k", "i"): 0x304D,
+ ("g", "i"): 0x304E,
+ ("k", "u"): 0x304F,
+ ("g", "u"): 0x3050,
+ ("k", "e"): 0x3051,
+ ("g", "e"): 0x3052,
+ ("k", "o"): 0x3053,
+ ("g", "o"): 0x3054,
+ ("s", "a"): 0x3055,
+ ("z", "a"): 0x3056,
+ ("s", "i"): 0x3057,
+ ("z", "i"): 0x3058,
+ ("s", "u"): 0x3059,
+ ("z", "u"): 0x305A,
+ ("s", "e"): 0x305B,
+ ("z", "e"): 0x305C,
+ ("s", "o"): 0x305D,
+ ("z", "o"): 0x305E,
+ ("t", "a"): 0x305F,
+ ("d", "a"): 0x3060,
+ ("t", "i"): 0x3061,
+ ("d", "i"): 0x3062,
+ ("t", "U"): 0x3063,
+ ("t", "u"): 0x3064,
+ ("d", "u"): 0x3065,
+ ("t", "e"): 0x3066,
+ ("d", "e"): 0x3067,
+ ("t", "o"): 0x3068,
+ ("d", "o"): 0x3069,
+ ("n", "a"): 0x306A,
+ ("n", "i"): 0x306B,
+ ("n", "u"): 0x306C,
+ ("n", "e"): 0x306D,
+ ("n", "o"): 0x306E,
+ ("h", "a"): 0x306F,
+ ("b", "a"): 0x3070,
+ ("p", "a"): 0x3071,
+ ("h", "i"): 0x3072,
+ ("b", "i"): 0x3073,
+ ("p", "i"): 0x3074,
+ ("h", "u"): 0x3075,
+ ("b", "u"): 0x3076,
+ ("p", "u"): 0x3077,
+ ("h", "e"): 0x3078,
+ ("b", "e"): 0x3079,
+ ("p", "e"): 0x307A,
+ ("h", "o"): 0x307B,
+ ("b", "o"): 0x307C,
+ ("p", "o"): 0x307D,
+ ("m", "a"): 0x307E,
+ ("m", "i"): 0x307F,
+ ("m", "u"): 0x3080,
+ ("m", "e"): 0x3081,
+ ("m", "o"): 0x3082,
+ ("y", "A"): 0x3083,
+ ("y", "a"): 0x3084,
+ ("y", "U"): 0x3085,
+ ("y", "u"): 0x3086,
+ ("y", "O"): 0x3087,
+ ("y", "o"): 0x3088,
+ ("r", "a"): 0x3089,
+ ("r", "i"): 0x308A,
+ ("r", "u"): 0x308B,
+ ("r", "e"): 0x308C,
+ ("r", "o"): 0x308D,
+ ("w", "A"): 0x308E,
+ ("w", "a"): 0x308F,
+ ("w", "i"): 0x3090,
+ ("w", "e"): 0x3091,
+ ("w", "o"): 0x3092,
+ ("n", "5"): 0x3093,
+ ("v", "u"): 0x3094,
+ ('"', "5"): 0x309B,
+ ("0", "5"): 0x309C,
+ ("*", "5"): 0x309D,
+ ("+", "5"): 0x309E,
+ ("a", "6"): 0x30A1,
+ ("A", "6"): 0x30A2,
+ ("i", "6"): 0x30A3,
+ ("I", "6"): 0x30A4,
+ ("u", "6"): 0x30A5,
+ ("U", "6"): 0x30A6,
+ ("e", "6"): 0x30A7,
+ ("E", "6"): 0x30A8,
+ ("o", "6"): 0x30A9,
+ ("O", "6"): 0x30AA,
+ ("K", "a"): 0x30AB,
+ ("G", "a"): 0x30AC,
+ ("K", "i"): 0x30AD,
+ ("G", "i"): 0x30AE,
+ ("K", "u"): 0x30AF,
+ ("G", "u"): 0x30B0,
+ ("K", "e"): 0x30B1,
+ ("G", "e"): 0x30B2,
+ ("K", "o"): 0x30B3,
+ ("G", "o"): 0x30B4,
+ ("S", "a"): 0x30B5,
+ ("Z", "a"): 0x30B6,
+ ("S", "i"): 0x30B7,
+ ("Z", "i"): 0x30B8,
+ ("S", "u"): 0x30B9,
+ ("Z", "u"): 0x30BA,
+ ("S", "e"): 0x30BB,
+ ("Z", "e"): 0x30BC,
+ ("S", "o"): 0x30BD,
+ ("Z", "o"): 0x30BE,
+ ("T", "a"): 0x30BF,
+ ("D", "a"): 0x30C0,
+ ("T", "i"): 0x30C1,
+ ("D", "i"): 0x30C2,
+ ("T", "U"): 0x30C3,
+ ("T", "u"): 0x30C4,
+ ("D", "u"): 0x30C5,
+ ("T", "e"): 0x30C6,
+ ("D", "e"): 0x30C7,
+ ("T", "o"): 0x30C8,
+ ("D", "o"): 0x30C9,
+ ("N", "a"): 0x30CA,
+ ("N", "i"): 0x30CB,
+ ("N", "u"): 0x30CC,
+ ("N", "e"): 0x30CD,
+ ("N", "o"): 0x30CE,
+ ("H", "a"): 0x30CF,
+ ("B", "a"): 0x30D0,
+ ("P", "a"): 0x30D1,
+ ("H", "i"): 0x30D2,
+ ("B", "i"): 0x30D3,
+ ("P", "i"): 0x30D4,
+ ("H", "u"): 0x30D5,
+ ("B", "u"): 0x30D6,
+ ("P", "u"): 0x30D7,
+ ("H", "e"): 0x30D8,
+ ("B", "e"): 0x30D9,
+ ("P", "e"): 0x30DA,
+ ("H", "o"): 0x30DB,
+ ("B", "o"): 0x30DC,
+ ("P", "o"): 0x30DD,
+ ("M", "a"): 0x30DE,
+ ("M", "i"): 0x30DF,
+ ("M", "u"): 0x30E0,
+ ("M", "e"): 0x30E1,
+ ("M", "o"): 0x30E2,
+ ("Y", "A"): 0x30E3,
+ ("Y", "a"): 0x30E4,
+ ("Y", "U"): 0x30E5,
+ ("Y", "u"): 0x30E6,
+ ("Y", "O"): 0x30E7,
+ ("Y", "o"): 0x30E8,
+ ("R", "a"): 0x30E9,
+ ("R", "i"): 0x30EA,
+ ("R", "u"): 0x30EB,
+ ("R", "e"): 0x30EC,
+ ("R", "o"): 0x30ED,
+ ("W", "A"): 0x30EE,
+ ("W", "a"): 0x30EF,
+ ("W", "i"): 0x30F0,
+ ("W", "e"): 0x30F1,
+ ("W", "o"): 0x30F2,
+ ("N", "6"): 0x30F3,
+ ("V", "u"): 0x30F4,
+ ("K", "A"): 0x30F5,
+ ("K", "E"): 0x30F6,
+ ("V", "a"): 0x30F7,
+ ("V", "i"): 0x30F8,
+ ("V", "e"): 0x30F9,
+ ("V", "o"): 0x30FA,
+ (".", "6"): 0x30FB,
+ ("-", "6"): 0x30FC,
+ ("*", "6"): 0x30FD,
+ ("+", "6"): 0x30FE,
+ ("b", "4"): 0x3105,
+ ("p", "4"): 0x3106,
+ ("m", "4"): 0x3107,
+ ("f", "4"): 0x3108,
+ ("d", "4"): 0x3109,
+ ("t", "4"): 0x310A,
+ ("n", "4"): 0x310B,
+ ("l", "4"): 0x310C,
+ ("g", "4"): 0x310D,
+ ("k", "4"): 0x310E,
+ ("h", "4"): 0x310F,
+ ("j", "4"): 0x3110,
+ ("q", "4"): 0x3111,
+ ("x", "4"): 0x3112,
+ ("z", "h"): 0x3113,
+ ("c", "h"): 0x3114,
+ ("s", "h"): 0x3115,
+ ("r", "4"): 0x3116,
+ ("z", "4"): 0x3117,
+ ("c", "4"): 0x3118,
+ ("s", "4"): 0x3119,
+ ("a", "4"): 0x311A,
+ ("o", "4"): 0x311B,
+ ("e", "4"): 0x311C,
+ ("a", "i"): 0x311E,
+ ("e", "i"): 0x311F,
+ ("a", "u"): 0x3120,
+ ("o", "u"): 0x3121,
+ ("a", "n"): 0x3122,
+ ("e", "n"): 0x3123,
+ ("a", "N"): 0x3124,
+ ("e", "N"): 0x3125,
+ ("e", "r"): 0x3126,
+ ("i", "4"): 0x3127,
+ ("u", "4"): 0x3128,
+ ("i", "u"): 0x3129,
+ ("v", "4"): 0x312A,
+ ("n", "G"): 0x312B,
+ ("g", "n"): 0x312C,
+ ("1", "c"): 0x3220,
+ ("2", "c"): 0x3221,
+ ("3", "c"): 0x3222,
+ ("4", "c"): 0x3223,
+ ("5", "c"): 0x3224,
+ ("6", "c"): 0x3225,
+ ("7", "c"): 0x3226,
+ ("8", "c"): 0x3227,
+ ("9", "c"): 0x3228,
+ # code points 0xe000 - 0xefff excluded, they have no assigned
+ # characters, only used in proposals.
+ ("f", "f"): 0xFB00,
+ ("f", "i"): 0xFB01,
+ ("f", "l"): 0xFB02,
+ ("f", "t"): 0xFB05,
+ ("s", "t"): 0xFB06,
+ # Vim 5.x compatible digraphs that don't conflict with the above
+ ("~", "!"): 161,
+ ("c", "|"): 162,
+ ("$", "$"): 163,
+ ("o", "x"): 164, # currency symbol in ISO 8859-1
+ ("Y", "-"): 165,
+ ("|", "|"): 166,
+ ("c", "O"): 169,
+ ("-", ","): 172,
+ ("-", "="): 175,
+ ("~", "o"): 176,
+ ("2", "2"): 178,
+ ("3", "3"): 179,
+ ("p", "p"): 182,
+ ("~", "."): 183,
+ ("1", "1"): 185,
+ ("~", "?"): 191,
+ ("A", "`"): 192,
+ ("A", "^"): 194,
+ ("A", "~"): 195,
+ ("A", '"'): 196,
+ ("A", "@"): 197,
+ ("E", "`"): 200,
+ ("E", "^"): 202,
+ ("E", '"'): 203,
+ ("I", "`"): 204,
+ ("I", "^"): 206,
+ ("I", '"'): 207,
+ ("N", "~"): 209,
+ ("O", "`"): 210,
+ ("O", "^"): 212,
+ ("O", "~"): 213,
+ ("/", "\\"): 215, # multiplication symbol in ISO 8859-1
+ ("U", "`"): 217,
+ ("U", "^"): 219,
+ ("I", "p"): 222,
+ ("a", "`"): 224,
+ ("a", "^"): 226,
+ ("a", "~"): 227,
+ ("a", '"'): 228,
+ ("a", "@"): 229,
+ ("e", "`"): 232,
+ ("e", "^"): 234,
+ ("e", '"'): 235,
+ ("i", "`"): 236,
+ ("i", "^"): 238,
+ ("n", "~"): 241,
+ ("o", "`"): 242,
+ ("o", "^"): 244,
+ ("o", "~"): 245,
+ ("u", "`"): 249,
+ ("u", "^"): 251,
+ ("y", '"'): 255,
+}
diff --git a/src/prompt_toolkit/key_binding/emacs_state.py b/src/prompt_toolkit/key_binding/emacs_state.py
index d60cbeb7..6a2ebf46 100644
--- a/src/prompt_toolkit/key_binding/emacs_state.py
+++ b/src/prompt_toolkit/key_binding/emacs_state.py
@@ -1,6 +1,10 @@
from __future__ import annotations
+
from .key_processor import KeyPress
-__all__ = ['EmacsState']
+
+__all__ = [
+ "EmacsState",
+]
class EmacsState:
@@ -8,19 +12,25 @@ class EmacsState:
Mutable class to hold Emacs specific state.
"""
- def __init__(self) ->None:
+ 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."""
- pass
+ 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."""
- pass
+ def start_macro(self) -> None:
+ "Start recording macro."
+ self.current_recording = []
- def end_macro(self) ->None:
- """End recording macro."""
- pass
+ def end_macro(self) -> None:
+ "End recording macro."
+ self.macro = self.current_recording
+ self.current_recording = None
diff --git a/src/prompt_toolkit/key_binding/key_bindings.py b/src/prompt_toolkit/key_binding/key_bindings.py
index 9d241939..62530f2b 100644
--- a/src/prompt_toolkit/key_binding/key_bindings.py
+++ b/src/prompt_toolkit/key_binding/key_bindings.py
@@ -35,20 +35,64 @@ been assigned, through the `key_binding` decorator.::
kb.add(Keys.A, my_key_binding)
"""
from __future__ import annotations
+
from abc import ABCMeta, abstractmethod, abstractproperty
from inspect import isawaitable
-from typing import TYPE_CHECKING, Any, Callable, Coroutine, Hashable, Sequence, Tuple, TypeVar, Union, cast
+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
-__all__ = ['NotImplementedOrNone', 'Binding', 'KeyBindingsBase',
- 'KeyBindings', 'ConditionalKeyBindings', 'merge_key_bindings',
- 'DynamicKeyBindings', 'GlobalOnlyKeyBindings']
-KeyHandlerCallable = Callable[['KeyPressEvent'], Union[
- 'NotImplementedOrNone', Coroutine[Any, Any, 'NotImplementedOrNone']]]
+ # 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:
@@ -60,11 +104,16 @@ class Binding:
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:
+ 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)
@@ -73,11 +122,32 @@ class Binding:
self.save_before = save_before
self.record_in_macro = to_filter(record_in_macro)
- def __repr__(self) ->str:
- return '{}(keys={!r}, handler={!r})'.format(self.__class__.__name__,
- self.keys, self.handler)
+ 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 "{}(keys={!r}, handler={!r})".format(
+ self.__class__.__name__,
+ self.keys,
+ self.handler,
+ )
+# Sequence of keys presses.
KeysTuple = Tuple[Union[Keys, str], ...]
@@ -87,15 +157,15 @@ class KeyBindingsBase(metaclass=ABCMeta):
"""
@abstractproperty
- def _version(self) ->Hashable:
+ def _version(self) -> Hashable:
"""
For cache invalidation. - This should increase every time that
something changes.
"""
- pass
+ return 0
@abstractmethod
- def get_bindings_for_keys(self, keys: KeysTuple) ->list[Binding]:
+ 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
@@ -103,10 +173,10 @@ class KeyBindingsBase(metaclass=ABCMeta):
:param keys: tuple of keys.
"""
- pass
+ return []
@abstractmethod
- def get_bindings_starting_with_keys(self, keys: KeysTuple) ->list[Binding]:
+ 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
@@ -115,19 +185,21 @@ class KeyBindingsBase(metaclass=ABCMeta):
:param keys: tuple of keys.
"""
- pass
+ return []
@abstractproperty
- def bindings(self) ->list[Binding]:
+ def bindings(self) -> list[Binding]:
"""
List of `Binding` objects.
(These need to be exposed, so that `KeyBindings` objects can be merged
together.)
"""
- pass
+ return []
+ # `add` and `remove` don't have to be part of this interface.
-T = TypeVar('T', bound=Union[KeyHandlerCallable, Binding])
+
+T = TypeVar("T", bound=Union[KeyHandlerCallable, Binding])
class KeyBindings(KeyBindingsBase):
@@ -152,18 +224,38 @@ class KeyBindings(KeyBindingsBase):
"""
- def __init__(self) ->None:
+ 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
-
- 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]:
+ 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.
@@ -182,9 +274,52 @@ class KeyBindings(KeyBindingsBase):
:param record_in_macro: Record these key bindings when a macro is
being recorded. (True by default.)
"""
- pass
-
- def remove(self, *args: (Keys | str | KeyHandlerCallable)) ->None:
+ 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.
@@ -198,11 +333,41 @@ class KeyBindings(KeyBindingsBase):
remove(handler) # Pass handler.
remove('c-x', 'c-a') # Or pass the key bindings.
"""
- pass
+ 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]:
+ 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
@@ -210,9 +375,34 @@ class KeyBindings(KeyBindingsBase):
:param keys: tuple of keys.
"""
- pass
- def get_bindings_starting_with_keys(self, keys: KeysTuple) ->list[Binding]:
+ 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
@@ -221,25 +411,83 @@ class KeyBindings(KeyBindingsBase):
:param keys: tuple of keys.
"""
- pass
+ 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):
+
+def _parse_key(key: Keys | str) -> str | Keys:
"""
Replace key by alias and verify whether it's a valid one.
"""
- pass
+ # 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]:
+
+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.
"""
- pass
+ 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):
@@ -247,16 +495,37 @@ class _Proxy(KeyBindingsBase):
Common part for ConditionalKeyBindings and _MergedKeyBindings.
"""
- def __init__(self) ->None:
+ 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:
+ def _update_cache(self) -> None:
"""
If `self._last_version` is outdated, then this should update
the version and `self._bindings2`.
"""
- pass
+ 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):
@@ -277,15 +546,37 @@ class ConditionalKeyBindings(_Proxy):
:param filter: :class:`~prompt_toolkit.filters.Filter` object.
"""
- def __init__(self, key_bindings: KeyBindingsBase, filter: FilterOrBool=True
- ) ->None:
+ 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."""
- pass
+ 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):
@@ -298,20 +589,28 @@ class _MergedKeyBindings(_Proxy):
:param registries: List of :class:`.KeyBindings` objects.
"""
- def __init__(self, registries: Sequence[KeyBindingsBase]) ->None:
+ def __init__(self, registries: Sequence[KeyBindingsBase]) -> None:
_Proxy.__init__(self)
self.registries = registries
- def _update_cache(self) ->None:
+ def _update_cache(self) -> None:
"""
If one of the original registries was changed. Update our merged
version.
"""
- pass
+ 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)
-def merge_key_bindings(bindings: Sequence[KeyBindingsBase]
- ) ->_MergedKeyBindings:
+ self._bindings2 = bindings2
+ self._last_version = expected_version
+
+
+def merge_key_bindings(bindings: Sequence[KeyBindingsBase]) -> _MergedKeyBindings:
"""
Merge multiple :class:`.Keybinding` objects together.
@@ -319,7 +618,7 @@ def merge_key_bindings(bindings: Sequence[KeyBindingsBase]
bindings = merge_key_bindings([bindings1, bindings2, ...])
"""
- pass
+ return _MergedKeyBindings(bindings)
class DynamicKeyBindings(_Proxy):
@@ -329,12 +628,19 @@ class DynamicKeyBindings(_Proxy):
:param get_key_bindings: Callable that returns a :class:`.KeyBindings` instance.
"""
- def __init__(self, get_key_bindings: Callable[[], KeyBindingsBase | None]
- ) ->None:
+ 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()
+ 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):
@@ -343,13 +649,23 @@ class GlobalOnlyKeyBindings(_Proxy):
key bindings.
"""
- def __init__(self, key_bindings: KeyBindingsBase) ->None:
+ def __init__(self, key_bindings: KeyBindingsBase) -> None:
_Proxy.__init__(self)
self.key_bindings = key_bindings
- def _update_cache(self) ->None:
+ def _update_cache(self) -> None:
"""
If one of the original registries was changed. Update our merged
version.
"""
- pass
+ 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
diff --git a/src/prompt_toolkit/key_binding/key_processor.py b/src/prompt_toolkit/key_binding/key_processor.py
index 4104a4f6..4c4f0d15 100644
--- a/src/prompt_toolkit/key_binding/key_processor.py
+++ b/src/prompt_toolkit/key_binding/key_processor.py
@@ -6,20 +6,30 @@ 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']
+
+
+__all__ = [
+ "KeyProcessor",
+ "KeyPress",
+ "KeyPressEvent",
+]
class KeyPress:
@@ -28,21 +38,22 @@ class KeyPress:
:param data: The received string on stdin. (Often vt100 escape codes.)
"""
- def __init__(self, key: (Keys | str), data: (str | None)=None) ->None:
+ 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
+ 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 __repr__(self) -> str:
+ return f"{self.__class__.__name__}(key={self.key!r}, data={self.data!r})"
- def __eq__(self, other: object) ->bool:
+ def __eq__(self, other: object) -> bool:
if not isinstance(other, KeyPress):
return False
return self.key == other.key and self.data == other.data
@@ -52,7 +63,7 @@ class KeyPress:
Helper object to indicate flush operation in the KeyProcessor.
NOTE: the implementation is very similar to the VT100 parser.
"""
-_Flush = KeyPress('?', data='_Flush')
+_Flush = KeyPress("?", data="_Flush")
class KeyProcessor:
@@ -65,8 +76,8 @@ class KeyProcessor:
p = KeyProcessor(key_bindings)
# Send keys into the processor.
- p.feed(KeyPress(Keys.ControlX, ''))
- p.feed(KeyPress(Keys.ControlC, '')
+ p.feed(KeyPress(Keys.ControlX, '\x18'))
+ p.feed(KeyPress(Keys.ControlC, '\x03')
# Process all the keys in the queue.
p.process_keys()
@@ -77,51 +88,144 @@ class KeyProcessor:
:param key_bindings: `KeyBindingsBase` instance.
"""
- def __init__(self, key_bindings: KeyBindingsBase) ->None:
+ 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 _get_matches(self, key_presses: list[KeyPress]) ->list[Binding]:
+ 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.
"""
- pass
+ 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:
+ 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.
"""
- pass
+ keys = tuple(k.key for k in key_presses)
- def _process(self) ->Generator[None, KeyPress, None]:
+ # 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.
"""
- pass
+ buffer = self.key_buffer
+ retry = False
+
+ while True:
+ flush = False
- def feed(self, key_press: KeyPress, first: bool=False) ->None:
+ 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.
"""
- pass
+ 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:
+ def feed_multiple(self, key_presses: list[KeyPress], first: bool = False) -> None:
"""
:param first: If true, insert before everything else.
"""
- pass
+ if first:
+ self.input_queue.extendleft(reversed(key_presses))
+ else:
+ self.input_queue.extend(key_presses)
- def process_keys(self) ->None:
+ def process_keys(self) -> None:
"""
Process all the keys in the `input_queue`.
(To be called after `feed`.)
@@ -130,30 +234,152 @@ class KeyProcessor:
possible to call `feed` from inside a key binding.
This function keeps looping until the queue is empty.
"""
- pass
+ 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()
- def empty_queue(self) ->list[KeyPress]:
+ 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.
"""
- pass
-
- def _fix_vi_cursor_position(self, event: KeyPressEvent) ->None:
+ 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.)
"""
- pass
+ 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
- def _leave_vi_temp_navigation_mode(self, event: KeyPressEvent) ->None:
+ # 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.
"""
- pass
+ 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:
+ def _start_timeout(self) -> None:
"""
Start auto flush timeout. Similar to Vim's `timeoutlen` option.
@@ -161,13 +387,37 @@ class KeyProcessor:
and no key was pressed in the meantime, we flush all data in the queue
and call the appropriate key binding handlers.
"""
- pass
+ 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()
- def send_sigint(self) ->None:
+ # 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.
"""
- pass
+ self.feed(KeyPress(key=Keys.SIGINT), first=True)
+ self.process_keys()
class KeyPressEvent:
@@ -181,57 +431,99 @@ class KeyPressEvent:
: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:
+ 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 ('KeyPressEvent(arg={!r}, key_sequence={!r}, is_repeat={!r})'
- .format(self.arg, self.key_sequence, self.is_repeat))
+ def __repr__(self) -> str:
+ return "KeyPressEvent(arg={!r}, key_sequence={!r}, is_repeat={!r})".format(
+ self.arg,
+ self.key_sequence,
+ self.is_repeat,
+ )
+
+ @property
+ def data(self) -> str:
+ return self.key_sequence[-1].data
+
+ @property
+ def key_processor(self) -> KeyProcessor:
+ processor = self._key_processor_ref()
+ if processor is None:
+ raise Exception("KeyProcessor was lost. This should not happen.")
+ return processor
@property
- def app(self) ->Application[Any]:
+ def app(self) -> Application[Any]:
"""
The current `Application` object.
"""
- pass
+ return self._app
@property
- def current_buffer(self) ->Buffer:
+ def current_buffer(self) -> Buffer:
"""
The current buffer.
"""
- pass
+ return self.app.current_buffer
@property
- def arg(self) ->int:
+ def arg(self) -> int:
"""
Repetition argument.
"""
- pass
+ 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:
+ def arg_present(self) -> bool:
"""
True if repetition argument was explicitly provided.
"""
- pass
+ return self._arg is not None
- def append_to_arg_count(self, data: str) ->None:
+ def append_to_arg_count(self, data: str) -> None:
"""
Add digit to the input argument.
:param data: the typed digit as string
"""
- pass
+ 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."""
- pass
+ def cli(self) -> Application[Any]:
+ "For backward-compatibility."
+ return self.app
diff --git a/src/prompt_toolkit/key_binding/vi_state.py b/src/prompt_toolkit/key_binding/vi_state.py
index be95b80e..7ec552fa 100644
--- a/src/prompt_toolkit/key_binding/vi_state.py
+++ b/src/prompt_toolkit/key_binding/vi_state.py
@@ -1,25 +1,33 @@
from __future__ import annotations
+
from enum import Enum
from typing import TYPE_CHECKING, Callable
+
from prompt_toolkit.clipboard import ClipboardData
+
if TYPE_CHECKING:
from .key_bindings.vi import TextObject
from .key_processor import KeyPressEvent
-__all__ = ['InputMode', 'CharacterFind', 'ViState']
+
+__all__ = [
+ "InputMode",
+ "CharacterFind",
+ "ViState",
+]
class InputMode(str, Enum):
value: str
- INSERT = 'vi-insert'
- INSERT_MULTIPLE = 'vi-insert-multiple'
- NAVIGATION = 'vi-navigation'
- REPLACE = 'vi-replace'
- REPLACE_SINGLE = 'vi-replace-single'
+ 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:
+class CharacterFind:
+ def __init__(self, character: str, backwards: bool = False) -> None:
self.character = character
self.backwards = backwards
@@ -29,32 +37,71 @@ class ViState:
Mutable class to hold the state of the Vi navigation.
"""
- def __init__(self) ->None:
+ 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
- self.operator_func: None | Callable[[KeyPressEvent, TextObject], 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
+ 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 = ''
+ 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`."""
- pass
+ def input_mode(self) -> InputMode:
+ "Get `InputMode`."
+ return self.__input_mode
@input_mode.setter
- def input_mode(self, value: InputMode) ->None:
- """Set `InputMode`."""
- pass
+ 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
- def reset(self) ->None:
+ self.__input_mode = value
+
+ def reset(self) -> None:
"""
Reset state, go back to the given mode. INSERT by default.
"""
- pass
+ # 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 = ""
diff --git a/src/prompt_toolkit/keys.py b/src/prompt_toolkit/keys.py
index ce6ce6bd..ee52aee8 100644
--- a/src/prompt_toolkit/keys.py
+++ b/src/prompt_toolkit/keys.py
@@ -1,6 +1,11 @@
from __future__ import annotations
+
from enum import Enum
-__all__ = ['Keys', 'ALL_KEYS']
+
+__all__ = [
+ "Keys",
+ "ALL_KEYS",
+]
class Keys(str, Enum):
@@ -10,162 +15,190 @@ class Keys(str, Enum):
Note that this is an "StrEnum", all values can be compared against
strings.
"""
+
value: str
- Escape = 'escape'
- ShiftEscape = 's-escape'
- ControlAt = 'c-@'
- 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'
- ControlJ = 'c-j'
- ControlK = 'c-k'
- ControlL = 'c-l'
- ControlM = 'c-m'
- 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'
- 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'
- Any = '<any>'
- ScrollUp = '<scroll-up>'
- ScrollDown = '<scroll-down>'
- CPRResponse = '<cursor-position-response>'
- Vt100MouseEvent = '<vt100-mouse-event>'
- WindowsMouseEvent = '<windows-mouse-event>'
- BracketedPaste = '<bracketed-paste>'
- SIGINT = '<sigint>'
- Ignore = '<ignore>'
+
+ 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
@@ -173,6 +206,17 @@ class Keys(str, Enum):
ALL_KEYS: list[str] = [k.value for k in Keys]
-KEY_ALIASES: dict[str, str] = {'backspace': 'c-h', 'c-space': 'c-@',
- 'enter': 'c-m', 'tab': 'c-i', 's-c-left': 'c-s-left', 's-c-right':
- 'c-s-right', 's-c-home': 'c-s-home', 's-c-end': 'c-s-end'}
+
+
+# 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",
+}
diff --git a/src/prompt_toolkit/layout/containers.py b/src/prompt_toolkit/layout/containers.py
index 52be90da..100d4aae 100644
--- a/src/prompt_toolkit/layout/containers.py
+++ b/src/prompt_toolkit/layout/containers.py
@@ -3,33 +3,79 @@ Container for the layout.
(Containers can contain other containers or user interface controls.)
"""
from __future__ import annotations
+
from abc import ABCMeta, abstractmethod
from enum import Enum
from functools import partial
from typing import TYPE_CHECKING, Callable, Sequence, Union, cast
+
from prompt_toolkit.application.current import get_app
from prompt_toolkit.cache import SimpleCache
from prompt_toolkit.data_structures import Point
-from prompt_toolkit.filters import FilterOrBool, emacs_insert_mode, to_filter, vi_insert_mode
-from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples, to_formatted_text
-from prompt_toolkit.formatted_text.utils import fragment_list_to_text, fragment_list_width
+from prompt_toolkit.filters import (
+ FilterOrBool,
+ emacs_insert_mode,
+ to_filter,
+ vi_insert_mode,
+)
+from prompt_toolkit.formatted_text import (
+ AnyFormattedText,
+ StyleAndTextTuples,
+ to_formatted_text,
+)
+from prompt_toolkit.formatted_text.utils import (
+ fragment_list_to_text,
+ fragment_list_width,
+)
from prompt_toolkit.key_binding import KeyBindingsBase
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from prompt_toolkit.utils import get_cwidth, take_using_weights, to_int, to_str
-from .controls import DummyControl, FormattedTextControl, GetLinePrefixCallable, UIContent, UIControl
-from .dimension import AnyDimension, Dimension, max_layout_dimensions, sum_layout_dimensions, to_dimension
+
+from .controls import (
+ DummyControl,
+ FormattedTextControl,
+ GetLinePrefixCallable,
+ UIContent,
+ UIControl,
+)
+from .dimension import (
+ AnyDimension,
+ Dimension,
+ max_layout_dimensions,
+ sum_layout_dimensions,
+ to_dimension,
+)
from .margins import Margin
from .mouse_handlers import MouseHandlers
from .screen import _CHAR_CACHE, Screen, WritePosition
from .utils import explode_text_fragments
+
if TYPE_CHECKING:
from typing_extensions import Protocol, TypeGuard
+
from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
-__all__ = ['AnyContainer', 'Container', 'HorizontalAlign', 'VerticalAlign',
- 'HSplit', 'VSplit', 'FloatContainer', 'Float', 'WindowAlign', 'Window',
- 'WindowRenderInfo', 'ConditionalContainer', 'ScrollOffsets',
- 'ColorColumn', 'to_container', 'to_window', 'is_container',
- 'DynamicContainer']
+
+
+__all__ = [
+ "AnyContainer",
+ "Container",
+ "HorizontalAlign",
+ "VerticalAlign",
+ "HSplit",
+ "VSplit",
+ "FloatContainer",
+ "Float",
+ "WindowAlign",
+ "Window",
+ "WindowRenderInfo",
+ "ConditionalContainer",
+ "ScrollOffsets",
+ "ColorColumn",
+ "to_container",
+ "to_window",
+ "is_container",
+ "DynamicContainer",
+]
class Container(metaclass=ABCMeta):
@@ -38,34 +84,36 @@ class Container(metaclass=ABCMeta):
"""
@abstractmethod
- def reset(self) ->None:
+ def reset(self) -> None:
"""
Reset the state of this container and all the children.
(E.g. reset scroll offsets, etc...)
"""
- pass
@abstractmethod
- def preferred_width(self, max_available_width: int) ->Dimension:
+ def preferred_width(self, max_available_width: int) -> Dimension:
"""
Return a :class:`~prompt_toolkit.layout.Dimension` that represents the
desired width for this container.
"""
- pass
@abstractmethod
- def preferred_height(self, width: int, max_available_height: int
- ) ->Dimension:
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
"""
Return a :class:`~prompt_toolkit.layout.Dimension` that represents the
desired height for this container.
"""
- pass
@abstractmethod
- def write_to_screen(self, screen: Screen, mouse_handlers: MouseHandlers,
- write_position: WritePosition, parent_style: str, erase_bg: bool,
- z_index: (int | None)) ->None:
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
"""
Write the actual content to the screen.
@@ -77,63 +125,67 @@ class Container(metaclass=ABCMeta):
style down to the windows that they contain.
:param z_index: Used for propagating z_index from parent to child.
"""
- pass
- def is_modal(self) ->bool:
+ def is_modal(self) -> bool:
"""
When this container is modal, key bindings from parent containers are
not taken into account if a user control in this container is focused.
"""
- pass
+ return False
- def get_key_bindings(self) ->(KeyBindingsBase | None):
+ def get_key_bindings(self) -> KeyBindingsBase | None:
"""
Returns a :class:`.KeyBindings` object. These bindings become active when any
user control in this container has the focus, except if any containers
between this container and the focused user control is modal.
"""
- pass
+ return None
@abstractmethod
- def get_children(self) ->list[Container]:
+ def get_children(self) -> list[Container]:
"""
Return the list of child :class:`.Container` objects.
"""
- pass
+ return []
if TYPE_CHECKING:
-
class MagicContainer(Protocol):
"""
Any object that implements ``__pt_container__`` represents a container.
"""
- def __pt_container__(self) ->AnyContainer:
+ def __pt_container__(self) -> AnyContainer:
...
-AnyContainer = Union[Container, 'MagicContainer']
-def _window_too_small() ->Window:
- """Create a `Window` that displays the 'Window too small' text."""
- pass
+AnyContainer = Union[Container, "MagicContainer"]
+
+
+def _window_too_small() -> Window:
+ "Create a `Window` that displays the 'Window too small' text."
+ return Window(
+ FormattedTextControl(text=[("class:window-too-small", " Window too small... ")])
+ )
class VerticalAlign(Enum):
- """Alignment for `HSplit`."""
- TOP = 'TOP'
- CENTER = 'CENTER'
- BOTTOM = 'BOTTOM'
- JUSTIFY = 'JUSTIFY'
+ "Alignment for `HSplit`."
+
+ TOP = "TOP"
+ CENTER = "CENTER"
+ BOTTOM = "BOTTOM"
+ JUSTIFY = "JUSTIFY"
class HorizontalAlign(Enum):
- """Alignment for `VSplit`."""
- LEFT = 'LEFT'
- CENTER = 'CENTER'
- RIGHT = 'RIGHT'
- JUSTIFY = 'JUSTIFY'
+ "Alignment for `VSplit`."
+
+ LEFT = "LEFT"
+ CENTER = "CENTER"
+ RIGHT = "RIGHT"
+ JUSTIFY = "JUSTIFY"
class _Split(Container):
@@ -141,24 +193,43 @@ class _Split(Container):
The common parts of `VSplit` and `HSplit`.
"""
- def __init__(self, children: Sequence[AnyContainer], window_too_small:
- (Container | None)=None, padding: AnyDimension=Dimension.exact(0),
- padding_char: (str | None)=None, padding_style: str='', width:
- AnyDimension=None, height: AnyDimension=None, z_index: (int | None)
- =None, modal: bool=False, key_bindings: (KeyBindingsBase | None)=
- None, style: (str | Callable[[], str])='') ->None:
+ def __init__(
+ self,
+ children: Sequence[AnyContainer],
+ window_too_small: Container | None = None,
+ padding: AnyDimension = Dimension.exact(0),
+ padding_char: str | None = None,
+ padding_style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: str | Callable[[], str] = "",
+ ) -> None:
self.children = [to_container(c) for c in children]
self.window_too_small = window_too_small or _window_too_small()
self.padding = padding
self.padding_char = padding_char
self.padding_style = padding_style
+
self.width = width
self.height = height
self.z_index = z_index
+
self.modal = modal
self.key_bindings = key_bindings
self.style = style
+ def is_modal(self) -> bool:
+ return self.modal
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ return self.key_bindings
+
+ def get_children(self) -> list[Container]:
+ return self.children
+
class HSplit(_Split):
"""
@@ -194,48 +265,208 @@ class HSplit(_Split):
:param padding_style: Style to applied to the padding.
"""
- def __init__(self, children: Sequence[AnyContainer], window_too_small:
- (Container | None)=None, align: VerticalAlign=VerticalAlign.JUSTIFY,
- padding: AnyDimension=0, padding_char: (str | None)=None,
- padding_style: str='', width: AnyDimension=None, height:
- AnyDimension=None, z_index: (int | None)=None, modal: bool=False,
- key_bindings: (KeyBindingsBase | None)=None, style: (str | Callable
- [[], str])='') ->None:
- super().__init__(children=children, window_too_small=
- window_too_small, padding=padding, padding_char=padding_char,
- padding_style=padding_style, width=width, height=height,
- z_index=z_index, modal=modal, key_bindings=key_bindings, style=
- style)
+ def __init__(
+ self,
+ children: Sequence[AnyContainer],
+ window_too_small: Container | None = None,
+ align: VerticalAlign = VerticalAlign.JUSTIFY,
+ padding: AnyDimension = 0,
+ padding_char: str | None = None,
+ padding_style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: str | Callable[[], str] = "",
+ ) -> None:
+ super().__init__(
+ children=children,
+ window_too_small=window_too_small,
+ padding=padding,
+ padding_char=padding_char,
+ padding_style=padding_style,
+ width=width,
+ height=height,
+ z_index=z_index,
+ modal=modal,
+ key_bindings=key_bindings,
+ style=style,
+ )
+
self.align = align
- self._children_cache: SimpleCache[tuple[Container, ...], list[
- Container]] = SimpleCache(maxsize=1)
- self._remaining_space_window = Window()
+
+ self._children_cache: SimpleCache[
+ tuple[Container, ...], list[Container]
+ ] = SimpleCache(maxsize=1)
+ self._remaining_space_window = Window() # Dummy window.
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ if self.width is not None:
+ return to_dimension(self.width)
+
+ if self.children:
+ dimensions = [c.preferred_width(max_available_width) for c in self.children]
+ return max_layout_dimensions(dimensions)
+ else:
+ return Dimension()
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ if self.height is not None:
+ return to_dimension(self.height)
+
+ dimensions = [
+ c.preferred_height(width, max_available_height) for c in self._all_children
+ ]
+ return sum_layout_dimensions(dimensions)
+
+ def reset(self) -> None:
+ for c in self.children:
+ c.reset()
@property
- def _all_children(self) ->list[Container]:
+ def _all_children(self) -> list[Container]:
"""
List of child objects, including padding.
"""
- pass
- def write_to_screen(self, screen: Screen, mouse_handlers: MouseHandlers,
- write_position: WritePosition, parent_style: str, erase_bg: bool,
- z_index: (int | None)) ->None:
+ def get() -> list[Container]:
+ result: list[Container] = []
+
+ # Padding Top.
+ if self.align in (VerticalAlign.CENTER, VerticalAlign.BOTTOM):
+ result.append(Window(width=Dimension(preferred=0)))
+
+ # The children with padding.
+ for child in self.children:
+ result.append(child)
+ result.append(
+ Window(
+ height=self.padding,
+ char=self.padding_char,
+ style=self.padding_style,
+ )
+ )
+ if result:
+ result.pop()
+
+ # Padding right.
+ if self.align in (VerticalAlign.CENTER, VerticalAlign.TOP):
+ result.append(Window(width=Dimension(preferred=0)))
+
+ return result
+
+ return self._children_cache.get(tuple(self.children), get)
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
"""
Render the prompt to a `Screen` instance.
:param screen: The :class:`~prompt_toolkit.layout.screen.Screen` class
to which the output has to be written.
"""
- pass
-
- def _divide_heights(self, write_position: WritePosition) ->(list[int] |
- None):
+ sizes = self._divide_heights(write_position)
+ style = parent_style + " " + to_str(self.style)
+ z_index = z_index if self.z_index is None else self.z_index
+
+ if sizes is None:
+ self.window_too_small.write_to_screen(
+ screen, mouse_handlers, write_position, style, erase_bg, z_index
+ )
+ else:
+ #
+ ypos = write_position.ypos
+ xpos = write_position.xpos
+ width = write_position.width
+
+ # Draw child panes.
+ for s, c in zip(sizes, self._all_children):
+ c.write_to_screen(
+ screen,
+ mouse_handlers,
+ WritePosition(xpos, ypos, width, s),
+ style,
+ erase_bg,
+ z_index,
+ )
+ ypos += s
+
+ # Fill in the remaining space. This happens when a child control
+ # refuses to take more space and we don't have any padding. Adding a
+ # dummy child control for this (in `self._all_children`) is not
+ # desired, because in some situations, it would take more space, even
+ # when it's not required. This is required to apply the styling.
+ remaining_height = write_position.ypos + write_position.height - ypos
+ if remaining_height > 0:
+ self._remaining_space_window.write_to_screen(
+ screen,
+ mouse_handlers,
+ WritePosition(xpos, ypos, width, remaining_height),
+ style,
+ erase_bg,
+ z_index,
+ )
+
+ def _divide_heights(self, write_position: WritePosition) -> list[int] | None:
"""
Return the heights for all rows.
Or None when there is not enough space.
"""
- pass
+ if not self.children:
+ return []
+
+ width = write_position.width
+ height = write_position.height
+
+ # Calculate heights.
+ dimensions = [c.preferred_height(width, height) for c in self._all_children]
+
+ # Sum dimensions
+ sum_dimensions = sum_layout_dimensions(dimensions)
+
+ # If there is not enough space for both.
+ # Don't do anything.
+ if sum_dimensions.min > height:
+ return None
+
+ # Find optimal sizes. (Start with minimal size, increase until we cover
+ # the whole height.)
+ sizes = [d.min for d in dimensions]
+
+ child_generator = take_using_weights(
+ items=list(range(len(dimensions))), weights=[d.weight for d in dimensions]
+ )
+
+ i = next(child_generator)
+
+ # Increase until we meet at least the 'preferred' size.
+ preferred_stop = min(height, sum_dimensions.preferred)
+ preferred_dimensions = [d.preferred for d in dimensions]
+
+ while sum(sizes) < preferred_stop:
+ if sizes[i] < preferred_dimensions[i]:
+ sizes[i] += 1
+ i = next(child_generator)
+
+ # Increase until we use all the available space. (or until "max")
+ if not get_app().is_done:
+ max_stop = min(height, sum_dimensions.max)
+ max_dimensions = [d.max for d in dimensions]
+
+ while sum(sizes) < max_stop:
+ if sizes[i] < max_dimensions[i]:
+ sizes[i] += 1
+ i = next(child_generator)
+
+ return sizes
class VSplit(_Split):
@@ -271,47 +502,234 @@ class VSplit(_Split):
:param padding_style: Style to applied to the padding.
"""
- def __init__(self, children: Sequence[AnyContainer], window_too_small:
- (Container | None)=None, align: HorizontalAlign=HorizontalAlign.
- JUSTIFY, padding: AnyDimension=0, padding_char: (str | None)=None,
- padding_style: str='', width: AnyDimension=None, height:
- AnyDimension=None, z_index: (int | None)=None, modal: bool=False,
- key_bindings: (KeyBindingsBase | None)=None, style: (str | Callable
- [[], str])='') ->None:
- super().__init__(children=children, window_too_small=
- window_too_small, padding=padding, padding_char=padding_char,
- padding_style=padding_style, width=width, height=height,
- z_index=z_index, modal=modal, key_bindings=key_bindings, style=
- style)
+ def __init__(
+ self,
+ children: Sequence[AnyContainer],
+ window_too_small: Container | None = None,
+ align: HorizontalAlign = HorizontalAlign.JUSTIFY,
+ padding: AnyDimension = 0,
+ padding_char: str | None = None,
+ padding_style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: str | Callable[[], str] = "",
+ ) -> None:
+ super().__init__(
+ children=children,
+ window_too_small=window_too_small,
+ padding=padding,
+ padding_char=padding_char,
+ padding_style=padding_style,
+ width=width,
+ height=height,
+ z_index=z_index,
+ modal=modal,
+ key_bindings=key_bindings,
+ style=style,
+ )
+
self.align = align
- self._children_cache: SimpleCache[tuple[Container, ...], list[
- Container]] = SimpleCache(maxsize=1)
- self._remaining_space_window = Window()
+
+ self._children_cache: SimpleCache[
+ tuple[Container, ...], list[Container]
+ ] = SimpleCache(maxsize=1)
+ self._remaining_space_window = Window() # Dummy window.
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ if self.width is not None:
+ return to_dimension(self.width)
+
+ dimensions = [
+ c.preferred_width(max_available_width) for c in self._all_children
+ ]
+
+ return sum_layout_dimensions(dimensions)
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ if self.height is not None:
+ return to_dimension(self.height)
+
+ # At the point where we want to calculate the heights, the widths have
+ # already been decided. So we can trust `width` to be the actual
+ # `width` that's going to be used for the rendering. So,
+ # `divide_widths` is supposed to use all of the available width.
+ # Using only the `preferred` width caused a bug where the reported
+ # height was more than required. (we had a `BufferControl` which did
+ # wrap lines because of the smaller width returned by `_divide_widths`.
+
+ sizes = self._divide_widths(width)
+ children = self._all_children
+
+ if sizes is None:
+ return Dimension()
+ else:
+ dimensions = [
+ c.preferred_height(s, max_available_height)
+ for s, c in zip(sizes, children)
+ ]
+ return max_layout_dimensions(dimensions)
+
+ def reset(self) -> None:
+ for c in self.children:
+ c.reset()
@property
- def _all_children(self) ->list[Container]:
+ def _all_children(self) -> list[Container]:
"""
List of child objects, including padding.
"""
- pass
- def _divide_widths(self, width: int) ->(list[int] | None):
+ def get() -> list[Container]:
+ result: list[Container] = []
+
+ # Padding left.
+ if self.align in (HorizontalAlign.CENTER, HorizontalAlign.RIGHT):
+ result.append(Window(width=Dimension(preferred=0)))
+
+ # The children with padding.
+ for child in self.children:
+ result.append(child)
+ result.append(
+ Window(
+ width=self.padding,
+ char=self.padding_char,
+ style=self.padding_style,
+ )
+ )
+ if result:
+ result.pop()
+
+ # Padding right.
+ if self.align in (HorizontalAlign.CENTER, HorizontalAlign.LEFT):
+ result.append(Window(width=Dimension(preferred=0)))
+
+ return result
+
+ return self._children_cache.get(tuple(self.children), get)
+
+ def _divide_widths(self, width: int) -> list[int] | None:
"""
Return the widths for all columns.
Or None when there is not enough space.
"""
- pass
+ children = self._all_children
+
+ if not children:
+ return []
+
+ # Calculate widths.
+ dimensions = [c.preferred_width(width) for c in children]
+ preferred_dimensions = [d.preferred for d in dimensions]
+
+ # Sum dimensions
+ sum_dimensions = sum_layout_dimensions(dimensions)
+
+ # If there is not enough space for both.
+ # Don't do anything.
+ if sum_dimensions.min > width:
+ return None
+
+ # Find optimal sizes. (Start with minimal size, increase until we cover
+ # the whole width.)
+ sizes = [d.min for d in dimensions]
+
+ child_generator = take_using_weights(
+ items=list(range(len(dimensions))), weights=[d.weight for d in dimensions]
+ )
+
+ i = next(child_generator)
+
+ # Increase until we meet at least the 'preferred' size.
+ preferred_stop = min(width, sum_dimensions.preferred)
+
+ while sum(sizes) < preferred_stop:
+ if sizes[i] < preferred_dimensions[i]:
+ sizes[i] += 1
+ i = next(child_generator)
+
+ # Increase until we use all the available space.
+ max_dimensions = [d.max for d in dimensions]
+ max_stop = min(width, sum_dimensions.max)
+
+ while sum(sizes) < max_stop:
+ if sizes[i] < max_dimensions[i]:
+ sizes[i] += 1
+ i = next(child_generator)
- def write_to_screen(self, screen: Screen, mouse_handlers: MouseHandlers,
- write_position: WritePosition, parent_style: str, erase_bg: bool,
- z_index: (int | None)) ->None:
+ return sizes
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
"""
Render the prompt to a `Screen` instance.
:param screen: The :class:`~prompt_toolkit.layout.screen.Screen` class
to which the output has to be written.
"""
- pass
+ if not self.children:
+ return
+
+ children = self._all_children
+ sizes = self._divide_widths(write_position.width)
+ style = parent_style + " " + to_str(self.style)
+ z_index = z_index if self.z_index is None else self.z_index
+
+ # If there is not enough space.
+ if sizes is None:
+ self.window_too_small.write_to_screen(
+ screen, mouse_handlers, write_position, style, erase_bg, z_index
+ )
+ return
+
+ # Calculate heights, take the largest possible, but not larger than
+ # write_position.height.
+ heights = [
+ child.preferred_height(width, write_position.height).preferred
+ for width, child in zip(sizes, children)
+ ]
+ height = max(write_position.height, min(write_position.height, max(heights)))
+
+ #
+ ypos = write_position.ypos
+ xpos = write_position.xpos
+
+ # Draw all child panes.
+ for s, c in zip(sizes, children):
+ c.write_to_screen(
+ screen,
+ mouse_handlers,
+ WritePosition(xpos, ypos, s, height),
+ style,
+ erase_bg,
+ z_index,
+ )
+ xpos += s
+
+ # Fill in the remaining space. This happens when a child control
+ # refuses to take more space and we don't have any padding. Adding a
+ # dummy child control for this (in `self._all_children`) is not
+ # desired, because in some situations, it would take more space, even
+ # when it's not required. This is required to apply the styling.
+ remaining_width = write_position.xpos + write_position.width - xpos
+ if remaining_width > 0:
+ self._remaining_space_window.write_to_screen(
+ screen,
+ mouse_handlers,
+ WritePosition(xpos, ypos, remaining_width, height),
+ style,
+ erase_bg,
+ z_index,
+ )
class FloatContainer(Container):
@@ -333,38 +751,269 @@ class FloatContainer(Container):
This is the z_index for the whole `Float` container as a whole.
"""
- def __init__(self, content: AnyContainer, floats: list[Float], modal:
- bool=False, key_bindings: (KeyBindingsBase | None)=None, style: (
- str | Callable[[], str])='', z_index: (int | None)=None) ->None:
+ def __init__(
+ self,
+ content: AnyContainer,
+ floats: list[Float],
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: str | Callable[[], str] = "",
+ z_index: int | None = None,
+ ) -> None:
self.content = to_container(content)
self.floats = floats
+
self.modal = modal
self.key_bindings = key_bindings
self.style = style
self.z_index = z_index
- def preferred_height(self, width: int, max_available_height: int
- ) ->Dimension:
+ def reset(self) -> None:
+ self.content.reset()
+
+ for f in self.floats:
+ f.content.reset()
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ return self.content.preferred_width(max_available_width)
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
"""
Return the preferred height of the float container.
(We don't care about the height of the floats, they should always fit
into the dimensions provided by the container.)
"""
- pass
-
- def _draw_float(self, fl: Float, screen: Screen, mouse_handlers:
- MouseHandlers, write_position: WritePosition, style: str, erase_bg:
- bool, z_index: (int | None)) ->None:
- """Draw a single Float."""
- pass
-
- def _area_is_empty(self, screen: Screen, write_position: WritePosition
- ) ->bool:
+ return self.content.preferred_height(width, max_available_height)
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ style = parent_style + " " + to_str(self.style)
+ z_index = z_index if self.z_index is None else self.z_index
+
+ self.content.write_to_screen(
+ screen, mouse_handlers, write_position, style, erase_bg, z_index
+ )
+
+ for number, fl in enumerate(self.floats):
+ # z_index of a Float is computed by summing the z_index of the
+ # container and the `Float`.
+ new_z_index = (z_index or 0) + fl.z_index
+ style = parent_style + " " + to_str(self.style)
+
+ # If the float that we have here, is positioned relative to the
+ # cursor position, but the Window that specifies the cursor
+ # position is not drawn yet, because it's a Float itself, we have
+ # to postpone this calculation. (This is a work-around, but good
+ # enough for now.)
+ postpone = fl.xcursor is not None or fl.ycursor is not None
+
+ if postpone:
+ new_z_index = (
+ number + 10**8
+ ) # Draw as late as possible, but keep the order.
+ screen.draw_with_z_index(
+ z_index=new_z_index,
+ draw_func=partial(
+ self._draw_float,
+ fl,
+ screen,
+ mouse_handlers,
+ write_position,
+ style,
+ erase_bg,
+ new_z_index,
+ ),
+ )
+ else:
+ self._draw_float(
+ fl,
+ screen,
+ mouse_handlers,
+ write_position,
+ style,
+ erase_bg,
+ new_z_index,
+ )
+
+ def _draw_float(
+ self,
+ fl: Float,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ "Draw a single Float."
+ # When a menu_position was given, use this instead of the cursor
+ # position. (These cursor positions are absolute, translate again
+ # relative to the write_position.)
+ # Note: This should be inside the for-loop, because one float could
+ # set the cursor position to be used for the next one.
+ cpos = screen.get_menu_position(
+ fl.attach_to_window or get_app().layout.current_window
+ )
+ cursor_position = Point(
+ x=cpos.x - write_position.xpos, y=cpos.y - write_position.ypos
+ )
+
+ fl_width = fl.get_width()
+ fl_height = fl.get_height()
+ width: int
+ height: int
+ xpos: int
+ ypos: int
+
+ # Left & width given.
+ if fl.left is not None and fl_width is not None:
+ xpos = fl.left
+ width = fl_width
+ # Left & right given -> calculate width.
+ elif fl.left is not None and fl.right is not None:
+ xpos = fl.left
+ width = write_position.width - fl.left - fl.right
+ # Width & right given -> calculate left.
+ elif fl_width is not None and fl.right is not None:
+ xpos = write_position.width - fl.right - fl_width
+ width = fl_width
+ # Near x position of cursor.
+ elif fl.xcursor:
+ if fl_width is None:
+ width = fl.content.preferred_width(write_position.width).preferred
+ width = min(write_position.width, width)
+ else:
+ width = fl_width
+
+ xpos = cursor_position.x
+ if xpos + width > write_position.width:
+ xpos = max(0, write_position.width - width)
+ # Only width given -> center horizontally.
+ elif fl_width:
+ xpos = int((write_position.width - fl_width) / 2)
+ width = fl_width
+ # Otherwise, take preferred width from float content.
+ else:
+ width = fl.content.preferred_width(write_position.width).preferred
+
+ if fl.left is not None:
+ xpos = fl.left
+ elif fl.right is not None:
+ xpos = max(0, write_position.width - width - fl.right)
+ else: # Center horizontally.
+ xpos = max(0, int((write_position.width - width) / 2))
+
+ # Trim.
+ width = min(width, write_position.width - xpos)
+
+ # Top & height given.
+ if fl.top is not None and fl_height is not None:
+ ypos = fl.top
+ height = fl_height
+ # Top & bottom given -> calculate height.
+ elif fl.top is not None and fl.bottom is not None:
+ ypos = fl.top
+ height = write_position.height - fl.top - fl.bottom
+ # Height & bottom given -> calculate top.
+ elif fl_height is not None and fl.bottom is not None:
+ ypos = write_position.height - fl_height - fl.bottom
+ height = fl_height
+ # Near cursor.
+ elif fl.ycursor:
+ ypos = cursor_position.y + (0 if fl.allow_cover_cursor else 1)
+
+ if fl_height is None:
+ height = fl.content.preferred_height(
+ width, write_position.height
+ ).preferred
+ else:
+ height = fl_height
+
+ # Reduce height if not enough space. (We can use the height
+ # when the content requires it.)
+ if height > write_position.height - ypos:
+ if write_position.height - ypos + 1 >= ypos:
+ # When the space below the cursor is more than
+ # the space above, just reduce the height.
+ height = write_position.height - ypos
+ else:
+ # Otherwise, fit the float above the cursor.
+ height = min(height, cursor_position.y)
+ ypos = cursor_position.y - height
+
+ # Only height given -> center vertically.
+ elif fl_height:
+ ypos = int((write_position.height - fl_height) / 2)
+ height = fl_height
+ # Otherwise, take preferred height from content.
+ else:
+ height = fl.content.preferred_height(width, write_position.height).preferred
+
+ if fl.top is not None:
+ ypos = fl.top
+ elif fl.bottom is not None:
+ ypos = max(0, write_position.height - height - fl.bottom)
+ else: # Center vertically.
+ ypos = max(0, int((write_position.height - height) / 2))
+
+ # Trim.
+ height = min(height, write_position.height - ypos)
+
+ # Write float.
+ # (xpos and ypos can be negative: a float can be partially visible.)
+ if height > 0 and width > 0:
+ wp = WritePosition(
+ xpos=xpos + write_position.xpos,
+ ypos=ypos + write_position.ypos,
+ width=width,
+ height=height,
+ )
+
+ if not fl.hide_when_covering_content or self._area_is_empty(screen, wp):
+ fl.content.write_to_screen(
+ screen,
+ mouse_handlers,
+ wp,
+ style,
+ erase_bg=not fl.transparent(),
+ z_index=z_index,
+ )
+
+ def _area_is_empty(self, screen: Screen, write_position: WritePosition) -> bool:
"""
Return True when the area below the write position is still empty.
(For floats that should not hide content underneath.)
"""
- pass
+ wp = write_position
+
+ for y in range(wp.ypos, wp.ypos + wp.height):
+ if y in screen.data_buffer:
+ row = screen.data_buffer[y]
+
+ for x in range(wp.xpos, wp.xpos + wp.width):
+ c = row[x]
+ if c.char != " ":
+ return False
+
+ return True
+
+ def is_modal(self) -> bool:
+ return self.modal
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ return self.key_bindings
+
+ def get_children(self) -> list[Container]:
+ children = [self.content]
+ children.extend(f.content for f in self.floats)
+ return children
class Float:
@@ -393,32 +1042,58 @@ class Float:
drawn transparently.
"""
- def __init__(self, content: AnyContainer, top: (int | None)=None, right:
- (int | None)=None, bottom: (int | None)=None, left: (int | None)=
- None, width: (int | Callable[[], int] | None)=None, height: (int |
- Callable[[], int] | None)=None, xcursor: bool=False, ycursor: bool=
- False, attach_to_window: (AnyContainer | None)=None,
- hide_when_covering_content: bool=False, allow_cover_cursor: bool=
- False, z_index: int=1, transparent: bool=False) ->None:
+ def __init__(
+ self,
+ content: AnyContainer,
+ top: int | None = None,
+ right: int | None = None,
+ bottom: int | None = None,
+ left: int | None = None,
+ width: int | Callable[[], int] | None = None,
+ height: int | Callable[[], int] | None = None,
+ xcursor: bool = False,
+ ycursor: bool = False,
+ attach_to_window: AnyContainer | None = None,
+ hide_when_covering_content: bool = False,
+ allow_cover_cursor: bool = False,
+ z_index: int = 1,
+ transparent: bool = False,
+ ) -> None:
assert z_index >= 1
+
self.left = left
self.right = right
self.top = top
self.bottom = bottom
+
self.width = width
self.height = height
+
self.xcursor = xcursor
self.ycursor = ycursor
- self.attach_to_window = to_window(attach_to_window
- ) if attach_to_window else None
+
+ self.attach_to_window = (
+ to_window(attach_to_window) if attach_to_window else None
+ )
+
self.content = to_container(content)
self.hide_when_covering_content = hide_when_covering_content
self.allow_cover_cursor = allow_cover_cursor
self.z_index = z_index
self.transparent = to_filter(transparent)
- def __repr__(self) ->str:
- return 'Float(content=%r)' % self.content
+ def get_width(self) -> int | None:
+ if callable(self.width):
+ return self.width()
+ return self.width
+
+ def get_height(self) -> int | None:
+ if callable(self.height):
+ return self.height()
+ return self.height
+
+ def __repr__(self) -> str:
+ return "Float(content=%r)" % self.content
class WindowRenderInfo:
@@ -448,121 +1123,197 @@ class WindowRenderInfo:
the rendered screen.
"""
- def __init__(self, window: Window, ui_content: UIContent,
- horizontal_scroll: int, vertical_scroll: int, window_width: int,
- window_height: int, configured_scroll_offsets: ScrollOffsets,
- visible_line_to_row_col: dict[int, tuple[int, int]], rowcol_to_yx:
- dict[tuple[int, int], tuple[int, int]], x_offset: int, y_offset:
- int, wrap_lines: bool) ->None:
+ def __init__(
+ self,
+ window: Window,
+ ui_content: UIContent,
+ horizontal_scroll: int,
+ vertical_scroll: int,
+ window_width: int,
+ window_height: int,
+ configured_scroll_offsets: ScrollOffsets,
+ visible_line_to_row_col: dict[int, tuple[int, int]],
+ rowcol_to_yx: dict[tuple[int, int], tuple[int, int]],
+ x_offset: int,
+ y_offset: int,
+ wrap_lines: bool,
+ ) -> None:
self.window = window
self.ui_content = ui_content
self.vertical_scroll = vertical_scroll
- self.window_width = window_width
+ self.window_width = window_width # Width without margins.
self.window_height = window_height
+
self.configured_scroll_offsets = configured_scroll_offsets
self.visible_line_to_row_col = visible_line_to_row_col
self.wrap_lines = wrap_lines
- self._rowcol_to_yx = rowcol_to_yx
+
+ self._rowcol_to_yx = rowcol_to_yx # row/col from input to absolute y/x
+ # screen coordinates.
self._x_offset = x_offset
self._y_offset = y_offset
@property
- def cursor_position(self) ->Point:
+ def visible_line_to_input_line(self) -> dict[int, int]:
+ return {
+ visible_line: rowcol[0]
+ for visible_line, rowcol in self.visible_line_to_row_col.items()
+ }
+
+ @property
+ def cursor_position(self) -> Point:
"""
Return the cursor position coordinates, relative to the left/top corner
of the rendered screen.
"""
- pass
+ cpos = self.ui_content.cursor_position
+ try:
+ y, x = self._rowcol_to_yx[cpos.y, cpos.x]
+ except KeyError:
+ # For `DummyControl` for instance, the content can be empty, and so
+ # will `_rowcol_to_yx` be. Return 0/0 by default.
+ return Point(x=0, y=0)
+ else:
+ return Point(x=x - self._x_offset, y=y - self._y_offset)
@property
- def applied_scroll_offsets(self) ->ScrollOffsets:
+ def applied_scroll_offsets(self) -> ScrollOffsets:
"""
Return a :class:`.ScrollOffsets` instance that indicates the actual
offset. This can be less than or equal to what's configured. E.g, when
the cursor is completely at the top, the top offset will be zero rather
than what's configured.
"""
- pass
+ if self.displayed_lines[0] == 0:
+ top = 0
+ else:
+ # Get row where the cursor is displayed.
+ y = self.input_line_to_visible_line[self.ui_content.cursor_position.y]
+ top = min(y, self.configured_scroll_offsets.top)
+
+ return ScrollOffsets(
+ top=top,
+ bottom=min(
+ self.ui_content.line_count - self.displayed_lines[-1] - 1,
+ self.configured_scroll_offsets.bottom,
+ ),
+ # For left/right, it probably doesn't make sense to return something.
+ # (We would have to calculate the widths of all the lines and keep
+ # double width characters in mind.)
+ left=0,
+ right=0,
+ )
@property
- def displayed_lines(self) ->list[int]:
+ def displayed_lines(self) -> list[int]:
"""
List of all the visible rows. (Line numbers of the input buffer.)
The last line may not be entirely visible.
"""
- pass
+ return sorted(row for row, col in self.visible_line_to_row_col.values())
@property
- def input_line_to_visible_line(self) ->dict[int, int]:
+ def input_line_to_visible_line(self) -> dict[int, int]:
"""
Return the dictionary mapping the line numbers of the input buffer to
the lines of the screen. When a line spans several rows at the screen,
the first row appears in the dictionary.
"""
- pass
+ result: dict[int, int] = {}
+ for k, v in self.visible_line_to_input_line.items():
+ if v in result:
+ result[v] = min(result[v], k)
+ else:
+ result[v] = k
+ return result
- def first_visible_line(self, after_scroll_offset: bool=False) ->int:
+ def first_visible_line(self, after_scroll_offset: bool = False) -> int:
"""
Return the line number (0 based) of the input document that corresponds
with the first visible line.
"""
- pass
+ if after_scroll_offset:
+ return self.displayed_lines[self.applied_scroll_offsets.top]
+ else:
+ return self.displayed_lines[0]
- def last_visible_line(self, before_scroll_offset: bool=False) ->int:
+ def last_visible_line(self, before_scroll_offset: bool = False) -> int:
"""
Like `first_visible_line`, but for the last visible line.
"""
- pass
+ if before_scroll_offset:
+ return self.displayed_lines[-1 - self.applied_scroll_offsets.bottom]
+ else:
+ return self.displayed_lines[-1]
- def center_visible_line(self, before_scroll_offset: bool=False,
- after_scroll_offset: bool=False) ->int:
+ def center_visible_line(
+ self, before_scroll_offset: bool = False, after_scroll_offset: bool = False
+ ) -> int:
"""
Like `first_visible_line`, but for the center visible line.
"""
- pass
+ return (
+ self.first_visible_line(after_scroll_offset)
+ + (
+ self.last_visible_line(before_scroll_offset)
+ - self.first_visible_line(after_scroll_offset)
+ )
+ // 2
+ )
@property
- def content_height(self) ->int:
+ def content_height(self) -> int:
"""
The full height of the user control.
"""
- pass
+ return self.ui_content.line_count
@property
- def full_height_visible(self) ->bool:
+ def full_height_visible(self) -> bool:
"""
True when the full height is visible (There is no vertical scroll.)
"""
- pass
+ return (
+ self.vertical_scroll == 0
+ and self.last_visible_line() == self.content_height
+ )
@property
- def top_visible(self) ->bool:
+ def top_visible(self) -> bool:
"""
True when the top of the buffer is visible.
"""
- pass
+ return self.vertical_scroll == 0
@property
- def bottom_visible(self) ->bool:
+ def bottom_visible(self) -> bool:
"""
True when the bottom of the buffer is visible.
"""
- pass
+ return self.last_visible_line() == self.content_height - 1
@property
- def vertical_scroll_percentage(self) ->int:
+ def vertical_scroll_percentage(self) -> int:
"""
Vertical scroll as a percentage. (0 means: the top is visible,
100 means: the bottom is visible.)
"""
- pass
+ if self.bottom_visible:
+ return 100
+ else:
+ return 100 * self.vertical_scroll // self.content_height
- def get_height_for_line(self, lineno: int) ->int:
+ def get_height_for_line(self, lineno: int) -> int:
"""
Return the height of the given line.
(The height that it would take, if this line became visible.)
"""
- pass
+ if self.wrap_lines:
+ return self.ui_content.get_height_for_line(
+ lineno, self.window_width, self.window.get_line_prefix
+ )
+ else:
+ return 1
class ScrollOffsets:
@@ -572,17 +1323,41 @@ class ScrollOffsets:
Note that left/right offsets only make sense if line wrapping is disabled.
"""
- def __init__(self, top: (int | Callable[[], int])=0, bottom: (int |
- Callable[[], int])=0, left: (int | Callable[[], int])=0, right: (
- int | Callable[[], int])=0) ->None:
+ def __init__(
+ self,
+ top: int | Callable[[], int] = 0,
+ bottom: int | Callable[[], int] = 0,
+ left: int | Callable[[], int] = 0,
+ right: int | Callable[[], int] = 0,
+ ) -> None:
self._top = top
self._bottom = bottom
self._left = left
self._right = right
- def __repr__(self) ->str:
- return ('ScrollOffsets(top={!r}, bottom={!r}, left={!r}, right={!r})'
- .format(self._top, self._bottom, self._left, self._right))
+ @property
+ def top(self) -> int:
+ return to_int(self._top)
+
+ @property
+ def bottom(self) -> int:
+ return to_int(self._bottom)
+
+ @property
+ def left(self) -> int:
+ return to_int(self._left)
+
+ @property
+ def right(self) -> int:
+ return to_int(self._right)
+
+ def __repr__(self) -> str:
+ return "ScrollOffsets(top={!r}, bottom={!r}, left={!r}, right={!r})".format(
+ self._top,
+ self._bottom,
+ self._left,
+ self._right,
+ )
class ColorColumn:
@@ -590,7 +1365,7 @@ class ColorColumn:
Column for a :class:`.Window` to be colored.
"""
- def __init__(self, position: int, style: str='class:color-column') ->None:
+ def __init__(self, position: int, style: str = "class:color-column") -> None:
self.position = position
self.style = style
@@ -606,9 +1381,10 @@ class WindowAlign(Enum):
which are used for the alignment of the child containers in respectively
`VSplit` and `HSplit`.
"""
- LEFT = 'LEFT'
- RIGHT = 'RIGHT'
- CENTER = 'CENTER'
+
+ LEFT = "LEFT"
+ RIGHT = "RIGHT"
+ CENTER = "CENTER"
class Window(Container):
@@ -673,28 +1449,40 @@ class Window(Container):
so on.
"""
- def __init__(self, content: (UIControl | None)=None, width:
- AnyDimension=None, height: AnyDimension=None, z_index: (int | None)
- =None, dont_extend_width: FilterOrBool=False, dont_extend_height:
- FilterOrBool=False, ignore_content_width: FilterOrBool=False,
- ignore_content_height: FilterOrBool=False, left_margins: (Sequence[
- Margin] | None)=None, right_margins: (Sequence[Margin] | None)=None,
- scroll_offsets: (ScrollOffsets | None)=None,
- allow_scroll_beyond_bottom: FilterOrBool=False, wrap_lines:
- FilterOrBool=False, get_vertical_scroll: (Callable[[Window], int] |
- None)=None, get_horizontal_scroll: (Callable[[Window], int] | None)
- =None, always_hide_cursor: FilterOrBool=False, cursorline:
- FilterOrBool=False, cursorcolumn: FilterOrBool=False, colorcolumns:
- (None | list[ColorColumn] | Callable[[], list[ColorColumn]])=None,
- align: (WindowAlign | Callable[[], WindowAlign])=WindowAlign.LEFT,
- style: (str | Callable[[], str])='', char: (None | str | Callable[[
- ], str])=None, get_line_prefix: (GetLinePrefixCallable | None)=None
- ) ->None:
+ def __init__(
+ self,
+ content: UIControl | None = None,
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ dont_extend_width: FilterOrBool = False,
+ dont_extend_height: FilterOrBool = False,
+ ignore_content_width: FilterOrBool = False,
+ ignore_content_height: FilterOrBool = False,
+ left_margins: Sequence[Margin] | None = None,
+ right_margins: Sequence[Margin] | None = None,
+ scroll_offsets: ScrollOffsets | None = None,
+ allow_scroll_beyond_bottom: FilterOrBool = False,
+ wrap_lines: FilterOrBool = False,
+ get_vertical_scroll: Callable[[Window], int] | None = None,
+ get_horizontal_scroll: Callable[[Window], int] | None = None,
+ always_hide_cursor: FilterOrBool = False,
+ cursorline: FilterOrBool = False,
+ cursorcolumn: FilterOrBool = False,
+ colorcolumns: (
+ None | list[ColorColumn] | Callable[[], list[ColorColumn]]
+ ) = None,
+ align: WindowAlign | Callable[[], WindowAlign] = WindowAlign.LEFT,
+ style: str | Callable[[], str] = "",
+ char: None | str | Callable[[], str] = None,
+ get_line_prefix: GetLinePrefixCallable | None = None,
+ ) -> None:
self.allow_scroll_beyond_bottom = to_filter(allow_scroll_beyond_bottom)
self.always_hide_cursor = to_filter(always_hide_cursor)
self.wrap_lines = to_filter(wrap_lines)
self.cursorline = to_filter(cursorline)
self.cursorcolumn = to_filter(cursorcolumn)
+
self.content = content or DummyControl()
self.dont_extend_width = to_filter(dont_extend_width)
self.dont_extend_height = to_filter(dont_extend_height)
@@ -710,81 +1498,450 @@ class Window(Container):
self.style = style
self.char = char
self.get_line_prefix = get_line_prefix
+
self.width = width
self.height = height
self.z_index = z_index
- self._ui_content_cache: SimpleCache[tuple[int, int, int], UIContent
- ] = SimpleCache(maxsize=8)
- self._margin_width_cache: SimpleCache[tuple[Margin, int], int
- ] = SimpleCache(maxsize=1)
+
+ # Cache for the screens generated by the margin.
+ self._ui_content_cache: SimpleCache[
+ tuple[int, int, int], UIContent
+ ] = SimpleCache(maxsize=8)
+ self._margin_width_cache: SimpleCache[tuple[Margin, int], int] = SimpleCache(
+ maxsize=1
+ )
+
self.reset()
- def __repr__(self) ->str:
- return 'Window(content=%r)' % self.content
+ def __repr__(self) -> str:
+ return "Window(content=%r)" % self.content
+
+ def reset(self) -> None:
+ self.content.reset()
+
+ #: Scrolling position of the main content.
+ self.vertical_scroll = 0
+ self.horizontal_scroll = 0
+
+ # Vertical scroll 2: this is the vertical offset that a line is
+ # scrolled if a single line (the one that contains the cursor) consumes
+ # all of the vertical space.
+ self.vertical_scroll_2 = 0
- def _get_margin_width(self, margin: Margin) ->int:
+ #: Keep render information (mappings between buffer input and render
+ #: output.)
+ self.render_info: WindowRenderInfo | None = None
+
+ def _get_margin_width(self, margin: Margin) -> int:
"""
Return the width for this margin.
(Calculate only once per render time.)
"""
- pass
- def _get_total_margin_width(self) ->int:
+ # Margin.get_width, needs to have a UIContent instance.
+ def get_ui_content() -> UIContent:
+ return self._get_ui_content(width=0, height=0)
+
+ def get_width() -> int:
+ return margin.get_width(get_ui_content)
+
+ key = (margin, get_app().render_counter)
+ return self._margin_width_cache.get(key, get_width)
+
+ def _get_total_margin_width(self) -> int:
"""
Calculate and return the width of the margin (left + right).
"""
- pass
+ return sum(self._get_margin_width(m) for m in self.left_margins) + sum(
+ self._get_margin_width(m) for m in self.right_margins
+ )
- def preferred_width(self, max_available_width: int) ->Dimension:
+ def preferred_width(self, max_available_width: int) -> Dimension:
"""
Calculate the preferred width for this window.
"""
- pass
- def preferred_height(self, width: int, max_available_height: int
- ) ->Dimension:
+ def preferred_content_width() -> int | None:
+ """Content width: is only calculated if no exact width for the
+ window was given."""
+ if self.ignore_content_width():
+ return None
+
+ # Calculate the width of the margin.
+ total_margin_width = self._get_total_margin_width()
+
+ # Window of the content. (Can be `None`.)
+ preferred_width = self.content.preferred_width(
+ max_available_width - total_margin_width
+ )
+
+ if preferred_width is not None:
+ # Include width of the margins.
+ preferred_width += total_margin_width
+ return preferred_width
+
+ # Merge.
+ return self._merge_dimensions(
+ dimension=to_dimension(self.width),
+ get_preferred=preferred_content_width,
+ dont_extend=self.dont_extend_width(),
+ )
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
"""
Calculate the preferred height for this window.
"""
- pass
+
+ def preferred_content_height() -> int | None:
+ """Content height: is only calculated if no exact height for the
+ window was given."""
+ if self.ignore_content_height():
+ return None
+
+ total_margin_width = self._get_total_margin_width()
+ wrap_lines = self.wrap_lines()
+
+ return self.content.preferred_height(
+ width - total_margin_width,
+ max_available_height,
+ wrap_lines,
+ self.get_line_prefix,
+ )
+
+ return self._merge_dimensions(
+ dimension=to_dimension(self.height),
+ get_preferred=preferred_content_height,
+ dont_extend=self.dont_extend_height(),
+ )
@staticmethod
- def _merge_dimensions(dimension: (Dimension | None), get_preferred:
- Callable[[], int | None], dont_extend: bool=False) ->Dimension:
+ def _merge_dimensions(
+ dimension: Dimension | None,
+ get_preferred: Callable[[], int | None],
+ dont_extend: bool = False,
+ ) -> Dimension:
"""
Take the Dimension from this `Window` class and the received preferred
size from the `UIControl` and return a `Dimension` to report to the
parent container.
"""
- pass
+ dimension = dimension or Dimension()
- def _get_ui_content(self, width: int, height: int) ->UIContent:
+ # When a preferred dimension was explicitly given to the Window,
+ # ignore the UIControl.
+ preferred: int | None
+
+ if dimension.preferred_specified:
+ preferred = dimension.preferred
+ else:
+ # Otherwise, calculate the preferred dimension from the UI control
+ # content.
+ preferred = get_preferred()
+
+ # When a 'preferred' dimension is given by the UIControl, make sure
+ # that it stays within the bounds of the Window.
+ if preferred is not None:
+ if dimension.max_specified:
+ preferred = min(preferred, dimension.max)
+
+ if dimension.min_specified:
+ preferred = max(preferred, dimension.min)
+
+ # When a `dont_extend` flag has been given, use the preferred dimension
+ # also as the max dimension.
+ max_: int | None
+ min_: int | None
+
+ if dont_extend and preferred is not None:
+ max_ = min(dimension.max, preferred)
+ else:
+ max_ = dimension.max if dimension.max_specified else None
+
+ min_ = dimension.min if dimension.min_specified else None
+
+ return Dimension(
+ min=min_, max=max_, preferred=preferred, weight=dimension.weight
+ )
+
+ def _get_ui_content(self, width: int, height: int) -> UIContent:
"""
Create a `UIContent` instance.
"""
- pass
-
- def _get_digraph_char(self) ->(str | None):
- """Return `False`, or the Digraph symbol to be used."""
- pass
- def write_to_screen(self, screen: Screen, mouse_handlers: MouseHandlers,
- write_position: WritePosition, parent_style: str, erase_bg: bool,
- z_index: (int | None)) ->None:
+ def get_content() -> UIContent:
+ return self.content.create_content(width=width, height=height)
+
+ key = (get_app().render_counter, width, height)
+ return self._ui_content_cache.get(key, get_content)
+
+ def _get_digraph_char(self) -> str | None:
+ "Return `False`, or the Digraph symbol to be used."
+ app = get_app()
+ if app.quoted_insert:
+ return "^"
+ if app.vi_state.waiting_for_digraph:
+ if app.vi_state.digraph_symbol1:
+ return app.vi_state.digraph_symbol1
+ return "?"
+ return None
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
"""
Write window to screen. This renders the user control, the margins and
copies everything over to the absolute position at the given screen.
"""
- pass
-
- def _copy_body(self, ui_content: UIContent, new_screen: Screen,
- write_position: WritePosition, move_x: int, width: int,
- vertical_scroll: int=0, horizontal_scroll: int=0, wrap_lines: bool=
- False, highlight_lines: bool=False, vertical_scroll_2: int=0,
- always_hide_cursor: bool=False, has_focus: bool=False, align:
- WindowAlign=WindowAlign.LEFT, get_line_prefix: (Callable[[int, int],
- AnyFormattedText] | None)=None) ->tuple[dict[int, tuple[int, int]],
- dict[tuple[int, int], tuple[int, int]]]:
+ # If dont_extend_width/height was given. Then reduce width/height in
+ # WritePosition if the parent wanted us to paint in a bigger area.
+ # (This happens if this window is bundled with another window in a
+ # HSplit/VSplit, but with different size requirements.)
+ write_position = WritePosition(
+ xpos=write_position.xpos,
+ ypos=write_position.ypos,
+ width=write_position.width,
+ height=write_position.height,
+ )
+
+ if self.dont_extend_width():
+ write_position.width = min(
+ write_position.width,
+ self.preferred_width(write_position.width).preferred,
+ )
+
+ if self.dont_extend_height():
+ write_position.height = min(
+ write_position.height,
+ self.preferred_height(
+ write_position.width, write_position.height
+ ).preferred,
+ )
+
+ # Draw
+ z_index = z_index if self.z_index is None else self.z_index
+
+ draw_func = partial(
+ self._write_to_screen_at_index,
+ screen,
+ mouse_handlers,
+ write_position,
+ parent_style,
+ erase_bg,
+ )
+
+ if z_index is None or z_index <= 0:
+ # When no z_index is given, draw right away.
+ draw_func()
+ else:
+ # Otherwise, postpone.
+ screen.draw_with_z_index(z_index=z_index, draw_func=draw_func)
+
+ def _write_to_screen_at_index(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ ) -> None:
+ # Don't bother writing invisible windows.
+ # (We save some time, but also avoid applying last-line styling.)
+ if write_position.height <= 0 or write_position.width <= 0:
+ return
+
+ # Calculate margin sizes.
+ left_margin_widths = [self._get_margin_width(m) for m in self.left_margins]
+ right_margin_widths = [self._get_margin_width(m) for m in self.right_margins]
+ total_margin_width = sum(left_margin_widths + right_margin_widths)
+
+ # Render UserControl.
+ ui_content = self.content.create_content(
+ write_position.width - total_margin_width, write_position.height
+ )
+ assert isinstance(ui_content, UIContent)
+
+ # Scroll content.
+ wrap_lines = self.wrap_lines()
+ self._scroll(
+ ui_content, write_position.width - total_margin_width, write_position.height
+ )
+
+ # Erase background and fill with `char`.
+ self._fill_bg(screen, write_position, erase_bg)
+
+ # Resolve `align` attribute.
+ align = self.align() if callable(self.align) else self.align
+
+ # Write body
+ visible_line_to_row_col, rowcol_to_yx = self._copy_body(
+ ui_content,
+ screen,
+ write_position,
+ sum(left_margin_widths),
+ write_position.width - total_margin_width,
+ self.vertical_scroll,
+ self.horizontal_scroll,
+ wrap_lines=wrap_lines,
+ highlight_lines=True,
+ vertical_scroll_2=self.vertical_scroll_2,
+ always_hide_cursor=self.always_hide_cursor(),
+ has_focus=get_app().layout.current_control == self.content,
+ align=align,
+ get_line_prefix=self.get_line_prefix,
+ )
+
+ # Remember render info. (Set before generating the margins. They need this.)
+ x_offset = write_position.xpos + sum(left_margin_widths)
+ y_offset = write_position.ypos
+
+ render_info = WindowRenderInfo(
+ window=self,
+ ui_content=ui_content,
+ horizontal_scroll=self.horizontal_scroll,
+ vertical_scroll=self.vertical_scroll,
+ window_width=write_position.width - total_margin_width,
+ window_height=write_position.height,
+ configured_scroll_offsets=self.scroll_offsets,
+ visible_line_to_row_col=visible_line_to_row_col,
+ rowcol_to_yx=rowcol_to_yx,
+ x_offset=x_offset,
+ y_offset=y_offset,
+ wrap_lines=wrap_lines,
+ )
+ self.render_info = render_info
+
+ # Set mouse handlers.
+ def mouse_handler(mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Wrapper around the mouse_handler of the `UIControl` that turns
+ screen coordinates into line coordinates.
+ Returns `NotImplemented` if no UI invalidation should be done.
+ """
+ # Don't handle mouse events outside of the current modal part of
+ # the UI.
+ if self not in get_app().layout.walk_through_modal_area():
+ return NotImplemented
+
+ # Find row/col position first.
+ yx_to_rowcol = {v: k for k, v in rowcol_to_yx.items()}
+ y = mouse_event.position.y
+ x = mouse_event.position.x
+
+ # If clicked below the content area, look for a position in the
+ # last line instead.
+ max_y = write_position.ypos + len(visible_line_to_row_col) - 1
+ y = min(max_y, y)
+ result: NotImplementedOrNone
+
+ while x >= 0:
+ try:
+ row, col = yx_to_rowcol[y, x]
+ except KeyError:
+ # Try again. (When clicking on the right side of double
+ # width characters, or on the right side of the input.)
+ x -= 1
+ else:
+ # Found position, call handler of UIControl.
+ result = self.content.mouse_handler(
+ MouseEvent(
+ position=Point(x=col, y=row),
+ event_type=mouse_event.event_type,
+ button=mouse_event.button,
+ modifiers=mouse_event.modifiers,
+ )
+ )
+ break
+ else:
+ # nobreak.
+ # (No x/y coordinate found for the content. This happens in
+ # case of a DummyControl, that does not have any content.
+ # Report (0,0) instead.)
+ result = self.content.mouse_handler(
+ MouseEvent(
+ position=Point(x=0, y=0),
+ event_type=mouse_event.event_type,
+ button=mouse_event.button,
+ modifiers=mouse_event.modifiers,
+ )
+ )
+
+ # If it returns NotImplemented, handle it here.
+ if result == NotImplemented:
+ result = self._mouse_handler(mouse_event)
+
+ return result
+
+ mouse_handlers.set_mouse_handler_for_range(
+ x_min=write_position.xpos + sum(left_margin_widths),
+ x_max=write_position.xpos + write_position.width - total_margin_width,
+ y_min=write_position.ypos,
+ y_max=write_position.ypos + write_position.height,
+ handler=mouse_handler,
+ )
+
+ # Render and copy margins.
+ move_x = 0
+
+ def render_margin(m: Margin, width: int) -> UIContent:
+ "Render margin. Return `Screen`."
+ # Retrieve margin fragments.
+ fragments = m.create_margin(render_info, width, write_position.height)
+
+ # Turn it into a UIContent object.
+ # already rendered those fragments using this size.)
+ return FormattedTextControl(fragments).create_content(
+ width + 1, write_position.height
+ )
+
+ for m, width in zip(self.left_margins, left_margin_widths):
+ if width > 0: # (ConditionalMargin returns a zero width. -- Don't render.)
+ # Create screen for margin.
+ margin_content = render_margin(m, width)
+
+ # Copy and shift X.
+ self._copy_margin(margin_content, screen, write_position, move_x, width)
+ move_x += width
+
+ move_x = write_position.width - sum(right_margin_widths)
+
+ for m, width in zip(self.right_margins, right_margin_widths):
+ # Create screen for margin.
+ margin_content = render_margin(m, width)
+
+ # Copy and shift X.
+ self._copy_margin(margin_content, screen, write_position, move_x, width)
+ move_x += width
+
+ # Apply 'self.style'
+ self._apply_style(screen, write_position, parent_style)
+
+ # Tell the screen that this user control has been painted at this
+ # position.
+ screen.visible_windows_to_write_positions[self] = write_position
+
+ def _copy_body(
+ self,
+ ui_content: UIContent,
+ new_screen: Screen,
+ write_position: WritePosition,
+ move_x: int,
+ width: int,
+ vertical_scroll: int = 0,
+ horizontal_scroll: int = 0,
+ wrap_lines: bool = False,
+ highlight_lines: bool = False,
+ vertical_scroll_2: int = 0,
+ always_hide_cursor: bool = False,
+ has_focus: bool = False,
+ align: WindowAlign = WindowAlign.LEFT,
+ get_line_prefix: Callable[[int, int], AnyFormattedText] | None = None,
+ ) -> tuple[dict[int, tuple[int, int]], dict[tuple[int, int], tuple[int, int]]]:
"""
Copy the UIContent into the output screen.
Return (visible_line_to_row_col, rowcol_to_yx) tuple.
@@ -792,24 +1949,277 @@ class Window(Container):
:param get_line_prefix: None or a callable that takes a line number
(int) and a wrap_count (int) and returns formatted text.
"""
- pass
-
- def _fill_bg(self, screen: Screen, write_position: WritePosition,
- erase_bg: bool) ->None:
+ xpos = write_position.xpos + move_x
+ ypos = write_position.ypos
+ line_count = ui_content.line_count
+ new_buffer = new_screen.data_buffer
+ empty_char = _CHAR_CACHE["", ""]
+
+ # Map visible line number to (row, col) of input.
+ # 'col' will always be zero if line wrapping is off.
+ visible_line_to_row_col: dict[int, tuple[int, int]] = {}
+
+ # Maps (row, col) from the input to (y, x) screen coordinates.
+ rowcol_to_yx: dict[tuple[int, int], tuple[int, int]] = {}
+
+ def copy_line(
+ line: StyleAndTextTuples,
+ lineno: int,
+ x: int,
+ y: int,
+ is_input: bool = False,
+ ) -> tuple[int, int]:
+ """
+ Copy over a single line to the output screen. This can wrap over
+ multiple lines in the output. It will call the prefix (prompt)
+ function before every line.
+ """
+ if is_input:
+ current_rowcol_to_yx = rowcol_to_yx
+ else:
+ current_rowcol_to_yx = {} # Throwaway dictionary.
+
+ # Draw line prefix.
+ if is_input and get_line_prefix:
+ prompt = to_formatted_text(get_line_prefix(lineno, 0))
+ x, y = copy_line(prompt, lineno, x, y, is_input=False)
+
+ # Scroll horizontally.
+ skipped = 0 # Characters skipped because of horizontal scrolling.
+ if horizontal_scroll and is_input:
+ h_scroll = horizontal_scroll
+ line = explode_text_fragments(line)
+ while h_scroll > 0 and line:
+ h_scroll -= get_cwidth(line[0][1])
+ skipped += 1
+ del line[:1] # Remove first character.
+
+ x -= h_scroll # When scrolling over double width character,
+ # this can end up being negative.
+
+ # Align this line. (Note that this doesn't work well when we use
+ # get_line_prefix and that function returns variable width prefixes.)
+ if align == WindowAlign.CENTER:
+ line_width = fragment_list_width(line)
+ if line_width < width:
+ x += (width - line_width) // 2
+ elif align == WindowAlign.RIGHT:
+ line_width = fragment_list_width(line)
+ if line_width < width:
+ x += width - line_width
+
+ col = 0
+ wrap_count = 0
+ for style, text, *_ in line:
+ new_buffer_row = new_buffer[y + ypos]
+
+ # Remember raw VT escape sequences. (E.g. FinalTerm's
+ # escape sequences.)
+ if "[ZeroWidthEscape]" in style:
+ new_screen.zero_width_escapes[y + ypos][x + xpos] += text
+ continue
+
+ for c in text:
+ char = _CHAR_CACHE[c, style]
+ char_width = char.width
+
+ # Wrap when the line width is exceeded.
+ if wrap_lines and x + char_width > width:
+ visible_line_to_row_col[y + 1] = (
+ lineno,
+ visible_line_to_row_col[y][1] + x,
+ )
+ y += 1
+ wrap_count += 1
+ x = 0
+
+ # Insert line prefix (continuation prompt).
+ if is_input and get_line_prefix:
+ prompt = to_formatted_text(
+ get_line_prefix(lineno, wrap_count)
+ )
+ x, y = copy_line(prompt, lineno, x, y, is_input=False)
+
+ new_buffer_row = new_buffer[y + ypos]
+
+ if y >= write_position.height:
+ return x, y # Break out of all for loops.
+
+ # Set character in screen and shift 'x'.
+ if x >= 0 and y >= 0 and x < width:
+ new_buffer_row[x + xpos] = char
+
+ # When we print a multi width character, make sure
+ # to erase the neighbors positions in the screen.
+ # (The empty string if different from everything,
+ # so next redraw this cell will repaint anyway.)
+ if char_width > 1:
+ for i in range(1, char_width):
+ new_buffer_row[x + xpos + i] = empty_char
+
+ # If this is a zero width characters, then it's
+ # probably part of a decomposed unicode character.
+ # See: https://en.wikipedia.org/wiki/Unicode_equivalence
+ # Merge it in the previous cell.
+ elif char_width == 0:
+ # Handle all character widths. If the previous
+ # character is a multiwidth character, then
+ # merge it two positions back.
+ for pw in [2, 1]: # Previous character width.
+ if (
+ x - pw >= 0
+ and new_buffer_row[x + xpos - pw].width == pw
+ ):
+ prev_char = new_buffer_row[x + xpos - pw]
+ char2 = _CHAR_CACHE[
+ prev_char.char + c, prev_char.style
+ ]
+ new_buffer_row[x + xpos - pw] = char2
+
+ # Keep track of write position for each character.
+ current_rowcol_to_yx[lineno, col + skipped] = (
+ y + ypos,
+ x + xpos,
+ )
+
+ col += 1
+ x += char_width
+ return x, y
+
+ # Copy content.
+ def copy() -> int:
+ y = -vertical_scroll_2
+ lineno = vertical_scroll
+
+ while y < write_position.height and lineno < line_count:
+ # Take the next line and copy it in the real screen.
+ line = ui_content.get_line(lineno)
+
+ visible_line_to_row_col[y] = (lineno, horizontal_scroll)
+
+ # Copy margin and actual line.
+ x = 0
+ x, y = copy_line(line, lineno, x, y, is_input=True)
+
+ lineno += 1
+ y += 1
+ return y
+
+ copy()
+
+ def cursor_pos_to_screen_pos(row: int, col: int) -> Point:
+ "Translate row/col from UIContent to real Screen coordinates."
+ try:
+ y, x = rowcol_to_yx[row, col]
+ except KeyError:
+ # Normally this should never happen. (It is a bug, if it happens.)
+ # But to be sure, return (0, 0)
+ return Point(x=0, y=0)
+
+ # raise ValueError(
+ # 'Invalid position. row=%r col=%r, vertical_scroll=%r, '
+ # 'horizontal_scroll=%r, height=%r' %
+ # (row, col, vertical_scroll, horizontal_scroll, write_position.height))
+ else:
+ return Point(x=x, y=y)
+
+ # Set cursor and menu positions.
+ if ui_content.cursor_position:
+ screen_cursor_position = cursor_pos_to_screen_pos(
+ ui_content.cursor_position.y, ui_content.cursor_position.x
+ )
+
+ if has_focus:
+ new_screen.set_cursor_position(self, screen_cursor_position)
+
+ if always_hide_cursor:
+ new_screen.show_cursor = False
+ else:
+ new_screen.show_cursor = ui_content.show_cursor
+
+ self._highlight_digraph(new_screen)
+
+ if highlight_lines:
+ self._highlight_cursorlines(
+ new_screen,
+ screen_cursor_position,
+ xpos,
+ ypos,
+ width,
+ write_position.height,
+ )
+
+ # Draw input characters from the input processor queue.
+ if has_focus and ui_content.cursor_position:
+ self._show_key_processor_key_buffer(new_screen)
+
+ # Set menu position.
+ if ui_content.menu_position:
+ new_screen.set_menu_position(
+ self,
+ cursor_pos_to_screen_pos(
+ ui_content.menu_position.y, ui_content.menu_position.x
+ ),
+ )
+
+ # Update output screen height.
+ new_screen.height = max(new_screen.height, ypos + write_position.height)
+
+ return visible_line_to_row_col, rowcol_to_yx
+
+ def _fill_bg(
+ self, screen: Screen, write_position: WritePosition, erase_bg: bool
+ ) -> None:
"""
Erase/fill the background.
(Useful for floats and when a `char` has been given.)
"""
- pass
-
- def _highlight_digraph(self, new_screen: Screen) ->None:
+ char: str | None
+ if callable(self.char):
+ char = self.char()
+ else:
+ char = self.char
+
+ if erase_bg or char:
+ wp = write_position
+ char_obj = _CHAR_CACHE[char or " ", ""]
+
+ for y in range(wp.ypos, wp.ypos + wp.height):
+ row = screen.data_buffer[y]
+ for x in range(wp.xpos, wp.xpos + wp.width):
+ row[x] = char_obj
+
+ def _apply_style(
+ self, new_screen: Screen, write_position: WritePosition, parent_style: str
+ ) -> None:
+ # Apply `self.style`.
+ style = parent_style + " " + to_str(self.style)
+
+ new_screen.fill_area(write_position, style=style, after=False)
+
+ # Apply the 'last-line' class to the last line of each Window. This can
+ # be used to apply an 'underline' to the user control.
+ wp = WritePosition(
+ write_position.xpos,
+ write_position.ypos + write_position.height - 1,
+ write_position.width,
+ 1,
+ )
+ new_screen.fill_area(wp, "class:last-line", after=True)
+
+ def _highlight_digraph(self, new_screen: Screen) -> None:
"""
When we are in Vi digraph mode, put a question mark underneath the
cursor.
"""
- pass
+ digraph_char = self._get_digraph_char()
+ if digraph_char:
+ cpos = new_screen.get_cursor_position(self)
+ new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[
+ digraph_char, "class:digraph"
+ ]
- def _show_key_processor_key_buffer(self, new_screen: Screen) ->None:
+ def _show_key_processor_key_buffer(self, new_screen: Screen) -> None:
"""
When the user is typing a key binding that consists of several keys,
display the last pressed key if the user is in insert mode and the key
@@ -817,49 +2227,324 @@ class Window(Container):
E.g. Some people want to bind 'jj' to escape in Vi insert mode. But the
first 'j' needs to be displayed in order to get some feedback.
"""
- pass
+ app = get_app()
+ key_buffer = app.key_processor.key_buffer
+
+ if key_buffer and _in_insert_mode() and not app.is_done:
+ # The textual data for the given key. (Can be a VT100 escape
+ # sequence.)
+ data = key_buffer[-1].data
+
+ # Display only if this is a 1 cell width character.
+ if get_cwidth(data) == 1:
+ cpos = new_screen.get_cursor_position(self)
+ new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[
+ data, "class:partial-key-binding"
+ ]
- def _highlight_cursorlines(self, new_screen: Screen, cpos: Point, x:
- int, y: int, width: int, height: int) ->None:
+ def _highlight_cursorlines(
+ self, new_screen: Screen, cpos: Point, x: int, y: int, width: int, height: int
+ ) -> None:
"""
Highlight cursor row/column.
"""
- pass
-
- def _copy_margin(self, margin_content: UIContent, new_screen: Screen,
- write_position: WritePosition, move_x: int, width: int) ->None:
+ cursor_line_style = " class:cursor-line "
+ cursor_column_style = " class:cursor-column "
+
+ data_buffer = new_screen.data_buffer
+
+ # Highlight cursor line.
+ if self.cursorline():
+ row = data_buffer[cpos.y]
+ for x in range(x, x + width):
+ original_char = row[x]
+ row[x] = _CHAR_CACHE[
+ original_char.char, original_char.style + cursor_line_style
+ ]
+
+ # Highlight cursor column.
+ if self.cursorcolumn():
+ for y2 in range(y, y + height):
+ row = data_buffer[y2]
+ original_char = row[cpos.x]
+ row[cpos.x] = _CHAR_CACHE[
+ original_char.char, original_char.style + cursor_column_style
+ ]
+
+ # Highlight color columns
+ colorcolumns = self.colorcolumns
+ if callable(colorcolumns):
+ colorcolumns = colorcolumns()
+
+ for cc in colorcolumns:
+ assert isinstance(cc, ColorColumn)
+ column = cc.position
+
+ if column < x + width: # Only draw when visible.
+ color_column_style = " " + cc.style
+
+ for y2 in range(y, y + height):
+ row = data_buffer[y2]
+ original_char = row[column + x]
+ row[column + x] = _CHAR_CACHE[
+ original_char.char, original_char.style + color_column_style
+ ]
+
+ def _copy_margin(
+ self,
+ margin_content: UIContent,
+ new_screen: Screen,
+ write_position: WritePosition,
+ move_x: int,
+ width: int,
+ ) -> None:
"""
Copy characters from the margin screen to the real screen.
"""
- pass
+ xpos = write_position.xpos + move_x
+ ypos = write_position.ypos
+
+ margin_write_position = WritePosition(xpos, ypos, width, write_position.height)
+ self._copy_body(margin_content, new_screen, margin_write_position, 0, width)
- def _scroll(self, ui_content: UIContent, width: int, height: int) ->None:
+ def _scroll(self, ui_content: UIContent, width: int, height: int) -> None:
"""
Scroll body. Ensure that the cursor is visible.
"""
- pass
+ if self.wrap_lines():
+ func = self._scroll_when_linewrapping
+ else:
+ func = self._scroll_without_linewrapping
- def _scroll_when_linewrapping(self, ui_content: UIContent, width: int,
- height: int) ->None:
+ func(ui_content, width, height)
+
+ def _scroll_when_linewrapping(
+ self, ui_content: UIContent, width: int, height: int
+ ) -> None:
"""
Scroll to make sure the cursor position is visible and that we maintain
the requested scroll offset.
Set `self.horizontal_scroll/vertical_scroll`.
"""
- pass
-
- def _scroll_without_linewrapping(self, ui_content: UIContent, width:
- int, height: int) ->None:
+ scroll_offsets_bottom = self.scroll_offsets.bottom
+ scroll_offsets_top = self.scroll_offsets.top
+
+ # We don't have horizontal scrolling.
+ self.horizontal_scroll = 0
+
+ def get_line_height(lineno: int) -> int:
+ return ui_content.get_height_for_line(lineno, width, self.get_line_prefix)
+
+ # When there is no space, reset `vertical_scroll_2` to zero and abort.
+ # This can happen if the margin is bigger than the window width.
+ # Otherwise the text height will become "infinite" (a big number) and
+ # the copy_line will spend a huge amount of iterations trying to render
+ # nothing.
+ if width <= 0:
+ self.vertical_scroll = ui_content.cursor_position.y
+ self.vertical_scroll_2 = 0
+ return
+
+ # If the current line consumes more than the whole window height,
+ # then we have to scroll vertically inside this line. (We don't take
+ # the scroll offsets into account for this.)
+ # Also, ignore the scroll offsets in this case. Just set the vertical
+ # scroll to this line.
+ line_height = get_line_height(ui_content.cursor_position.y)
+ if line_height > height - scroll_offsets_top:
+ # Calculate the height of the text before the cursor (including
+ # line prefixes).
+ text_before_height = ui_content.get_height_for_line(
+ ui_content.cursor_position.y,
+ width,
+ self.get_line_prefix,
+ slice_stop=ui_content.cursor_position.x,
+ )
+
+ # Adjust scroll offset.
+ self.vertical_scroll = ui_content.cursor_position.y
+ self.vertical_scroll_2 = min(
+ text_before_height - 1, # Keep the cursor visible.
+ line_height
+ - height, # Avoid blank lines at the bottom when scrolling up again.
+ self.vertical_scroll_2,
+ )
+ self.vertical_scroll_2 = max(
+ 0, text_before_height - height, self.vertical_scroll_2
+ )
+ return
+ else:
+ self.vertical_scroll_2 = 0
+
+ # Current line doesn't consume the whole height. Take scroll offsets into account.
+ def get_min_vertical_scroll() -> int:
+ # Make sure that the cursor line is not below the bottom.
+ # (Calculate how many lines can be shown between the cursor and the .)
+ used_height = 0
+ prev_lineno = ui_content.cursor_position.y
+
+ for lineno in range(ui_content.cursor_position.y, -1, -1):
+ used_height += get_line_height(lineno)
+
+ if used_height > height - scroll_offsets_bottom:
+ return prev_lineno
+ else:
+ prev_lineno = lineno
+ return 0
+
+ def get_max_vertical_scroll() -> int:
+ # Make sure that the cursor line is not above the top.
+ prev_lineno = ui_content.cursor_position.y
+ used_height = 0
+
+ for lineno in range(ui_content.cursor_position.y - 1, -1, -1):
+ used_height += get_line_height(lineno)
+
+ if used_height > scroll_offsets_top:
+ return prev_lineno
+ else:
+ prev_lineno = lineno
+ return prev_lineno
+
+ def get_topmost_visible() -> int:
+ """
+ Calculate the upper most line that can be visible, while the bottom
+ is still visible. We should not allow scroll more than this if
+ `allow_scroll_beyond_bottom` is false.
+ """
+ prev_lineno = ui_content.line_count - 1
+ used_height = 0
+ for lineno in range(ui_content.line_count - 1, -1, -1):
+ used_height += get_line_height(lineno)
+ if used_height > height:
+ return prev_lineno
+ else:
+ prev_lineno = lineno
+ return prev_lineno
+
+ # Scroll vertically. (Make sure that the whole line which contains the
+ # cursor is visible.
+ topmost_visible = get_topmost_visible()
+
+ # Note: the `min(topmost_visible, ...)` is to make sure that we
+ # don't require scrolling up because of the bottom scroll offset,
+ # when we are at the end of the document.
+ self.vertical_scroll = max(
+ self.vertical_scroll, min(topmost_visible, get_min_vertical_scroll())
+ )
+ self.vertical_scroll = min(self.vertical_scroll, get_max_vertical_scroll())
+
+ # Disallow scrolling beyond bottom?
+ if not self.allow_scroll_beyond_bottom():
+ self.vertical_scroll = min(self.vertical_scroll, topmost_visible)
+
+ def _scroll_without_linewrapping(
+ self, ui_content: UIContent, width: int, height: int
+ ) -> None:
"""
Scroll to make sure the cursor position is visible and that we maintain
the requested scroll offset.
Set `self.horizontal_scroll/vertical_scroll`.
"""
- pass
-
- def _mouse_handler(self, mouse_event: MouseEvent) ->NotImplementedOrNone:
+ cursor_position = ui_content.cursor_position or Point(x=0, y=0)
+
+ # Without line wrapping, we will never have to scroll vertically inside
+ # a single line.
+ self.vertical_scroll_2 = 0
+
+ if ui_content.line_count == 0:
+ self.vertical_scroll = 0
+ self.horizontal_scroll = 0
+ return
+ else:
+ current_line_text = fragment_list_to_text(
+ ui_content.get_line(cursor_position.y)
+ )
+
+ def do_scroll(
+ current_scroll: int,
+ scroll_offset_start: int,
+ scroll_offset_end: int,
+ cursor_pos: int,
+ window_size: int,
+ content_size: int,
+ ) -> int:
+ "Scrolling algorithm. Used for both horizontal and vertical scrolling."
+ # Calculate the scroll offset to apply.
+ # This can obviously never be more than have the screen size. Also, when the
+ # cursor appears at the top or bottom, we don't apply the offset.
+ scroll_offset_start = int(
+ min(scroll_offset_start, window_size / 2, cursor_pos)
+ )
+ scroll_offset_end = int(
+ min(scroll_offset_end, window_size / 2, content_size - 1 - cursor_pos)
+ )
+
+ # Prevent negative scroll offsets.
+ if current_scroll < 0:
+ current_scroll = 0
+
+ # Scroll back if we scrolled to much and there's still space to show more of the document.
+ if (
+ not self.allow_scroll_beyond_bottom()
+ and current_scroll > content_size - window_size
+ ):
+ current_scroll = max(0, content_size - window_size)
+
+ # Scroll up if cursor is before visible part.
+ if current_scroll > cursor_pos - scroll_offset_start:
+ current_scroll = max(0, cursor_pos - scroll_offset_start)
+
+ # Scroll down if cursor is after visible part.
+ if current_scroll < (cursor_pos + 1) - window_size + scroll_offset_end:
+ current_scroll = (cursor_pos + 1) - window_size + scroll_offset_end
+
+ return current_scroll
+
+ # When a preferred scroll is given, take that first into account.
+ if self.get_vertical_scroll:
+ self.vertical_scroll = self.get_vertical_scroll(self)
+ assert isinstance(self.vertical_scroll, int)
+ if self.get_horizontal_scroll:
+ self.horizontal_scroll = self.get_horizontal_scroll(self)
+ assert isinstance(self.horizontal_scroll, int)
+
+ # Update horizontal/vertical scroll to make sure that the cursor
+ # remains visible.
+ offsets = self.scroll_offsets
+
+ self.vertical_scroll = do_scroll(
+ current_scroll=self.vertical_scroll,
+ scroll_offset_start=offsets.top,
+ scroll_offset_end=offsets.bottom,
+ cursor_pos=ui_content.cursor_position.y,
+ window_size=height,
+ content_size=ui_content.line_count,
+ )
+
+ if self.get_line_prefix:
+ current_line_prefix_width = fragment_list_width(
+ to_formatted_text(self.get_line_prefix(ui_content.cursor_position.y, 0))
+ )
+ else:
+ current_line_prefix_width = 0
+
+ self.horizontal_scroll = do_scroll(
+ current_scroll=self.horizontal_scroll,
+ scroll_offset_start=offsets.left,
+ scroll_offset_end=offsets.right,
+ cursor_pos=get_cwidth(current_line_text[: ui_content.cursor_position.x]),
+ window_size=width - current_line_prefix_width,
+ # We can only analyze the current line. Calculating the width off
+ # all the lines is too expensive.
+ content_size=max(
+ get_cwidth(current_line_text), self.horizontal_scroll + width
+ ),
+ )
+
+ def _mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
"""
Mouse handler. Called when the UI control doesn't handle this
particular event.
@@ -867,15 +2552,50 @@ class Window(Container):
Return `NotImplemented` if nothing was done as a consequence of this
key binding (no UI invalidate required in that case).
"""
- pass
+ if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
+ self._scroll_down()
+ return None
+ elif mouse_event.event_type == MouseEventType.SCROLL_UP:
+ self._scroll_up()
+ return None
+
+ return NotImplemented
+
+ def _scroll_down(self) -> None:
+ "Scroll window down."
+ info = self.render_info
+
+ if info is None:
+ return
+
+ if self.vertical_scroll < info.content_height - info.window_height:
+ if info.cursor_position.y <= info.configured_scroll_offsets.top:
+ self.content.move_cursor_down()
+
+ self.vertical_scroll += 1
+
+ def _scroll_up(self) -> None:
+ "Scroll window up."
+ info = self.render_info
+
+ if info is None:
+ return
+
+ if info.vertical_scroll > 0:
+ # TODO: not entirely correct yet in case of line wrapping and long lines.
+ if (
+ info.cursor_position.y
+ >= info.window_height - 1 - info.configured_scroll_offsets.bottom
+ ):
+ self.content.move_cursor_up()
- def _scroll_down(self) ->None:
- """Scroll window down."""
- pass
+ self.vertical_scroll -= 1
- def _scroll_up(self) ->None:
- """Scroll window up."""
- pass
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ return self.content.get_key_bindings()
+
+ def get_children(self) -> list[Container]:
+ return []
class ConditionalContainer(Container):
@@ -888,13 +2608,44 @@ class ConditionalContainer(Container):
:param filter: :class:`.Filter` instance.
"""
- def __init__(self, content: AnyContainer, filter: FilterOrBool) ->None:
+ def __init__(self, content: AnyContainer, filter: FilterOrBool) -> None:
self.content = to_container(content)
self.filter = to_filter(filter)
- def __repr__(self) ->str:
- return (
- f'ConditionalContainer({self.content!r}, filter={self.filter!r})')
+ def __repr__(self) -> str:
+ return f"ConditionalContainer({self.content!r}, filter={self.filter!r})"
+
+ def reset(self) -> None:
+ self.content.reset()
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ if self.filter():
+ return self.content.preferred_width(max_available_width)
+ else:
+ return Dimension.zero()
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ if self.filter():
+ return self.content.preferred_height(width, max_available_height)
+ else:
+ return Dimension.zero()
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ if self.filter():
+ return self.content.write_to_screen(
+ screen, mouse_handlers, write_position, parent_style, erase_bg, z_index
+ )
+
+ def get_children(self) -> list[Container]:
+ return [self.content]
class DynamicContainer(Container):
@@ -905,36 +2656,88 @@ class DynamicContainer(Container):
or any widget with a ``__pt_container__`` method.
"""
- def __init__(self, get_container: Callable[[], AnyContainer]) ->None:
+ def __init__(self, get_container: Callable[[], AnyContainer]) -> None:
self.get_container = get_container
- def _get_container(self) ->Container:
+ def _get_container(self) -> Container:
"""
Return the current container object.
We call `to_container`, because `get_container` can also return a
widget with a ``__pt_container__`` method.
"""
- pass
+ obj = self.get_container()
+ return to_container(obj)
+
+ def reset(self) -> None:
+ self._get_container().reset()
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ return self._get_container().preferred_width(max_available_width)
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ return self._get_container().preferred_height(width, max_available_height)
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ self._get_container().write_to_screen(
+ screen, mouse_handlers, write_position, parent_style, erase_bg, z_index
+ )
+
+ def is_modal(self) -> bool:
+ return False
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ # Key bindings will be collected when `layout.walk()` finds the child
+ # container.
+ return None
+
+ def get_children(self) -> list[Container]:
+ # Here we have to return the current active container itself, not its
+ # children. Otherwise, we run into issues where `layout.walk()` will
+ # never see an object of type `Window` if this contains a window. We
+ # can't/shouldn't proxy the "isinstance" check.
+ return [self._get_container()]
-def to_container(container: AnyContainer) ->Container:
+def to_container(container: AnyContainer) -> Container:
"""
Make sure that the given object is a :class:`.Container`.
"""
- pass
+ if isinstance(container, Container):
+ return container
+ elif hasattr(container, "__pt_container__"):
+ return to_container(container.__pt_container__())
+ else:
+ raise ValueError(f"Not a container object: {container!r}")
-def to_window(container: AnyContainer) ->Window:
+def to_window(container: AnyContainer) -> Window:
"""
Make sure that the given argument is a :class:`.Window`.
"""
- pass
+ if isinstance(container, Window):
+ return container
+ elif hasattr(container, "__pt_container__"):
+ return to_window(cast("MagicContainer", container).__pt_container__())
+ else:
+ raise ValueError(f"Not a Window object: {container!r}.")
-def is_container(value: object) ->TypeGuard[AnyContainer]:
+def is_container(value: object) -> TypeGuard[AnyContainer]:
"""
Checks whether the given value is a container object
(for use in assert statements).
"""
- pass
+ if isinstance(value, Container):
+ return True
+ if hasattr(value, "__pt_container__"):
+ return is_container(cast("MagicContainer", value).__pt_container__())
+ return False
diff --git a/src/prompt_toolkit/layout/controls.py b/src/prompt_toolkit/layout/controls.py
index d76f0c21..c30c0eff 100644
--- a/src/prompt_toolkit/layout/controls.py
+++ b/src/prompt_toolkit/layout/controls.py
@@ -2,28 +2,60 @@
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.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
+
+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.key_binding.key_bindings import (
+ KeyBindingsBase,
+ NotImplementedOrNone,
+ )
from prompt_toolkit.utils import Event
-__all__ = ['BufferControl', 'SearchBufferControl', 'DummyControl',
- 'FormattedTextControl', 'UIControl', 'UIContent']
+
+
+__all__ = [
+ "BufferControl",
+ "SearchBufferControl",
+ "DummyControl",
+ "FormattedTextControl",
+ "UIControl",
+ "UIContent",
+]
+
GetLinePrefixCallable = Callable[[int, int], AnyFormattedText]
@@ -32,22 +64,37 @@ class UIControl(metaclass=ABCMeta):
Base class for all user interface controls.
"""
- def is_focusable(self) ->bool:
+ 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.
"""
- pass
+ return False
@abstractmethod
- def create_content(self, width: int, height: int) ->UIContent:
+ def create_content(self, width: int, height: int) -> UIContent:
"""
Generate the content for this user control.
Returns a :class:`.UIContent` instance.
"""
- pass
- def mouse_handler(self, mouse_event: MouseEvent) ->NotImplementedOrNone:
+ def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
"""
Handle mouse events.
@@ -57,38 +104,35 @@ class UIControl(metaclass=ABCMeta):
:param mouse_event: `MouseEvent` instance.
"""
- pass
+ return NotImplemented
- def move_cursor_down(self) ->None:
+ 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.
"""
- pass
- def move_cursor_up(self) ->None:
+ def move_cursor_up(self) -> None:
"""
Request to move the cursor up.
"""
- pass
- def get_key_bindings(self) ->(KeyBindingsBase | None):
+ 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.
"""
- pass
- def get_invalidate_events(self) ->Iterable[Event[object]]:
+ 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.)
"""
- pass
+ return []
class UIContent:
@@ -104,25 +148,37 @@ class UIContent:
: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):
+ 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)."""
+ 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:
+ 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).
@@ -134,7 +190,60 @@ class UIContent:
when line wrapping.
:returns: The computed height.
"""
- pass
+ # 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):
@@ -180,50 +289,136 @@ class FormattedTextControl(UIControl):
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
+ 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
- self._content_cache: SimpleCache[Hashable, UIContent] = SimpleCache(
- maxsize=18)
- self._fragment_cache: SimpleCache[int, StyleAndTextTuples
- ] = SimpleCache(maxsize=1)
+
+ #: 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 __repr__(self) ->str:
- return f'{self.__class__.__name__}({self.text!r})'
+ 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:
+ 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.)
"""
- pass
+ 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:
+ def preferred_width(self, max_available_width: int) -> int:
"""
Return the preferred width for this control.
That is the width of the longest line.
"""
- pass
+ 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):
+ 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.
"""
- pass
+ 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:
+ def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
"""
Handle mouse events.
@@ -233,7 +428,40 @@ class FormattedTextControl(UIControl):
:class:`~prompt_toolkit.layout.Window` to handle this particular
event.)
"""
- pass
+ 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):
@@ -245,6 +473,15 @@ class DummyControl(UIControl):
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
@@ -272,50 +509,92 @@ class BufferControl(UIControl):
: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):
+ 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(),
+ self.include_default_input_processors = include_default_input_processors
+
+ self.default_input_processors = [
+ HighlightSearchProcessor(),
HighlightIncrementalSearchProcessor(),
- HighlightSelectionProcessor(), DisplayMultipleCursors()]
+ 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
- self._fragment_cache: SimpleCache[Hashable, Callable[[int],
- StyleAndTextTuples]] = SimpleCache(maxsize=8)
+
+ #: 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
+ 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}>'
- )
+ def __repr__(self) -> str:
+ return f"<{self.__class__.__name__} buffer={self.buffer!r} at {id(self)!r}>"
@property
- def search_state(self) ->SearchState:
+ 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`.
"""
- pass
+ 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):
+ def preferred_width(self, max_available_width: int) -> int | None:
"""
This should return the preferred width.
@@ -326,48 +605,313 @@ class BufferControl(UIControl):
unfeasible for a larger document, and doing it for small
documents only would result in inconsistent behavior.
"""
- pass
+ 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
- def _get_formatted_text_for_line_func(self, document: Document) ->Callable[
- [int], StyleAndTextTuples]:
+ # 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.
"""
- pass
- def _create_get_processed_line_func(self, document: Document, width:
- int, height: int) ->Callable[[int], _ProcessedLine]:
+ # 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.
"""
- pass
+ # Merge all input processors together.
+ input_processors = self.input_processors or []
+ if self.include_default_input_processors:
+ input_processors = self.default_input_processors + input_processors
+
+ merged_processor = merge_processors(input_processors)
+
+ def transform(lineno: int, fragments: StyleAndTextTuples) -> _ProcessedLine:
+ "Transform the fragments for a given line number."
- def create_content(self, width: int, height: int, preview_search: bool=
- False) ->UIContent:
+ # Get cursor position at this line.
+ def source_to_display(i: int) -> int:
+ """X position from the buffer to the x position in the
+ processed fragment list. By default, we start from the 'identity'
+ operation."""
+ return i
+
+ transformation = merged_processor.apply_transformation(
+ TransformationInput(
+ self, document, lineno, source_to_display, fragments, width, height
+ )
+ )
+
+ return _ProcessedLine(
+ transformation.fragments,
+ transformation.source_to_display,
+ transformation.display_to_source,
+ )
+
+ def create_func() -> Callable[[int], _ProcessedLine]:
+ get_line = self._get_formatted_text_for_line_func(document)
+ cache: dict[int, _ProcessedLine] = {}
+
+ def get_processed_line(i: int) -> _ProcessedLine:
+ try:
+ return cache[i]
+ except KeyError:
+ processed_line = transform(i, get_line(i))
+ cache[i] = processed_line
+ return processed_line
+
+ return get_processed_line
+
+ return create_func()
+
+ def create_content(
+ self, width: int, height: int, preview_search: bool = False
+ ) -> UIContent:
"""
Create a UIContent.
"""
- pass
-
- def mouse_handler(self, mouse_event: MouseEvent) ->NotImplementedOrNone:
+ 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.
"""
- pass
+ 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 get_key_bindings(self) ->(KeyBindingsBase | 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.
"""
- pass
+ return self.key_bindings
- def get_invalidate_events(self) ->Iterable[Event[object]]:
+ def get_invalidate_events(self) -> Iterable[Event[object]]:
"""
Return the Window invalidate events.
"""
- pass
+ # 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):
@@ -378,11 +922,23 @@ class SearchBufferControl(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)
+ def __init__(
+ self,
+ buffer: Buffer | None = None,
+ input_processors: list[Processor] | None = None,
+ lexer: Lexer | None = None,
+ focus_on_click: FilterOrBool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ ignore_case: FilterOrBool = False,
+ ):
+ super().__init__(
+ buffer=buffer,
+ input_processors=input_processors,
+ lexer=lexer,
+ focus_on_click=focus_on_click,
+ key_bindings=key_bindings,
+ )
+
+ # If this BufferControl is used as a search field for one or more other
+ # BufferControls, then represents the search state.
self.searcher_search_state = SearchState(ignore_case=ignore_case)
diff --git a/src/prompt_toolkit/layout/dimension.py b/src/prompt_toolkit/layout/dimension.py
index 27439360..c1f05f94 100644
--- a/src/prompt_toolkit/layout/dimension.py
+++ b/src/prompt_toolkit/layout/dimension.py
@@ -3,9 +3,19 @@ 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']
+
+__all__ = [
+ "Dimension",
+ "D",
+ "sum_layout_dimensions",
+ "max_layout_dimensions",
+ "AnyDimension",
+ "to_dimension",
+ "is_dimension",
+]
+
if TYPE_CHECKING:
from typing_extensions import TypeGuard
@@ -28,101 +38,182 @@ class Dimension:
:param preferred: Preferred size.
"""
- def __init__(self, min: (int | None)=None, max: (int | None)=None,
- weight: (int | None)=None, preferred: (int | None)=None) ->None:
+ 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
+ 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
- if max is None:
- max = 1000 ** 10
+ 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.')
+ 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:
+ def exact(cls, amount: int) -> Dimension:
"""
Return a :class:`.Dimension` with an exact size. (min, max and
preferred set to ``amount``).
"""
- pass
+ return cls(min=amount, max=amount, preferred=amount)
@classmethod
- def zero(cls) ->Dimension:
+ def zero(cls) -> Dimension:
"""
Create a dimension that represents a zero size. (Used for 'invisible'
controls.)
"""
- pass
+ return cls.exact(amount=0)
- def is_zero(self) ->bool:
- """True if this `Dimension` represents a zero size."""
- pass
+ def is_zero(self) -> bool:
+ "True if this `Dimension` represents a zero size."
+ return self.preferred == 0 or self.max == 0
- def __repr__(self) ->str:
+ def __repr__(self) -> str:
fields = []
if self.min_specified:
- fields.append('min=%r' % self.min)
+ fields.append("min=%r" % self.min)
if self.max_specified:
- fields.append('max=%r' % self.max)
+ fields.append("max=%r" % self.max)
if self.preferred_specified:
- fields.append('preferred=%r' % self.preferred)
+ fields.append("preferred=%r" % self.preferred)
if self.weight_specified:
- fields.append('weight=%r' % self.weight)
- return 'Dimension(%s)' % ', '.join(fields)
+ fields.append("weight=%r" % self.weight)
+
+ return "Dimension(%s)" % ", ".join(fields)
-def sum_layout_dimensions(dimensions: list[Dimension]) ->Dimension:
+def sum_layout_dimensions(dimensions: list[Dimension]) -> Dimension:
"""
Sum a list of :class:`.Dimension` instances.
"""
- pass
+ 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:
+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.)
"""
- pass
-
-
-AnyDimension = Union[None, int, Dimension, Callable[[], Any]]
-
-
-def to_dimension(value: AnyDimension) ->Dimension:
+ if not len(dimensions):
+ return Dimension.zero()
+
+ # If all dimensions are size zero. Return zero.
+ # (This is important for HSplit/VSplit, to report the right values to their
+ # parent when all children are invisible.)
+ if all(d.is_zero() for d in dimensions):
+ return dimensions[0]
+
+ # Ignore empty dimensions. (They should not reduce the size of others.)
+ dimensions = [d for d in dimensions if not d.is_zero()]
+
+ if dimensions:
+ # Take the highest minimum dimension.
+ min_ = max(d.min for d in dimensions)
+
+ # For the maximum, we would prefer not to go larger than then smallest
+ # 'max' value, unless other dimensions have a bigger preferred value.
+ # This seems to work best:
+ # - We don't want that a widget with a small height in a VSplit would
+ # shrink other widgets in the split.
+ # If it doesn't work well enough, then it's up to the UI designer to
+ # explicitly pass dimensions.
+ max_ = min(d.max for d in dimensions)
+ max_ = max(max_, max(d.preferred for d in dimensions))
+
+ # Make sure that min>=max. In some scenarios, when certain min..max
+ # ranges don't have any overlap, we can end up in such an impossible
+ # situation. In that case, give priority to the max value.
+ # E.g. taking (1..5) and (8..9) would return (8..5). Instead take (8..8).
+ if min_ > max_:
+ max_ = min_
+
+ preferred = max(d.preferred for d in dimensions)
+
+ return Dimension(min=min_, max=max_, preferred=preferred)
+ else:
+ return Dimension()
+
+
+# Anything that can be converted to a dimension.
+AnyDimension = Union[
+ None, # None is a valid dimension that will fit anything.
+ int,
+ Dimension,
+ # Callable[[], 'AnyDimension'] # Recursive definition not supported by mypy.
+ Callable[[], Any],
+]
+
+
+def to_dimension(value: AnyDimension) -> Dimension:
"""
Turn the given object into a `Dimension` object.
"""
- pass
+ 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]:
+
+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.)
"""
- pass
+ if value is None:
+ return True
+ if callable(value):
+ return True # Assume it's a callable that doesn't take arguments.
+ if isinstance(value, (int, Dimension)):
+ return True
+ return False
+# Common alias.
D = Dimension
+
+# For backward-compatibility.
LayoutDimension = Dimension
diff --git a/src/prompt_toolkit/layout/dummy.py b/src/prompt_toolkit/layout/dummy.py
index 5c47d1f0..139f3115 100644
--- a/src/prompt_toolkit/layout/dummy.py
+++ b/src/prompt_toolkit/layout/dummy.py
@@ -3,20 +3,37 @@ 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']
+
+__all__ = [
+ "create_dummy_layout",
+]
+
E = KeyPressEvent
-def create_dummy_layout() ->Layout:
+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.
"""
- pass
+ kb = KeyBindings()
+
+ @kb.add("enter")
+ def enter(event: E) -> None:
+ event.app.exit()
+
+ control = FormattedTextControl(
+ HTML("No layout specified. Press <reverse>ENTER</reverse> to quit."),
+ key_bindings=kb,
+ )
+ window = Window(content=control, height=D(min=1))
+ return Layout(container=window, focused_element=window)
diff --git a/src/prompt_toolkit/layout/layout.py b/src/prompt_toolkit/layout/layout.py
index e6a57ed0..a5e7a80e 100644
--- a/src/prompt_toolkit/layout/layout.py
+++ b/src/prompt_toolkit/layout/layout.py
@@ -2,11 +2,26 @@
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 .containers import (
+ AnyContainer,
+ ConditionalContainer,
+ Container,
+ Window,
+ to_container,
+)
from .controls import BufferControl, SearchBufferControl, UIControl
-__all__ = ['Layout', 'InvalidLayoutError', 'walk']
+
+__all__ = [
+ "Layout",
+ "InvalidLayoutError",
+ "walk",
+]
+
FocusableElement = Union[str, Buffer, UIControl, AnyContainer]
@@ -21,35 +36,56 @@ class Layout:
the `focus` function accepts.)
"""
- def __init__(self, container: AnyContainer, focused_element: (
- FocusableElement | None)=None) ->None:
+ 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
+ "Invalid layout. The layout does not contain any Window object."
+ ) from e
else:
self.focus(focused_element)
- self.visible_windows: list[Window] = []
- def __repr__(self) ->str:
- return (
- f'Layout({self.container!r}, current_window={self.current_window!r})'
- )
+ # List of visible windows.
+ self.visible_windows: list[Window] = [] # List of `Window` objects.
- def find_all_windows(self) ->Generator[Window, None, None]:
+ 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.
"""
- pass
+ 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:
+ def focus(self, value: FocusableElement) -> None:
"""
Focus the given UI element.
@@ -62,145 +98,314 @@ class Layout:
from this container that was focused most recent, or the very first
focusable :class:`.Window` of the container.
"""
- pass
-
- def has_focus(self, value: FocusableElement) ->bool:
+ # 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.
"""
- pass
+ 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:
+ def current_control(self) -> UIControl:
"""
Get the :class:`.UIControl` to currently has the focus.
"""
- pass
+ return self._stack[-1].content
@current_control.setter
- def current_control(self, control: UIControl) ->None:
+ def current_control(self, control: UIControl) -> None:
"""
Set the :class:`.UIControl` to receive the focus.
"""
- pass
+ 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."""
- pass
+ 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."""
- pass
+ 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."""
- pass
+ 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):
+ def search_target_buffer_control(self) -> BufferControl | None:
"""
Return the :class:`.BufferControl` in which we are searching or `None`.
"""
- pass
+ # Not every `UIControl` is a `BufferControl`. This only applies to
+ # `BufferControl`.
+ control = self.current_control
- def get_focusable_windows(self) ->Iterable[Window]:
+ 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).
"""
- pass
+ 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]:
+ def get_visible_focusable_windows(self) -> list[Window]:
"""
Return a list of :class:`.Window` objects that are focusable.
"""
- pass
+ # 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):
+ def current_buffer(self) -> Buffer | None:
"""
The currently focused :class:`~.Buffer` or `None`.
"""
- pass
+ 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):
+ 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.
"""
- pass
+ 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:
+ 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.)
"""
- pass
+ ui_control = self.current_control
+ return isinstance(ui_control, BufferControl)
@property
- def previous_control(self) ->UIControl:
+ def previous_control(self) -> UIControl:
"""
Get the :class:`.UIControl` to previously had the focus.
"""
- pass
+ try:
+ return self._stack[-2].content
+ except IndexError:
+ return self._stack[-1].content
- def focus_last(self) ->None:
+ def focus_last(self) -> None:
"""
Give the focus to the last focused control.
"""
- pass
+ if len(self._stack) > 1:
+ self._stack = self._stack[:-1]
- def focus_next(self) ->None:
+ def focus_next(self) -> None:
"""
Focus the next visible/focusable Window.
"""
- pass
+ 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:
+ def focus_previous(self) -> None:
"""
Focus the previous visible/focusable Window.
"""
- pass
+ 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]:
+ def walk(self) -> Iterable[Container]:
"""
Walk through all the layout nodes (and their children) and yield them.
"""
- pass
+ yield from walk(self.container)
- def walk_through_modal_area(self) ->Iterable[Container]:
+ def walk_through_modal_area(self) -> Iterable[Container]:
"""
Walk through all the containers which are in the current 'modal' part
of the layout.
"""
- pass
+ # 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]
- def update_parents_relations(self) ->None:
+ yield from walk(root)
+
+ def update_parents_relations(self) -> None:
"""
Update child->parent relationships mapping.
"""
- pass
+ 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 get_parent(self, container: Container) ->(Container | None):
+ 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.
"""
- pass
+ 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]:
+def walk(container: Container, skip_hidden: bool = False) -> Iterable[Container]:
"""
Walk through layout, starting at this container.
"""
- pass
+ # When `skip_hidden` is set, don't go into disabled ConditionalContainer containers.
+ if (
+ skip_hidden
+ and isinstance(container, ConditionalContainer)
+ and not container.filter()
+ ):
+ return
+
+ yield container
+
+ for c in container.get_children():
+ # yield from walk(c)
+ yield from walk(c, skip_hidden=skip_hidden)
diff --git a/src/prompt_toolkit/layout/margins.py b/src/prompt_toolkit/layout/margins.py
index 221ae7c0..cc9dd964 100644
--- a/src/prompt_toolkit/layout/margins.py
+++ b/src/prompt_toolkit/layout/margins.py
@@ -2,16 +2,30 @@
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.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']
+
+__all__ = [
+ "Margin",
+ "NumberedMargin",
+ "ScrollbarMargin",
+ "ConditionalMargin",
+ "PromptMargin",
+]
class Margin(metaclass=ABCMeta):
@@ -20,7 +34,7 @@ class Margin(metaclass=ABCMeta):
"""
@abstractmethod
- def get_width(self, get_ui_content: Callable[[], UIContent]) ->int:
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
"""
Return the width that this margin is going to consume.
@@ -28,11 +42,12 @@ class Margin(metaclass=ABCMeta):
a :class:`.UIContent` instance. This can be used for instance to
obtain the number of lines.
"""
- pass
+ return 0
@abstractmethod
- def create_margin(self, window_render_info: WindowRenderInfo, width:
- int, height: int) ->StyleAndTextTuples:
+ 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.
@@ -47,7 +62,7 @@ class Margin(metaclass=ABCMeta):
:param height: The height that's available for this margin. (The height
of the :class:`~prompt_toolkit.layout.containers.Window`.)
"""
- pass
+ return []
class NumberedMargin(Margin):
@@ -60,21 +75,87 @@ class NumberedMargin(Margin):
like Vi does.
"""
- def __init__(self, relative: FilterOrBool=False, display_tildes:
- FilterOrBool=False) ->None:
+ def __init__(
+ self, relative: FilterOrBool = False, display_tildes: FilterOrBool = False
+ ) -> None:
self.relative = to_filter(relative)
self.display_tildes = to_filter(display_tildes)
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+ line_count = get_ui_content().line_count
+ return max(3, len("%s" % line_count) + 1)
+
+ def create_margin(
+ self, window_render_info: WindowRenderInfo, width: int, height: int
+ ) -> StyleAndTextTuples:
+ relative = self.relative()
+
+ style = "class:line-number"
+ style_current = "class:line-number.current"
+
+ # Get current line number.
+ current_lineno = window_render_info.ui_content.cursor_position.y
+
+ # Construct margin.
+ result: StyleAndTextTuples = []
+ last_lineno = None
+
+ for y, lineno in enumerate(window_render_info.displayed_lines):
+ # Only display line number if this line is not a continuation of the previous line.
+ if lineno != last_lineno:
+ if lineno is None:
+ pass
+ elif lineno == current_lineno:
+ # Current line.
+ if relative:
+ # Left align current number in relative mode.
+ result.append((style_current, "%i" % (lineno + 1)))
+ else:
+ result.append(
+ (style_current, ("%i " % (lineno + 1)).rjust(width))
+ )
+ else:
+ # Other lines.
+ if relative:
+ lineno = abs(lineno - current_lineno) - 1
+
+ result.append((style, ("%i " % (lineno + 1)).rjust(width)))
+
+ last_lineno = lineno
+ result.append(("", "\n"))
+
+ # Fill with tildes.
+ if self.display_tildes():
+ while y < window_render_info.window_height:
+ result.append(("class:tilde", "~\n"))
+ y += 1
+
+ return result
+
class ConditionalMargin(Margin):
"""
Wrapper around other :class:`.Margin` classes to show/hide them.
"""
- def __init__(self, margin: Margin, filter: FilterOrBool) ->None:
+ 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):
"""
@@ -83,12 +164,84 @@ class ScrollbarMargin(Margin):
: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:
+ 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):
"""
@@ -112,12 +265,39 @@ class PromptMargin(Margin):
input.
"""
- def __init__(self, get_prompt: Callable[[], StyleAndTextTuples],
- get_continuation: (None | Callable[[int, int, bool],
- StyleAndTextTuples])=None) ->None:
+ 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`."""
- pass
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+ "Width to report to the `Window`."
+ # Take the width from the first line.
+ text = fragment_list_to_text(self.get_prompt())
+ return get_cwidth(text)
+
+ def create_margin(
+ self, window_render_info: WindowRenderInfo, width: int, height: int
+ ) -> StyleAndTextTuples:
+ get_continuation = self.get_continuation
+ result: StyleAndTextTuples = []
+
+ # First line.
+ result.extend(to_formatted_text(self.get_prompt()))
+
+ # Next lines.
+ if get_continuation:
+ last_y = None
+
+ for y in window_render_info.displayed_lines[1:]:
+ result.append(("", "\n"))
+ result.extend(
+ to_formatted_text(get_continuation(width, y, y == last_y))
+ )
+ last_y = y
+
+ return result
diff --git a/src/prompt_toolkit/layout/menus.py b/src/prompt_toolkit/layout/menus.py
index 8231f28b..2c2ccb64 100644
--- a/src/prompt_toolkit/layout/menus.py
+++ b/src/prompt_toolkit/layout/menus.py
@@ -1,25 +1,48 @@
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.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']
+ from prompt_toolkit.key_binding.key_bindings import (
+ KeyBindings,
+ NotImplementedOrNone,
+ )
+
+
+__all__ = [
+ "CompletionsMenu",
+ "MultiColumnCompletionsMenu",
+]
+
E = KeyPressEvent
@@ -32,73 +55,242 @@ class CompletionsMenuControl(UIControl):
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 create_content(self, width: int, height: int) ->UIContent:
+ 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.
"""
- pass
-
- def _show_meta(self, complete_state: CompletionState) ->bool:
+ 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.
"""
- pass
+ return any(c.display_meta_text for c in complete_state.completions)
- def _get_menu_width(self, max_width: int, complete_state: CompletionState
- ) ->int:
+ def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int:
"""
Return the width of the main column.
"""
- pass
-
- def _get_menu_meta_width(self, max_width: int, complete_state:
- CompletionState) ->int:
+ 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.
"""
- pass
- def mouse_handler(self, mouse_event: MouseEvent) ->NotImplementedOrNone:
+ 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.
"""
- pass
+ 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)
-def _get_menu_item_fragments(completion: Completion, is_current_completion:
- bool, width: int, space_after: bool=False) ->StyleAndTextTuples:
+ 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.
"""
- pass
+ if is_current_completion:
+ style_str = "class:completion-menu.completion.current {} {}".format(
+ completion.style,
+ completion.selected_style,
+ )
+ else:
+ style_str = "class:completion-menu.completion " + completion.style
+
+ text, tw = _trim_formatted_text(
+ completion.display, (width - 2 if space_after else width - 1)
+ )
+ padding = " " * (width - 1 - tw)
-def _trim_formatted_text(formatted_text: StyleAndTextTuples, max_width: int
- ) ->tuple[StyleAndTextTuples, int]:
+ 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.
"""
- pass
+ 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):
- 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:
+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), filter=extra_filter &
- has_completions & ~is_done)
+
+ 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):
@@ -123,16 +315,27 @@ class MultiColumnCompletionMenuControl(UIControl):
if there is one very wide completion, that it doesn't significantly
reduce the amount of columns.
"""
- _required_margin = 3
- def __init__(self, min_rows: int=3, suggested_max_column_width: int=30
- ) ->None:
+ _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()
+ CompletionState, tuple[int, int]
+ ] = WeakKeyDictionary()
+
+ # Info of last rendering.
self._rendered_rows = 0
self._rendered_columns = 0
self._total_columns = 0
@@ -141,45 +344,287 @@ class MultiColumnCompletionMenuControl(UIControl):
self._render_right_arrow = False
self._render_width = 0
- def preferred_width(self, max_available_width: int) ->(int | None):
+ 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.
"""
- pass
-
- 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 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.
"""
- pass
+ 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)
- def create_content(self, width: int, height: int) ->UIContent:
+ 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.
"""
- pass
-
- def _get_column_width(self, completion_state: CompletionState) ->int:
+ 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.
"""
- pass
-
- def mouse_handler(self, mouse_event: MouseEvent) ->NotImplementedOrNone:
+ 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.
"""
- pass
+ 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)
- def get_key_bindings(self) ->KeyBindings:
+ return None
+
+ def get_key_bindings(self) -> KeyBindings:
"""
Expose key bindings that handle the left/right arrow keys when the menu
is displayed.
"""
- pass
+ 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):
@@ -189,25 +634,52 @@ class MultiColumnCompletionsMenu(HSplit):
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:
+ 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:
+ 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)
- 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)
+ 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)
@@ -216,7 +688,7 @@ class _SelectedCompletionMetaControl(UIControl):
Control that shows the meta information of the selected completion.
"""
- def preferred_width(self, max_available_width: int) ->(int | None):
+ 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.
@@ -224,4 +696,56 @@ class _SelectedCompletionMetaControl(UIControl):
layout doesn't change when we select another completion (E.g. that
completions are suddenly shown in more or fewer columns.)
"""
- pass
+ app = get_app()
+ if app.current_buffer.complete_state:
+ state = app.current_buffer.complete_state
+
+ if len(state.completions) >= 30:
+ # When there are many completions, calling `get_cwidth` for
+ # every `display_meta_text` is too expensive. In this case,
+ # just return the max available width. There will be enough
+ # columns anyway so that the whole screen is filled with
+ # completions and `create_content` will then take up as much
+ # space as needed.
+ return max_available_width
+
+ return 2 + max(
+ get_cwidth(c.display_meta_text) for c in state.completions[:100]
+ )
+ else:
+ return 0
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ return 1
+
+ def create_content(self, width: int, height: int) -> UIContent:
+ fragments = self._get_text_fragments()
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ return fragments
+
+ return UIContent(get_line=get_line, line_count=1 if fragments else 0)
+
+ def _get_text_fragments(self) -> StyleAndTextTuples:
+ style = "class:completion-menu.multi-column-meta"
+ state = get_app().current_buffer.complete_state
+
+ if (
+ state
+ and state.current_completion
+ and state.current_completion.display_meta_text
+ ):
+ return to_formatted_text(
+ cast(StyleAndTextTuples, [("", " ")])
+ + state.current_completion.display_meta
+ + [("", " ")],
+ style=style,
+ )
+
+ return []
diff --git a/src/prompt_toolkit/layout/mouse_handlers.py b/src/prompt_toolkit/layout/mouse_handlers.py
index 52c54dc6..56a4eddd 100644
--- a/src/prompt_toolkit/layout/mouse_handlers.py
+++ b/src/prompt_toolkit/layout/mouse_handlers.py
@@ -1,11 +1,20 @@
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']
+
+__all__ = [
+ "MouseHandler",
+ "MouseHandlers",
+]
+
+
+MouseHandler = Callable[[MouseEvent], "NotImplementedOrNone"]
class MouseHandlers:
@@ -13,20 +22,35 @@ class MouseHandlers:
Two dimensional raster of callbacks for mouse events.
"""
- def __init__(self) ->None:
-
- def dummy_callback(mouse_event: MouseEvent) ->NotImplementedOrNone:
+ def __init__(self) -> None:
+ def dummy_callback(mouse_event: MouseEvent) -> NotImplementedOrNone:
"""
:param mouse_event: `MouseEvent` instance.
"""
return NotImplemented
- 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:
+ # 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.
"""
- pass
+ for y in range(y_min, y_max):
+ row = self.mouse_handlers[y]
+
+ for x in range(x_min, x_max):
+ row[x] = handler
diff --git a/src/prompt_toolkit/layout/processors.py b/src/prompt_toolkit/layout/processors.py
index 71fc93e2..b7376115 100644
--- a/src/prompt_toolkit/layout/processors.py
+++ b/src/prompt_toolkit/layout/processors.py
@@ -6,29 +6,52 @@ They can insert fragments before or after, or highlight fragments by replacing t
fragment types.
"""
from __future__ import annotations
+
import re
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING, Callable, Hashable, cast
+
from prompt_toolkit.application.current import get_app
from prompt_toolkit.cache import SimpleCache
from prompt_toolkit.document import Document
from prompt_toolkit.filters import FilterOrBool, to_filter, vi_insert_multiple_mode
-from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples, to_formatted_text
+from prompt_toolkit.formatted_text import (
+ AnyFormattedText,
+ StyleAndTextTuples,
+ to_formatted_text,
+)
from prompt_toolkit.formatted_text.utils import fragment_list_len, fragment_list_to_text
from prompt_toolkit.search import SearchDirection
from prompt_toolkit.utils import to_int, to_str
+
from .utils import explode_text_fragments
+
if TYPE_CHECKING:
from .controls import BufferControl, UIContent
-__all__ = ['Processor', 'TransformationInput', 'Transformation',
- 'DummyProcessor', 'HighlightSearchProcessor',
- 'HighlightIncrementalSearchProcessor', 'HighlightSelectionProcessor',
- 'PasswordProcessor', 'HighlightMatchingBracketProcessor',
- 'DisplayMultipleCursors', 'BeforeInput', 'ShowArg', 'AfterInput',
- 'AppendAutoSuggestion', 'ConditionalProcessor',
- 'ShowLeadingWhiteSpaceProcessor', 'ShowTrailingWhiteSpaceProcessor',
- 'TabsProcessor', 'ReverseSearchProcessor', 'DynamicProcessor',
- 'merge_processors']
+
+__all__ = [
+ "Processor",
+ "TransformationInput",
+ "Transformation",
+ "DummyProcessor",
+ "HighlightSearchProcessor",
+ "HighlightIncrementalSearchProcessor",
+ "HighlightSelectionProcessor",
+ "PasswordProcessor",
+ "HighlightMatchingBracketProcessor",
+ "DisplayMultipleCursors",
+ "BeforeInput",
+ "ShowArg",
+ "AfterInput",
+ "AppendAutoSuggestion",
+ "ConditionalProcessor",
+ "ShowLeadingWhiteSpaceProcessor",
+ "ShowTrailingWhiteSpaceProcessor",
+ "TabsProcessor",
+ "ReverseSearchProcessor",
+ "DynamicProcessor",
+ "merge_processors",
+]
class Processor(metaclass=ABCMeta):
@@ -38,14 +61,15 @@ class Processor(metaclass=ABCMeta):
"""
@abstractmethod
- def apply_transformation(self, transformation_input: TransformationInput
- ) ->Transformation:
+ def apply_transformation(
+ self, transformation_input: TransformationInput
+ ) -> Transformation:
"""
Apply transformation. Returns a :class:`.Transformation` instance.
:param transformation_input: :class:`.TransformationInput` object.
"""
- pass
+ return Transformation(transformation_input.fragments)
SourceToDisplay = Callable[[int], int]
@@ -63,9 +87,16 @@ class TransformationInput:
previous processor.)
"""
- def __init__(self, buffer_control: BufferControl, document: Document,
- lineno: int, source_to_display: SourceToDisplay, fragments:
- StyleAndTextTuples, width: int, height: int) ->None:
+ def __init__(
+ self,
+ buffer_control: BufferControl,
+ document: Document,
+ lineno: int,
+ source_to_display: SourceToDisplay,
+ fragments: StyleAndTextTuples,
+ width: int,
+ height: int,
+ ) -> None:
self.buffer_control = buffer_control
self.document = document
self.lineno = lineno
@@ -74,6 +105,21 @@ class TransformationInput:
self.width = width
self.height = height
+ def unpack(
+ self,
+ ) -> tuple[
+ BufferControl, Document, int, SourceToDisplay, StyleAndTextTuples, int, int
+ ]:
+ return (
+ self.buffer_control,
+ self.document,
+ self.lineno,
+ self.source_to_display,
+ self.fragments,
+ self.width,
+ self.height,
+ )
+
class Transformation:
"""
@@ -90,9 +136,12 @@ class Transformation:
original string.
"""
- def __init__(self, fragments: StyleAndTextTuples, source_to_display: (
- SourceToDisplay | None)=None, display_to_source: (DisplayToSource |
- None)=None) ->None:
+ def __init__(
+ self,
+ fragments: StyleAndTextTuples,
+ source_to_display: SourceToDisplay | None = None,
+ display_to_source: DisplayToSource | None = None,
+ ) -> None:
self.fragments = fragments
self.source_to_display = source_to_display or (lambda i: i)
self.display_to_source = display_to_source or (lambda i: i)
@@ -103,6 +152,11 @@ class DummyProcessor(Processor):
A `Processor` that doesn't do anything.
"""
+ def apply_transformation(
+ self, transformation_input: TransformationInput
+ ) -> Transformation:
+ return Transformation(transformation_input.fragments)
+
class HighlightSearchProcessor(Processor):
"""
@@ -112,14 +166,70 @@ class HighlightSearchProcessor(Processor):
The style classes 'search' and 'search.current' will be applied to the
content.
"""
- _classname = 'search'
- _classname_current = 'search.current'
- def _get_search_text(self, buffer_control: BufferControl) ->str:
+ _classname = "search"
+ _classname_current = "search.current"
+
+ def _get_search_text(self, buffer_control: BufferControl) -> str:
"""
The text we are searching for.
"""
- pass
+ return buffer_control.search_state.text
+
+ def apply_transformation(
+ self, transformation_input: TransformationInput
+ ) -> Transformation:
+ (
+ buffer_control,
+ document,
+ lineno,
+ source_to_display,
+ fragments,
+ _,
+ _,
+ ) = transformation_input.unpack()
+
+ search_text = self._get_search_text(buffer_control)
+ searchmatch_fragment = f" class:{self._classname} "
+ searchmatch_current_fragment = f" class:{self._classname_current} "
+
+ if search_text and not get_app().is_done:
+ # For each search match, replace the style string.
+ line_text = fragment_list_to_text(fragments)
+ fragments = explode_text_fragments(fragments)
+
+ if buffer_control.search_state.ignore_case():
+ flags = re.IGNORECASE
+ else:
+ flags = re.RegexFlag(0)
+
+ # Get cursor column.
+ cursor_column: int | None
+ if document.cursor_position_row == lineno:
+ cursor_column = source_to_display(document.cursor_position_col)
+ else:
+ cursor_column = None
+
+ for match in re.finditer(re.escape(search_text), line_text, flags=flags):
+ if cursor_column is not None:
+ on_cursor = match.start() <= cursor_column < match.end()
+ else:
+ on_cursor = False
+
+ for i in range(match.start(), match.end()):
+ old_fragment, text, *_ = fragments[i]
+ if on_cursor:
+ fragments[i] = (
+ old_fragment + searchmatch_current_fragment,
+ fragments[i][1],
+ )
+ else:
+ fragments[i] = (
+ old_fragment + searchmatch_fragment,
+ fragments[i][1],
+ )
+
+ return Transformation(fragments)
class HighlightIncrementalSearchProcessor(HighlightSearchProcessor):
@@ -131,14 +241,19 @@ class HighlightIncrementalSearchProcessor(HighlightSearchProcessor):
`BufferControl`. Otherwise, the cursor position won't be set to the search
match while searching, and nothing happens.
"""
- _classname = 'incsearch'
- _classname_current = 'incsearch.current'
- def _get_search_text(self, buffer_control: BufferControl) ->str:
+ _classname = "incsearch"
+ _classname_current = "incsearch.current"
+
+ def _get_search_text(self, buffer_control: BufferControl) -> str:
"""
The text we are searching for.
"""
- pass
+ # When the search buffer has focus, take that text.
+ search_buffer = buffer_control.search_buffer
+ if search_buffer is not None and search_buffer.text:
+ return search_buffer.text
+ return ""
class HighlightSelectionProcessor(Processor):
@@ -146,6 +261,45 @@ class HighlightSelectionProcessor(Processor):
Processor that highlights the selection in the document.
"""
+ def apply_transformation(
+ self, transformation_input: TransformationInput
+ ) -> Transformation:
+ (
+ buffer_control,
+ document,
+ lineno,
+ source_to_display,
+ fragments,
+ _,
+ _,
+ ) = transformation_input.unpack()
+
+ selected_fragment = " class:selected "
+
+ # In case of selection, highlight all matches.
+ selection_at_line = document.selection_range_at_line(lineno)
+
+ if selection_at_line:
+ from_, to = selection_at_line
+ from_ = source_to_display(from_)
+ to = source_to_display(to)
+
+ fragments = explode_text_fragments(fragments)
+
+ if from_ == 0 and to == 0 and len(fragments) == 0:
+ # When this is an empty line, insert a space in order to
+ # visualize the selection.
+ return Transformation([(selected_fragment, " ")])
+ else:
+ for i in range(from_, to):
+ if i < len(fragments):
+ old_fragment, old_text, *_ = fragments[i]
+ fragments[i] = (old_fragment + selected_fragment, old_text)
+ elif i == len(fragments):
+ fragments.append((selected_fragment, " "))
+
+ return Transformation(fragments)
+
class PasswordProcessor(Processor):
"""
@@ -154,9 +308,20 @@ class PasswordProcessor(Processor):
:param char: (string) Character to be used. "*" by default.
"""
- def __init__(self, char: str='*') ->None:
+ def __init__(self, char: str = "*") -> None:
self.char = char
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ fragments: StyleAndTextTuples = cast(
+ StyleAndTextTuples,
+ [
+ (style, self.char * len(text), *handler)
+ for style, text, *handler in ti.fragments
+ ],
+ )
+
+ return Transformation(fragments)
+
class HighlightMatchingBracketProcessor(Processor):
"""
@@ -169,21 +334,97 @@ class HighlightMatchingBracketProcessor(Processor):
to scan the whole document for matching brackets on each key press, so
we limit to this value.)
"""
- _closing_braces = '])}>'
- def __init__(self, chars: str='[](){}<>', max_cursor_distance: int=1000
- ) ->None:
+ _closing_braces = "])}>"
+
+ def __init__(
+ self, chars: str = "[](){}<>", max_cursor_distance: int = 1000
+ ) -> None:
self.chars = chars
self.max_cursor_distance = max_cursor_distance
- self._positions_cache: SimpleCache[Hashable, list[tuple[int, int]]
- ] = SimpleCache(maxsize=8)
- def _get_positions_to_highlight(self, document: Document) ->list[tuple[
- int, int]]:
+ self._positions_cache: SimpleCache[
+ Hashable, list[tuple[int, int]]
+ ] = SimpleCache(maxsize=8)
+
+ def _get_positions_to_highlight(self, document: Document) -> list[tuple[int, int]]:
"""
Return a list of (row, col) tuples that need to be highlighted.
"""
- pass
+ pos: int | None
+
+ # Try for the character under the cursor.
+ if document.current_char and document.current_char in self.chars:
+ pos = document.find_matching_bracket_position(
+ start_pos=document.cursor_position - self.max_cursor_distance,
+ end_pos=document.cursor_position + self.max_cursor_distance,
+ )
+
+ # Try for the character before the cursor.
+ elif (
+ document.char_before_cursor
+ and document.char_before_cursor in self._closing_braces
+ and document.char_before_cursor in self.chars
+ ):
+ document = Document(document.text, document.cursor_position - 1)
+
+ pos = document.find_matching_bracket_position(
+ start_pos=document.cursor_position - self.max_cursor_distance,
+ end_pos=document.cursor_position + self.max_cursor_distance,
+ )
+ else:
+ pos = None
+
+ # Return a list of (row, col) tuples that need to be highlighted.
+ if pos:
+ pos += document.cursor_position # pos is relative.
+ row, col = document.translate_index_to_position(pos)
+ return [
+ (row, col),
+ (document.cursor_position_row, document.cursor_position_col),
+ ]
+ else:
+ return []
+
+ def apply_transformation(
+ self, transformation_input: TransformationInput
+ ) -> Transformation:
+ (
+ buffer_control,
+ document,
+ lineno,
+ source_to_display,
+ fragments,
+ _,
+ _,
+ ) = transformation_input.unpack()
+
+ # When the application is in the 'done' state, don't highlight.
+ if get_app().is_done:
+ return Transformation(fragments)
+
+ # Get the highlight positions.
+ key = (get_app().render_counter, document.text, document.cursor_position)
+ positions = self._positions_cache.get(
+ key, lambda: self._get_positions_to_highlight(document)
+ )
+
+ # Apply if positions were found at this line.
+ if positions:
+ for row, col in positions:
+ if row == lineno:
+ col = source_to_display(col)
+ fragments = explode_text_fragments(fragments)
+ style, text, *_ = fragments[col]
+
+ if col == document.cursor_position_col:
+ style += " class:matching-bracket.cursor "
+ else:
+ style += " class:matching-bracket.other "
+
+ fragments[col] = (style, text)
+
+ return Transformation(fragments)
class DisplayMultipleCursors(Processor):
@@ -191,6 +432,49 @@ class DisplayMultipleCursors(Processor):
When we're in Vi block insert mode, display all the cursors.
"""
+ def apply_transformation(
+ self, transformation_input: TransformationInput
+ ) -> Transformation:
+ (
+ buffer_control,
+ document,
+ lineno,
+ source_to_display,
+ fragments,
+ _,
+ _,
+ ) = transformation_input.unpack()
+
+ buff = buffer_control.buffer
+
+ if vi_insert_multiple_mode():
+ cursor_positions = buff.multiple_cursor_positions
+ fragments = explode_text_fragments(fragments)
+
+ # If any cursor appears on the current line, highlight that.
+ start_pos = document.translate_row_col_to_index(lineno, 0)
+ end_pos = start_pos + len(document.lines[lineno])
+
+ fragment_suffix = " class:multiple-cursors"
+
+ for p in cursor_positions:
+ if start_pos <= p <= end_pos:
+ column = source_to_display(p - start_pos)
+
+ # Replace fragment.
+ try:
+ style, text, *_ = fragments[column]
+ except IndexError:
+ # Cursor needs to be displayed after the current text.
+ fragments.append((fragment_suffix, " "))
+ else:
+ style += fragment_suffix
+ fragments[column] = (style, text)
+
+ return Transformation(fragments)
+ else:
+ return Transformation(fragments)
+
class BeforeInput(Processor):
"""
@@ -201,12 +485,35 @@ class BeforeInput(Processor):
:param style: style to be applied to this prompt/prefix.
"""
- def __init__(self, text: AnyFormattedText, style: str='') ->None:
+ def __init__(self, text: AnyFormattedText, style: str = "") -> None:
self.text = text
self.style = style
- def __repr__(self) ->str:
- return f'BeforeInput({self.text!r}, {self.style!r})'
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ source_to_display: SourceToDisplay | None
+ display_to_source: DisplayToSource | None
+
+ if ti.lineno == 0:
+ # Get fragments.
+ fragments_before = to_formatted_text(self.text, self.style)
+ fragments = fragments_before + ti.fragments
+
+ shift_position = fragment_list_len(fragments_before)
+ source_to_display = lambda i: i + shift_position
+ display_to_source = lambda i: i - shift_position
+ else:
+ fragments = ti.fragments
+ source_to_display = None
+ display_to_source = None
+
+ return Transformation(
+ fragments,
+ source_to_display=source_to_display,
+ display_to_source=display_to_source,
+ )
+
+ def __repr__(self) -> str:
+ return f"BeforeInput({self.text!r}, {self.style!r})"
class ShowArg(BeforeInput):
@@ -217,11 +524,24 @@ class ShowArg(BeforeInput):
`Window.get_line_prefix` function instead.
"""
- def __init__(self) ->None:
+ def __init__(self) -> None:
super().__init__(self._get_text_fragments)
- def __repr__(self) ->str:
- return 'ShowArg()'
+ def _get_text_fragments(self) -> StyleAndTextTuples:
+ app = get_app()
+ if app.key_processor.arg is None:
+ return []
+ else:
+ arg = app.key_processor.arg
+
+ return [
+ ("class:prompt.arg", "(arg: "),
+ ("class:prompt.arg.text", str(arg)),
+ ("class:prompt.arg", ") "),
+ ]
+
+ def __repr__(self) -> str:
+ return "ShowArg()"
class AfterInput(Processor):
@@ -233,13 +553,21 @@ class AfterInput(Processor):
:param style: style to be applied to this prompt/prefix.
"""
- def __init__(self, text: AnyFormattedText, style: str='') ->None:
+ def __init__(self, text: AnyFormattedText, style: str = "") -> None:
self.text = text
self.style = style
- def __repr__(self) ->str:
- return (
- f'{self.__class__.__name__}({self.text!r}, style={self.style!r})')
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ # Insert fragments after the last line.
+ if ti.lineno == ti.document.line_count - 1:
+ # Get fragments.
+ fragments_after = to_formatted_text(self.text, self.style)
+ return Transformation(fragments=ti.fragments + fragments_after)
+ else:
+ return Transformation(fragments=ti.fragments)
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.text!r}, style={self.style!r})"
class AppendAutoSuggestion(Processor):
@@ -248,9 +576,23 @@ class AppendAutoSuggestion(Processor):
(The user can then press the right arrow the insert the suggestion.)
"""
- def __init__(self, style: str='class:auto-suggestion') ->None:
+ def __init__(self, style: str = "class:auto-suggestion") -> None:
self.style = style
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ # Insert fragments after the last line.
+ if ti.lineno == ti.document.line_count - 1:
+ buffer = ti.buffer_control.buffer
+
+ if buffer.suggestion and ti.document.is_cursor_at_the_end:
+ suggestion = buffer.suggestion.text
+ else:
+ suggestion = ""
+
+ return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
+ else:
+ return Transformation(fragments=ti.fragments)
+
class ShowLeadingWhiteSpaceProcessor(Processor):
"""
@@ -259,17 +601,36 @@ class ShowLeadingWhiteSpaceProcessor(Processor):
:param get_char: Callable that returns one character.
"""
- def __init__(self, get_char: (Callable[[], str] | None)=None, style:
- str='class:leading-whitespace') ->None:
-
- def default_get_char() ->str:
- if '·'.encode(get_app().output.encoding(), 'replace') == b'?':
- return '.'
+ def __init__(
+ self,
+ get_char: Callable[[], str] | None = None,
+ style: str = "class:leading-whitespace",
+ ) -> None:
+ def default_get_char() -> str:
+ if "\xb7".encode(get_app().output.encoding(), "replace") == b"?":
+ return "."
else:
- return '·'
+ return "\xb7"
+
self.style = style
self.get_char = get_char or default_get_char
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ fragments = ti.fragments
+
+ # Walk through all te fragments.
+ if fragments and fragment_list_to_text(fragments).startswith(" "):
+ t = (self.style, self.get_char())
+ fragments = explode_text_fragments(fragments)
+
+ for i in range(len(fragments)):
+ if fragments[i][1] == " ":
+ fragments[i] = t
+ else:
+ break
+
+ return Transformation(fragments)
+
class ShowTrailingWhiteSpaceProcessor(Processor):
"""
@@ -278,17 +639,37 @@ class ShowTrailingWhiteSpaceProcessor(Processor):
:param get_char: Callable that returns one character.
"""
- def __init__(self, get_char: (Callable[[], str] | None)=None, style:
- str='class:training-whitespace') ->None:
-
- def default_get_char() ->str:
- if '·'.encode(get_app().output.encoding(), 'replace') == b'?':
- return '.'
+ def __init__(
+ self,
+ get_char: Callable[[], str] | None = None,
+ style: str = "class:training-whitespace",
+ ) -> None:
+ def default_get_char() -> str:
+ if "\xb7".encode(get_app().output.encoding(), "replace") == b"?":
+ return "."
else:
- return '·'
+ return "\xb7"
+
self.style = style
self.get_char = get_char or default_get_char
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ fragments = ti.fragments
+
+ if fragments and fragments[-1][1].endswith(" "):
+ t = (self.style, self.get_char())
+ fragments = explode_text_fragments(fragments)
+
+ # Walk backwards through all te fragments and replace whitespace.
+ for i in range(len(fragments) - 1, -1, -1):
+ char = fragments[i][1]
+ if char == " ":
+ fragments[i] = t
+ else:
+ break
+
+ return Transformation(fragments)
+
class TabsProcessor(Processor):
"""
@@ -302,14 +683,76 @@ class TabsProcessor(Processor):
:param char2: Like `char1`, but for the rest of the space.
"""
- def __init__(self, tabstop: (int | Callable[[], int])=4, char1: (str |
- Callable[[], str])='|', char2: (str | Callable[[], str])='┈', style:
- str='class:tab') ->None:
+ def __init__(
+ self,
+ tabstop: int | Callable[[], int] = 4,
+ char1: str | Callable[[], str] = "|",
+ char2: str | Callable[[], str] = "\u2508",
+ style: str = "class:tab",
+ ) -> None:
self.char1 = char1
self.char2 = char2
self.tabstop = tabstop
self.style = style
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ tabstop = to_int(self.tabstop)
+ style = self.style
+
+ # Create separator for tabs.
+ separator1 = to_str(self.char1)
+ separator2 = to_str(self.char2)
+
+ # Transform fragments.
+ fragments = explode_text_fragments(ti.fragments)
+
+ position_mappings = {}
+ result_fragments: StyleAndTextTuples = []
+ pos = 0
+
+ for i, fragment_and_text in enumerate(fragments):
+ position_mappings[i] = pos
+
+ if fragment_and_text[1] == "\t":
+ # Calculate how many characters we have to insert.
+ count = tabstop - (pos % tabstop)
+ if count == 0:
+ count = tabstop
+
+ # Insert tab.
+ result_fragments.append((style, separator1))
+ result_fragments.append((style, separator2 * (count - 1)))
+ pos += count
+ else:
+ result_fragments.append(fragment_and_text)
+ pos += 1
+
+ position_mappings[len(fragments)] = pos
+ # Add `pos+1` to mapping, because the cursor can be right after the
+ # line as well.
+ position_mappings[len(fragments) + 1] = pos + 1
+
+ def source_to_display(from_position: int) -> int:
+ "Maps original cursor position to the new one."
+ return position_mappings[from_position]
+
+ def display_to_source(display_pos: int) -> int:
+ "Maps display cursor position to the original one."
+ position_mappings_reversed = {v: k for k, v in position_mappings.items()}
+
+ while display_pos >= 0:
+ try:
+ return position_mappings_reversed[display_pos]
+ except KeyError:
+ display_pos -= 1
+ return 0
+
+ return Transformation(
+ result_fragments,
+ source_to_display=source_to_display,
+ display_to_source=display_to_source,
+ )
+
class ReverseSearchProcessor(Processor):
"""
@@ -319,9 +762,133 @@ class ReverseSearchProcessor(Processor):
Note: This processor is meant to be applied to the BufferControl that
contains the search buffer, it's not meant for the original input.
"""
+
_excluded_input_processors: list[type[Processor]] = [
- HighlightSearchProcessor, HighlightSelectionProcessor, BeforeInput,
- AfterInput]
+ HighlightSearchProcessor,
+ HighlightSelectionProcessor,
+ BeforeInput,
+ AfterInput,
+ ]
+
+ def _get_main_buffer(self, buffer_control: BufferControl) -> BufferControl | None:
+ from prompt_toolkit.layout.controls import BufferControl
+
+ prev_control = get_app().layout.search_target_buffer_control
+ if (
+ isinstance(prev_control, BufferControl)
+ and prev_control.search_buffer_control == buffer_control
+ ):
+ return prev_control
+ return None
+
+ def _content(
+ self, main_control: BufferControl, ti: TransformationInput
+ ) -> UIContent:
+ from prompt_toolkit.layout.controls import BufferControl
+
+ # Emulate the BufferControl through which we are searching.
+ # For this we filter out some of the input processors.
+ excluded_processors = tuple(self._excluded_input_processors)
+
+ def filter_processor(item: Processor) -> Processor | None:
+ """Filter processors from the main control that we want to disable
+ here. This returns either an accepted processor or None."""
+ # For a `_MergedProcessor`, check each individual processor, recursively.
+ if isinstance(item, _MergedProcessor):
+ accepted_processors = [filter_processor(p) for p in item.processors]
+ return merge_processors(
+ [p for p in accepted_processors if p is not None]
+ )
+
+ # For a `ConditionalProcessor`, check the body.
+ elif isinstance(item, ConditionalProcessor):
+ p = filter_processor(item.processor)
+ if p:
+ return ConditionalProcessor(p, item.filter)
+
+ # Otherwise, check the processor itself.
+ else:
+ if not isinstance(item, excluded_processors):
+ return item
+
+ return None
+
+ filtered_processor = filter_processor(
+ merge_processors(main_control.input_processors or [])
+ )
+ highlight_processor = HighlightIncrementalSearchProcessor()
+
+ if filtered_processor:
+ new_processors = [filtered_processor, highlight_processor]
+ else:
+ new_processors = [highlight_processor]
+
+ from .controls import SearchBufferControl
+
+ assert isinstance(ti.buffer_control, SearchBufferControl)
+
+ buffer_control = BufferControl(
+ buffer=main_control.buffer,
+ input_processors=new_processors,
+ include_default_input_processors=False,
+ lexer=main_control.lexer,
+ preview_search=True,
+ search_buffer_control=ti.buffer_control,
+ )
+
+ return buffer_control.create_content(ti.width, ti.height, preview_search=True)
+
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ from .controls import SearchBufferControl
+
+ assert isinstance(
+ ti.buffer_control, SearchBufferControl
+ ), "`ReverseSearchProcessor` should be applied to a `SearchBufferControl` only."
+
+ source_to_display: SourceToDisplay | None
+ display_to_source: DisplayToSource | None
+
+ main_control = self._get_main_buffer(ti.buffer_control)
+
+ if ti.lineno == 0 and main_control:
+ content = self._content(main_control, ti)
+
+ # Get the line from the original document for this search.
+ line_fragments = content.get_line(content.cursor_position.y)
+
+ if main_control.search_state.direction == SearchDirection.FORWARD:
+ direction_text = "i-search"
+ else:
+ direction_text = "reverse-i-search"
+
+ fragments_before: StyleAndTextTuples = [
+ ("class:prompt.search", "("),
+ ("class:prompt.search", direction_text),
+ ("class:prompt.search", ")`"),
+ ]
+
+ fragments = (
+ fragments_before
+ + [
+ ("class:prompt.search.text", fragment_list_to_text(ti.fragments)),
+ ("", "': "),
+ ]
+ + line_fragments
+ )
+
+ shift_position = fragment_list_len(fragments_before)
+ source_to_display = lambda i: i + shift_position
+ display_to_source = lambda i: i - shift_position
+ else:
+ source_to_display = None
+ display_to_source = None
+ fragments = ti.fragments
+
+ return Transformation(
+ fragments,
+ source_to_display=source_to_display,
+ display_to_source=display_to_source,
+ )
class ConditionalProcessor(Processor):
@@ -343,13 +910,25 @@ class ConditionalProcessor(Processor):
:param filter: :class:`~prompt_toolkit.filters.Filter` instance.
"""
- def __init__(self, processor: Processor, filter: FilterOrBool) ->None:
+ def __init__(self, processor: Processor, filter: FilterOrBool) -> None:
self.processor = processor
self.filter = to_filter(filter)
- def __repr__(self) ->str:
- return '{}(processor={!r}, filter={!r})'.format(self.__class__.
- __name__, self.processor, self.filter)
+ def apply_transformation(
+ self, transformation_input: TransformationInput
+ ) -> Transformation:
+ # Run processor when enabled.
+ if self.filter():
+ return self.processor.apply_transformation(transformation_input)
+ else:
+ return Transformation(transformation_input.fragments)
+
+ def __repr__(self) -> str:
+ return "{}(processor={!r}, filter={!r})".format(
+ self.__class__.__name__,
+ self.processor,
+ self.filter,
+ )
class DynamicProcessor(Processor):
@@ -359,15 +938,25 @@ class DynamicProcessor(Processor):
:param get_processor: Callable that returns a :class:`.Processor` instance.
"""
- def __init__(self, get_processor: Callable[[], Processor | None]) ->None:
+ def __init__(self, get_processor: Callable[[], Processor | None]) -> None:
self.get_processor = get_processor
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ processor = self.get_processor() or DummyProcessor()
+ return processor.apply_transformation(ti)
+
-def merge_processors(processors: list[Processor]) ->Processor:
+def merge_processors(processors: list[Processor]) -> Processor:
"""
Merge multiple `Processor` objects into one.
"""
- pass
+ if len(processors) == 0:
+ return DummyProcessor()
+
+ if len(processors) == 1:
+ return processors[0] # Nothing to merge.
+
+ return _MergedProcessor(processors)
class _MergedProcessor(Processor):
@@ -378,3 +967,47 @@ class _MergedProcessor(Processor):
def __init__(self, processors: list[Processor]):
self.processors = processors
+
+ def apply_transformation(self, ti: TransformationInput) -> Transformation:
+ source_to_display_functions = [ti.source_to_display]
+ display_to_source_functions = []
+ fragments = ti.fragments
+
+ def source_to_display(i: int) -> int:
+ """Translate x position from the buffer to the x position in the
+ processor fragments list."""
+ for f in source_to_display_functions:
+ i = f(i)
+ return i
+
+ for p in self.processors:
+ transformation = p.apply_transformation(
+ TransformationInput(
+ ti.buffer_control,
+ ti.document,
+ ti.lineno,
+ source_to_display,
+ fragments,
+ ti.width,
+ ti.height,
+ )
+ )
+ fragments = transformation.fragments
+ display_to_source_functions.append(transformation.display_to_source)
+ source_to_display_functions.append(transformation.source_to_display)
+
+ def display_to_source(i: int) -> int:
+ for f in reversed(display_to_source_functions):
+ i = f(i)
+ return i
+
+ # In the case of a nested _MergedProcessor, each processor wants to
+ # receive a 'source_to_display' function (as part of the
+ # TransformationInput) that has everything in the chain before
+ # included, because it can be called as part of the
+ # `apply_transformation` function. However, this first
+ # `source_to_display` should not be part of the output that we are
+ # returning. (This is the most consistent with `display_to_source`.)
+ del source_to_display_functions[:1]
+
+ return Transformation(fragments, source_to_display, display_to_source)
diff --git a/src/prompt_toolkit/layout/screen.py b/src/prompt_toolkit/layout/screen.py
index 29cbf2a9..49aebbd6 100644
--- a/src/prompt_toolkit/layout/screen.py
+++ b/src/prompt_toolkit/layout/screen.py
@@ -1,12 +1,20 @@
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']
+
+
+__all__ = [
+ "Screen",
+ "Char",
+]
class Char:
@@ -18,45 +26,125 @@ class Char:
:param char: A single character (can be a double-width character).
:param style: A style string. (Can contain classnames.)
"""
- __slots__ = 'char', 'style', 'width'
- display_mappings: dict[str, str] = {'\x00': '^@', '\x01': '^A', '\x02':
- '^B', '\x03': '^C', '\x04': '^D', '\x05': '^E', '\x06': '^F',
- '\x07': '^G', '\x08': '^H', '\t': '^I', '\n': '^J', '\x0b': '^K',
- '\x0c': '^L', '\r': '^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': '^[', '\x1c': '^\\', '\x1d': '^]', '\x1e':
- '^^', '\x1f': '^_', '\x7f': '^?', '\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>', '\xa0': ' '}
-
- def __init__(self, char: str=' ', style: str='') ->None:
+
+ __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 '
+ if char == "\xa0":
+ style += " class:nbsp " # Will be underlined.
else:
- style += ' class:control-character '
+ 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})'
+ 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]'
+_CHAR_CACHE: FastDictCache[tuple[str, str], Char] = FastDictCache(
+ Char, size=1000 * 1000
+)
+Transparent = "[transparent]"
class Screen:
@@ -64,91 +152,178 @@ 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:
+ 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]
+ default_char2 = _CHAR_CACHE[" ", Transparent]
else:
default_char2 = default_char
- self.data_buffer: defaultdict[int, defaultdict[int, Char]
- ] = defaultdict(lambda : defaultdict(lambda : default_char2))
- self.zero_width_escapes: defaultdict[int, defaultdict[int, str]
- ] = defaultdict(lambda : defaultdict(lambda : ''))
- self.cursor_positions: dict[Window, Point] = {}
+
+ self.data_buffer: defaultdict[int, defaultdict[int, Char]] = defaultdict(
+ lambda: defaultdict(lambda: default_char2)
+ )
+
+ #: Escape sequences to be injected.
+ self.zero_width_escapes: defaultdict[int, defaultdict[int, str]] = defaultdict(
+ lambda: defaultdict(lambda: "")
+ )
+
+ #: Position of the cursor.
+ self.cursor_positions: dict[
+ Window, Point
+ ] = {} # Map `Window` objects to `Point` objects.
+
+ #: Visibility of the cursor.
self.show_cursor = True
- self.menu_positions: dict[Window, Point] = {}
+
+ #: (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
- self.visible_windows_to_write_positions: dict[Window, WritePosition
- ] = {}
+
+ # 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]]] = []
- def set_cursor_position(self, window: Window, position: Point) ->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.
"""
- pass
+ self.cursor_positions[window] = position
- def set_menu_position(self, window: Window, position: Point) ->None:
+ def set_menu_position(self, window: Window, position: Point) -> None:
"""
Set the cursor position for a given window.
"""
- pass
+ self.menu_positions[window] = position
- def get_cursor_position(self, window: Window) ->Point:
+ def get_cursor_position(self, window: Window) -> Point:
"""
Get the cursor position for a given window.
Returns a `Point`.
"""
- pass
+ try:
+ return self.cursor_positions[window]
+ except KeyError:
+ return Point(x=0, y=0)
- def get_menu_position(self, window: Window) ->Point:
+ 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.)
"""
- pass
+ 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:
+ 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.
"""
- pass
+ self._draw_float_functions.append((z_index, draw_func))
- def draw_all_floats(self) ->None:
+ def draw_all_floats(self) -> None:
"""
Draw all float functions in order of z-index.
"""
- pass
+ # 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:
+ 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`.
"""
- pass
+ b = self.data_buffer
+ char_cache = _CHAR_CACHE
- def fill_area(self, write_position: WritePosition, style: str='', after:
- bool=False) ->None:
+ 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.
"""
- pass
+ 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
-class WritePosition:
+ if after:
+ append_style = " " + style
+ prepend_style = ""
+ else:
+ append_style = ""
+ prepend_style = style + " "
- def __init__(self, xpos: int, ypos: int, width: int, height: int) ->None:
+ for y in range(
+ write_position.ypos, write_position.ypos + write_position.height
+ ):
+ row = data_buffer[y]
+ for x in range(xmin, xmax):
+ cell = row[x]
+ row[x] = char_cache[
+ cell.char, prepend_style + cell.style + append_style
+ ]
+
+
+class WritePosition:
+ def __init__(self, xpos: int, ypos: int, width: int, height: int) -> None:
assert height >= 0
assert width >= 0
+ # xpos and ypos can be negative. (A float can be partially visible.)
+
self.xpos = xpos
self.ypos = ypos
self.width = width
self.height = height
- def __repr__(self) ->str:
- return '{}(x={!r}, y={!r}, width={!r}, height={!r})'.format(self.
- __class__.__name__, self.xpos, self.ypos, self.width, self.height)
+ def __repr__(self) -> str:
+ return "{}(x={!r}, y={!r}, width={!r}, height={!r})".format(
+ self.__class__.__name__,
+ self.xpos,
+ self.ypos,
+ self.width,
+ self.height,
+ )
diff --git a/src/prompt_toolkit/layout/scrollable_pane.py b/src/prompt_toolkit/layout/scrollable_pane.py
index 22c7047e..e38fd761 100644
--- a/src/prompt_toolkit/layout/scrollable_pane.py
+++ b/src/prompt_toolkit/layout/scrollable_pane.py
@@ -1,14 +1,19 @@
from __future__ import annotations
+
from prompt_toolkit.data_structures import Point
from prompt_toolkit.filters import FilterOrBool, to_filter
from prompt_toolkit.key_binding import KeyBindingsBase
from prompt_toolkit.mouse_events import MouseEvent
+
from .containers import Container, ScrollOffsets
from .dimension import AnyDimension, Dimension, sum_layout_dimensions, to_dimension
from .mouse_handlers import MouseHandler, MouseHandlers
from .screen import Char, Screen, WritePosition
-__all__ = ['ScrollablePane']
-MAX_AVAILABLE_HEIGHT = 10000
+
+__all__ = ["ScrollablePane"]
+
+# Never go beyond this height, because performance will degrade.
+MAX_AVAILABLE_HEIGHT = 10_000
class ScrollablePane(Container):
@@ -44,18 +49,24 @@ class ScrollablePane(Container):
:param show_scrollbar: When `True` display a scrollbar on the right.
"""
- def __init__(self, content: Container, scroll_offsets: (ScrollOffsets |
- None)=None, keep_cursor_visible: FilterOrBool=True,
- keep_focused_window_visible: FilterOrBool=True,
- max_available_height: int=MAX_AVAILABLE_HEIGHT, width: AnyDimension
- =None, height: AnyDimension=None, show_scrollbar: FilterOrBool=True,
- display_arrows: FilterOrBool=True, up_arrow_symbol: str='^',
- down_arrow_symbol: str='v') ->None:
+ def __init__(
+ self,
+ content: Container,
+ scroll_offsets: ScrollOffsets | None = None,
+ keep_cursor_visible: FilterOrBool = True,
+ keep_focused_window_visible: FilterOrBool = True,
+ max_available_height: int = MAX_AVAILABLE_HEIGHT,
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ show_scrollbar: FilterOrBool = True,
+ display_arrows: FilterOrBool = True,
+ up_arrow_symbol: str = "^",
+ down_arrow_symbol: str = "v",
+ ) -> None:
self.content = content
self.scroll_offsets = scroll_offsets or ScrollOffsets(top=1, bottom=1)
self.keep_cursor_visible = to_filter(keep_cursor_visible)
- self.keep_focused_window_visible = to_filter(
- keep_focused_window_visible)
+ self.keep_focused_window_visible = to_filter(keep_focused_window_visible)
self.max_available_height = max_available_height
self.width = width
self.height = height
@@ -63,57 +74,293 @@ class ScrollablePane(Container):
self.display_arrows = to_filter(display_arrows)
self.up_arrow_symbol = up_arrow_symbol
self.down_arrow_symbol = down_arrow_symbol
+
self.vertical_scroll = 0
- def __repr__(self) ->str:
- return f'ScrollablePane({self.content!r})'
+ def __repr__(self) -> str:
+ return f"ScrollablePane({self.content!r})"
+
+ def reset(self) -> None:
+ self.content.reset()
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ if self.width is not None:
+ return to_dimension(self.width)
+
+ # We're only scrolling vertical. So the preferred width is equal to
+ # that of the content.
+ content_width = self.content.preferred_width(max_available_width)
- def write_to_screen(self, screen: Screen, mouse_handlers: MouseHandlers,
- write_position: WritePosition, parent_style: str, erase_bg: bool,
- z_index: (int | None)) ->None:
+ # If a scrollbar needs to be displayed, add +1 to the content width.
+ if self.show_scrollbar():
+ return sum_layout_dimensions([Dimension.exact(1), content_width])
+
+ return content_width
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ if self.height is not None:
+ return to_dimension(self.height)
+
+ # Prefer a height large enough so that it fits all the content. If not,
+ # we'll make the pane scrollable.
+ if self.show_scrollbar():
+ # If `show_scrollbar` is set. Always reserve space for the scrollbar.
+ width -= 1
+
+ dimension = self.content.preferred_height(width, self.max_available_height)
+
+ # Only take 'preferred' into account. Min/max can be anything.
+ return Dimension(min=0, preferred=dimension.preferred)
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
"""
Render scrollable pane content.
This works by rendering on an off-screen canvas, and copying over the
visible region.
"""
- pass
+ show_scrollbar = self.show_scrollbar()
+
+ if show_scrollbar:
+ virtual_width = write_position.width - 1
+ else:
+ virtual_width = write_position.width
+
+ # Compute preferred height again.
+ virtual_height = self.content.preferred_height(
+ virtual_width, self.max_available_height
+ ).preferred
+
+ # Ensure virtual height is at least the available height.
+ virtual_height = max(virtual_height, write_position.height)
+ virtual_height = min(virtual_height, self.max_available_height)
+
+ # First, write the content to a virtual screen, then copy over the
+ # visible part to the real screen.
+ temp_screen = Screen(default_char=Char(char=" ", style=parent_style))
+ temp_screen.show_cursor = screen.show_cursor
+ temp_write_position = WritePosition(
+ xpos=0, ypos=0, width=virtual_width, height=virtual_height
+ )
+
+ temp_mouse_handlers = MouseHandlers()
+
+ self.content.write_to_screen(
+ temp_screen,
+ temp_mouse_handlers,
+ temp_write_position,
+ parent_style,
+ erase_bg,
+ z_index,
+ )
+ temp_screen.draw_all_floats()
+
+ # If anything in the virtual screen is focused, move vertical scroll to
+ from prompt_toolkit.application import get_app
+
+ focused_window = get_app().layout.current_window
+
+ try:
+ visible_win_write_pos = temp_screen.visible_windows_to_write_positions[
+ focused_window
+ ]
+ except KeyError:
+ pass # No window focused here. Don't scroll.
+ else:
+ # Make sure this window is visible.
+ self._make_window_visible(
+ write_position.height,
+ virtual_height,
+ visible_win_write_pos,
+ temp_screen.cursor_positions.get(focused_window),
+ )
+
+ # Copy over virtual screen and zero width escapes to real screen.
+ self._copy_over_screen(screen, temp_screen, write_position, virtual_width)
+
+ # Copy over mouse handlers.
+ self._copy_over_mouse_handlers(
+ mouse_handlers, temp_mouse_handlers, write_position, virtual_width
+ )
+
+ # Set screen.width/height.
+ ypos = write_position.ypos
+ xpos = write_position.xpos
+
+ screen.width = max(screen.width, xpos + virtual_width)
+ screen.height = max(screen.height, ypos + write_position.height)
+
+ # Copy over window write positions.
+ self._copy_over_write_positions(screen, temp_screen, write_position)
+
+ if temp_screen.show_cursor:
+ screen.show_cursor = True
+
+ # Copy over cursor positions, if they are visible.
+ for window, point in temp_screen.cursor_positions.items():
+ if (
+ 0 <= point.x < write_position.width
+ and self.vertical_scroll
+ <= point.y
+ < write_position.height + self.vertical_scroll
+ ):
+ screen.cursor_positions[window] = Point(
+ x=point.x + xpos, y=point.y + ypos - self.vertical_scroll
+ )
+
+ # Copy over menu positions, but clip them to the visible area.
+ for window, point in temp_screen.menu_positions.items():
+ screen.menu_positions[window] = self._clip_point_to_visible_area(
+ Point(x=point.x + xpos, y=point.y + ypos - self.vertical_scroll),
+ write_position,
+ )
- def _clip_point_to_visible_area(self, point: Point, write_position:
- WritePosition) ->Point:
+ # Draw scrollbar.
+ if show_scrollbar:
+ self._draw_scrollbar(
+ write_position,
+ virtual_height,
+ screen,
+ )
+
+ def _clip_point_to_visible_area(
+ self, point: Point, write_position: WritePosition
+ ) -> Point:
"""
Ensure that the cursor and menu positions always are always reported
"""
- pass
+ if point.x < write_position.xpos:
+ point = point._replace(x=write_position.xpos)
+ if point.y < write_position.ypos:
+ point = point._replace(y=write_position.ypos)
+ if point.x >= write_position.xpos + write_position.width:
+ point = point._replace(x=write_position.xpos + write_position.width - 1)
+ if point.y >= write_position.ypos + write_position.height:
+ point = point._replace(y=write_position.ypos + write_position.height - 1)
+
+ return point
- def _copy_over_screen(self, screen: Screen, temp_screen: Screen,
- write_position: WritePosition, virtual_width: int) ->None:
+ def _copy_over_screen(
+ self,
+ screen: Screen,
+ temp_screen: Screen,
+ write_position: WritePosition,
+ virtual_width: int,
+ ) -> None:
"""
Copy over visible screen content and "zero width escape sequences".
"""
- pass
+ ypos = write_position.ypos
+ xpos = write_position.xpos
+
+ for y in range(write_position.height):
+ temp_row = temp_screen.data_buffer[y + self.vertical_scroll]
+ row = screen.data_buffer[y + ypos]
+ temp_zero_width_escapes = temp_screen.zero_width_escapes[
+ y + self.vertical_scroll
+ ]
+ zero_width_escapes = screen.zero_width_escapes[y + ypos]
+
+ for x in range(virtual_width):
+ row[x + xpos] = temp_row[x]
- def _copy_over_mouse_handlers(self, mouse_handlers: MouseHandlers,
- temp_mouse_handlers: MouseHandlers, write_position: WritePosition,
- virtual_width: int) ->None:
+ if x in temp_zero_width_escapes:
+ zero_width_escapes[x + xpos] = temp_zero_width_escapes[x]
+
+ def _copy_over_mouse_handlers(
+ self,
+ mouse_handlers: MouseHandlers,
+ temp_mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ virtual_width: int,
+ ) -> None:
"""
Copy over mouse handlers from virtual screen to real screen.
Note: we take `virtual_width` because we don't want to copy over mouse
handlers that we possibly have behind the scrollbar.
"""
- pass
+ ypos = write_position.ypos
+ xpos = write_position.xpos
+
+ # Cache mouse handlers when wrapping them. Very often the same mouse
+ # handler is registered for many positions.
+ mouse_handler_wrappers: dict[MouseHandler, MouseHandler] = {}
+
+ def wrap_mouse_handler(handler: MouseHandler) -> MouseHandler:
+ "Wrap mouse handler. Translate coordinates in `MouseEvent`."
+ if handler not in mouse_handler_wrappers:
+
+ def new_handler(event: MouseEvent) -> None:
+ new_event = MouseEvent(
+ position=Point(
+ x=event.position.x - xpos,
+ y=event.position.y + self.vertical_scroll - ypos,
+ ),
+ event_type=event.event_type,
+ button=event.button,
+ modifiers=event.modifiers,
+ )
+ handler(new_event)
- def _copy_over_write_positions(self, screen: Screen, temp_screen:
- Screen, write_position: WritePosition) ->None:
+ mouse_handler_wrappers[handler] = new_handler
+ return mouse_handler_wrappers[handler]
+
+ # Copy handlers.
+ mouse_handlers_dict = mouse_handlers.mouse_handlers
+ temp_mouse_handlers_dict = temp_mouse_handlers.mouse_handlers
+
+ for y in range(write_position.height):
+ if y in temp_mouse_handlers_dict:
+ temp_mouse_row = temp_mouse_handlers_dict[y + self.vertical_scroll]
+ mouse_row = mouse_handlers_dict[y + ypos]
+ for x in range(virtual_width):
+ if x in temp_mouse_row:
+ mouse_row[x + xpos] = wrap_mouse_handler(temp_mouse_row[x])
+
+ def _copy_over_write_positions(
+ self, screen: Screen, temp_screen: Screen, write_position: WritePosition
+ ) -> None:
"""
Copy over window write positions.
"""
- pass
+ ypos = write_position.ypos
+ xpos = write_position.xpos
+
+ for win, write_pos in temp_screen.visible_windows_to_write_positions.items():
+ screen.visible_windows_to_write_positions[win] = WritePosition(
+ xpos=write_pos.xpos + xpos,
+ ypos=write_pos.ypos + ypos - self.vertical_scroll,
+ # TODO: if the window is only partly visible, then truncate width/height.
+ # This could be important if we have nested ScrollablePanes.
+ height=write_pos.height,
+ width=write_pos.width,
+ )
- def _make_window_visible(self, visible_height: int, virtual_height: int,
- visible_win_write_pos: WritePosition, cursor_position: (Point | None)
- ) ->None:
+ def is_modal(self) -> bool:
+ return self.content.is_modal()
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ return self.content.get_key_bindings()
+
+ def get_children(self) -> list[Container]:
+ return [self.content]
+
+ def _make_window_visible(
+ self,
+ visible_height: int,
+ virtual_height: int,
+ visible_win_write_pos: WritePosition,
+ cursor_position: Point | None,
+ ) -> None:
"""
Scroll the scrollable pane, so that this window becomes visible.
@@ -124,14 +371,124 @@ class ScrollablePane(Container):
:param cursor_position: The location of the cursor position of this
window on the temp screen.
"""
- pass
+ # Start with maximum allowed scroll range, and then reduce according to
+ # the focused window and cursor position.
+ min_scroll = 0
+ max_scroll = virtual_height - visible_height
+
+ if self.keep_cursor_visible():
+ # Reduce min/max scroll according to the cursor in the focused window.
+ if cursor_position is not None:
+ offsets = self.scroll_offsets
+ cpos_min_scroll = (
+ cursor_position.y - visible_height + 1 + offsets.bottom
+ )
+ cpos_max_scroll = cursor_position.y - offsets.top
+ min_scroll = max(min_scroll, cpos_min_scroll)
+ max_scroll = max(0, min(max_scroll, cpos_max_scroll))
- def _draw_scrollbar(self, write_position: WritePosition, content_height:
- int, screen: Screen) ->None:
+ if self.keep_focused_window_visible():
+ # Reduce min/max scroll according to focused window position.
+ # If the window is small enough, bot the top and bottom of the window
+ # should be visible.
+ if visible_win_write_pos.height <= visible_height:
+ window_min_scroll = (
+ visible_win_write_pos.ypos
+ + visible_win_write_pos.height
+ - visible_height
+ )
+ window_max_scroll = visible_win_write_pos.ypos
+ else:
+ # Window does not fit on the screen. Make sure at least the whole
+ # screen is occupied with this window, and nothing else is shown.
+ window_min_scroll = visible_win_write_pos.ypos
+ window_max_scroll = (
+ visible_win_write_pos.ypos
+ + visible_win_write_pos.height
+ - visible_height
+ )
+
+ min_scroll = max(min_scroll, window_min_scroll)
+ max_scroll = min(max_scroll, window_max_scroll)
+
+ if min_scroll > max_scroll:
+ min_scroll = max_scroll # Should not happen.
+
+ # Finally, properly clip the vertical scroll.
+ if self.vertical_scroll > max_scroll:
+ self.vertical_scroll = max_scroll
+ if self.vertical_scroll < min_scroll:
+ self.vertical_scroll = min_scroll
+
+ def _draw_scrollbar(
+ self, write_position: WritePosition, content_height: int, screen: Screen
+ ) -> None:
"""
Draw the scrollbar on the screen.
Note: There is some code duplication with the `ScrollbarMargin`
implementation.
"""
- pass
+
+ window_height = write_position.height
+ display_arrows = self.display_arrows()
+
+ if display_arrows:
+ window_height -= 2
+
+ try:
+ fraction_visible = write_position.height / float(content_height)
+ fraction_above = self.vertical_scroll / float(content_height)
+
+ scrollbar_height = int(
+ min(window_height, max(1, window_height * fraction_visible))
+ )
+ scrollbar_top = int(window_height * fraction_above)
+ except ZeroDivisionError:
+ return
+ else:
+
+ def is_scroll_button(row: int) -> bool:
+ "True if we should display a button on this row."
+ return scrollbar_top <= row <= scrollbar_top + scrollbar_height
+
+ xpos = write_position.xpos + write_position.width - 1
+ ypos = write_position.ypos
+ data_buffer = screen.data_buffer
+
+ # Up arrow.
+ if display_arrows:
+ data_buffer[ypos][xpos] = Char(
+ self.up_arrow_symbol, "class:scrollbar.arrow"
+ )
+ ypos += 1
+
+ # Scrollbar body.
+ scrollbar_background = "class:scrollbar.background"
+ scrollbar_background_start = "class:scrollbar.background,scrollbar.start"
+ scrollbar_button = "class:scrollbar.button"
+ scrollbar_button_end = "class:scrollbar.button,scrollbar.end"
+
+ for i in range(window_height):
+ style = ""
+ if is_scroll_button(i):
+ if not is_scroll_button(i + 1):
+ # Give the last cell a different style, because we want
+ # to underline this.
+ style = scrollbar_button_end
+ else:
+ style = scrollbar_button
+ else:
+ if is_scroll_button(i + 1):
+ style = scrollbar_background_start
+ else:
+ style = scrollbar_background
+
+ data_buffer[ypos][xpos] = Char(" ", style)
+ ypos += 1
+
+ # Down arrow
+ if display_arrows:
+ data_buffer[ypos][xpos] = Char(
+ self.down_arrow_symbol, "class:scrollbar.arrow"
+ )
diff --git a/src/prompt_toolkit/layout/utils.py b/src/prompt_toolkit/layout/utils.py
index b4df4dc7..0f78f371 100644
--- a/src/prompt_toolkit/layout/utils.py
+++ b/src/prompt_toolkit/layout/utils.py
@@ -1,10 +1,17 @@
from __future__ import annotations
+
from typing import TYPE_CHECKING, Iterable, List, TypeVar, cast, overload
+
from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple
+
if TYPE_CHECKING:
from typing_extensions import SupportsIndex
-__all__ = ['explode_text_fragments']
-_T = TypeVar('_T', bound=OneStyleAndTextTuple)
+
+__all__ = [
+ "explode_text_fragments",
+]
+
+_T = TypeVar("_T", bound=OneStyleAndTextTuple)
class _ExplodedList(List[_T]):
@@ -14,18 +21,31 @@ class _ExplodedList(List[_T]):
As soon as items are added or the list is extended, the new items are
automatically exploded as well.
"""
+
exploded = True
+ def append(self, item: _T) -> None:
+ self.extend([item])
+
+ def extend(self, lst: Iterable[_T]) -> None:
+ super().extend(explode_text_fragments(lst))
+
+ def insert(self, index: SupportsIndex, item: _T) -> None:
+ raise NotImplementedError # TODO
+
+ # TODO: When creating a copy() or [:], return also an _ExplodedList.
+
@overload
- def __setitem__(self, index: SupportsIndex, value: _T) ->None:
+ def __setitem__(self, index: SupportsIndex, value: _T) -> None:
...
@overload
- def __setitem__(self, index: slice, value: Iterable[_T]) ->None:
+ def __setitem__(self, index: slice, value: Iterable[_T]) -> None:
...
- def __setitem__(self, index: (SupportsIndex | slice), value: (_T |
- Iterable[_T])) ->None:
+ def __setitem__(
+ self, index: SupportsIndex | slice, value: _T | Iterable[_T]
+ ) -> None:
"""
Ensure that when `(style_str, 'long string')` is set, the string will be
exploded.
@@ -33,12 +53,13 @@ class _ExplodedList(List[_T]):
if not isinstance(index, slice):
int_index = index.__index__()
index = slice(int_index, int_index + 1)
- if isinstance(value, tuple):
- value = cast('List[_T]', [value])
+ if isinstance(value, tuple): # In case of `OneStyleAndTextTuple`.
+ value = cast("List[_T]", [value])
+
super().__setitem__(index, explode_text_fragments(value))
-def explode_text_fragments(fragments: Iterable[_T]) ->_ExplodedList[_T]:
+def explode_text_fragments(fragments: Iterable[_T]) -> _ExplodedList[_T]:
"""
Turn a list of (style_str, text) tuples into another list where each string is
exactly one character.
@@ -48,4 +69,14 @@ def explode_text_fragments(fragments: Iterable[_T]) ->_ExplodedList[_T]:
:param fragments: List of (style, text) tuples.
"""
- pass
+ # When the fragments is already exploded, don't explode again.
+ if isinstance(fragments, _ExplodedList):
+ return fragments
+
+ result: list[_T] = []
+
+ for style, string, *rest in fragments:
+ for c in string:
+ result.append((style, c, *rest)) # type: ignore
+
+ return _ExplodedList(result)
diff --git a/src/prompt_toolkit/lexers/base.py b/src/prompt_toolkit/lexers/base.py
index a6b501bd..3f65f8e7 100644
--- a/src/prompt_toolkit/lexers/base.py
+++ b/src/prompt_toolkit/lexers/base.py
@@ -2,11 +2,18 @@
Base classes for prompt_toolkit lexers.
"""
from __future__ import annotations
+
from abc import ABCMeta, abstractmethod
from typing import Callable, Hashable
+
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text.base import StyleAndTextTuples
-__all__ = ['Lexer', 'SimpleLexer', 'DynamicLexer']
+
+__all__ = [
+ "Lexer",
+ "SimpleLexer",
+ "DynamicLexer",
+]
class Lexer(metaclass=ABCMeta):
@@ -15,8 +22,7 @@ class Lexer(metaclass=ABCMeta):
"""
@abstractmethod
- def lex_document(self, document: Document) ->Callable[[int],
- StyleAndTextTuples]:
+ def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
"""
Takes a :class:`~prompt_toolkit.document.Document` and returns a
callable that takes a line number and returns a list of
@@ -25,14 +31,13 @@ class Lexer(metaclass=ABCMeta):
XXX: Note that in the past, this was supposed to return a list
of ``(Token, text)`` tuples, just like a Pygments lexer.
"""
- pass
- def invalidation_hash(self) ->Hashable:
+ def invalidation_hash(self) -> Hashable:
"""
When this changes, `lex_document` could give a different output.
(Only used for `DynamicLexer`.)
"""
- pass
+ return id(self)
class SimpleLexer(Lexer):
@@ -43,9 +48,21 @@ class SimpleLexer(Lexer):
:param style: The style string for this lexer.
"""
- def __init__(self, style: str='') ->None:
+ def __init__(self, style: str = "") -> None:
self.style = style
+ def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
+ lines = document.lines
+
+ def get_line(lineno: int) -> StyleAndTextTuples:
+ "Return the tokens for the given line."
+ try:
+ return [(self.style, lines[lineno])]
+ except IndexError:
+ return []
+
+ return get_line
+
class DynamicLexer(Lexer):
"""
@@ -54,6 +71,14 @@ class DynamicLexer(Lexer):
:param get_lexer: Callable that returns a :class:`.Lexer` instance.
"""
- def __init__(self, get_lexer: Callable[[], Lexer | None]) ->None:
+ def __init__(self, get_lexer: Callable[[], Lexer | None]) -> None:
self.get_lexer = get_lexer
self._dummy = SimpleLexer()
+
+ def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
+ lexer = self.get_lexer() or self._dummy
+ return lexer.lex_document(document)
+
+ def invalidation_hash(self) -> Hashable:
+ lexer = self.get_lexer() or self._dummy
+ return id(lexer)
diff --git a/src/prompt_toolkit/lexers/pygments.py b/src/prompt_toolkit/lexers/pygments.py
index 79f8caa1..4721d730 100644
--- a/src/prompt_toolkit/lexers/pygments.py
+++ b/src/prompt_toolkit/lexers/pygments.py
@@ -5,18 +5,28 @@ This includes syntax synchronization code, so that we don't have to start
lexing at the beginning of a document, when displaying a very large text.
"""
from __future__ import annotations
+
import re
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING, Callable, Dict, Generator, Iterable, Tuple
+
from prompt_toolkit.document import Document
from prompt_toolkit.filters import FilterOrBool, to_filter
from prompt_toolkit.formatted_text.base import StyleAndTextTuples
from prompt_toolkit.formatted_text.utils import split_lines
from prompt_toolkit.styles.pygments import pygments_token_to_classname
+
from .base import Lexer, SimpleLexer
+
if TYPE_CHECKING:
from pygments.lexer import Lexer as PygmentsLexerCls
-__all__ = ['PygmentsLexer', 'SyntaxSync', 'SyncFromStart', 'RegexSync']
+
+__all__ = [
+ "PygmentsLexer",
+ "SyntaxSync",
+ "SyncFromStart",
+ "RegexSync",
+]
class SyntaxSync(metaclass=ABCMeta):
@@ -28,8 +38,9 @@ class SyntaxSync(metaclass=ABCMeta):
"""
@abstractmethod
- def get_sync_start_position(self, document: Document, lineno: int) ->tuple[
- int, int]:
+ def get_sync_start_position(
+ self, document: Document, lineno: int
+ ) -> tuple[int, int]:
"""
Return the position from where we can start lexing as a (row, column)
tuple.
@@ -38,7 +49,6 @@ class SyntaxSync(metaclass=ABCMeta):
:param lineno: The line that we want to highlight. (We need to return
this line, or an earlier position.)
"""
- pass
class SyncFromStart(SyntaxSync):
@@ -46,30 +56,70 @@ class SyncFromStart(SyntaxSync):
Always start the syntax highlighting from the beginning.
"""
+ def get_sync_start_position(
+ self, document: Document, lineno: int
+ ) -> tuple[int, int]:
+ return 0, 0
+
class RegexSync(SyntaxSync):
"""
Synchronize by starting at a line that matches the given regex pattern.
"""
+
+ # Never go more than this amount of lines backwards for synchronization.
+ # That would be too CPU intensive.
MAX_BACKWARDS = 500
+
+ # Start lexing at the start, if we are in the first 'n' lines and no
+ # synchronization position was found.
FROM_START_IF_NO_SYNC_POS_FOUND = 100
- def __init__(self, pattern: str) ->None:
+ def __init__(self, pattern: str) -> None:
self._compiled_pattern = re.compile(pattern)
- def get_sync_start_position(self, document: Document, lineno: int) ->tuple[
- int, int]:
+ def get_sync_start_position(
+ self, document: Document, lineno: int
+ ) -> tuple[int, int]:
"""
Scan backwards, and find a possible position to start.
"""
- pass
+ pattern = self._compiled_pattern
+ lines = document.lines
+
+ # Scan upwards, until we find a point where we can start the syntax
+ # synchronization.
+ for i in range(lineno, max(-1, lineno - self.MAX_BACKWARDS), -1):
+ match = pattern.match(lines[i])
+ if match:
+ return i, match.start()
+
+ # No synchronization point found. If we aren't that far from the
+ # beginning, start at the very beginning, otherwise, just try to start
+ # at the current line.
+ if lineno < self.FROM_START_IF_NO_SYNC_POS_FOUND:
+ return 0, 0
+ else:
+ return lineno, 0
@classmethod
- def from_pygments_lexer_cls(cls, lexer_cls: PygmentsLexerCls) ->RegexSync:
+ def from_pygments_lexer_cls(cls, lexer_cls: PygmentsLexerCls) -> RegexSync:
"""
Create a :class:`.RegexSync` instance for this Pygments lexer class.
"""
- pass
+ patterns = {
+ # For Python, start highlighting at any class/def block.
+ "Python": r"^\s*(class|def)\s+",
+ "Python 3": r"^\s*(class|def)\s+",
+ # For HTML, start at any open/close tag definition.
+ "HTML": r"<[/a-zA-Z]",
+ # For javascript, start at a function.
+ "JavaScript": r"\bfunction\b",
+ # TODO: Add definitions for other languages.
+ # By default, we start at every possible line.
+ }
+ p = patterns.get(lexer_cls.name, "^")
+ return cls(p)
class _TokenCache(Dict[Tuple[str, ...], str]):
@@ -80,8 +130,8 @@ class _TokenCache(Dict[Tuple[str, ...], str]):
``class:pygments,pygments.A,pygments.A.B,pygments.A.B.C``
"""
- def __missing__(self, key: tuple[str, ...]) ->str:
- result = 'class:' + pygments_token_to_classname(key)
+ def __missing__(self, key: tuple[str, ...]) -> str:
+ result = "class:" + pygments_token_to_classname(key)
self[key] = result
return result
@@ -113,31 +163,165 @@ class PygmentsLexer(Lexer):
than 1,000 lines.
:param syntax_sync: `SyntaxSync` object.
"""
+
+ # Minimum amount of lines to go backwards when starting the parser.
+ # This is important when the lines are retrieved in reverse order, or when
+ # scrolling upwards. (Due to the complexity of calculating the vertical
+ # scroll offset in the `Window` class, lines are not always retrieved in
+ # order.)
MIN_LINES_BACKWARDS = 50
+
+ # When a parser was started this amount of lines back, read the parser
+ # until we get the current line. Otherwise, start a new parser.
+ # (This should probably be bigger than MIN_LINES_BACKWARDS.)
REUSE_GENERATOR_MAX_DISTANCE = 100
- def __init__(self, pygments_lexer_cls: type[PygmentsLexerCls],
- sync_from_start: FilterOrBool=True, syntax_sync: (SyntaxSync | None
- )=None) ->None:
+ def __init__(
+ self,
+ pygments_lexer_cls: type[PygmentsLexerCls],
+ sync_from_start: FilterOrBool = True,
+ syntax_sync: SyntaxSync | None = None,
+ ) -> None:
self.pygments_lexer_cls = pygments_lexer_cls
self.sync_from_start = to_filter(sync_from_start)
- self.pygments_lexer = pygments_lexer_cls(stripnl=False, stripall=
- False, ensurenl=False)
+
+ # Instantiate the Pygments lexer.
+ self.pygments_lexer = pygments_lexer_cls(
+ stripnl=False, stripall=False, ensurenl=False
+ )
+
+ # Create syntax sync instance.
self.syntax_sync = syntax_sync or RegexSync.from_pygments_lexer_cls(
- pygments_lexer_cls)
+ pygments_lexer_cls
+ )
@classmethod
- def from_filename(cls, filename: str, sync_from_start: FilterOrBool=True
- ) ->Lexer:
+ def from_filename(
+ cls, filename: str, sync_from_start: FilterOrBool = True
+ ) -> Lexer:
"""
Create a `Lexer` from a filename.
"""
- pass
+ # Inline imports: the Pygments dependency is optional!
+ from pygments.lexers import get_lexer_for_filename
+ from pygments.util import ClassNotFound
- def lex_document(self, document: Document) ->Callable[[int],
- StyleAndTextTuples]:
+ try:
+ pygments_lexer = get_lexer_for_filename(filename)
+ except ClassNotFound:
+ return SimpleLexer()
+ else:
+ return cls(pygments_lexer.__class__, sync_from_start=sync_from_start)
+
+ def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
"""
Create a lexer function that takes a line number and returns the list
of (style_str, text) tuples as the Pygments lexer returns for that line.
"""
- pass
+ LineGenerator = Generator[Tuple[int, StyleAndTextTuples], None, None]
+
+ # Cache of already lexed lines.
+ cache: dict[int, StyleAndTextTuples] = {}
+
+ # Pygments generators that are currently lexing.
+ # Map lexer generator to the line number.
+ line_generators: dict[LineGenerator, int] = {}
+
+ def get_syntax_sync() -> SyntaxSync:
+ "The Syntax synchronization object that we currently use."
+ if self.sync_from_start():
+ return SyncFromStart()
+ else:
+ return self.syntax_sync
+
+ def find_closest_generator(i: int) -> LineGenerator | None:
+ "Return a generator close to line 'i', or None if none was found."
+ for generator, lineno in line_generators.items():
+ if lineno < i and i - lineno < self.REUSE_GENERATOR_MAX_DISTANCE:
+ return generator
+ return None
+
+ def create_line_generator(start_lineno: int, column: int = 0) -> LineGenerator:
+ """
+ Create a generator that yields the lexed lines.
+ Each iteration it yields a (line_number, [(style_str, text), ...]) tuple.
+ """
+
+ def get_text_fragments() -> Iterable[tuple[str, str]]:
+ text = "\n".join(document.lines[start_lineno:])[column:]
+
+ # We call `get_text_fragments_unprocessed`, because `get_tokens` will
+ # still replace \r\n and \r by \n. (We don't want that,
+ # Pygments should return exactly the same amount of text, as we
+ # have given as input.)
+ for _, t, v in self.pygments_lexer.get_tokens_unprocessed(text):
+ # Turn Pygments `Token` object into prompt_toolkit style
+ # strings.
+ yield _token_cache[t], v
+
+ yield from enumerate(split_lines(list(get_text_fragments())), start_lineno)
+
+ def get_generator(i: int) -> LineGenerator:
+ """
+ Find an already started generator that is close, or create a new one.
+ """
+ # Find closest line generator.
+ generator = find_closest_generator(i)
+ if generator:
+ return generator
+
+ # No generator found. Determine starting point for the syntax
+ # synchronization first.
+
+ # Go at least x lines back. (Make scrolling upwards more
+ # efficient.)
+ i = max(0, i - self.MIN_LINES_BACKWARDS)
+
+ if i == 0:
+ row = 0
+ column = 0
+ else:
+ row, column = get_syntax_sync().get_sync_start_position(document, i)
+
+ # Find generator close to this point, or otherwise create a new one.
+ generator = find_closest_generator(i)
+ if generator:
+ return generator
+ else:
+ generator = create_line_generator(row, column)
+
+ # If the column is not 0, ignore the first line. (Which is
+ # incomplete. This happens when the synchronization algorithm tells
+ # us to start parsing in the middle of a line.)
+ if column:
+ next(generator)
+ row += 1
+
+ line_generators[generator] = row
+ return generator
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ "Return the tokens for a given line number."
+ try:
+ return cache[i]
+ except KeyError:
+ generator = get_generator(i)
+
+ # Exhaust the generator, until we find the requested line.
+ for num, line in generator:
+ cache[num] = line
+ if num == i:
+ line_generators[generator] = i
+
+ # Remove the next item from the cache.
+ # (It could happen that it's already there, because of
+ # another generator that started filling these lines,
+ # but we want to synchronize these lines with the
+ # current lexer's state.)
+ if num + 1 in cache:
+ del cache[num + 1]
+
+ return cache[num]
+ return []
+
+ return get_line
diff --git a/src/prompt_toolkit/log.py b/src/prompt_toolkit/log.py
index 6ac74254..adb5172a 100644
--- a/src/prompt_toolkit/log.py
+++ b/src/prompt_toolkit/log.py
@@ -2,6 +2,11 @@
Logging configuration.
"""
from __future__ import annotations
+
import logging
-__all__ = ['logger']
+
+__all__ = [
+ "logger",
+]
+
logger = logging.getLogger(__package__)
diff --git a/src/prompt_toolkit/mouse_events.py b/src/prompt_toolkit/mouse_events.py
index 304c3a19..743773b5 100644
--- a/src/prompt_toolkit/mouse_events.py
+++ b/src/prompt_toolkit/mouse_events.py
@@ -16,31 +16,48 @@ coordinates to coordinates relative to the user control, and there
`UIControl.mouse_handler` is called.
"""
from __future__ import annotations
+
from enum import Enum
+
from .data_structures import Point
-__all__ = ['MouseEventType', 'MouseButton', 'MouseModifier', 'MouseEvent']
+
+__all__ = ["MouseEventType", "MouseButton", "MouseModifier", "MouseEvent"]
class MouseEventType(Enum):
- MOUSE_UP = 'MOUSE_UP'
- MOUSE_DOWN = 'MOUSE_DOWN'
- SCROLL_UP = 'SCROLL_UP'
- SCROLL_DOWN = 'SCROLL_DOWN'
- MOUSE_MOVE = 'MOUSE_MOVE'
+ # Mouse up: This same event type is fired for all three events: left mouse
+ # up, right mouse up, or middle mouse up
+ MOUSE_UP = "MOUSE_UP"
+
+ # Mouse down: This implicitly refers to the left mouse down (this event is
+ # not fired upon pressing the middle or right mouse buttons).
+ MOUSE_DOWN = "MOUSE_DOWN"
+
+ SCROLL_UP = "SCROLL_UP"
+ SCROLL_DOWN = "SCROLL_DOWN"
+
+ # Triggered when the left mouse button is held down, and the mouse moves
+ MOUSE_MOVE = "MOUSE_MOVE"
class MouseButton(Enum):
- LEFT = 'LEFT'
- MIDDLE = 'MIDDLE'
- RIGHT = 'RIGHT'
- NONE = 'NONE'
- UNKNOWN = 'UNKNOWN'
+ LEFT = "LEFT"
+ MIDDLE = "MIDDLE"
+ RIGHT = "RIGHT"
+
+ # When we're scrolling, or just moving the mouse and not pressing a button.
+ NONE = "NONE"
+
+ # This is for when we don't know which mouse button was pressed, but we do
+ # know that one has been pressed during this mouse event (as opposed to
+ # scrolling, for example)
+ UNKNOWN = "UNKNOWN"
class MouseModifier(Enum):
- SHIFT = 'SHIFT'
- ALT = 'ALT'
- CONTROL = 'CONTROL'
+ SHIFT = "SHIFT"
+ ALT = "ALT"
+ CONTROL = "CONTROL"
class MouseEvent:
@@ -51,13 +68,22 @@ class MouseEvent:
:param event_type: `MouseEventType`.
"""
- def __init__(self, position: Point, event_type: MouseEventType, button:
- MouseButton, modifiers: frozenset[MouseModifier]) ->None:
+ def __init__(
+ self,
+ position: Point,
+ event_type: MouseEventType,
+ button: MouseButton,
+ modifiers: frozenset[MouseModifier],
+ ) -> None:
self.position = position
self.event_type = event_type
self.button = button
self.modifiers = modifiers
- def __repr__(self) ->str:
- return 'MouseEvent({!r},{!r},{!r},{!r})'.format(self.position, self
- .event_type, self.button, self.modifiers)
+ def __repr__(self) -> str:
+ return "MouseEvent({!r},{!r},{!r},{!r})".format(
+ self.position,
+ self.event_type,
+ self.button,
+ self.modifiers,
+ )
diff --git a/src/prompt_toolkit/output/base.py b/src/prompt_toolkit/output/base.py
index 8c4343ff..3c38cec8 100644
--- a/src/prompt_toolkit/output/base.py
+++ b/src/prompt_toolkit/output/base.py
@@ -2,13 +2,20 @@
Interface for an output.
"""
from __future__ import annotations
+
from abc import ABCMeta, abstractmethod
from typing import TextIO
+
from prompt_toolkit.cursor_shapes import CursorShape
from prompt_toolkit.data_structures import Size
from prompt_toolkit.styles import Attrs
+
from .color_depth import ColorDepth
-__all__ = ['Output', 'DummyOutput']
+
+__all__ = [
+ "Output",
+ "DummyOutput",
+]
class Output(metaclass=ABCMeta):
@@ -20,165 +27,138 @@ class Output(metaclass=ABCMeta):
:class:`~prompt_toolkit.output.vt100.Vt100_Output` and
:class:`~prompt_toolkit.output.win32.Win32Output`.
"""
+
stdout: TextIO | None = None
@abstractmethod
- def fileno(self) ->int:
- """Return the file descriptor to which we can write for the output."""
- pass
+ def fileno(self) -> int:
+ "Return the file descriptor to which we can write for the output."
@abstractmethod
- def encoding(self) ->str:
+ def encoding(self) -> str:
"""
Return the encoding for this output, e.g. 'utf-8'.
(This is used mainly to know which characters are supported by the
output the data, so that the UI can provide alternatives, when
required.)
"""
- pass
@abstractmethod
- def write(self, data: str) ->None:
- """Write text (Terminal escape sequences will be removed/escaped.)"""
- pass
+ def write(self, data: str) -> None:
+ "Write text (Terminal escape sequences will be removed/escaped.)"
@abstractmethod
- def write_raw(self, data: str) ->None:
- """Write text."""
- pass
+ def write_raw(self, data: str) -> None:
+ "Write text."
@abstractmethod
- def set_title(self, title: str) ->None:
- """Set terminal title."""
- pass
+ def set_title(self, title: str) -> None:
+ "Set terminal title."
@abstractmethod
- def clear_title(self) ->None:
- """Clear title again. (or restore previous title.)"""
- pass
+ def clear_title(self) -> None:
+ "Clear title again. (or restore previous title.)"
@abstractmethod
- def flush(self) ->None:
- """Write to output stream and flush."""
- pass
+ def flush(self) -> None:
+ "Write to output stream and flush."
@abstractmethod
- def erase_screen(self) ->None:
+ def erase_screen(self) -> None:
"""
Erases the screen with the background color and moves the cursor to
home.
"""
- pass
@abstractmethod
- def enter_alternate_screen(self) ->None:
- """Go to the alternate screen buffer. (For full screen applications)."""
- pass
+ def enter_alternate_screen(self) -> None:
+ "Go to the alternate screen buffer. (For full screen applications)."
@abstractmethod
- def quit_alternate_screen(self) ->None:
- """Leave the alternate screen buffer."""
- pass
+ def quit_alternate_screen(self) -> None:
+ "Leave the alternate screen buffer."
@abstractmethod
- def enable_mouse_support(self) ->None:
- """Enable mouse."""
- pass
+ def enable_mouse_support(self) -> None:
+ "Enable mouse."
@abstractmethod
- def disable_mouse_support(self) ->None:
- """Disable mouse."""
- pass
+ def disable_mouse_support(self) -> None:
+ "Disable mouse."
@abstractmethod
- def erase_end_of_line(self) ->None:
+ def erase_end_of_line(self) -> None:
"""
Erases from the current cursor position to the end of the current line.
"""
- pass
@abstractmethod
- def erase_down(self) ->None:
+ def erase_down(self) -> None:
"""
Erases the screen from the current line down to the bottom of the
screen.
"""
- pass
@abstractmethod
- def reset_attributes(self) ->None:
- """Reset color and styling attributes."""
- pass
+ def reset_attributes(self) -> None:
+ "Reset color and styling attributes."
@abstractmethod
- def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) ->None:
- """Set new color and styling attributes."""
- pass
+ def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None:
+ "Set new color and styling attributes."
@abstractmethod
- def disable_autowrap(self) ->None:
- """Disable auto line wrapping."""
- pass
+ def disable_autowrap(self) -> None:
+ "Disable auto line wrapping."
@abstractmethod
- def enable_autowrap(self) ->None:
- """Enable auto line wrapping."""
- pass
+ def enable_autowrap(self) -> None:
+ "Enable auto line wrapping."
@abstractmethod
- def cursor_goto(self, row: int=0, column: int=0) ->None:
- """Move cursor position."""
- pass
+ def cursor_goto(self, row: int = 0, column: int = 0) -> None:
+ "Move cursor position."
@abstractmethod
- def cursor_up(self, amount: int) ->None:
- """Move cursor `amount` place up."""
- pass
+ def cursor_up(self, amount: int) -> None:
+ "Move cursor `amount` place up."
@abstractmethod
- def cursor_down(self, amount: int) ->None:
- """Move cursor `amount` place down."""
- pass
+ def cursor_down(self, amount: int) -> None:
+ "Move cursor `amount` place down."
@abstractmethod
- def cursor_forward(self, amount: int) ->None:
- """Move cursor `amount` place forward."""
- pass
+ def cursor_forward(self, amount: int) -> None:
+ "Move cursor `amount` place forward."
@abstractmethod
- def cursor_backward(self, amount: int) ->None:
- """Move cursor `amount` place backward."""
- pass
+ def cursor_backward(self, amount: int) -> None:
+ "Move cursor `amount` place backward."
@abstractmethod
- def hide_cursor(self) ->None:
- """Hide cursor."""
- pass
+ def hide_cursor(self) -> None:
+ "Hide cursor."
@abstractmethod
- def show_cursor(self) ->None:
- """Show cursor."""
- pass
+ def show_cursor(self) -> None:
+ "Show cursor."
@abstractmethod
- def set_cursor_shape(self, cursor_shape: CursorShape) ->None:
- """Set cursor shape to block, beam or underline."""
- pass
+ def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
+ "Set cursor shape to block, beam or underline."
@abstractmethod
- def reset_cursor_shape(self) ->None:
- """Reset cursor shape."""
- pass
+ def reset_cursor_shape(self) -> None:
+ "Reset cursor shape."
- def ask_for_cpr(self) ->None:
+ def ask_for_cpr(self) -> None:
"""
Asks for a cursor position report (CPR).
(VT100 only.)
"""
- pass
@property
- def responds_to_cpr(self) ->bool:
+ def responds_to_cpr(self) -> bool:
"""
`True` if the `Application` can expect to receive a CPR response after
calling `ask_for_cpr` (this will come back through the corresponding
@@ -191,44 +171,38 @@ class Output(metaclass=ABCMeta):
On Windows, we don't need this, there we have
`get_rows_below_cursor_position`.
"""
- pass
+ return False
@abstractmethod
- def get_size(self) ->Size:
- """Return the size of the output window."""
- pass
+ def get_size(self) -> Size:
+ "Return the size of the output window."
- def bell(self) ->None:
- """Sound bell."""
- pass
+ def bell(self) -> None:
+ "Sound bell."
- def enable_bracketed_paste(self) ->None:
- """For vt100 only."""
- pass
+ def enable_bracketed_paste(self) -> None:
+ "For vt100 only."
- def disable_bracketed_paste(self) ->None:
- """For vt100 only."""
- pass
+ def disable_bracketed_paste(self) -> None:
+ "For vt100 only."
- def reset_cursor_key_mode(self) ->None:
+ def reset_cursor_key_mode(self) -> None:
"""
For vt100 only.
Put the terminal in normal cursor mode (instead of application mode).
See: https://vt100.net/docs/vt100-ug/chapter3.html
"""
- pass
- def scroll_buffer_to_prompt(self) ->None:
- """For Win32 only."""
- pass
+ def scroll_buffer_to_prompt(self) -> None:
+ "For Win32 only."
- def get_rows_below_cursor_position(self) ->int:
- """For Windows only."""
- pass
+ def get_rows_below_cursor_position(self) -> int:
+ "For Windows only."
+ raise NotImplementedError
@abstractmethod
- def get_default_color_depth(self) ->ColorDepth:
+ def get_default_color_depth(self) -> ColorDepth:
"""
Get default color depth for this output.
@@ -243,7 +217,6 @@ class Output(metaclass=ABCMeta):
here. (This is not used when the output corresponds to a
prompt_toolkit SSH/Telnet session.)
"""
- pass
class DummyOutput(Output):
@@ -251,6 +224,108 @@ class DummyOutput(Output):
For testing. An output class that doesn't render anything.
"""
- def fileno(self) ->int:
- """There is no sensible default for fileno()."""
+ def fileno(self) -> int:
+ "There is no sensible default for fileno()."
+ raise NotImplementedError
+
+ def encoding(self) -> str:
+ return "utf-8"
+
+ def write(self, data: str) -> None:
pass
+
+ def write_raw(self, data: str) -> None:
+ pass
+
+ def set_title(self, title: str) -> None:
+ pass
+
+ def clear_title(self) -> None:
+ pass
+
+ def flush(self) -> None:
+ pass
+
+ def erase_screen(self) -> None:
+ pass
+
+ def enter_alternate_screen(self) -> None:
+ pass
+
+ def quit_alternate_screen(self) -> None:
+ pass
+
+ def enable_mouse_support(self) -> None:
+ pass
+
+ def disable_mouse_support(self) -> None:
+ pass
+
+ def erase_end_of_line(self) -> None:
+ pass
+
+ def erase_down(self) -> None:
+ pass
+
+ def reset_attributes(self) -> None:
+ pass
+
+ def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None:
+ pass
+
+ def disable_autowrap(self) -> None:
+ pass
+
+ def enable_autowrap(self) -> None:
+ pass
+
+ def cursor_goto(self, row: int = 0, column: int = 0) -> None:
+ pass
+
+ def cursor_up(self, amount: int) -> None:
+ pass
+
+ def cursor_down(self, amount: int) -> None:
+ pass
+
+ def cursor_forward(self, amount: int) -> None:
+ pass
+
+ def cursor_backward(self, amount: int) -> None:
+ pass
+
+ def hide_cursor(self) -> None:
+ pass
+
+ def show_cursor(self) -> None:
+ pass
+
+ def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
+ pass
+
+ def reset_cursor_shape(self) -> None:
+ pass
+
+ def ask_for_cpr(self) -> None:
+ pass
+
+ def bell(self) -> None:
+ pass
+
+ def enable_bracketed_paste(self) -> None:
+ pass
+
+ def disable_bracketed_paste(self) -> None:
+ pass
+
+ def scroll_buffer_to_prompt(self) -> None:
+ pass
+
+ def get_size(self) -> Size:
+ return Size(rows=40, columns=80)
+
+ def get_rows_below_cursor_position(self) -> int:
+ return 40
+
+ def get_default_color_depth(self) -> ColorDepth:
+ return ColorDepth.DEPTH_1_BIT
diff --git a/src/prompt_toolkit/output/color_depth.py b/src/prompt_toolkit/output/color_depth.py
index 079d6e36..f66d2bea 100644
--- a/src/prompt_toolkit/output/color_depth.py
+++ b/src/prompt_toolkit/output/color_depth.py
@@ -1,25 +1,40 @@
from __future__ import annotations
+
import os
from enum import Enum
-__all__ = ['ColorDepth']
+
+__all__ = [
+ "ColorDepth",
+]
class ColorDepth(str, Enum):
"""
Possible color depth values for the output.
"""
+
value: str
- DEPTH_1_BIT = 'DEPTH_1_BIT'
- DEPTH_4_BIT = 'DEPTH_4_BIT'
- DEPTH_8_BIT = 'DEPTH_8_BIT'
- DEPTH_24_BIT = 'DEPTH_24_BIT'
+
+ #: One color only.
+ DEPTH_1_BIT = "DEPTH_1_BIT"
+
+ #: ANSI Colors.
+ DEPTH_4_BIT = "DEPTH_4_BIT"
+
+ #: The default.
+ DEPTH_8_BIT = "DEPTH_8_BIT"
+
+ #: 24 bit True color.
+ DEPTH_24_BIT = "DEPTH_24_BIT"
+
+ # Aliases.
MONOCHROME = DEPTH_1_BIT
ANSI_COLORS_ONLY = DEPTH_4_BIT
DEFAULT = DEPTH_8_BIT
TRUE_COLOR = DEPTH_24_BIT
@classmethod
- def from_env(cls) ->(ColorDepth | None):
+ def from_env(cls) -> ColorDepth | None:
"""
Return the color depth if the $PROMPT_TOOLKIT_COLOR_DEPTH environment
variable has been set.
@@ -27,11 +42,23 @@ class ColorDepth(str, Enum):
This is a way to enforce a certain color depth in all prompt_toolkit
applications.
"""
- pass
+ # Disable color if a `NO_COLOR` environment variable is set.
+ # See: https://no-color.org/
+ if os.environ.get("NO_COLOR"):
+ return cls.DEPTH_1_BIT
+
+ # Check the `PROMPT_TOOLKIT_COLOR_DEPTH` environment variable.
+ all_values = [i.value for i in ColorDepth]
+ if os.environ.get("PROMPT_TOOLKIT_COLOR_DEPTH") in all_values:
+ return cls(os.environ["PROMPT_TOOLKIT_COLOR_DEPTH"])
+
+ return None
@classmethod
- def default(cls) ->ColorDepth:
+ def default(cls) -> ColorDepth:
"""
Return the default color depth for the default output.
"""
- pass
+ from .defaults import create_output
+
+ return create_output().get_default_color_depth()
diff --git a/src/prompt_toolkit/output/conemu.py b/src/prompt_toolkit/output/conemu.py
index 120f4767..63699449 100644
--- a/src/prompt_toolkit/output/conemu.py
+++ b/src/prompt_toolkit/output/conemu.py
@@ -1,13 +1,21 @@
from __future__ import annotations
+
import sys
-assert sys.platform == 'win32'
+
+assert sys.platform == "win32"
+
from typing import Any, TextIO
+
from prompt_toolkit.data_structures import Size
+
from .base import Output
from .color_depth import ColorDepth
from .vt100 import Vt100_Output
from .win32 import Win32Output
-__all__ = ['ConEmuOutput']
+
+__all__ = [
+ "ConEmuOutput",
+]
class ConEmuOutput:
@@ -26,18 +34,29 @@ class ConEmuOutput:
http://gooseberrycreative.com/cmder/
"""
- def __init__(self, stdout: TextIO, default_color_depth: (ColorDepth |
- None)=None) ->None:
- self.win32_output = Win32Output(stdout, default_color_depth=
- default_color_depth)
- self.vt100_output = Vt100_Output(stdout, lambda : Size(0, 0),
- default_color_depth=default_color_depth)
-
- def __getattr__(self, name: str) ->Any:
- if name in ('get_size', 'get_rows_below_cursor_position',
- 'enable_mouse_support', 'disable_mouse_support',
- 'scroll_buffer_to_prompt', 'get_win32_screen_buffer_info',
- 'enable_bracketed_paste', 'disable_bracketed_paste'):
+ def __init__(
+ self, stdout: TextIO, default_color_depth: ColorDepth | None = None
+ ) -> None:
+ self.win32_output = Win32Output(stdout, default_color_depth=default_color_depth)
+ self.vt100_output = Vt100_Output(
+ stdout, lambda: Size(0, 0), default_color_depth=default_color_depth
+ )
+
+ @property
+ def responds_to_cpr(self) -> bool:
+ return False # We don't need this on Windows.
+
+ def __getattr__(self, name: str) -> Any:
+ if name in (
+ "get_size",
+ "get_rows_below_cursor_position",
+ "enable_mouse_support",
+ "disable_mouse_support",
+ "scroll_buffer_to_prompt",
+ "get_win32_screen_buffer_info",
+ "enable_bracketed_paste",
+ "disable_bracketed_paste",
+ ):
return getattr(self.win32_output, name)
else:
return getattr(self.vt100_output, name)
diff --git a/src/prompt_toolkit/output/defaults.py b/src/prompt_toolkit/output/defaults.py
index 396c16f3..ed114e32 100644
--- a/src/prompt_toolkit/output/defaults.py
+++ b/src/prompt_toolkit/output/defaults.py
@@ -1,15 +1,26 @@
from __future__ import annotations
+
import sys
from typing import TextIO, cast
-from prompt_toolkit.utils import get_bell_environment_variable, get_term_environment_variable, is_conemu_ansi
+
+from prompt_toolkit.utils import (
+ get_bell_environment_variable,
+ get_term_environment_variable,
+ is_conemu_ansi,
+)
+
from .base import DummyOutput, Output
from .color_depth import ColorDepth
from .plain_text import PlainTextOutput
-__all__ = ['create_output']
+
+__all__ = [
+ "create_output",
+]
-def create_output(stdout: (TextIO | None)=None, always_prefer_tty: bool=False
- ) ->Output:
+def create_output(
+ stdout: TextIO | None = None, always_prefer_tty: bool = False
+) -> Output:
"""
Return an :class:`~prompt_toolkit.output.Output` instance for the command
line.
@@ -24,4 +35,68 @@ def create_output(stdout: (TextIO | None)=None, always_prefer_tty: bool=False
That way, tools like `print_formatted_text` will write plain text into
that file.
"""
- pass
+ # Consider TERM, PROMPT_TOOLKIT_BELL, and PROMPT_TOOLKIT_COLOR_DEPTH
+ # environment variables. Notice that PROMPT_TOOLKIT_COLOR_DEPTH value is
+ # the default that's used if the Application doesn't override it.
+ term_from_env = get_term_environment_variable()
+ bell_from_env = get_bell_environment_variable()
+ color_depth_from_env = ColorDepth.from_env()
+
+ if stdout is None:
+ # By default, render to stdout. If the output is piped somewhere else,
+ # render to stderr.
+ stdout = sys.stdout
+
+ if always_prefer_tty:
+ for io in [sys.stdout, sys.stderr]:
+ if io is not None and io.isatty():
+ # (This is `None` when using `pythonw.exe` on Windows.)
+ stdout = io
+ break
+
+ # If the output is still `None`, use a DummyOutput.
+ # This happens for instance on Windows, when running the application under
+ # `pythonw.exe`. In that case, there won't be a terminal Window, and
+ # stdin/stdout/stderr are `None`.
+ if stdout is None:
+ return DummyOutput()
+
+ # If the patch_stdout context manager has been used, then sys.stdout is
+ # replaced by this proxy. For prompt_toolkit applications, we want to use
+ # the real stdout.
+ from prompt_toolkit.patch_stdout import StdoutProxy
+
+ while isinstance(stdout, StdoutProxy):
+ stdout = stdout.original_stdout
+
+ if sys.platform == "win32":
+ from .conemu import ConEmuOutput
+ from .win32 import Win32Output
+ from .windows10 import Windows10_Output, is_win_vt100_enabled
+
+ if is_win_vt100_enabled():
+ return cast(
+ Output,
+ Windows10_Output(stdout, default_color_depth=color_depth_from_env),
+ )
+ if is_conemu_ansi():
+ return cast(
+ Output, ConEmuOutput(stdout, default_color_depth=color_depth_from_env)
+ )
+ else:
+ return Win32Output(stdout, default_color_depth=color_depth_from_env)
+ else:
+ from .vt100 import Vt100_Output
+
+ # Stdout is not a TTY? Render as plain text.
+ # This is mostly useful if stdout is redirected to a file, and
+ # `print_formatted_text` is used.
+ if not stdout.isatty():
+ return PlainTextOutput(stdout)
+
+ return Vt100_Output.from_pty(
+ stdout,
+ term=term_from_env,
+ default_color_depth=color_depth_from_env,
+ enable_bell=bell_from_env,
+ )
diff --git a/src/prompt_toolkit/output/flush_stdout.py b/src/prompt_toolkit/output/flush_stdout.py
index 5a1f98e9..daf58efe 100644
--- a/src/prompt_toolkit/output/flush_stdout.py
+++ b/src/prompt_toolkit/output/flush_stdout.py
@@ -1,15 +1,87 @@
from __future__ import annotations
+
import errno
import os
import sys
from contextlib import contextmanager
from typing import IO, Iterator, TextIO
-__all__ = ['flush_stdout']
+
+__all__ = ["flush_stdout"]
+
+
+def flush_stdout(stdout: TextIO, data: str) -> None:
+ # If the IO object has an `encoding` and `buffer` attribute, it means that
+ # we can access the underlying BinaryIO object and write into it in binary
+ # mode. This is preferred if possible.
+ # NOTE: When used in a Jupyter notebook, don't write binary.
+ # `ipykernel.iostream.OutStream` has an `encoding` attribute, but not
+ # a `buffer` attribute, so we can't write binary in it.
+ has_binary_io = hasattr(stdout, "encoding") and hasattr(stdout, "buffer")
+
+ try:
+ # Ensure that `stdout` is made blocking when writing into it.
+ # Otherwise, when uvloop is activated (which makes stdout
+ # non-blocking), and we write big amounts of text, then we get a
+ # `BlockingIOError` here.
+ with _blocking_io(stdout):
+ # (We try to encode ourself, because that way we can replace
+ # characters that don't exist in the character set, avoiding
+ # UnicodeEncodeError crashes. E.g. u'\xb7' does not appear in 'ascii'.)
+ # My Arch Linux installation of july 2015 reported 'ANSI_X3.4-1968'
+ # for sys.stdout.encoding in xterm.
+ if has_binary_io:
+ stdout.buffer.write(data.encode(stdout.encoding or "utf-8", "replace"))
+ else:
+ stdout.write(data)
+
+ stdout.flush()
+ except OSError as e:
+ if e.args and e.args[0] == errno.EINTR:
+ # Interrupted system call. Can happen in case of a window
+ # resize signal. (Just ignore. The resize handler will render
+ # again anyway.)
+ pass
+ elif e.args and e.args[0] == 0:
+ # This can happen when there is a lot of output and the user
+ # sends a KeyboardInterrupt by pressing Control-C. E.g. in
+ # a Python REPL when we execute "while True: print('test')".
+ # (The `ptpython` REPL uses this `Output` class instead of
+ # `stdout` directly -- in order to be network transparent.)
+ # So, just ignore.
+ pass
+ else:
+ raise
@contextmanager
-def _blocking_io(io: IO[str]) ->Iterator[None]:
+def _blocking_io(io: IO[str]) -> Iterator[None]:
"""
Ensure that the FD for `io` is set to blocking in here.
"""
- pass
+ if sys.platform == "win32":
+ # On Windows, the `os` module doesn't have a `get/set_blocking`
+ # function.
+ yield
+ return
+
+ try:
+ fd = io.fileno()
+ blocking = os.get_blocking(fd)
+ except: # noqa
+ # Failed somewhere.
+ # `get_blocking` can raise `OSError`.
+ # The io object can raise `AttributeError` when no `fileno()` method is
+ # present if we're not a real file object.
+ blocking = True # Assume we're good, and don't do anything.
+
+ try:
+ # Make blocking if we weren't blocking yet.
+ if not blocking:
+ os.set_blocking(fd, True)
+
+ yield
+
+ finally:
+ # Restore original blocking mode.
+ if not blocking:
+ os.set_blocking(fd, blocking)
diff --git a/src/prompt_toolkit/output/plain_text.py b/src/prompt_toolkit/output/plain_text.py
index 59ecf92e..4b24ad96 100644
--- a/src/prompt_toolkit/output/plain_text.py
+++ b/src/prompt_toolkit/output/plain_text.py
@@ -1,12 +1,16 @@
from __future__ import annotations
+
from typing import TextIO
+
from prompt_toolkit.cursor_shapes import CursorShape
from prompt_toolkit.data_structures import Size
from prompt_toolkit.styles import Attrs
+
from .base import Output
from .color_depth import ColorDepth
from .flush_stdout import flush_stdout
-__all__ = ['PlainTextOutput']
+
+__all__ = ["PlainTextOutput"]
class PlainTextOutput(Output):
@@ -21,11 +25,119 @@ class PlainTextOutput(Output):
formatting.)
"""
- def __init__(self, stdout: TextIO) ->None:
- assert all(hasattr(stdout, a) for a in ('write', 'flush'))
+ def __init__(self, stdout: TextIO) -> None:
+ assert all(hasattr(stdout, a) for a in ("write", "flush"))
+
self.stdout: TextIO = stdout
self._buffer: list[str] = []
- def fileno(self) ->int:
- """There is no sensible default for fileno()."""
+ def fileno(self) -> int:
+ "There is no sensible default for fileno()."
+ return self.stdout.fileno()
+
+ def encoding(self) -> str:
+ return "utf-8"
+
+ def write(self, data: str) -> None:
+ self._buffer.append(data)
+
+ def write_raw(self, data: str) -> None:
+ self._buffer.append(data)
+
+ def set_title(self, title: str) -> None:
+ pass
+
+ def clear_title(self) -> None:
+ pass
+
+ def flush(self) -> None:
+ if not self._buffer:
+ return
+
+ data = "".join(self._buffer)
+ self._buffer = []
+ flush_stdout(self.stdout, data)
+
+ def erase_screen(self) -> None:
+ pass
+
+ def enter_alternate_screen(self) -> None:
+ pass
+
+ def quit_alternate_screen(self) -> None:
+ pass
+
+ def enable_mouse_support(self) -> None:
+ pass
+
+ def disable_mouse_support(self) -> None:
+ pass
+
+ def erase_end_of_line(self) -> None:
+ pass
+
+ def erase_down(self) -> None:
+ pass
+
+ def reset_attributes(self) -> None:
+ pass
+
+ def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None:
+ pass
+
+ def disable_autowrap(self) -> None:
+ pass
+
+ def enable_autowrap(self) -> None:
+ pass
+
+ def cursor_goto(self, row: int = 0, column: int = 0) -> None:
+ pass
+
+ def cursor_up(self, amount: int) -> None:
+ pass
+
+ def cursor_down(self, amount: int) -> None:
+ self._buffer.append("\n")
+
+ def cursor_forward(self, amount: int) -> None:
+ self._buffer.append(" " * amount)
+
+ def cursor_backward(self, amount: int) -> None:
+ pass
+
+ def hide_cursor(self) -> None:
+ pass
+
+ def show_cursor(self) -> None:
+ pass
+
+ def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
+ pass
+
+ def reset_cursor_shape(self) -> None:
+ pass
+
+ def ask_for_cpr(self) -> None:
+ pass
+
+ def bell(self) -> None:
+ pass
+
+ def enable_bracketed_paste(self) -> None:
+ pass
+
+ def disable_bracketed_paste(self) -> None:
+ pass
+
+ def scroll_buffer_to_prompt(self) -> None:
pass
+
+ def get_size(self) -> Size:
+ return Size(rows=40, columns=80)
+
+ def get_rows_below_cursor_position(self) -> int:
+ return 8
+
+ def get_default_color_depth(self) -> ColorDepth:
+ return ColorDepth.DEPTH_1_BIT
diff --git a/src/prompt_toolkit/output/vt100.py b/src/prompt_toolkit/output/vt100.py
index d371a044..142deab0 100644
--- a/src/prompt_toolkit/output/vt100.py
+++ b/src/prompt_toolkit/output/vt100.py
@@ -7,43 +7,104 @@ everything has been highly optimized.)
http://pygments.org/
"""
from __future__ import annotations
+
import io
import os
import sys
from typing import Callable, Dict, Hashable, Iterable, Sequence, TextIO, Tuple
+
from prompt_toolkit.cursor_shapes import CursorShape
from prompt_toolkit.data_structures import Size
from prompt_toolkit.output import Output
from prompt_toolkit.styles import ANSI_COLOR_NAMES, Attrs
from prompt_toolkit.utils import is_dumb_terminal
+
from .color_depth import ColorDepth
from .flush_stdout import flush_stdout
-__all__ = ['Vt100_Output']
-FG_ANSI_COLORS = {'ansidefault': 39, 'ansiblack': 30, 'ansired': 31,
- 'ansigreen': 32, 'ansiyellow': 33, 'ansiblue': 34, 'ansimagenta': 35,
- 'ansicyan': 36, 'ansigray': 37, 'ansibrightblack': 90, 'ansibrightred':
- 91, 'ansibrightgreen': 92, 'ansibrightyellow': 93, 'ansibrightblue': 94,
- 'ansibrightmagenta': 95, 'ansibrightcyan': 96, 'ansiwhite': 97}
-BG_ANSI_COLORS = {'ansidefault': 49, 'ansiblack': 40, 'ansired': 41,
- 'ansigreen': 42, 'ansiyellow': 43, 'ansiblue': 44, 'ansimagenta': 45,
- 'ansicyan': 46, 'ansigray': 47, 'ansibrightblack': 100, 'ansibrightred':
- 101, 'ansibrightgreen': 102, 'ansibrightyellow': 103, 'ansibrightblue':
- 104, 'ansibrightmagenta': 105, 'ansibrightcyan': 106, 'ansiwhite': 107}
-ANSI_COLORS_TO_RGB = {'ansidefault': (0, 0, 0), 'ansiblack': (0, 0, 0),
- 'ansigray': (229, 229, 229), 'ansibrightblack': (127, 127, 127),
- 'ansiwhite': (255, 255, 255), 'ansired': (205, 0, 0), 'ansigreen': (0,
- 205, 0), 'ansiyellow': (205, 205, 0), 'ansiblue': (0, 0, 205),
- 'ansimagenta': (205, 0, 205), 'ansicyan': (0, 205, 205),
- 'ansibrightred': (255, 0, 0), 'ansibrightgreen': (0, 255, 0),
- 'ansibrightyellow': (255, 255, 0), 'ansibrightblue': (0, 0, 255),
- 'ansibrightmagenta': (255, 0, 255), 'ansibrightcyan': (0, 255, 255)}
+
+__all__ = [
+ "Vt100_Output",
+]
+
+
+FG_ANSI_COLORS = {
+ "ansidefault": 39,
+ # Low intensity.
+ "ansiblack": 30,
+ "ansired": 31,
+ "ansigreen": 32,
+ "ansiyellow": 33,
+ "ansiblue": 34,
+ "ansimagenta": 35,
+ "ansicyan": 36,
+ "ansigray": 37,
+ # High intensity.
+ "ansibrightblack": 90,
+ "ansibrightred": 91,
+ "ansibrightgreen": 92,
+ "ansibrightyellow": 93,
+ "ansibrightblue": 94,
+ "ansibrightmagenta": 95,
+ "ansibrightcyan": 96,
+ "ansiwhite": 97,
+}
+
+BG_ANSI_COLORS = {
+ "ansidefault": 49,
+ # Low intensity.
+ "ansiblack": 40,
+ "ansired": 41,
+ "ansigreen": 42,
+ "ansiyellow": 43,
+ "ansiblue": 44,
+ "ansimagenta": 45,
+ "ansicyan": 46,
+ "ansigray": 47,
+ # High intensity.
+ "ansibrightblack": 100,
+ "ansibrightred": 101,
+ "ansibrightgreen": 102,
+ "ansibrightyellow": 103,
+ "ansibrightblue": 104,
+ "ansibrightmagenta": 105,
+ "ansibrightcyan": 106,
+ "ansiwhite": 107,
+}
+
+
+ANSI_COLORS_TO_RGB = {
+ "ansidefault": (
+ 0x00,
+ 0x00,
+ 0x00,
+ ), # Don't use, 'default' doesn't really have a value.
+ "ansiblack": (0x00, 0x00, 0x00),
+ "ansigray": (0xE5, 0xE5, 0xE5),
+ "ansibrightblack": (0x7F, 0x7F, 0x7F),
+ "ansiwhite": (0xFF, 0xFF, 0xFF),
+ # Low intensity.
+ "ansired": (0xCD, 0x00, 0x00),
+ "ansigreen": (0x00, 0xCD, 0x00),
+ "ansiyellow": (0xCD, 0xCD, 0x00),
+ "ansiblue": (0x00, 0x00, 0xCD),
+ "ansimagenta": (0xCD, 0x00, 0xCD),
+ "ansicyan": (0x00, 0xCD, 0xCD),
+ # High intensity.
+ "ansibrightred": (0xFF, 0x00, 0x00),
+ "ansibrightgreen": (0x00, 0xFF, 0x00),
+ "ansibrightyellow": (0xFF, 0xFF, 0x00),
+ "ansibrightblue": (0x00, 0x00, 0xFF),
+ "ansibrightmagenta": (0xFF, 0x00, 0xFF),
+ "ansibrightcyan": (0x00, 0xFF, 0xFF),
+}
+
+
assert set(FG_ANSI_COLORS) == set(ANSI_COLOR_NAMES)
assert set(BG_ANSI_COLORS) == set(ANSI_COLOR_NAMES)
assert set(ANSI_COLORS_TO_RGB) == set(ANSI_COLOR_NAMES)
-def _get_closest_ansi_color(r: int, g: int, b: int, exclude: Sequence[str]=()
- ) ->str:
+def _get_closest_ansi_color(r: int, g: int, b: int, exclude: Sequence[str] = ()) -> str:
"""
Find closest ANSI color. Return it by name.
@@ -52,7 +113,29 @@ def _get_closest_ansi_color(r: int, g: int, b: int, exclude: Sequence[str]=()
:param b: Blue (Between 0 and 255.)
:param exclude: A tuple of color names to exclude. (E.g. ``('ansired', )``.)
"""
- pass
+ exclude = list(exclude)
+
+ # When we have a bit of saturation, avoid the gray-like colors, otherwise,
+ # too often the distance to the gray color is less.
+ saturation = abs(r - g) + abs(g - b) + abs(b - r) # Between 0..510
+
+ if saturation > 30:
+ exclude.extend(["ansilightgray", "ansidarkgray", "ansiwhite", "ansiblack"])
+
+ # Take the closest color.
+ # (Thanks to Pygments for this part.)
+ distance = 257 * 257 * 3 # "infinity" (>distance from #000000 to #ffffff)
+ match = "ansidefault"
+
+ for name, (r2, g2, b2) in ANSI_COLORS_TO_RGB.items():
+ if name != "ansidefault" and name not in exclude:
+ d = (r - r2) ** 2 + (g - g2) ** 2 + (b - b2) ** 2
+
+ if d < distance:
+ match = name
+ distance = d
+
+ return match
_ColorCodeAndName = Tuple[int, str]
@@ -65,17 +148,38 @@ class _16ColorCache:
:param bg: Cache for background colors, instead of foreground.
"""
- def __init__(self, bg: bool=False) ->None:
+ def __init__(self, bg: bool = False) -> None:
self.bg = bg
self._cache: dict[Hashable, _ColorCodeAndName] = {}
- def get_code(self, value: tuple[int, int, int], exclude: Sequence[str]=()
- ) ->_ColorCodeAndName:
+ def get_code(
+ self, value: tuple[int, int, int], exclude: Sequence[str] = ()
+ ) -> _ColorCodeAndName:
"""
Return a (ansi_code, ansi_name) tuple. (E.g. ``(44, 'ansiblue')``.) for
a given (r,g,b) value.
"""
- pass
+ key: Hashable = (value, tuple(exclude))
+ cache = self._cache
+
+ if key not in cache:
+ cache[key] = self._get(value, exclude)
+
+ return cache[key]
+
+ def _get(
+ self, value: tuple[int, int, int], exclude: Sequence[str] = ()
+ ) -> _ColorCodeAndName:
+ r, g, b = value
+ match = _get_closest_ansi_color(r, g, b, exclude=exclude)
+
+ # Turn color name into code.
+ if self.bg:
+ code = BG_ANSI_COLORS[match]
+ else:
+ code = FG_ANSI_COLORS[match]
+
+ return code, match
class _256ColorCache(Dict[Tuple[int, int, int], int]):
@@ -83,45 +187,63 @@ class _256ColorCache(Dict[Tuple[int, int, int], int]):
Cache which maps (r, g, b) tuples to 256 colors.
"""
- def __init__(self) ->None:
+ def __init__(self) -> None:
+ # Build color table.
colors: list[tuple[int, int, int]] = []
- colors.append((0, 0, 0))
- colors.append((205, 0, 0))
- colors.append((0, 205, 0))
- colors.append((205, 205, 0))
- colors.append((0, 0, 238))
- colors.append((205, 0, 205))
- colors.append((0, 205, 205))
- colors.append((229, 229, 229))
- colors.append((127, 127, 127))
- colors.append((255, 0, 0))
- colors.append((0, 255, 0))
- colors.append((255, 255, 0))
- colors.append((92, 92, 255))
- colors.append((255, 0, 255))
- colors.append((0, 255, 255))
- colors.append((255, 255, 255))
- valuerange = 0, 95, 135, 175, 215, 255
+
+ # colors 0..15: 16 basic colors
+ colors.append((0x00, 0x00, 0x00)) # 0
+ colors.append((0xCD, 0x00, 0x00)) # 1
+ colors.append((0x00, 0xCD, 0x00)) # 2
+ colors.append((0xCD, 0xCD, 0x00)) # 3
+ colors.append((0x00, 0x00, 0xEE)) # 4
+ colors.append((0xCD, 0x00, 0xCD)) # 5
+ colors.append((0x00, 0xCD, 0xCD)) # 6
+ colors.append((0xE5, 0xE5, 0xE5)) # 7
+ colors.append((0x7F, 0x7F, 0x7F)) # 8
+ colors.append((0xFF, 0x00, 0x00)) # 9
+ colors.append((0x00, 0xFF, 0x00)) # 10
+ colors.append((0xFF, 0xFF, 0x00)) # 11
+ colors.append((0x5C, 0x5C, 0xFF)) # 12
+ colors.append((0xFF, 0x00, 0xFF)) # 13
+ colors.append((0x00, 0xFF, 0xFF)) # 14
+ colors.append((0xFF, 0xFF, 0xFF)) # 15
+
+ # colors 16..232: the 6x6x6 color cube
+ valuerange = (0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF)
+
for i in range(217):
- r = valuerange[i // 36 % 6]
- g = valuerange[i // 6 % 6]
+ r = valuerange[(i // 36) % 6]
+ g = valuerange[(i // 6) % 6]
b = valuerange[i % 6]
colors.append((r, g, b))
+
+ # colors 233..253: grayscale
for i in range(1, 22):
v = 8 + i * 10
colors.append((v, v, v))
+
self.colors = colors
- def __missing__(self, value: tuple[int, int, int]) ->int:
+ def __missing__(self, value: tuple[int, int, int]) -> int:
r, g, b = value
- distance = 257 * 257 * 3
+
+ # Find closest color.
+ # (Thanks to Pygments for this!)
+ distance = 257 * 257 * 3 # "infinity" (>distance from #000000 to #ffffff)
match = 0
+
for i, (r2, g2, b2) in enumerate(self.colors):
- if i >= 16:
+ if i >= 16: # XXX: We ignore the 16 ANSI colors when mapping RGB
+ # to the 256 colors, because these highly depend on
+ # the color scheme of the terminal.
d = (r - r2) ** 2 + (g - g2) ** 2 + (b - b2) ** 2
+
if d < distance:
match = i
distance = d
+
+ # Turn color name into code.
self[value] = match
return match
@@ -140,54 +262,127 @@ class _EscapeCodeCache(Dict[Attrs, str]):
:param true_color: When True, use 24bit colors instead of 256 colors.
"""
- def __init__(self, color_depth: ColorDepth) ->None:
+ def __init__(self, color_depth: ColorDepth) -> None:
self.color_depth = color_depth
- def __missing__(self, attrs: Attrs) ->str:
- (fgcolor, bgcolor, bold, underline, strike, italic, blink, reverse,
- hidden) = attrs
+ def __missing__(self, attrs: Attrs) -> str:
+ (
+ fgcolor,
+ bgcolor,
+ bold,
+ underline,
+ strike,
+ italic,
+ blink,
+ reverse,
+ hidden,
+ ) = attrs
parts: list[str] = []
- parts.extend(self._colors_to_code(fgcolor or '', bgcolor or ''))
+
+ parts.extend(self._colors_to_code(fgcolor or "", bgcolor or ""))
+
if bold:
- parts.append('1')
+ parts.append("1")
if italic:
- parts.append('3')
+ parts.append("3")
if blink:
- parts.append('5')
+ parts.append("5")
if underline:
- parts.append('4')
+ parts.append("4")
if reverse:
- parts.append('7')
+ parts.append("7")
if hidden:
- parts.append('8')
+ parts.append("8")
if strike:
- parts.append('9')
+ parts.append("9")
+
if parts:
- result = '\x1b[0;' + ';'.join(parts) + 'm'
+ result = "\x1b[0;" + ";".join(parts) + "m"
else:
- result = '\x1b[0m'
+ result = "\x1b[0m"
+
self[attrs] = result
return result
- def _color_name_to_rgb(self, color: str) ->tuple[int, int, int]:
- """Turn 'ffffff', into (0xff, 0xff, 0xff)."""
- pass
+ def _color_name_to_rgb(self, color: str) -> tuple[int, int, int]:
+ "Turn 'ffffff', into (0xff, 0xff, 0xff)."
+ try:
+ rgb = int(color, 16)
+ except ValueError:
+ raise
+ else:
+ r = (rgb >> 16) & 0xFF
+ g = (rgb >> 8) & 0xFF
+ b = rgb & 0xFF
+ return r, g, b
- def _colors_to_code(self, fg_color: str, bg_color: str) ->Iterable[str]:
+ def _colors_to_code(self, fg_color: str, bg_color: str) -> Iterable[str]:
"""
Return a tuple with the vt100 values that represent this color.
"""
- pass
-
-
-def _get_size(fileno: int) ->tuple[int, int]:
+ # When requesting ANSI colors only, and both fg/bg color were converted
+ # to ANSI, ensure that the foreground and background color are not the
+ # same. (Unless they were explicitly defined to be the same color.)
+ fg_ansi = ""
+
+ def get(color: str, bg: bool) -> list[int]:
+ nonlocal fg_ansi
+
+ table = BG_ANSI_COLORS if bg else FG_ANSI_COLORS
+
+ if not color or self.color_depth == ColorDepth.DEPTH_1_BIT:
+ return []
+
+ # 16 ANSI colors. (Given by name.)
+ elif color in table:
+ return [table[color]]
+
+ # RGB colors. (Defined as 'ffffff'.)
+ else:
+ try:
+ rgb = self._color_name_to_rgb(color)
+ except ValueError:
+ return []
+
+ # When only 16 colors are supported, use that.
+ if self.color_depth == ColorDepth.DEPTH_4_BIT:
+ if bg: # Background.
+ if fg_color != bg_color:
+ exclude = [fg_ansi]
+ else:
+ exclude = []
+ code, name = _16_bg_colors.get_code(rgb, exclude=exclude)
+ return [code]
+ else: # Foreground.
+ code, name = _16_fg_colors.get_code(rgb)
+ fg_ansi = name
+ return [code]
+
+ # True colors. (Only when this feature is enabled.)
+ elif self.color_depth == ColorDepth.DEPTH_24_BIT:
+ r, g, b = rgb
+ return [(48 if bg else 38), 2, r, g, b]
+
+ # 256 RGB colors.
+ else:
+ return [(48 if bg else 38), 5, _256_colors[rgb]]
+
+ result: list[int] = []
+ result.extend(get(fg_color, False))
+ result.extend(get(bg_color, True))
+
+ return map(str, result)
+
+
+def _get_size(fileno: int) -> tuple[int, int]:
"""
Get the size of this pseudo terminal.
:param fileno: stdout.fileno()
:returns: A (rows, cols) tuple.
"""
- pass
+ size = os.get_terminal_size(fileno)
+ return size.lines, size.columns
class Vt100_Output(Output):
@@ -203,12 +398,22 @@ class Vt100_Output(Output):
does respond to CPR escapes. When `False`, never attempt to send CPR
requests.
"""
+
+ # For the error messages. Only display "Output is not a terminal" once per
+ # file descriptor.
_fds_not_a_terminal: set[int] = set()
- def __init__(self, stdout: TextIO, get_size: Callable[[], Size], term:
- (str | None)=None, default_color_depth: (ColorDepth | None)=None,
- enable_bell: bool=True, enable_cpr: bool=True) ->None:
- assert all(hasattr(stdout, a) for a in ('write', 'flush'))
+ def __init__(
+ self,
+ stdout: TextIO,
+ get_size: Callable[[], Size],
+ term: str | None = None,
+ default_color_depth: ColorDepth | None = None,
+ enable_bell: bool = True,
+ enable_cpr: bool = True,
+ ) -> None:
+ assert all(hasattr(stdout, a) for a in ("write", "flush"))
+
self._buffer: list[str] = []
self.stdout: TextIO = stdout
self.default_color_depth = default_color_depth
@@ -216,114 +421,308 @@ class Vt100_Output(Output):
self.term = term
self.enable_bell = enable_bell
self.enable_cpr = enable_cpr
+
+ # Cache for escape codes.
self._escape_code_caches: dict[ColorDepth, _EscapeCodeCache] = {
- ColorDepth.DEPTH_1_BIT: _EscapeCodeCache(ColorDepth.DEPTH_1_BIT
- ), ColorDepth.DEPTH_4_BIT: _EscapeCodeCache(ColorDepth.
- DEPTH_4_BIT), ColorDepth.DEPTH_8_BIT: _EscapeCodeCache(
- ColorDepth.DEPTH_8_BIT), ColorDepth.DEPTH_24_BIT:
- _EscapeCodeCache(ColorDepth.DEPTH_24_BIT)}
+ ColorDepth.DEPTH_1_BIT: _EscapeCodeCache(ColorDepth.DEPTH_1_BIT),
+ ColorDepth.DEPTH_4_BIT: _EscapeCodeCache(ColorDepth.DEPTH_4_BIT),
+ ColorDepth.DEPTH_8_BIT: _EscapeCodeCache(ColorDepth.DEPTH_8_BIT),
+ ColorDepth.DEPTH_24_BIT: _EscapeCodeCache(ColorDepth.DEPTH_24_BIT),
+ }
+
+ # Keep track of whether the cursor shape was ever changed.
+ # (We don't restore the cursor shape if it was never changed - by
+ # default, we don't change them.)
self._cursor_shape_changed = False
@classmethod
- def from_pty(cls, stdout: TextIO, term: (str | None)=None,
- default_color_depth: (ColorDepth | None)=None, enable_bell: bool=True
- ) ->Vt100_Output:
+ def from_pty(
+ cls,
+ stdout: TextIO,
+ term: str | None = None,
+ default_color_depth: ColorDepth | None = None,
+ enable_bell: bool = True,
+ ) -> Vt100_Output:
"""
Create an Output class from a pseudo terminal.
(This will take the dimensions by reading the pseudo
terminal attributes.)
"""
- pass
-
- def fileno(self) ->int:
- """Return file descriptor."""
- pass
-
- def encoding(self) ->str:
- """Return encoding used for stdout."""
- pass
-
- def write_raw(self, data: str) ->None:
+ fd: int | None
+ # Normally, this requires a real TTY device, but people instantiate
+ # this class often during unit tests as well. For convenience, we print
+ # an error message, use standard dimensions, and go on.
+ try:
+ fd = stdout.fileno()
+ except io.UnsupportedOperation:
+ fd = None
+
+ if not stdout.isatty() and (fd is None or fd not in cls._fds_not_a_terminal):
+ msg = "Warning: Output is not a terminal (fd=%r).\n"
+ sys.stderr.write(msg % fd)
+ sys.stderr.flush()
+ if fd is not None:
+ cls._fds_not_a_terminal.add(fd)
+
+ def get_size() -> Size:
+ # If terminal (incorrectly) reports its size as 0, pick a
+ # reasonable default. See
+ # https://github.com/ipython/ipython/issues/10071
+ rows, columns = (None, None)
+
+ # It is possible that `stdout` is no longer a TTY device at this
+ # point. In that case we get an `OSError` in the ioctl call in
+ # `get_size`. See:
+ # https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1021
+ try:
+ rows, columns = _get_size(stdout.fileno())
+ except OSError:
+ pass
+ return Size(rows=rows or 24, columns=columns or 80)
+
+ return cls(
+ stdout,
+ get_size,
+ term=term,
+ default_color_depth=default_color_depth,
+ enable_bell=enable_bell,
+ )
+
+ def get_size(self) -> Size:
+ return self._get_size()
+
+ def fileno(self) -> int:
+ "Return file descriptor."
+ return self.stdout.fileno()
+
+ def encoding(self) -> str:
+ "Return encoding used for stdout."
+ return self.stdout.encoding
+
+ def write_raw(self, data: str) -> None:
"""
Write raw data to output.
"""
- pass
+ self._buffer.append(data)
- def write(self, data: str) ->None:
+ def write(self, data: str) -> None:
"""
Write text to output.
(Removes vt100 escape codes. -- used for safely writing text.)
"""
- pass
+ self._buffer.append(data.replace("\x1b", "?"))
- def set_title(self, title: str) ->None:
+ def set_title(self, title: str) -> None:
"""
Set terminal title.
"""
- pass
-
- def erase_screen(self) ->None:
+ if self.term not in (
+ "linux",
+ "eterm-color",
+ ): # Not supported by the Linux console.
+ self.write_raw(
+ "\x1b]2;%s\x07" % title.replace("\x1b", "").replace("\x07", "")
+ )
+
+ def clear_title(self) -> None:
+ self.set_title("")
+
+ def erase_screen(self) -> None:
"""
Erases the screen with the background color and moves the cursor to
home.
"""
- pass
+ self.write_raw("\x1b[2J")
+
+ def enter_alternate_screen(self) -> None:
+ self.write_raw("\x1b[?1049h\x1b[H")
+
+ def quit_alternate_screen(self) -> None:
+ self.write_raw("\x1b[?1049l")
+
+ def enable_mouse_support(self) -> None:
+ self.write_raw("\x1b[?1000h")
- def erase_end_of_line(self) ->None:
+ # Enable mouse-drag support.
+ self.write_raw("\x1b[?1003h")
+
+ # Enable urxvt Mouse mode. (For terminals that understand this.)
+ self.write_raw("\x1b[?1015h")
+
+ # Also enable Xterm SGR mouse mode. (For terminals that understand this.)
+ self.write_raw("\x1b[?1006h")
+
+ # Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr
+ # extensions.
+
+ def disable_mouse_support(self) -> None:
+ self.write_raw("\x1b[?1000l")
+ self.write_raw("\x1b[?1015l")
+ self.write_raw("\x1b[?1006l")
+ self.write_raw("\x1b[?1003l")
+
+ def erase_end_of_line(self) -> None:
"""
Erases from the current cursor position to the end of the current line.
"""
- pass
+ self.write_raw("\x1b[K")
- def erase_down(self) ->None:
+ def erase_down(self) -> None:
"""
Erases the screen from the current line down to the bottom of the
screen.
"""
- pass
+ self.write_raw("\x1b[J")
+
+ def reset_attributes(self) -> None:
+ self.write_raw("\x1b[0m")
- def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) ->None:
+ def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None:
"""
Create new style and output.
:param attrs: `Attrs` instance.
"""
- pass
+ # Get current depth.
+ escape_code_cache = self._escape_code_caches[color_depth]
+
+ # Write escape character.
+ self.write_raw(escape_code_cache[attrs])
+
+ def disable_autowrap(self) -> None:
+ self.write_raw("\x1b[?7l")
- def reset_cursor_key_mode(self) ->None:
+ def enable_autowrap(self) -> None:
+ self.write_raw("\x1b[?7h")
+
+ def enable_bracketed_paste(self) -> None:
+ self.write_raw("\x1b[?2004h")
+
+ def disable_bracketed_paste(self) -> None:
+ self.write_raw("\x1b[?2004l")
+
+ def reset_cursor_key_mode(self) -> None:
"""
For vt100 only.
Put the terminal in cursor mode (instead of application mode).
"""
- pass
+ # Put the terminal in cursor mode. (Instead of application mode.)
+ self.write_raw("\x1b[?1l")
- def cursor_goto(self, row: int=0, column: int=0) ->None:
+ def cursor_goto(self, row: int = 0, column: int = 0) -> None:
"""
Move cursor position.
"""
- pass
+ self.write_raw("\x1b[%i;%iH" % (row, column))
- def reset_cursor_shape(self) ->None:
- """Reset cursor shape."""
- pass
+ def cursor_up(self, amount: int) -> None:
+ if amount == 0:
+ pass
+ elif amount == 1:
+ self.write_raw("\x1b[A")
+ else:
+ self.write_raw("\x1b[%iA" % amount)
+
+ def cursor_down(self, amount: int) -> None:
+ if amount == 0:
+ pass
+ elif amount == 1:
+ # Note: Not the same as '\n', '\n' can cause the window content to
+ # scroll.
+ self.write_raw("\x1b[B")
+ else:
+ self.write_raw("\x1b[%iB" % amount)
- def flush(self) ->None:
+ def cursor_forward(self, amount: int) -> None:
+ if amount == 0:
+ pass
+ elif amount == 1:
+ self.write_raw("\x1b[C")
+ else:
+ self.write_raw("\x1b[%iC" % amount)
+
+ def cursor_backward(self, amount: int) -> None:
+ if amount == 0:
+ pass
+ elif amount == 1:
+ self.write_raw("\b") # '\x1b[D'
+ else:
+ self.write_raw("\x1b[%iD" % amount)
+
+ def hide_cursor(self) -> None:
+ self.write_raw("\x1b[?25l")
+
+ def show_cursor(self) -> None:
+ self.write_raw("\x1b[?12l\x1b[?25h") # Stop blinking cursor and show.
+
+ def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
+ if cursor_shape == CursorShape._NEVER_CHANGE:
+ return
+
+ self._cursor_shape_changed = True
+ self.write_raw(
+ {
+ CursorShape.BLOCK: "\x1b[2 q",
+ CursorShape.BEAM: "\x1b[6 q",
+ CursorShape.UNDERLINE: "\x1b[4 q",
+ CursorShape.BLINKING_BLOCK: "\x1b[1 q",
+ CursorShape.BLINKING_BEAM: "\x1b[5 q",
+ CursorShape.BLINKING_UNDERLINE: "\x1b[3 q",
+ }.get(cursor_shape, "")
+ )
+
+ def reset_cursor_shape(self) -> None:
+ "Reset cursor shape."
+ # (Only reset cursor shape, if we ever changed it.)
+ if self._cursor_shape_changed:
+ self._cursor_shape_changed = False
+
+ # Reset cursor shape.
+ self.write_raw("\x1b[0 q")
+
+ def flush(self) -> None:
"""
Write to output stream and flush.
"""
- pass
+ if not self._buffer:
+ return
+
+ data = "".join(self._buffer)
+ self._buffer = []
- def ask_for_cpr(self) ->None:
+ flush_stdout(self.stdout, data)
+
+ def ask_for_cpr(self) -> None:
"""
Asks for a cursor position report (CPR).
"""
- pass
-
- def bell(self) ->None:
- """Sound bell."""
- pass
-
- def get_default_color_depth(self) ->ColorDepth:
+ self.write_raw("\x1b[6n")
+ self.flush()
+
+ @property
+ def responds_to_cpr(self) -> bool:
+ if not self.enable_cpr:
+ return False
+
+ # When the input is a tty, we assume that CPR is supported.
+ # It's not when the input is piped from Pexpect.
+ if os.environ.get("PROMPT_TOOLKIT_NO_CPR", "") == "1":
+ return False
+
+ if is_dumb_terminal(self.term):
+ return False
+ try:
+ return self.stdout.isatty()
+ except ValueError:
+ return False # ValueError: I/O operation on closed file
+
+ def bell(self) -> None:
+ "Sound bell."
+ if self.enable_bell:
+ self.write_raw("\a")
+ self.flush()
+
+ def get_default_color_depth(self) -> ColorDepth:
"""
Return the default color depth for a vt100 terminal, according to the
our term value.
@@ -331,4 +730,18 @@ class Vt100_Output(Output):
We prefer 256 colors almost always, because this is what most terminals
support these days, and is a good default.
"""
- pass
+ if self.default_color_depth is not None:
+ return self.default_color_depth
+
+ term = self.term
+
+ if term is None:
+ return ColorDepth.DEFAULT
+
+ if is_dumb_terminal(term):
+ return ColorDepth.DEPTH_1_BIT
+
+ if term in ("linux", "eterm-color"):
+ return ColorDepth.DEPTH_4_BIT
+
+ return ColorDepth.DEFAULT
diff --git a/src/prompt_toolkit/output/win32.py b/src/prompt_toolkit/output/win32.py
index 15c3a7b8..edeca096 100644
--- a/src/prompt_toolkit/output/win32.py
+++ b/src/prompt_toolkit/output/win32.py
@@ -1,24 +1,42 @@
from __future__ import annotations
+
import sys
-assert sys.platform == 'win32'
+
+assert sys.platform == "win32"
+
import os
from ctypes import ArgumentError, byref, c_char, c_long, c_uint, c_ulong, pointer
from ctypes.wintypes import DWORD, HANDLE
from typing import Callable, TextIO, TypeVar
+
from prompt_toolkit.cursor_shapes import CursorShape
from prompt_toolkit.data_structures import Size
from prompt_toolkit.styles import ANSI_COLOR_NAMES, Attrs
from prompt_toolkit.utils import get_cwidth
-from prompt_toolkit.win32_types import CONSOLE_SCREEN_BUFFER_INFO, COORD, SMALL_RECT, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE
+from prompt_toolkit.win32_types import (
+ CONSOLE_SCREEN_BUFFER_INFO,
+ COORD,
+ SMALL_RECT,
+ STD_INPUT_HANDLE,
+ STD_OUTPUT_HANDLE,
+)
+
from ..utils import SPHINX_AUTODOC_RUNNING
from .base import Output
from .color_depth import ColorDepth
+
+# 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
-__all__ = ['Win32Output']
-def _coord_byval(coord: COORD) ->c_long:
+__all__ = [
+ "Win32Output",
+]
+
+
+def _coord_byval(coord: COORD) -> c_long:
"""
Turns a COORD object into a c_long.
This will cause it to be passed by value instead of by reference. (That is what I think at least.)
@@ -33,11 +51,14 @@ def _coord_byval(coord: COORD) ->c_long:
More info: http://msdn.microsoft.com/en-us/library/windows/desktop/ms686025(v=vs.85).aspx
"""
- pass
+ return c_long(coord.Y * 0x10000 | coord.X & 0xFFFF)
+#: If True: write the output of the renderer also to the following file. This
+#: is very useful for debugging. (e.g.: to see that we don't write more bytes
+#: than required.)
_DEBUG_RENDER_OUTPUT = False
-_DEBUG_RENDER_OUTPUT_FILENAME = 'prompt-toolkit-windows-output.log'
+_DEBUG_RENDER_OUTPUT_FILENAME = r"prompt-toolkit-windows-output.log"
class NoConsoleScreenBufferError(Exception):
@@ -46,18 +67,24 @@ class NoConsoleScreenBufferError(Exception):
the user tries to instantiate Win32Output.
"""
- def __init__(self) ->None:
- xterm = 'xterm' in os.environ.get('TERM', '')
+ def __init__(self) -> None:
+ # Are we running in 'xterm' on Windows, like git-bash for instance?
+ xterm = "xterm" in os.environ.get("TERM", "")
+
if xterm:
message = (
- 'Found %s, while expecting a Windows console. Maybe try to run this program using "winpty" or run it in cmd.exe instead. Or otherwise, in case of Cygwin, use the Python executable that is compiled for Cygwin.'
- % os.environ['TERM'])
+ "Found %s, while expecting a Windows console. "
+ 'Maybe try to run this program using "winpty" '
+ "or run it in cmd.exe instead. Or otherwise, "
+ "in case of Cygwin, use the Python executable "
+ "that is compiled for Cygwin." % os.environ["TERM"]
+ )
else:
- message = 'No Windows console found. Are you running cmd.exe?'
+ message = "No Windows console found. Are you running cmd.exe?"
super().__init__(message)
-_T = TypeVar('_T')
+_T = TypeVar("_T")
class Win32Output(Output):
@@ -66,86 +93,414 @@ class Win32Output(Output):
(cmd.exe and similar.)
"""
- def __init__(self, stdout: TextIO, use_complete_width: bool=False,
- default_color_depth: (ColorDepth | None)=None) ->None:
+ def __init__(
+ self,
+ stdout: TextIO,
+ use_complete_width: bool = False,
+ default_color_depth: ColorDepth | None = None,
+ ) -> None:
self.use_complete_width = use_complete_width
self.default_color_depth = default_color_depth
+
self._buffer: list[str] = []
self.stdout: TextIO = stdout
self.hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE))
+
self._in_alternate_screen = False
self._hidden = False
+
self.color_lookup_table = ColorLookupTable()
+
+ # Remember the default console colors.
info = self.get_win32_screen_buffer_info()
self.default_attrs = info.wAttributes if info else 15
+
if _DEBUG_RENDER_OUTPUT:
- self.LOG = open(_DEBUG_RENDER_OUTPUT_FILENAME, 'ab')
+ self.LOG = open(_DEBUG_RENDER_OUTPUT_FILENAME, "ab")
- def fileno(self) ->int:
- """Return file descriptor."""
- pass
+ def fileno(self) -> int:
+ "Return file descriptor."
+ return self.stdout.fileno()
- def encoding(self) ->str:
- """Return encoding used for stdout."""
- pass
+ def encoding(self) -> str:
+ "Return encoding used for stdout."
+ return self.stdout.encoding
- def write_raw(self, data: str) ->None:
- """For win32, there is no difference between write and write_raw."""
- pass
+ def write(self, data: str) -> None:
+ if self._hidden:
+ data = " " * get_cwidth(data)
+
+ self._buffer.append(data)
+
+ def write_raw(self, data: str) -> None:
+ "For win32, there is no difference between write and write_raw."
+ self.write(data)
+
+ def get_size(self) -> Size:
+ info = self.get_win32_screen_buffer_info()
- def _winapi(self, func: Callable[..., _T], *a: object, **kw: object) ->_T:
+ # We take the width of the *visible* region as the size. Not the width
+ # of the complete screen buffer. (Unless use_complete_width has been
+ # set.)
+ if self.use_complete_width:
+ width = info.dwSize.X
+ else:
+ width = info.srWindow.Right - info.srWindow.Left
+
+ height = info.srWindow.Bottom - info.srWindow.Top + 1
+
+ # We avoid the right margin, windows will wrap otherwise.
+ maxwidth = info.dwSize.X - 1
+ width = min(maxwidth, width)
+
+ # Create `Size` object.
+ return Size(rows=height, columns=width)
+
+ def _winapi(self, func: Callable[..., _T], *a: object, **kw: object) -> _T:
"""
Flush and call win API function.
"""
- pass
+ self.flush()
- def get_win32_screen_buffer_info(self) ->CONSOLE_SCREEN_BUFFER_INFO:
+ if _DEBUG_RENDER_OUTPUT:
+ self.LOG.write(("%r" % func.__name__).encode("utf-8") + b"\n")
+ self.LOG.write(
+ b" " + ", ".join(["%r" % i for i in a]).encode("utf-8") + b"\n"
+ )
+ self.LOG.write(
+ b" "
+ + ", ".join(["%r" % type(i) for i in a]).encode("utf-8")
+ + b"\n"
+ )
+ self.LOG.flush()
+
+ try:
+ return func(*a, **kw)
+ except ArgumentError as e:
+ if _DEBUG_RENDER_OUTPUT:
+ self.LOG.write((f" Error in {func.__name__!r} {e!r} {e}\n").encode())
+
+ raise
+
+ def get_win32_screen_buffer_info(self) -> CONSOLE_SCREEN_BUFFER_INFO:
"""
Return Screen buffer info.
"""
- pass
+ # NOTE: We don't call the `GetConsoleScreenBufferInfo` API through
+ # `self._winapi`. Doing so causes Python to crash on certain 64bit
+ # Python versions. (Reproduced with 64bit Python 2.7.6, on Windows
+ # 10). It is not clear why. Possibly, it has to do with passing
+ # these objects as an argument, or through *args.
+
+ # The Python documentation contains the following - possibly related - warning:
+ # ctypes does not support passing unions or structures with
+ # bit-fields to functions by value. While this may work on 32-bit
+ # x86, it's not guaranteed by the library to work in the general
+ # case. Unions and structures with bit-fields should always be
+ # passed to functions by pointer.
+
+ # Also see:
+ # - https://github.com/ipython/ipython/issues/10070
+ # - https://github.com/jonathanslenders/python-prompt-toolkit/issues/406
+ # - https://github.com/jonathanslenders/python-prompt-toolkit/issues/86
+
+ self.flush()
+ sbinfo = CONSOLE_SCREEN_BUFFER_INFO()
+ success = windll.kernel32.GetConsoleScreenBufferInfo(
+ self.hconsole, byref(sbinfo)
+ )
+
+ # success = self._winapi(windll.kernel32.GetConsoleScreenBufferInfo,
+ # self.hconsole, byref(sbinfo))
+
+ if success:
+ return sbinfo
+ else:
+ raise NoConsoleScreenBufferError
- def set_title(self, title: str) ->None:
+ def set_title(self, title: str) -> None:
"""
Set terminal title.
"""
- pass
+ self._winapi(windll.kernel32.SetConsoleTitleW, title)
+
+ def clear_title(self) -> None:
+ self._winapi(windll.kernel32.SetConsoleTitleW, "")
- def erase_end_of_line(self) ->None:
+ def erase_screen(self) -> None:
+ start = COORD(0, 0)
+ sbinfo = self.get_win32_screen_buffer_info()
+ length = sbinfo.dwSize.X * sbinfo.dwSize.Y
+
+ self.cursor_goto(row=0, column=0)
+ self._erase(start, length)
+
+ def erase_down(self) -> None:
+ sbinfo = self.get_win32_screen_buffer_info()
+ size = sbinfo.dwSize
+
+ start = sbinfo.dwCursorPosition
+ length = (size.X - size.X) + size.X * (size.Y - sbinfo.dwCursorPosition.Y)
+
+ self._erase(start, length)
+
+ def erase_end_of_line(self) -> None:
""""""
+ sbinfo = self.get_win32_screen_buffer_info()
+ start = sbinfo.dwCursorPosition
+ length = sbinfo.dwSize.X - sbinfo.dwCursorPosition.X
+
+ self._erase(start, length)
+
+ def _erase(self, start: COORD, length: int) -> None:
+ chars_written = c_ulong()
+
+ self._winapi(
+ windll.kernel32.FillConsoleOutputCharacterA,
+ self.hconsole,
+ c_char(b" "),
+ DWORD(length),
+ _coord_byval(start),
+ byref(chars_written),
+ )
+
+ # Reset attributes.
+ sbinfo = self.get_win32_screen_buffer_info()
+ self._winapi(
+ windll.kernel32.FillConsoleOutputAttribute,
+ self.hconsole,
+ sbinfo.wAttributes,
+ length,
+ _coord_byval(start),
+ byref(chars_written),
+ )
+
+ def reset_attributes(self) -> None:
+ "Reset the console foreground/background color."
+ self._winapi(
+ windll.kernel32.SetConsoleTextAttribute, self.hconsole, self.default_attrs
+ )
+ self._hidden = False
+
+ def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None:
+ (
+ fgcolor,
+ bgcolor,
+ bold,
+ underline,
+ strike,
+ italic,
+ blink,
+ reverse,
+ hidden,
+ ) = attrs
+ self._hidden = bool(hidden)
+
+ # Start from the default attributes.
+ win_attrs: int = self.default_attrs
+
+ if color_depth != ColorDepth.DEPTH_1_BIT:
+ # Override the last four bits: foreground color.
+ if fgcolor:
+ win_attrs = win_attrs & ~0xF
+ win_attrs |= self.color_lookup_table.lookup_fg_color(fgcolor)
+
+ # Override the next four bits: background color.
+ if bgcolor:
+ win_attrs = win_attrs & ~0xF0
+ win_attrs |= self.color_lookup_table.lookup_bg_color(bgcolor)
+
+ # Reverse: swap these four bits groups.
+ if reverse:
+ win_attrs = (
+ (win_attrs & ~0xFF)
+ | ((win_attrs & 0xF) << 4)
+ | ((win_attrs & 0xF0) >> 4)
+ )
+
+ self._winapi(windll.kernel32.SetConsoleTextAttribute, self.hconsole, win_attrs)
+
+ def disable_autowrap(self) -> None:
+ # Not supported by Windows.
pass
- def reset_attributes(self) ->None:
- """Reset the console foreground/background color."""
+ def enable_autowrap(self) -> None:
+ # Not supported by Windows.
pass
- def flush(self) ->None:
+ def cursor_goto(self, row: int = 0, column: int = 0) -> None:
+ pos = COORD(X=column, Y=row)
+ self._winapi(
+ windll.kernel32.SetConsoleCursorPosition, self.hconsole, _coord_byval(pos)
+ )
+
+ def cursor_up(self, amount: int) -> None:
+ sr = self.get_win32_screen_buffer_info().dwCursorPosition
+ pos = COORD(X=sr.X, Y=sr.Y - amount)
+ self._winapi(
+ windll.kernel32.SetConsoleCursorPosition, self.hconsole, _coord_byval(pos)
+ )
+
+ def cursor_down(self, amount: int) -> None:
+ self.cursor_up(-amount)
+
+ def cursor_forward(self, amount: int) -> None:
+ sr = self.get_win32_screen_buffer_info().dwCursorPosition
+ # assert sr.X + amount >= 0, 'Negative cursor position: x=%r amount=%r' % (sr.X, amount)
+
+ pos = COORD(X=max(0, sr.X + amount), Y=sr.Y)
+ self._winapi(
+ windll.kernel32.SetConsoleCursorPosition, self.hconsole, _coord_byval(pos)
+ )
+
+ def cursor_backward(self, amount: int) -> None:
+ self.cursor_forward(-amount)
+
+ def flush(self) -> None:
"""
Write to output stream and flush.
"""
- pass
+ if not self._buffer:
+ # Only flush stdout buffer. (It could be that Python still has
+ # something in its buffer. -- We want to be sure to print that in
+ # the correct color.)
+ self.stdout.flush()
+ return
+
+ data = "".join(self._buffer)
- def scroll_buffer_to_prompt(self) ->None:
+ if _DEBUG_RENDER_OUTPUT:
+ self.LOG.write(("%r" % data).encode("utf-8") + b"\n")
+ self.LOG.flush()
+
+ # Print characters one by one. This appears to be the best solution
+ # in order to avoid traces of vertical lines when the completion
+ # menu disappears.
+ for b in data:
+ written = DWORD()
+
+ retval = windll.kernel32.WriteConsoleW(
+ self.hconsole, b, 1, byref(written), None
+ )
+ assert retval != 0
+
+ self._buffer = []
+
+ def get_rows_below_cursor_position(self) -> int:
+ info = self.get_win32_screen_buffer_info()
+ return info.srWindow.Bottom - info.dwCursorPosition.Y + 1
+
+ def scroll_buffer_to_prompt(self) -> None:
"""
To be called before drawing the prompt. This should scroll the console
to left, with the cursor at the bottom (if possible).
"""
- pass
+ # Get current window size
+ info = self.get_win32_screen_buffer_info()
+ sr = info.srWindow
+ cursor_pos = info.dwCursorPosition
+
+ result = SMALL_RECT()
+
+ # Scroll to the left.
+ result.Left = 0
+ result.Right = sr.Right - sr.Left
- def enter_alternate_screen(self) ->None:
+ # Scroll vertical
+ win_height = sr.Bottom - sr.Top
+ if 0 < sr.Bottom - cursor_pos.Y < win_height - 1:
+ # no vertical scroll if cursor already on the screen
+ result.Bottom = sr.Bottom
+ else:
+ result.Bottom = max(win_height, cursor_pos.Y)
+ result.Top = result.Bottom - win_height
+
+ # Scroll API
+ self._winapi(
+ windll.kernel32.SetConsoleWindowInfo, self.hconsole, True, byref(result)
+ )
+
+ def enter_alternate_screen(self) -> None:
"""
Go to alternate screen buffer.
"""
- pass
-
- def quit_alternate_screen(self) ->None:
+ if not self._in_alternate_screen:
+ GENERIC_READ = 0x80000000
+ GENERIC_WRITE = 0x40000000
+
+ # Create a new console buffer and activate that one.
+ handle = HANDLE(
+ self._winapi(
+ windll.kernel32.CreateConsoleScreenBuffer,
+ GENERIC_READ | GENERIC_WRITE,
+ DWORD(0),
+ None,
+ DWORD(1),
+ None,
+ )
+ )
+
+ self._winapi(windll.kernel32.SetConsoleActiveScreenBuffer, handle)
+ self.hconsole = handle
+ self._in_alternate_screen = True
+
+ def quit_alternate_screen(self) -> None:
"""
Make stdout again the active buffer.
"""
+ if self._in_alternate_screen:
+ stdout = HANDLE(
+ self._winapi(windll.kernel32.GetStdHandle, STD_OUTPUT_HANDLE)
+ )
+ self._winapi(windll.kernel32.SetConsoleActiveScreenBuffer, stdout)
+ self._winapi(windll.kernel32.CloseHandle, self.hconsole)
+ self.hconsole = stdout
+ self._in_alternate_screen = False
+
+ def enable_mouse_support(self) -> None:
+ ENABLE_MOUSE_INPUT = 0x10
+
+ # This `ENABLE_QUICK_EDIT_MODE` flag needs to be cleared for mouse
+ # support to work, but it's possible that it was already cleared
+ # before.
+ ENABLE_QUICK_EDIT_MODE = 0x0040
+
+ handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
+
+ original_mode = DWORD()
+ self._winapi(windll.kernel32.GetConsoleMode, handle, pointer(original_mode))
+ self._winapi(
+ windll.kernel32.SetConsoleMode,
+ handle,
+ (original_mode.value | ENABLE_MOUSE_INPUT) & ~ENABLE_QUICK_EDIT_MODE,
+ )
+
+ def disable_mouse_support(self) -> None:
+ ENABLE_MOUSE_INPUT = 0x10
+ handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
+
+ original_mode = DWORD()
+ self._winapi(windll.kernel32.GetConsoleMode, handle, pointer(original_mode))
+ self._winapi(
+ windll.kernel32.SetConsoleMode,
+ handle,
+ original_mode.value & ~ENABLE_MOUSE_INPUT,
+ )
+
+ def hide_cursor(self) -> None:
+ pass
+
+ def show_cursor(self) -> None:
+ pass
+
+ def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
+ pass
+
+ def reset_cursor_shape(self) -> None:
pass
@classmethod
- def win32_refresh_window(cls) ->None:
+ def win32_refresh_window(cls) -> None:
"""
Call win32 API to refresh the whole Window.
@@ -153,50 +508,79 @@ class Win32Output(Output):
for completion menus. When the menu disappears, it leaves traces due
to a bug in the Windows Console. Sending a repaint request solves it.
"""
- pass
+ # Get console handle
+ handle = HANDLE(windll.kernel32.GetConsoleWindow())
+
+ RDW_INVALIDATE = 0x0001
+ windll.user32.RedrawWindow(handle, None, None, c_uint(RDW_INVALIDATE))
- def get_default_color_depth(self) ->ColorDepth:
+ def get_default_color_depth(self) -> ColorDepth:
"""
Return the default color depth for a windows terminal.
Contrary to the Vt100 implementation, this doesn't depend on a $TERM
variable.
"""
- pass
+ if self.default_color_depth is not None:
+ return self.default_color_depth
+
+ return ColorDepth.DEPTH_4_BIT
class FOREGROUND_COLOR:
- BLACK = 0
- BLUE = 1
- GREEN = 2
- CYAN = 3
- RED = 4
- MAGENTA = 5
- YELLOW = 6
- GRAY = 7
- INTENSITY = 8
+ BLACK = 0x0000
+ BLUE = 0x0001
+ GREEN = 0x0002
+ CYAN = 0x0003
+ RED = 0x0004
+ MAGENTA = 0x0005
+ YELLOW = 0x0006
+ GRAY = 0x0007
+ INTENSITY = 0x0008 # Foreground color is intensified.
class BACKGROUND_COLOR:
- BLACK = 0
- BLUE = 16
- GREEN = 32
- CYAN = 48
- RED = 64
- MAGENTA = 80
- YELLOW = 96
- GRAY = 112
- INTENSITY = 128
-
-
-def _create_ansi_color_dict(color_cls: (type[FOREGROUND_COLOR] | type[
- BACKGROUND_COLOR])) ->dict[str, int]:
- """Create a table that maps the 16 named ansi colors to their Windows code."""
- pass
+ BLACK = 0x0000
+ BLUE = 0x0010
+ GREEN = 0x0020
+ CYAN = 0x0030
+ RED = 0x0040
+ MAGENTA = 0x0050
+ YELLOW = 0x0060
+ GRAY = 0x0070
+ INTENSITY = 0x0080 # Background color is intensified.
+
+
+def _create_ansi_color_dict(
+ color_cls: type[FOREGROUND_COLOR] | type[BACKGROUND_COLOR],
+) -> dict[str, int]:
+ "Create a table that maps the 16 named ansi colors to their Windows code."
+ return {
+ "ansidefault": color_cls.BLACK,
+ "ansiblack": color_cls.BLACK,
+ "ansigray": color_cls.GRAY,
+ "ansibrightblack": color_cls.BLACK | color_cls.INTENSITY,
+ "ansiwhite": color_cls.GRAY | color_cls.INTENSITY,
+ # Low intensity.
+ "ansired": color_cls.RED,
+ "ansigreen": color_cls.GREEN,
+ "ansiyellow": color_cls.YELLOW,
+ "ansiblue": color_cls.BLUE,
+ "ansimagenta": color_cls.MAGENTA,
+ "ansicyan": color_cls.CYAN,
+ # High intensity.
+ "ansibrightred": color_cls.RED | color_cls.INTENSITY,
+ "ansibrightgreen": color_cls.GREEN | color_cls.INTENSITY,
+ "ansibrightyellow": color_cls.YELLOW | color_cls.INTENSITY,
+ "ansibrightblue": color_cls.BLUE | color_cls.INTENSITY,
+ "ansibrightmagenta": color_cls.MAGENTA | color_cls.INTENSITY,
+ "ansibrightcyan": color_cls.CYAN | color_cls.INTENSITY,
+ }
FG_ANSI_COLORS = _create_ansi_color_dict(FOREGROUND_COLOR)
BG_ANSI_COLORS = _create_ansi_color_dict(BACKGROUND_COLOR)
+
assert set(FG_ANSI_COLORS) == set(ANSI_COLOR_NAMES)
assert set(BG_ANSI_COLORS) == set(ANSI_COLOR_NAMES)
@@ -206,31 +590,94 @@ class ColorLookupTable:
Inspired by pygments/formatters/terminal256.py
"""
- def __init__(self) ->None:
+ def __init__(self) -> None:
self._win32_colors = self._build_color_table()
+
+ # Cache (map color string to foreground and background code).
self.best_match: dict[str, tuple[int, int]] = {}
@staticmethod
- def _build_color_table() ->list[tuple[int, int, int, int, int]]:
+ def _build_color_table() -> list[tuple[int, int, int, int, int]]:
"""
Build an RGB-to-256 color conversion table
"""
- pass
-
- def lookup_fg_color(self, fg_color: str) ->int:
+ FG = FOREGROUND_COLOR
+ BG = BACKGROUND_COLOR
+
+ return [
+ (0x00, 0x00, 0x00, FG.BLACK, BG.BLACK),
+ (0x00, 0x00, 0xAA, FG.BLUE, BG.BLUE),
+ (0x00, 0xAA, 0x00, FG.GREEN, BG.GREEN),
+ (0x00, 0xAA, 0xAA, FG.CYAN, BG.CYAN),
+ (0xAA, 0x00, 0x00, FG.RED, BG.RED),
+ (0xAA, 0x00, 0xAA, FG.MAGENTA, BG.MAGENTA),
+ (0xAA, 0xAA, 0x00, FG.YELLOW, BG.YELLOW),
+ (0x88, 0x88, 0x88, FG.GRAY, BG.GRAY),
+ (0x44, 0x44, 0xFF, FG.BLUE | FG.INTENSITY, BG.BLUE | BG.INTENSITY),
+ (0x44, 0xFF, 0x44, FG.GREEN | FG.INTENSITY, BG.GREEN | BG.INTENSITY),
+ (0x44, 0xFF, 0xFF, FG.CYAN | FG.INTENSITY, BG.CYAN | BG.INTENSITY),
+ (0xFF, 0x44, 0x44, FG.RED | FG.INTENSITY, BG.RED | BG.INTENSITY),
+ (0xFF, 0x44, 0xFF, FG.MAGENTA | FG.INTENSITY, BG.MAGENTA | BG.INTENSITY),
+ (0xFF, 0xFF, 0x44, FG.YELLOW | FG.INTENSITY, BG.YELLOW | BG.INTENSITY),
+ (0x44, 0x44, 0x44, FG.BLACK | FG.INTENSITY, BG.BLACK | BG.INTENSITY),
+ (0xFF, 0xFF, 0xFF, FG.GRAY | FG.INTENSITY, BG.GRAY | BG.INTENSITY),
+ ]
+
+ def _closest_color(self, r: int, g: int, b: int) -> tuple[int, int]:
+ distance = 257 * 257 * 3 # "infinity" (>distance from #000000 to #ffffff)
+ fg_match = 0
+ bg_match = 0
+
+ for r_, g_, b_, fg_, bg_ in self._win32_colors:
+ rd = r - r_
+ gd = g - g_
+ bd = b - b_
+
+ d = rd * rd + gd * gd + bd * bd
+
+ if d < distance:
+ fg_match = fg_
+ bg_match = bg_
+ distance = d
+ return fg_match, bg_match
+
+ def _color_indexes(self, color: str) -> tuple[int, int]:
+ indexes = self.best_match.get(color, None)
+ if indexes is None:
+ try:
+ rgb = int(str(color), 16)
+ except ValueError:
+ rgb = 0
+
+ r = (rgb >> 16) & 0xFF
+ g = (rgb >> 8) & 0xFF
+ b = rgb & 0xFF
+ indexes = self._closest_color(r, g, b)
+ self.best_match[color] = indexes
+ return indexes
+
+ def lookup_fg_color(self, fg_color: str) -> int:
"""
Return the color for use in the
`windll.kernel32.SetConsoleTextAttribute` API call.
:param fg_color: Foreground as text. E.g. 'ffffff' or 'red'
"""
- pass
+ # Foreground.
+ if fg_color in FG_ANSI_COLORS:
+ return FG_ANSI_COLORS[fg_color]
+ else:
+ return self._color_indexes(fg_color)[0]
- def lookup_bg_color(self, bg_color: str) ->int:
+ def lookup_bg_color(self, bg_color: str) -> int:
"""
Return the color for use in the
`windll.kernel32.SetConsoleTextAttribute` API call.
:param bg_color: Background as text. E.g. 'ffffff' or 'red'
"""
- pass
+ # Background.
+ if bg_color in BG_ANSI_COLORS:
+ return BG_ANSI_COLORS[bg_color]
+ else:
+ return self._color_indexes(bg_color)[1]
diff --git a/src/prompt_toolkit/output/windows10.py b/src/prompt_toolkit/output/windows10.py
index 8ed52363..c39f3ecf 100644
--- a/src/prompt_toolkit/output/windows10.py
+++ b/src/prompt_toolkit/output/windows10.py
@@ -1,18 +1,28 @@
from __future__ import annotations
+
import sys
-assert sys.platform == 'win32'
+
+assert sys.platform == "win32"
+
from ctypes import byref, windll
from ctypes.wintypes import DWORD, HANDLE
from typing import Any, TextIO
+
from prompt_toolkit.data_structures import Size
from prompt_toolkit.win32_types import STD_OUTPUT_HANDLE
+
from .base import Output
from .color_depth import ColorDepth
from .vt100 import Vt100_Output
from .win32 import Win32Output
-__all__ = ['Windows10_Output']
-ENABLE_PROCESSED_INPUT = 1
-ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4
+
+__all__ = [
+ "Windows10_Output",
+]
+
+# See: https://msdn.microsoft.com/pl-pl/library/windows/desktop/ms686033(v=vs.85).aspx
+ENABLE_PROCESSED_INPUT = 0x0001
+ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
class Windows10_Output:
@@ -20,47 +30,99 @@ class Windows10_Output:
Windows 10 output abstraction. This enables and uses vt100 escape sequences.
"""
- def __init__(self, stdout: TextIO, default_color_depth: (ColorDepth |
- None)=None) ->None:
+ def __init__(
+ self, stdout: TextIO, default_color_depth: ColorDepth | None = None
+ ) -> None:
self.default_color_depth = default_color_depth
- self.win32_output = Win32Output(stdout, default_color_depth=
- default_color_depth)
- self.vt100_output = Vt100_Output(stdout, lambda : Size(0, 0),
- default_color_depth=default_color_depth)
- self._hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
- )
-
- def flush(self) ->None:
+ self.win32_output = Win32Output(stdout, default_color_depth=default_color_depth)
+ self.vt100_output = Vt100_Output(
+ stdout, lambda: Size(0, 0), default_color_depth=default_color_depth
+ )
+ self._hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE))
+
+ def flush(self) -> None:
"""
Write to output stream and flush.
"""
- pass
+ original_mode = DWORD(0)
+
+ # Remember the previous console mode.
+ windll.kernel32.GetConsoleMode(self._hconsole, byref(original_mode))
- def __getattr__(self, name: str) ->Any:
- if name in ('get_size', 'get_rows_below_cursor_position',
- 'enable_mouse_support', 'disable_mouse_support',
- 'scroll_buffer_to_prompt', 'get_win32_screen_buffer_info',
- 'enable_bracketed_paste', 'disable_bracketed_paste'):
+ # Enable processing of vt100 sequences.
+ windll.kernel32.SetConsoleMode(
+ self._hconsole,
+ DWORD(ENABLE_PROCESSED_INPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING),
+ )
+
+ try:
+ self.vt100_output.flush()
+ finally:
+ # Restore console mode.
+ windll.kernel32.SetConsoleMode(self._hconsole, original_mode)
+
+ @property
+ def responds_to_cpr(self) -> bool:
+ return False # We don't need this on Windows.
+
+ def __getattr__(self, name: str) -> Any:
+ if name in (
+ "get_size",
+ "get_rows_below_cursor_position",
+ "enable_mouse_support",
+ "disable_mouse_support",
+ "scroll_buffer_to_prompt",
+ "get_win32_screen_buffer_info",
+ "enable_bracketed_paste",
+ "disable_bracketed_paste",
+ ):
return getattr(self.win32_output, name)
else:
return getattr(self.vt100_output, name)
- def get_default_color_depth(self) ->ColorDepth:
+ def get_default_color_depth(self) -> ColorDepth:
"""
Return the default color depth for a windows terminal.
Contrary to the Vt100 implementation, this doesn't depend on a $TERM
variable.
"""
- pass
+ if self.default_color_depth is not None:
+ return self.default_color_depth
+
+ # Previously, we used `DEPTH_4_BIT`, even on Windows 10. This was
+ # because true color support was added after "Console Virtual Terminal
+ # Sequences" support was added, and there was no good way to detect
+ # what support was given.
+ # 24bit color support was added in 2016, so let's assume it's safe to
+ # take that as a default:
+ # https://devblogs.microsoft.com/commandline/24-bit-color-in-the-windows-console/
+ return ColorDepth.TRUE_COLOR
Output.register(Windows10_Output)
-def is_win_vt100_enabled() ->bool:
+def is_win_vt100_enabled() -> bool:
"""
Returns True when we're running Windows and VT100 escape sequences are
supported.
"""
- pass
+ if sys.platform != "win32":
+ return False
+
+ hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_OUTPUT_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_PROCESSED_INPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
+ )
+
+ return result == 1
+ finally:
+ windll.kernel32.SetConsoleMode(hconsole, original_mode)
diff --git a/src/prompt_toolkit/patch_stdout.py b/src/prompt_toolkit/patch_stdout.py
index c07354d2..528bec7f 100644
--- a/src/prompt_toolkit/patch_stdout.py
+++ b/src/prompt_toolkit/patch_stdout.py
@@ -18,6 +18,7 @@ Multiple applications can run in the body of the context manager, one after the
other.
"""
from __future__ import annotations
+
import asyncio
import queue
import sys
@@ -25,13 +26,18 @@ import threading
import time
from contextlib import contextmanager
from typing import Generator, TextIO, cast
+
from .application import get_app_session, run_in_terminal
from .output import Output
-__all__ = ['patch_stdout', 'StdoutProxy']
+
+__all__ = [
+ "patch_stdout",
+ "StdoutProxy",
+]
@contextmanager
-def patch_stdout(raw: bool=False) ->Generator[None, None, None]:
+def patch_stdout(raw: bool = False) -> Generator[None, None, None]:
"""
Replace `sys.stdout` by an :class:`_StdoutProxy` instance.
@@ -48,11 +54,23 @@ def patch_stdout(raw: bool=False) ->Generator[None, None, None]:
:param raw: (`bool`) When True, vt100 terminal escape sequences are not
removed/escaped.
"""
- pass
+ with StdoutProxy(raw=raw) as proxy:
+ original_stdout = sys.stdout
+ original_stderr = sys.stderr
+
+ # Enter.
+ sys.stdout = cast(TextIO, proxy)
+ sys.stderr = cast(TextIO, proxy)
+
+ try:
+ yield
+ finally:
+ sys.stdout = original_stdout
+ sys.stderr = original_stderr
class _Done:
- """Sentinel value for stopping the stdout proxy."""
+ "Sentinel value for stopping the stdout proxy."
class StdoutProxy:
@@ -73,63 +91,206 @@ class StdoutProxy:
between writes in order to bundle many smaller writes in a short timespan.
"""
- def __init__(self, sleep_between_writes: float=0.2, raw: bool=False
- ) ->None:
+ def __init__(
+ self,
+ sleep_between_writes: float = 0.2,
+ raw: bool = False,
+ ) -> None:
self.sleep_between_writes = sleep_between_writes
self.raw = raw
+
self._lock = threading.RLock()
self._buffer: list[str] = []
+
+ # Keep track of the curret app session.
self.app_session = get_app_session()
+
+ # See what output is active *right now*. We should do it at this point,
+ # before this `StdoutProxy` instance is possibly assigned to `sys.stdout`.
+ # Otherwise, if `patch_stdout` is used, and no `Output` instance has
+ # been created, then the default output creation code will see this
+ # proxy object as `sys.stdout`, and get in a recursive loop trying to
+ # access `StdoutProxy.isatty()` which will again retrieve the output.
self._output: Output = self.app_session.output
+
+ # Flush thread
self._flush_queue: queue.Queue[str | _Done] = queue.Queue()
self._flush_thread = self._start_write_thread()
self.closed = False
- def __enter__(self) ->StdoutProxy:
+ def __enter__(self) -> StdoutProxy:
return self
- def __exit__(self, *args: object) ->None:
+ def __exit__(self, *args: object) -> None:
self.close()
- def close(self) ->None:
+ def close(self) -> None:
"""
Stop `StdoutProxy` proxy.
This will terminate the write thread, make sure everything is flushed
and wait for the write thread to finish.
"""
- pass
+ if not self.closed:
+ self._flush_queue.put(_Done())
+ self._flush_thread.join()
+ self.closed = True
+
+ def _start_write_thread(self) -> threading.Thread:
+ thread = threading.Thread(
+ target=self._write_thread,
+ name="patch-stdout-flush-thread",
+ daemon=True,
+ )
+ thread.start()
+ return thread
+
+ def _write_thread(self) -> None:
+ done = False
+
+ while not done:
+ item = self._flush_queue.get()
+
+ if isinstance(item, _Done):
+ break
+
+ # Don't bother calling when we got an empty string.
+ if not item:
+ continue
+
+ text = []
+ text.append(item)
+
+ # Read the rest of the queue if more data was queued up.
+ while True:
+ try:
+ item = self._flush_queue.get_nowait()
+ except queue.Empty:
+ break
+ else:
+ if isinstance(item, _Done):
+ done = True
+ else:
+ text.append(item)
+
+ app_loop = self._get_app_loop()
+ self._write_and_flush(app_loop, "".join(text))
+
+ # If an application was running that requires repainting, then wait
+ # for a very short time, in order to bundle actual writes and avoid
+ # having to repaint to often.
+ if app_loop is not None:
+ time.sleep(self.sleep_between_writes)
- def _get_app_loop(self) ->(asyncio.AbstractEventLoop | None):
+ def _get_app_loop(self) -> asyncio.AbstractEventLoop | None:
"""
Return the event loop for the application currently running in our
`AppSession`.
"""
- pass
+ app = self.app_session.app
- def _write_and_flush(self, loop: (asyncio.AbstractEventLoop | None),
- text: str) ->None:
+ if app is None:
+ return None
+
+ return app.loop
+
+ def _write_and_flush(
+ self, loop: asyncio.AbstractEventLoop | None, text: str
+ ) -> None:
"""
Write the given text to stdout and flush.
If an application is running, use `run_in_terminal`.
"""
- pass
- def _write(self, data: str) ->None:
+ def write_and_flush() -> None:
+ # Ensure that autowrap is enabled before calling `write`.
+ # XXX: On Windows, the `Windows10_Output` enables/disables VT
+ # terminal processing for every flush. It turns out that this
+ # causes autowrap to be reset (disabled) after each flush. So,
+ # we have to enable it again before writing text.
+ self._output.enable_autowrap()
+
+ if self.raw:
+ self._output.write_raw(text)
+ else:
+ self._output.write(text)
+
+ self._output.flush()
+
+ def write_and_flush_in_loop() -> None:
+ # If an application is running, use `run_in_terminal`, otherwise
+ # call it directly.
+ run_in_terminal(write_and_flush, in_executor=False)
+
+ if loop is None:
+ # No loop, write immediately.
+ write_and_flush()
+ else:
+ # Make sure `write_and_flush` is executed *in* the event loop, not
+ # in another thread.
+ loop.call_soon_threadsafe(write_and_flush_in_loop)
+
+ def _write(self, data: str) -> None:
"""
Note: print()-statements cause to multiple write calls.
- (write('line') and write('
-')). Of course we don't want to call
+ (write('line') and write('\n')). Of course we don't want to call
`run_in_terminal` for every individual call, because that's too
expensive, and as long as the newline hasn't been written, the
text itself is again overwritten by the rendering of the input
command line. Therefor, we have a little buffer which holds the
text until a newline is written to stdout.
"""
- pass
+ if "\n" in data:
+ # When there is a newline in the data, write everything before the
+ # newline, including the newline itself.
+ before, after = data.rsplit("\n", 1)
+ to_write = self._buffer + [before, "\n"]
+ self._buffer = [after]
+
+ text = "".join(to_write)
+ self._flush_queue.put(text)
+ else:
+ # Otherwise, cache in buffer.
+ self._buffer.append(data)
+
+ def _flush(self) -> None:
+ text = "".join(self._buffer)
+ self._buffer = []
+ self._flush_queue.put(text)
- def flush(self) ->None:
+ def write(self, data: str) -> int:
+ with self._lock:
+ self._write(data)
+
+ return len(data) # Pretend everything was written.
+
+ def flush(self) -> None:
"""
Flush buffered output.
"""
- pass
+ with self._lock:
+ self._flush()
+
+ @property
+ def original_stdout(self) -> TextIO:
+ return self._output.stdout or sys.__stdout__
+
+ # Attributes for compatibility with sys.__stdout__:
+
+ def fileno(self) -> int:
+ return self._output.fileno()
+
+ def isatty(self) -> bool:
+ stdout = self._output.stdout
+ if stdout is None:
+ return False
+
+ return stdout.isatty()
+
+ @property
+ def encoding(self) -> str:
+ return self._output.encoding()
+
+ @property
+ def errors(self) -> str:
+ return "strict"
diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py
index d60c37f7..5ad1dd68 100644
--- a/src/prompt_toolkit/renderer.py
+++ b/src/prompt_toolkit/renderer.py
@@ -3,10 +3,12 @@ Renders the command line on the console.
(Redraws parts of the input line that were changed.)
"""
from __future__ import annotations
+
from asyncio import FIRST_COMPLETED, Future, ensure_future, sleep, wait
from collections import deque
from enum import Enum
from typing import TYPE_CHECKING, Any, Callable, Dict, Hashable
+
from prompt_toolkit.application.current import get_app
from prompt_toolkit.cursor_shapes import CursorShape
from prompt_toolkit.data_structures import Point, Size
@@ -15,19 +17,39 @@ from prompt_toolkit.formatted_text import AnyFormattedText, to_formatted_text
from prompt_toolkit.layout.mouse_handlers import MouseHandlers
from prompt_toolkit.layout.screen import Char, Screen, WritePosition
from prompt_toolkit.output import ColorDepth, Output
-from prompt_toolkit.styles import Attrs, BaseStyle, DummyStyleTransformation, StyleTransformation
+from prompt_toolkit.styles import (
+ Attrs,
+ BaseStyle,
+ DummyStyleTransformation,
+ StyleTransformation,
+)
+
if TYPE_CHECKING:
from prompt_toolkit.application import Application
from prompt_toolkit.layout.layout import Layout
-__all__ = ['Renderer', 'print_formatted_text']
-def _output_screen_diff(app: Application[Any], output: Output, screen:
- Screen, current_pos: Point, color_depth: ColorDepth, previous_screen: (
- Screen | None), last_style: (str | None), is_done: bool, full_screen:
- bool, attrs_for_style_string: _StyleStringToAttrsCache,
- style_string_has_style: _StyleStringHasStyleCache, size: Size,
- previous_width: int) ->tuple[Point, str | None]:
+__all__ = [
+ "Renderer",
+ "print_formatted_text",
+]
+
+
+def _output_screen_diff(
+ app: Application[Any],
+ output: Output,
+ screen: Screen,
+ current_pos: Point,
+ color_depth: ColorDepth,
+ previous_screen: Screen | None,
+ last_style: str | None,
+ is_done: bool, # XXX: drop is_done
+ full_screen: bool,
+ attrs_for_style_string: _StyleStringToAttrsCache,
+ style_string_has_style: _StyleStringHasStyleCache,
+ size: Size,
+ previous_width: int,
+) -> tuple[Point, str | None]:
"""
Render the diff between this screen and the previous screen.
@@ -47,11 +69,201 @@ def _output_screen_diff(app: Application[Any], output: Output, screen:
:param width: The width of the terminal.
:param previous_width: The width of the terminal during the last rendering.
"""
- pass
+ width, height = size.columns, size.rows
+
+ #: Variable for capturing the output.
+ write = output.write
+ write_raw = output.write_raw
+
+ # Create locals for the most used output methods.
+ # (Save expensive attribute lookups.)
+ _output_set_attributes = output.set_attributes
+ _output_reset_attributes = output.reset_attributes
+ _output_cursor_forward = output.cursor_forward
+ _output_cursor_up = output.cursor_up
+ _output_cursor_backward = output.cursor_backward
+
+ # Hide cursor before rendering. (Avoid flickering.)
+ output.hide_cursor()
+
+ def reset_attributes() -> None:
+ "Wrapper around Output.reset_attributes."
+ nonlocal last_style
+ _output_reset_attributes()
+ last_style = None # Forget last char after resetting attributes.
+
+ def move_cursor(new: Point) -> Point:
+ "Move cursor to this `new` point. Returns the given Point."
+ current_x, current_y = current_pos.x, current_pos.y
+
+ if new.y > current_y:
+ # Use newlines instead of CURSOR_DOWN, because this might add new lines.
+ # CURSOR_DOWN will never create new lines at the bottom.
+ # Also reset attributes, otherwise the newline could draw a
+ # background color.
+ reset_attributes()
+ write("\r\n" * (new.y - current_y))
+ current_x = 0
+ _output_cursor_forward(new.x)
+ return new
+ elif new.y < current_y:
+ _output_cursor_up(current_y - new.y)
+
+ if current_x >= width - 1:
+ write("\r")
+ _output_cursor_forward(new.x)
+ elif new.x < current_x or current_x >= width - 1:
+ _output_cursor_backward(current_x - new.x)
+ elif new.x > current_x:
+ _output_cursor_forward(new.x - current_x)
+
+ return new
+
+ def output_char(char: Char) -> None:
+ """
+ Write the output of this character.
+ """
+ nonlocal last_style
+
+ # If the last printed character has the same style, don't output the
+ # style again.
+ if last_style == char.style:
+ write(char.char)
+ else:
+ # Look up `Attr` for this style string. Only set attributes if different.
+ # (Two style strings can still have the same formatting.)
+ # Note that an empty style string can have formatting that needs to
+ # be applied, because of style transformations.
+ new_attrs = attrs_for_style_string[char.style]
+ if not last_style or new_attrs != attrs_for_style_string[last_style]:
+ _output_set_attributes(new_attrs, color_depth)
+
+ write(char.char)
+ last_style = char.style
+
+ def get_max_column_index(row: dict[int, Char]) -> int:
+ """
+ Return max used column index, ignoring whitespace (without style) at
+ the end of the line. This is important for people that copy/paste
+ terminal output.
+
+ There are two reasons we are sometimes seeing whitespace at the end:
+ - `BufferControl` adds a trailing space to each line, because it's a
+ possible cursor position, so that the line wrapping won't change if
+ the cursor position moves around.
+ - The `Window` adds a style class to the current line for highlighting
+ (cursor-line).
+ """
+ numbers = (
+ index
+ for index, cell in row.items()
+ if cell.char != " " or style_string_has_style[cell.style]
+ )
+ return max(numbers, default=0)
+
+ # Render for the first time: reset styling.
+ if not previous_screen:
+ reset_attributes()
+
+ # Disable autowrap. (When entering a the alternate screen, or anytime when
+ # we have a prompt. - In the case of a REPL, like IPython, people can have
+ # background threads, and it's hard for debugging if their output is not
+ # wrapped.)
+ if not previous_screen or not full_screen:
+ output.disable_autowrap()
+
+ # When the previous screen has a different size, redraw everything anyway.
+ # Also when we are done. (We might take up less rows, so clearing is important.)
+ if (
+ is_done or not previous_screen or previous_width != width
+ ): # XXX: also consider height??
+ current_pos = move_cursor(Point(x=0, y=0))
+ reset_attributes()
+ output.erase_down()
+
+ previous_screen = Screen()
+
+ # Get height of the screen.
+ # (height changes as we loop over data_buffer, so remember the current value.)
+ # (Also make sure to clip the height to the size of the output.)
+ current_height = min(screen.height, height)
+
+ # Loop over the rows.
+ row_count = min(max(screen.height, previous_screen.height), height)
+
+ for y in range(row_count):
+ new_row = screen.data_buffer[y]
+ previous_row = previous_screen.data_buffer[y]
+ zero_width_escapes_row = screen.zero_width_escapes[y]
+
+ new_max_line_len = min(width - 1, get_max_column_index(new_row))
+ previous_max_line_len = min(width - 1, get_max_column_index(previous_row))
+
+ # Loop over the columns.
+ c = 0 # Column counter.
+ while c <= new_max_line_len:
+ new_char = new_row[c]
+ old_char = previous_row[c]
+ char_width = new_char.width or 1
+
+ # When the old and new character at this position are different,
+ # draw the output. (Because of the performance, we don't call
+ # `Char.__ne__`, but inline the same expression.)
+ if new_char.char != old_char.char or new_char.style != old_char.style:
+ current_pos = move_cursor(Point(x=c, y=y))
+
+ # Send injected escape sequences to output.
+ if c in zero_width_escapes_row:
+ write_raw(zero_width_escapes_row[c])
+
+ output_char(new_char)
+ current_pos = Point(x=current_pos.x + char_width, y=current_pos.y)
+
+ c += char_width
+
+ # If the new line is shorter, trim it.
+ if previous_screen and new_max_line_len < previous_max_line_len:
+ current_pos = move_cursor(Point(x=new_max_line_len + 1, y=y))
+ reset_attributes()
+ output.erase_end_of_line()
+
+ # Correctly reserve vertical space as required by the layout.
+ # When this is a new screen (drawn for the first time), or for some reason
+ # higher than the previous one. Move the cursor once to the bottom of the
+ # output. That way, we're sure that the terminal scrolls up, even when the
+ # lower lines of the canvas just contain whitespace.
+
+ # The most obvious reason that we actually want this behavior is the avoid
+ # the artifact of the input scrolling when the completion menu is shown.
+ # (If the scrolling is actually wanted, the layout can still be build in a
+ # way to behave that way by setting a dynamic height.)
+ if current_height > previous_screen.height:
+ current_pos = move_cursor(Point(x=0, y=current_height - 1))
+
+ # Move cursor:
+ if is_done:
+ current_pos = move_cursor(Point(x=0, y=current_height))
+ output.erase_down()
+ else:
+ current_pos = move_cursor(screen.get_cursor_position(app.layout.current_window))
+
+ if is_done or not full_screen:
+ output.enable_autowrap()
+
+ # Always reset the color attributes. This is important because a background
+ # thread could print data to stdout and we want that to be displayed in the
+ # default colors. (Also, if a background color has been set, many terminals
+ # give weird artifacts on resize events.)
+ reset_attributes()
+
+ if screen.show_cursor or is_done:
+ output.show_cursor()
+
+ return current_pos, last_style
class HeightIsUnknownError(Exception):
- """Information unavailable. Did not yet receive the CPR response."""
+ "Information unavailable. Did not yet receive the CPR response."
class _StyleStringToAttrsCache(Dict[str, Attrs]):
@@ -60,14 +272,18 @@ class _StyleStringToAttrsCache(Dict[str, Attrs]):
(This is an important speed up.)
"""
- def __init__(self, get_attrs_for_style_str: Callable[[str], Attrs],
- style_transformation: StyleTransformation) ->None:
+ def __init__(
+ self,
+ get_attrs_for_style_str: Callable[[str], Attrs],
+ style_transformation: StyleTransformation,
+ ) -> None:
self.get_attrs_for_style_str = get_attrs_for_style_str
self.style_transformation = style_transformation
- def __missing__(self, style_str: str) ->Attrs:
+ def __missing__(self, style_str: str) -> Attrs:
attrs = self.get_attrs_for_style_str(style_str)
attrs = self.style_transformation.transform_attrs(attrs)
+
self[style_str] = attrs
return attrs
@@ -83,22 +299,30 @@ class _StyleStringHasStyleCache(Dict[str, bool]):
output if there's no text in the cell.
"""
- def __init__(self, style_string_to_attrs: dict[str, Attrs]) ->None:
+ def __init__(self, style_string_to_attrs: dict[str, Attrs]) -> None:
self.style_string_to_attrs = style_string_to_attrs
- def __missing__(self, style_str: str) ->bool:
+ def __missing__(self, style_str: str) -> bool:
attrs = self.style_string_to_attrs[style_str]
- is_default = bool(attrs.color or attrs.bgcolor or attrs.underline or
- attrs.strike or attrs.blink or attrs.reverse)
+ is_default = bool(
+ attrs.color
+ or attrs.bgcolor
+ or attrs.underline
+ or attrs.strike
+ or attrs.blink
+ or attrs.reverse
+ )
+
self[style_str] = is_default
return is_default
class CPR_Support(Enum):
- """Enum: whether or not CPR is supported."""
- SUPPORTED = 'SUPPORTED'
- NOT_SUPPORTED = 'NOT_SUPPORTED'
- UNKNOWN = 'UNKNOWN'
+ "Enum: whether or not CPR is supported."
+
+ SUPPORTED = "SUPPORTED"
+ NOT_SUPPORTED = "NOT_SUPPORTED"
+ UNKNOWN = "UNKNOWN"
class Renderer:
@@ -111,56 +335,130 @@ class Renderer:
r = Renderer(style, output)
r.render(app, layout=...)
"""
- CPR_TIMEOUT = 2
- def __init__(self, style: BaseStyle, output: Output, full_screen: bool=
- False, mouse_support: FilterOrBool=False,
- cpr_not_supported_callback: (Callable[[], None] | None)=None) ->None:
+ CPR_TIMEOUT = 2 # Time to wait until we consider CPR to be not supported.
+
+ def __init__(
+ self,
+ style: BaseStyle,
+ output: Output,
+ full_screen: bool = False,
+ mouse_support: FilterOrBool = False,
+ cpr_not_supported_callback: Callable[[], None] | None = None,
+ ) -> None:
self.style = style
self.output = output
self.full_screen = full_screen
self.mouse_support = to_filter(mouse_support)
self.cpr_not_supported_callback = cpr_not_supported_callback
+
self._in_alternate_screen = False
self._mouse_support_enabled = False
self._bracketed_paste_enabled = False
self._cursor_key_mode_reset = False
+
+ # Future set when we are waiting for a CPR flag.
self._waiting_for_cpr_futures: deque[Future[None]] = deque()
self.cpr_support = CPR_Support.UNKNOWN
+
if not output.responds_to_cpr:
self.cpr_support = CPR_Support.NOT_SUPPORTED
+
+ # Cache for the style.
self._attrs_for_style: _StyleStringToAttrsCache | None = None
self._style_string_has_style: _StyleStringHasStyleCache | None = None
self._last_style_hash: Hashable | None = None
self._last_transformation_hash: Hashable | None = None
self._last_color_depth: ColorDepth | None = None
+
self.reset(_scroll=True)
+ def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> None:
+ # Reset position
+ self._cursor_pos = Point(x=0, y=0)
+
+ # Remember the last screen instance between renderers. This way,
+ # we can create a `diff` between two screens and only output the
+ # difference. It's also to remember the last height. (To show for
+ # instance a toolbar at the bottom position.)
+ self._last_screen: Screen | None = None
+ self._last_size: Size | None = None
+ self._last_style: str | None = None
+ self._last_cursor_shape: CursorShape | None = None
+
+ # Default MouseHandlers. (Just empty.)
+ self.mouse_handlers = MouseHandlers()
+
+ #: Space from the top of the layout, until the bottom of the terminal.
+ #: We don't know this until a `report_absolute_cursor_row` call.
+ self._min_available_height = 0
+
+ # In case of Windows, also make sure to scroll to the current cursor
+ # position. (Only when rendering the first time.)
+ # It does nothing for vt100 terminals.
+ if _scroll:
+ self.output.scroll_buffer_to_prompt()
+
+ # Quit alternate screen.
+ if self._in_alternate_screen and leave_alternate_screen:
+ self.output.quit_alternate_screen()
+ self._in_alternate_screen = False
+
+ # Disable mouse support.
+ if self._mouse_support_enabled:
+ self.output.disable_mouse_support()
+ self._mouse_support_enabled = False
+
+ # Disable bracketed paste.
+ if self._bracketed_paste_enabled:
+ self.output.disable_bracketed_paste()
+ self._bracketed_paste_enabled = False
+
+ self.output.reset_cursor_shape()
+
+ # NOTE: No need to set/reset cursor key mode here.
+
+ # Flush output. `disable_mouse_support` needs to write to stdout.
+ self.output.flush()
+
@property
- def last_rendered_screen(self) ->(Screen | None):
+ def last_rendered_screen(self) -> Screen | None:
"""
The `Screen` class that was generated during the last rendering.
This can be `None`.
"""
- pass
+ return self._last_screen
@property
- def height_is_known(self) ->bool:
+ def height_is_known(self) -> bool:
"""
True when the height from the cursor until the bottom of the terminal
is known. (It's often nicer to draw bottom toolbars only if the height
is known, in order to avoid flickering when the CPR response arrives.)
"""
- pass
+ if self.full_screen or self._min_available_height > 0:
+ return True
+ try:
+ self._min_available_height = self.output.get_rows_below_cursor_position()
+ return True
+ except NotImplementedError:
+ return False
@property
- def rows_above_layout(self) ->int:
+ def rows_above_layout(self) -> int:
"""
Return the number of rows visible in the terminal above the layout.
"""
- pass
-
- def request_absolute_cursor_position(self) ->None:
+ if self._in_alternate_screen:
+ return 0
+ elif self._min_available_height > 0:
+ total_rows = self.output.get_size().rows
+ last_screen_height = self._last_screen.height if self._last_screen else 0
+ return total_rows - max(self._min_available_height, last_screen_height)
+ else:
+ raise HeightIsUnknownError("Rows above layout is unknown.")
+
+ def request_absolute_cursor_position(self) -> None:
"""
Get current cursor position.
@@ -171,40 +469,262 @@ class Renderer:
For vt100: Do CPR request. (answer will arrive later.)
For win32: Do API call. (Answer comes immediately.)
"""
- pass
+ # Only do this request when the cursor is at the top row. (after a
+ # clear or reset). We will rely on that in `report_absolute_cursor_row`.
+ assert self._cursor_pos.y == 0
+
+ # In full-screen mode, always use the total height as min-available-height.
+ if self.full_screen:
+ self._min_available_height = self.output.get_size().rows
+ return
+
+ # For Win32, we have an API call to get the number of rows below the
+ # cursor.
+ try:
+ self._min_available_height = self.output.get_rows_below_cursor_position()
+ return
+ except NotImplementedError:
+ pass
+
+ # Use CPR.
+ if self.cpr_support == CPR_Support.NOT_SUPPORTED:
+ return
+
+ def do_cpr() -> None:
+ # Asks for a cursor position report (CPR).
+ self._waiting_for_cpr_futures.append(Future())
+ self.output.ask_for_cpr()
+
+ if self.cpr_support == CPR_Support.SUPPORTED:
+ do_cpr()
+ return
+
+ # If we don't know whether CPR is supported, only do a request if
+ # none is pending, and test it, using a timer.
+ if self.waiting_for_cpr:
+ return
+
+ do_cpr()
+
+ async def timer() -> None:
+ await sleep(self.CPR_TIMEOUT)
+
+ # Not set in the meantime -> not supported.
+ if self.cpr_support == CPR_Support.UNKNOWN:
+ self.cpr_support = CPR_Support.NOT_SUPPORTED
+
+ if self.cpr_not_supported_callback:
+ # Make sure to call this callback in the main thread.
+ self.cpr_not_supported_callback()
- def report_absolute_cursor_row(self, row: int) ->None:
+ get_app().create_background_task(timer())
+
+ def report_absolute_cursor_row(self, row: int) -> None:
"""
To be called when we know the absolute cursor position.
(As an answer of a "Cursor Position Request" response.)
"""
- pass
+ self.cpr_support = CPR_Support.SUPPORTED
+
+ # Calculate the amount of rows from the cursor position until the
+ # bottom of the terminal.
+ total_rows = self.output.get_size().rows
+ rows_below_cursor = total_rows - row + 1
+
+ # Set the minimum available height.
+ self._min_available_height = rows_below_cursor
+
+ # Pop and set waiting for CPR future.
+ try:
+ f = self._waiting_for_cpr_futures.popleft()
+ except IndexError:
+ pass # Received CPR response without having a CPR.
+ else:
+ f.set_result(None)
@property
- def waiting_for_cpr(self) ->bool:
+ def waiting_for_cpr(self) -> bool:
"""
Waiting for CPR flag. True when we send the request, but didn't got a
response.
"""
- pass
+ return bool(self._waiting_for_cpr_futures)
- async def wait_for_cpr_responses(self, timeout: int=1) ->None:
+ async def wait_for_cpr_responses(self, timeout: int = 1) -> None:
"""
Wait for a CPR response.
"""
- pass
-
- def render(self, app: Application[Any], layout: Layout, is_done: bool=False
- ) ->None:
+ cpr_futures = list(self._waiting_for_cpr_futures) # Make copy.
+
+ # When there are no CPRs in the queue. Don't do anything.
+ if not cpr_futures or self.cpr_support == CPR_Support.NOT_SUPPORTED:
+ return None
+
+ async def wait_for_responses() -> None:
+ for response_f in cpr_futures:
+ await response_f
+
+ async def wait_for_timeout() -> None:
+ await sleep(timeout)
+
+ # Got timeout, erase queue.
+ for response_f in cpr_futures:
+ response_f.cancel()
+ self._waiting_for_cpr_futures = deque()
+
+ tasks = {
+ ensure_future(wait_for_responses()),
+ ensure_future(wait_for_timeout()),
+ }
+ _, pending = await wait(tasks, return_when=FIRST_COMPLETED)
+ for task in pending:
+ task.cancel()
+
+ def render(
+ self, app: Application[Any], layout: Layout, is_done: bool = False
+ ) -> None:
"""
Render the current interface to the output.
:param is_done: When True, put the cursor at the end of the interface. We
won't print any changes to this part.
"""
- pass
-
- def erase(self, leave_alternate_screen: bool=True) ->None:
+ output = self.output
+
+ # Enter alternate screen.
+ if self.full_screen and not self._in_alternate_screen:
+ self._in_alternate_screen = True
+ output.enter_alternate_screen()
+
+ # Enable bracketed paste.
+ if not self._bracketed_paste_enabled:
+ self.output.enable_bracketed_paste()
+ self._bracketed_paste_enabled = True
+
+ # Reset cursor key mode.
+ if not self._cursor_key_mode_reset:
+ self.output.reset_cursor_key_mode()
+ self._cursor_key_mode_reset = True
+
+ # Enable/disable mouse support.
+ needs_mouse_support = self.mouse_support()
+
+ if needs_mouse_support and not self._mouse_support_enabled:
+ output.enable_mouse_support()
+ self._mouse_support_enabled = True
+
+ elif not needs_mouse_support and self._mouse_support_enabled:
+ output.disable_mouse_support()
+ self._mouse_support_enabled = False
+
+ # Create screen and write layout to it.
+ size = output.get_size()
+ screen = Screen()
+ screen.show_cursor = False # Hide cursor by default, unless one of the
+ # containers decides to display it.
+ mouse_handlers = MouseHandlers()
+
+ # Calculate height.
+ if self.full_screen:
+ height = size.rows
+ elif is_done:
+ # When we are done, we don't necessary want to fill up until the bottom.
+ height = layout.container.preferred_height(
+ size.columns, size.rows
+ ).preferred
+ else:
+ last_height = self._last_screen.height if self._last_screen else 0
+ height = max(
+ self._min_available_height,
+ last_height,
+ layout.container.preferred_height(size.columns, size.rows).preferred,
+ )
+
+ height = min(height, size.rows)
+
+ # When the size changes, don't consider the previous screen.
+ if self._last_size != size:
+ self._last_screen = None
+
+ # When we render using another style or another color depth, do a full
+ # repaint. (Forget about the previous rendered screen.)
+ # (But note that we still use _last_screen to calculate the height.)
+ if (
+ self.style.invalidation_hash() != self._last_style_hash
+ or app.style_transformation.invalidation_hash()
+ != self._last_transformation_hash
+ or app.color_depth != self._last_color_depth
+ ):
+ self._last_screen = None
+ self._attrs_for_style = None
+ self._style_string_has_style = None
+
+ if self._attrs_for_style is None:
+ self._attrs_for_style = _StyleStringToAttrsCache(
+ self.style.get_attrs_for_style_str, app.style_transformation
+ )
+ if self._style_string_has_style is None:
+ self._style_string_has_style = _StyleStringHasStyleCache(
+ self._attrs_for_style
+ )
+
+ self._last_style_hash = self.style.invalidation_hash()
+ self._last_transformation_hash = app.style_transformation.invalidation_hash()
+ self._last_color_depth = app.color_depth
+
+ layout.container.write_to_screen(
+ screen,
+ mouse_handlers,
+ WritePosition(xpos=0, ypos=0, width=size.columns, height=height),
+ parent_style="",
+ erase_bg=False,
+ z_index=None,
+ )
+ screen.draw_all_floats()
+
+ # When grayed. Replace all styles in the new screen.
+ if app.exit_style:
+ screen.append_style_to_content(app.exit_style)
+
+ # Process diff and write to output.
+ self._cursor_pos, self._last_style = _output_screen_diff(
+ app,
+ output,
+ screen,
+ self._cursor_pos,
+ app.color_depth,
+ self._last_screen,
+ self._last_style,
+ is_done,
+ full_screen=self.full_screen,
+ attrs_for_style_string=self._attrs_for_style,
+ style_string_has_style=self._style_string_has_style,
+ size=size,
+ previous_width=(self._last_size.columns if self._last_size else 0),
+ )
+ self._last_screen = screen
+ self._last_size = size
+ self.mouse_handlers = mouse_handlers
+
+ # Handle cursor shapes.
+ new_cursor_shape = app.cursor.get_cursor_shape(app)
+ if (
+ self._last_cursor_shape is None
+ or self._last_cursor_shape != new_cursor_shape
+ ):
+ output.set_cursor_shape(new_cursor_shape)
+ self._last_cursor_shape = new_cursor_shape
+
+ # Flush buffered output.
+ output.flush()
+
+ # Set visible windows in layout.
+ app.layout.visible_windows = screen.visible_windows
+
+ if is_done:
+ self.reset()
+
+ def erase(self, leave_alternate_screen: bool = True) -> None:
"""
Hide all output and put the cursor back at the first line. This is for
instance used for running a system command (while hiding the CLI) and
@@ -213,19 +733,81 @@ class Renderer:
:param leave_alternate_screen: When True, and when inside an alternate
screen buffer, quit the alternate screen.
"""
- pass
+ output = self.output
- def clear(self) ->None:
+ output.cursor_backward(self._cursor_pos.x)
+ output.cursor_up(self._cursor_pos.y)
+ output.erase_down()
+ output.reset_attributes()
+ output.enable_autowrap()
+
+ output.flush()
+
+ self.reset(leave_alternate_screen=leave_alternate_screen)
+
+ def clear(self) -> None:
"""
Clear screen and go to 0,0
"""
- pass
+ # Erase current output first.
+ self.erase()
+
+ # Send "Erase Screen" command and go to (0, 0).
+ output = self.output
+
+ output.erase_screen()
+ output.cursor_goto(0, 0)
+ output.flush()
+
+ self.request_absolute_cursor_position()
-def print_formatted_text(output: Output, formatted_text: AnyFormattedText,
- style: BaseStyle, style_transformation: (StyleTransformation | None)=
- None, color_depth: (ColorDepth | None)=None) ->None:
+def print_formatted_text(
+ output: Output,
+ formatted_text: AnyFormattedText,
+ style: BaseStyle,
+ style_transformation: StyleTransformation | None = None,
+ color_depth: ColorDepth | None = None,
+) -> None:
"""
Print a list of (style_str, text) tuples in the given style to the output.
"""
- pass
+ fragments = to_formatted_text(formatted_text)
+ style_transformation = style_transformation or DummyStyleTransformation()
+ color_depth = color_depth or output.get_default_color_depth()
+
+ # Reset first.
+ output.reset_attributes()
+ output.enable_autowrap()
+ last_attrs: Attrs | None = None
+
+ # Print all (style_str, text) tuples.
+ attrs_for_style_string = _StyleStringToAttrsCache(
+ style.get_attrs_for_style_str, style_transformation
+ )
+
+ for style_str, text, *_ in fragments:
+ attrs = attrs_for_style_string[style_str]
+
+ # Set style attributes if something changed.
+ if attrs != last_attrs:
+ if attrs:
+ output.set_attributes(attrs, color_depth)
+ else:
+ output.reset_attributes()
+ last_attrs = attrs
+
+ # Print escape sequences as raw output
+ if "[ZeroWidthEscape]" in style_str:
+ output.write_raw(text)
+ else:
+ # Eliminate carriage returns
+ text = text.replace("\r", "")
+ # Insert a carriage return before every newline (important when the
+ # front-end is a telnet client).
+ text = text.replace("\n", "\r\n")
+ output.write(text)
+
+ # Reset again.
+ output.reset_attributes()
+ output.flush()
diff --git a/src/prompt_toolkit/search.py b/src/prompt_toolkit/search.py
index 60c9f64a..fd90a04e 100644
--- a/src/prompt_toolkit/search.py
+++ b/src/prompt_toolkit/search.py
@@ -6,20 +6,28 @@ For the key bindings implementation with attached filters, check
instead of calling these function directly.)
"""
from __future__ import annotations
+
from enum import Enum
from typing import TYPE_CHECKING
+
from .application.current import get_app
from .filters import FilterOrBool, is_searching, to_filter
from .key_binding.vi_state import InputMode
+
if TYPE_CHECKING:
from prompt_toolkit.layout.controls import BufferControl, SearchBufferControl
from prompt_toolkit.layout.layout import Layout
-__all__ = ['SearchDirection', 'start_search', 'stop_search']
+
+__all__ = [
+ "SearchDirection",
+ "start_search",
+ "stop_search",
+]
class SearchDirection(Enum):
- FORWARD = 'FORWARD'
- BACKWARD = 'BACKWARD'
+ FORWARD = "FORWARD"
+ BACKWARD = "BACKWARD"
class SearchState:
@@ -37,19 +45,28 @@ class SearchState:
they can have a different `SearchState` each (if they have a different
search control).
"""
- __slots__ = 'text', 'direction', 'ignore_case'
- def __init__(self, text: str='', direction: SearchDirection=
- SearchDirection.FORWARD, ignore_case: FilterOrBool=False) ->None:
+ __slots__ = ("text", "direction", "ignore_case")
+
+ def __init__(
+ self,
+ text: str = "",
+ direction: SearchDirection = SearchDirection.FORWARD,
+ ignore_case: FilterOrBool = False,
+ ) -> None:
self.text = text
self.direction = direction
self.ignore_case = to_filter(ignore_case)
- def __repr__(self) ->str:
- return '{}({!r}, direction={!r}, ignore_case={!r})'.format(self.
- __class__.__name__, self.text, self.direction, self.ignore_case)
+ def __repr__(self) -> str:
+ return "{}({!r}, direction={!r}, ignore_case={!r})".format(
+ self.__class__.__name__,
+ self.text,
+ self.direction,
+ self.ignore_case,
+ )
- def __invert__(self) ->SearchState:
+ def __invert__(self) -> SearchState:
"""
Create a new SearchState where backwards becomes forwards and the other
way around.
@@ -58,12 +75,16 @@ class SearchState:
direction = SearchDirection.FORWARD
else:
direction = SearchDirection.BACKWARD
- return SearchState(text=self.text, direction=direction, ignore_case
- =self.ignore_case)
+ return SearchState(
+ text=self.text, direction=direction, ignore_case=self.ignore_case
+ )
-def start_search(buffer_control: (BufferControl | None)=None, direction:
- SearchDirection=SearchDirection.FORWARD) ->None:
+
+def start_search(
+ buffer_control: BufferControl | None = None,
+ direction: SearchDirection = SearchDirection.FORWARD,
+) -> None:
"""
Start search through the given `buffer_control` using the
`search_buffer_control`.
@@ -71,33 +92,139 @@ def start_search(buffer_control: (BufferControl | None)=None, direction:
:param buffer_control: Start search for this `BufferControl`. If not given,
search through the current control.
"""
- pass
+ from prompt_toolkit.layout.controls import BufferControl
+
+ assert buffer_control is None or isinstance(buffer_control, BufferControl)
+
+ layout = get_app().layout
+
+ # When no control is given, use the current control if that's a BufferControl.
+ if buffer_control is None:
+ if not isinstance(layout.current_control, BufferControl):
+ return
+ buffer_control = layout.current_control
+ # Only if this control is searchable.
+ search_buffer_control = buffer_control.search_buffer_control
-def stop_search(buffer_control: (BufferControl | None)=None) ->None:
+ if search_buffer_control:
+ buffer_control.search_state.direction = direction
+
+ # Make sure to focus the search BufferControl
+ layout.focus(search_buffer_control)
+
+ # Remember search link.
+ layout.search_links[search_buffer_control] = buffer_control
+
+ # If we're in Vi mode, make sure to go into insert mode.
+ get_app().vi_state.input_mode = InputMode.INSERT
+
+
+def stop_search(buffer_control: BufferControl | None = None) -> None:
"""
Stop search through the given `buffer_control`.
"""
- pass
+ layout = get_app().layout
+
+ if buffer_control is None:
+ buffer_control = layout.search_target_buffer_control
+ if buffer_control is None:
+ # (Should not happen, but possible when `stop_search` is called
+ # when we're not searching.)
+ return
+ search_buffer_control = buffer_control.search_buffer_control
+ else:
+ assert buffer_control in layout.search_links.values()
+ search_buffer_control = _get_reverse_search_links(layout)[buffer_control]
+
+ # Focus the original buffer again.
+ layout.focus(buffer_control)
+
+ if search_buffer_control is not None:
+ # Remove the search link.
+ del layout.search_links[search_buffer_control]
+ # Reset content of search control.
+ search_buffer_control.buffer.reset()
-def do_incremental_search(direction: SearchDirection, count: int=1) ->None:
+ # If we're in Vi mode, go back to navigation mode.
+ get_app().vi_state.input_mode = InputMode.NAVIGATION
+
+
+def do_incremental_search(direction: SearchDirection, count: int = 1) -> None:
"""
Apply search, but keep search buffer focused.
"""
- pass
+ assert is_searching()
+
+ layout = get_app().layout
+
+ # Only search if the current control is a `BufferControl`.
+ from prompt_toolkit.layout.controls import BufferControl
+
+ search_control = layout.current_control
+ if not isinstance(search_control, BufferControl):
+ return
+
+ prev_control = layout.search_target_buffer_control
+ if prev_control is None:
+ return
+ search_state = prev_control.search_state
+
+ # Update search_state.
+ direction_changed = search_state.direction != direction
+
+ search_state.text = search_control.buffer.text
+ search_state.direction = direction
+
+ # Apply search to current buffer.
+ if not direction_changed:
+ prev_control.buffer.apply_search(
+ search_state, include_current_position=False, count=count
+ )
-def accept_search() ->None:
+def accept_search() -> None:
"""
Accept current search query. Focus original `BufferControl` again.
"""
- pass
+ layout = get_app().layout
+
+ search_control = layout.current_control
+ target_buffer_control = layout.search_target_buffer_control
+
+ from prompt_toolkit.layout.controls import BufferControl
+
+ if not isinstance(search_control, BufferControl):
+ return
+ if target_buffer_control is None:
+ return
+
+ search_state = target_buffer_control.search_state
+
+ # Update search state.
+ if search_control.buffer.text:
+ search_state.text = search_control.buffer.text
+
+ # Apply search.
+ target_buffer_control.buffer.apply_search(
+ search_state, include_current_position=True
+ )
+
+ # Add query to history of search line.
+ search_control.buffer.append_to_history()
+
+ # Stop search and focus previous control again.
+ stop_search(target_buffer_control)
-def _get_reverse_search_links(layout: Layout) ->dict[BufferControl,
- SearchBufferControl]:
+def _get_reverse_search_links(
+ layout: Layout,
+) -> dict[BufferControl, SearchBufferControl]:
"""
Return mapping from BufferControl to SearchBufferControl.
"""
- pass
+ return {
+ buffer_control: search_buffer_control
+ for search_buffer_control, buffer_control in layout.search_links.items()
+ }
diff --git a/src/prompt_toolkit/selection.py b/src/prompt_toolkit/selection.py
index 3beccb22..2158fa92 100644
--- a/src/prompt_toolkit/selection.py
+++ b/src/prompt_toolkit/selection.py
@@ -2,23 +2,35 @@
Data structures for the selection.
"""
from __future__ import annotations
+
from enum import Enum
-__all__ = ['SelectionType', 'PasteMode', 'SelectionState']
+
+__all__ = [
+ "SelectionType",
+ "PasteMode",
+ "SelectionState",
+]
class SelectionType(Enum):
"""
Type of selection.
"""
- CHARACTERS = 'CHARACTERS'
- LINES = 'LINES'
- BLOCK = 'BLOCK'
+
+ #: Characters. (Visual in Vi.)
+ CHARACTERS = "CHARACTERS"
+
+ #: Whole lines. (Visual-Line in Vi.)
+ LINES = "LINES"
+
+ #: A block selection. (Visual-Block in Vi.)
+ BLOCK = "BLOCK"
class PasteMode(Enum):
- EMACS = 'EMACS'
- VI_AFTER = 'VI_AFTER'
- VI_BEFORE = 'VI_BEFORE'
+ EMACS = "EMACS" # Yank like emacs.
+ VI_AFTER = "VI_AFTER" # When pressing 'p' in Vi.
+ VI_BEFORE = "VI_BEFORE" # When pressing 'P' in Vi.
class SelectionState:
@@ -29,12 +41,21 @@ class SelectionState:
:param type: :class:`~.SelectionType`
"""
- def __init__(self, original_cursor_position: int=0, type: SelectionType
- =SelectionType.CHARACTERS) ->None:
+ def __init__(
+ self,
+ original_cursor_position: int = 0,
+ type: SelectionType = SelectionType.CHARACTERS,
+ ) -> None:
self.original_cursor_position = original_cursor_position
self.type = type
self.shift_mode = False
- def __repr__(self) ->str:
- return '{}(original_cursor_position={!r}, type={!r})'.format(self.
- __class__.__name__, self.original_cursor_position, self.type)
+ def enter_shift_mode(self) -> None:
+ self.shift_mode = True
+
+ def __repr__(self) -> str:
+ return "{}(original_cursor_position={!r}, type={!r})".format(
+ self.__class__.__name__,
+ self.original_cursor_position,
+ self.type,
+ )
diff --git a/src/prompt_toolkit/shortcuts/dialogs.py b/src/prompt_toolkit/shortcuts/dialogs.py
index 1ae0d91d..d78e7dbd 100644
--- a/src/prompt_toolkit/shortcuts/dialogs.py
+++ b/src/prompt_toolkit/shortcuts/dialogs.py
@@ -1,7 +1,9 @@
from __future__ import annotations
+
import functools
from asyncio import get_running_loop
from typing import Any, Callable, Sequence, TypeVar
+
from prompt_toolkit.application import Application
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import Buffer
@@ -17,90 +19,312 @@ from prompt_toolkit.layout.containers import AnyContainer, HSplit
from prompt_toolkit.layout.dimension import Dimension as D
from prompt_toolkit.styles import BaseStyle
from prompt_toolkit.validation import Validator
-from prompt_toolkit.widgets import Box, Button, CheckboxList, Dialog, Label, ProgressBar, RadioList, TextArea, ValidationToolbar
-__all__ = ['yes_no_dialog', 'button_dialog', 'input_dialog',
- 'message_dialog', 'radiolist_dialog', 'checkboxlist_dialog',
- 'progress_dialog']
+from prompt_toolkit.widgets import (
+ Box,
+ Button,
+ CheckboxList,
+ Dialog,
+ Label,
+ ProgressBar,
+ RadioList,
+ TextArea,
+ ValidationToolbar,
+)
+
+__all__ = [
+ "yes_no_dialog",
+ "button_dialog",
+ "input_dialog",
+ "message_dialog",
+ "radiolist_dialog",
+ "checkboxlist_dialog",
+ "progress_dialog",
+]
-def yes_no_dialog(title: AnyFormattedText='', text: AnyFormattedText='',
- yes_text: str='Yes', no_text: str='No', style: (BaseStyle | None)=None
- ) ->Application[bool]:
+def yes_no_dialog(
+ title: AnyFormattedText = "",
+ text: AnyFormattedText = "",
+ yes_text: str = "Yes",
+ no_text: str = "No",
+ style: BaseStyle | None = None,
+) -> Application[bool]:
"""
Display a Yes/No dialog.
Return a boolean.
"""
- pass
+ def yes_handler() -> None:
+ get_app().exit(result=True)
+
+ def no_handler() -> None:
+ get_app().exit(result=False)
+
+ dialog = Dialog(
+ title=title,
+ body=Label(text=text, dont_extend_height=True),
+ buttons=[
+ Button(text=yes_text, handler=yes_handler),
+ Button(text=no_text, handler=no_handler),
+ ],
+ with_background=True,
+ )
-_T = TypeVar('_T')
+ return _create_app(dialog, style)
-def button_dialog(title: AnyFormattedText='', text: AnyFormattedText='',
- buttons: list[tuple[str, _T]]=[], style: (BaseStyle | None)=None
- ) ->Application[_T]:
+_T = TypeVar("_T")
+
+
+def button_dialog(
+ title: AnyFormattedText = "",
+ text: AnyFormattedText = "",
+ buttons: list[tuple[str, _T]] = [],
+ style: BaseStyle | None = None,
+) -> Application[_T]:
"""
Display a dialog with button choices (given as a list of tuples).
Return the value associated with button.
"""
- pass
+ def button_handler(v: _T) -> None:
+ get_app().exit(result=v)
+
+ dialog = Dialog(
+ title=title,
+ body=Label(text=text, dont_extend_height=True),
+ buttons=[
+ Button(text=t, handler=functools.partial(button_handler, v))
+ for t, v in buttons
+ ],
+ with_background=True,
+ )
+
+ return _create_app(dialog, style)
-def input_dialog(title: AnyFormattedText='', text: AnyFormattedText='',
- ok_text: str='OK', cancel_text: str='Cancel', completer: (Completer |
- None)=None, validator: (Validator | None)=None, password: FilterOrBool=
- False, style: (BaseStyle | None)=None, default: str='') ->Application[str]:
+
+def input_dialog(
+ title: AnyFormattedText = "",
+ text: AnyFormattedText = "",
+ ok_text: str = "OK",
+ cancel_text: str = "Cancel",
+ completer: Completer | None = None,
+ validator: Validator | None = None,
+ password: FilterOrBool = False,
+ style: BaseStyle | None = None,
+ default: str = "",
+) -> Application[str]:
"""
Display a text input box.
Return the given text, or None when cancelled.
"""
- pass
+ def accept(buf: Buffer) -> bool:
+ get_app().layout.focus(ok_button)
+ return True # Keep text.
+
+ def ok_handler() -> None:
+ get_app().exit(result=textfield.text)
+
+ ok_button = Button(text=ok_text, handler=ok_handler)
+ cancel_button = Button(text=cancel_text, handler=_return_none)
+
+ textfield = TextArea(
+ text=default,
+ multiline=False,
+ password=password,
+ completer=completer,
+ validator=validator,
+ accept_handler=accept,
+ )
+
+ dialog = Dialog(
+ title=title,
+ body=HSplit(
+ [
+ Label(text=text, dont_extend_height=True),
+ textfield,
+ ValidationToolbar(),
+ ],
+ padding=D(preferred=1, max=1),
+ ),
+ buttons=[ok_button, cancel_button],
+ with_background=True,
+ )
-def message_dialog(title: AnyFormattedText='', text: AnyFormattedText='',
- ok_text: str='Ok', style: (BaseStyle | None)=None) ->Application[None]:
+ return _create_app(dialog, style)
+
+
+def message_dialog(
+ title: AnyFormattedText = "",
+ text: AnyFormattedText = "",
+ ok_text: str = "Ok",
+ style: BaseStyle | None = None,
+) -> Application[None]:
"""
Display a simple message box and wait until the user presses enter.
"""
- pass
+ dialog = Dialog(
+ title=title,
+ body=Label(text=text, dont_extend_height=True),
+ buttons=[Button(text=ok_text, handler=_return_none)],
+ with_background=True,
+ )
+ return _create_app(dialog, style)
-def radiolist_dialog(title: AnyFormattedText='', text: AnyFormattedText='',
- ok_text: str='Ok', cancel_text: str='Cancel', values: (Sequence[tuple[
- _T, AnyFormattedText]] | None)=None, default: (_T | None)=None, style:
- (BaseStyle | None)=None) ->Application[_T]:
+
+def radiolist_dialog(
+ title: AnyFormattedText = "",
+ text: AnyFormattedText = "",
+ ok_text: str = "Ok",
+ cancel_text: str = "Cancel",
+ values: Sequence[tuple[_T, AnyFormattedText]] | None = None,
+ default: _T | None = None,
+ style: BaseStyle | None = None,
+) -> Application[_T]:
"""
Display a simple list of element the user can choose amongst.
Only one element can be selected at a time using Arrow keys and Enter.
The focus can be moved between the list and the Ok/Cancel button with tab.
"""
- pass
+ if values is None:
+ values = []
+
+ def ok_handler() -> None:
+ get_app().exit(result=radio_list.current_value)
+ radio_list = RadioList(values=values, default=default)
-def checkboxlist_dialog(title: AnyFormattedText='', text: AnyFormattedText=
- '', ok_text: str='Ok', cancel_text: str='Cancel', values: (Sequence[
- tuple[_T, AnyFormattedText]] | None)=None, default_values: (Sequence[_T
- ] | None)=None, style: (BaseStyle | None)=None) ->Application[list[_T]]:
+ dialog = Dialog(
+ title=title,
+ body=HSplit(
+ [Label(text=text, dont_extend_height=True), radio_list],
+ padding=1,
+ ),
+ buttons=[
+ Button(text=ok_text, handler=ok_handler),
+ Button(text=cancel_text, handler=_return_none),
+ ],
+ with_background=True,
+ )
+
+ return _create_app(dialog, style)
+
+
+def checkboxlist_dialog(
+ title: AnyFormattedText = "",
+ text: AnyFormattedText = "",
+ ok_text: str = "Ok",
+ cancel_text: str = "Cancel",
+ values: Sequence[tuple[_T, AnyFormattedText]] | None = None,
+ default_values: Sequence[_T] | None = None,
+ style: BaseStyle | None = None,
+) -> Application[list[_T]]:
"""
Display a simple list of element the user can choose multiple values amongst.
Several elements can be selected at a time using Arrow keys and Enter.
The focus can be moved between the list and the Ok/Cancel button with tab.
"""
- pass
+ if values is None:
+ values = []
+
+ def ok_handler() -> None:
+ get_app().exit(result=cb_list.current_values)
+
+ cb_list = CheckboxList(values=values, default_values=default_values)
+ dialog = Dialog(
+ title=title,
+ body=HSplit(
+ [Label(text=text, dont_extend_height=True), cb_list],
+ padding=1,
+ ),
+ buttons=[
+ Button(text=ok_text, handler=ok_handler),
+ Button(text=cancel_text, handler=_return_none),
+ ],
+ with_background=True,
+ )
-def progress_dialog(title: AnyFormattedText='', text: AnyFormattedText='',
- run_callback: Callable[[Callable[[int], None], Callable[[str], None]],
- None]=lambda *a: None, style: (BaseStyle | None)=None) ->Application[None]:
+ return _create_app(dialog, style)
+
+
+def progress_dialog(
+ title: AnyFormattedText = "",
+ text: AnyFormattedText = "",
+ run_callback: Callable[[Callable[[int], None], Callable[[str], None]], None] = (
+ lambda *a: None
+ ),
+ style: BaseStyle | None = None,
+) -> Application[None]:
"""
:param run_callback: A function that receives as input a `set_percentage`
function and it does the work.
"""
- pass
+ loop = get_running_loop()
+ progressbar = ProgressBar()
+ text_area = TextArea(
+ focusable=False,
+ # Prefer this text area as big as possible, to avoid having a window
+ # that keeps resizing when we add text to it.
+ height=D(preferred=10**10),
+ )
+
+ dialog = Dialog(
+ body=HSplit(
+ [
+ Box(Label(text=text)),
+ Box(text_area, padding=D.exact(1)),
+ progressbar,
+ ]
+ ),
+ title=title,
+ with_background=True,
+ )
+ app = _create_app(dialog, style)
+
+ def set_percentage(value: int) -> None:
+ progressbar.percentage = int(value)
+ app.invalidate()
+
+ def log_text(text: str) -> None:
+ loop.call_soon_threadsafe(text_area.buffer.insert_text, text)
+ app.invalidate()
+
+ # Run the callback in the executor. When done, set a return value for the
+ # UI, so that it quits.
+ def start() -> None:
+ try:
+ run_callback(set_percentage, log_text)
+ finally:
+ app.exit()
+
+ def pre_run() -> None:
+ run_in_executor_with_context(start)
+
+ app.pre_run_callables.append(pre_run)
+
+ return app
+
+
+def _create_app(dialog: AnyContainer, style: BaseStyle | None) -> Application[Any]:
+ # Key bindings.
+ bindings = KeyBindings()
+ bindings.add("tab")(focus_next)
+ bindings.add("s-tab")(focus_previous)
+
+ return Application(
+ layout=Layout(dialog),
+ key_bindings=merge_key_bindings([load_key_bindings(), bindings]),
+ mouse_support=True,
+ style=style,
+ full_screen=True,
+ )
-def _return_none() ->None:
- """Button handler that returns None."""
- pass
+def _return_none() -> None:
+ "Button handler that returns None."
+ get_app().exit()
diff --git a/src/prompt_toolkit/shortcuts/progress_bar/base.py b/src/prompt_toolkit/shortcuts/progress_bar/base.py
index 3cc8cbce..21aa1bec 100644
--- a/src/prompt_toolkit/shortcuts/progress_bar/base.py
+++ b/src/prompt_toolkit/shortcuts/progress_bar/base.py
@@ -8,6 +8,7 @@ Progress bar implementation on top of prompt_toolkit.
...
"""
from __future__ import annotations
+
import contextvars
import datetime
import functools
@@ -15,36 +16,75 @@ import os
import signal
import threading
import traceback
-from typing import Callable, Generic, Iterable, Iterator, Sequence, Sized, TextIO, TypeVar, cast
+from typing import (
+ Callable,
+ Generic,
+ Iterable,
+ Iterator,
+ Sequence,
+ Sized,
+ TextIO,
+ TypeVar,
+ cast,
+)
+
from prompt_toolkit.application import Application
from prompt_toolkit.application.current import get_app_session
from prompt_toolkit.filters import Condition, is_done, renderer_height_is_known
-from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples, to_formatted_text
+from prompt_toolkit.formatted_text import (
+ AnyFormattedText,
+ StyleAndTextTuples,
+ to_formatted_text,
+)
from prompt_toolkit.input import Input
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
-from prompt_toolkit.layout import ConditionalContainer, FormattedTextControl, HSplit, Layout, VSplit, Window
+from prompt_toolkit.layout import (
+ ConditionalContainer,
+ FormattedTextControl,
+ HSplit,
+ Layout,
+ VSplit,
+ Window,
+)
from prompt_toolkit.layout.controls import UIContent, UIControl
from prompt_toolkit.layout.dimension import AnyDimension, D
from prompt_toolkit.output import ColorDepth, Output
from prompt_toolkit.styles import BaseStyle
from prompt_toolkit.utils import in_main_thread
+
from .formatters import Formatter, create_default_formatters
-__all__ = ['ProgressBar']
+
+__all__ = ["ProgressBar"]
+
E = KeyPressEvent
-_SIGWINCH = getattr(signal, 'SIGWINCH', None)
+
+_SIGWINCH = getattr(signal, "SIGWINCH", None)
-def create_key_bindings(cancel_callback: (Callable[[], None] | None)
- ) ->KeyBindings:
+def create_key_bindings(cancel_callback: Callable[[], None] | None) -> KeyBindings:
"""
Key bindings handled by the progress bar.
(The main thread is not supposed to handle any key bindings.)
"""
- pass
+ kb = KeyBindings()
+ @kb.add("c-l")
+ def _clear(event: E) -> None:
+ event.app.renderer.clear()
-_T = TypeVar('_T')
+ if cancel_callback is not None:
+
+ @kb.add("c-c")
+ def _interrupt(event: E) -> None:
+ "Kill the 'body' of the progress bar, but only if we run from the main thread."
+ assert cancel_callback is not None
+ cancel_callback()
+
+ return kb
+
+
+_T = TypeVar("_T")
class ProgressBar:
@@ -74,12 +114,19 @@ class ProgressBar:
:param input: :class:`~prompt_toolkit.input.Input` instance.
"""
- def __init__(self, title: AnyFormattedText=None, formatters: (Sequence[
- Formatter] | None)=None, bottom_toolbar: AnyFormattedText=None,
- style: (BaseStyle | None)=None, key_bindings: (KeyBindings | None)=
- None, cancel_callback: (Callable[[], None] | None)=None, file: (
- TextIO | None)=None, color_depth: (ColorDepth | None)=None, output:
- (Output | None)=None, input: (Input | None)=None) ->None:
+ def __init__(
+ self,
+ title: AnyFormattedText = None,
+ formatters: Sequence[Formatter] | None = None,
+ bottom_toolbar: AnyFormattedText = None,
+ style: BaseStyle | None = None,
+ key_bindings: KeyBindings | None = None,
+ cancel_callback: Callable[[], None] | None = None,
+ file: TextIO | None = None,
+ color_depth: ColorDepth | None = None,
+ output: Output | None = None,
+ input: Input | None = None,
+ ) -> None:
self.title = title
self.formatters = formatters or create_default_formatters()
self.bottom_toolbar = bottom_toolbar
@@ -87,61 +134,125 @@ class ProgressBar:
self.style = style
self.key_bindings = key_bindings
self.cancel_callback = cancel_callback
+
+ # If no `cancel_callback` was given, and we're creating the progress
+ # bar from the main thread. Cancel by sending a `KeyboardInterrupt` to
+ # the main thread.
if self.cancel_callback is None and in_main_thread():
- def keyboard_interrupt_to_main_thread() ->None:
+ def keyboard_interrupt_to_main_thread() -> None:
os.kill(os.getpid(), signal.SIGINT)
+
self.cancel_callback = keyboard_interrupt_to_main_thread
+
+ # Note that we use __stderr__ as default error output, because that
+ # works best with `patch_stdout`.
self.color_depth = color_depth
self.output = output or get_app_session().output
self.input = input or get_app_session().input
+
self._thread: threading.Thread | None = None
+
self._has_sigwinch = False
self._app_started = threading.Event()
- def __enter__(self) ->ProgressBar:
- title_toolbar = ConditionalContainer(Window(FormattedTextControl(lambda
- : self.title), height=1, style='class:progressbar,title'),
- filter=Condition(lambda : self.title is not None))
- bottom_toolbar = ConditionalContainer(Window(FormattedTextControl(
- lambda : self.bottom_toolbar, style='class:bottom-toolbar.text'
- ), style='class:bottom-toolbar', height=1), filter=~is_done &
- renderer_height_is_known & Condition(lambda : self.
- bottom_toolbar is not None))
-
- def width_for_formatter(formatter: Formatter) ->AnyDimension:
+ def __enter__(self) -> ProgressBar:
+ # Create UI Application.
+ title_toolbar = ConditionalContainer(
+ Window(
+ FormattedTextControl(lambda: self.title),
+ height=1,
+ style="class:progressbar,title",
+ ),
+ filter=Condition(lambda: self.title is not None),
+ )
+
+ bottom_toolbar = ConditionalContainer(
+ Window(
+ FormattedTextControl(
+ lambda: self.bottom_toolbar, style="class:bottom-toolbar.text"
+ ),
+ style="class:bottom-toolbar",
+ height=1,
+ ),
+ filter=~is_done
+ & renderer_height_is_known
+ & Condition(lambda: self.bottom_toolbar is not None),
+ )
+
+ def width_for_formatter(formatter: Formatter) -> AnyDimension:
+ # Needs to be passed as callable (partial) to the 'width'
+ # parameter, because we want to call it on every resize.
return formatter.get_width(progress_bar=self)
- progress_controls = [Window(content=_ProgressControl(self, f, self.
- cancel_callback), width=functools.partial(width_for_formatter,
- f)) for f in self.formatters]
- self.app: Application[None] = Application(min_redraw_interval=0.05,
- layout=Layout(HSplit([title_toolbar, VSplit(progress_controls,
- height=lambda : D(preferred=len(self.counters), max=len(self.
- counters))), Window(), bottom_toolbar])), style=self.style,
- key_bindings=self.key_bindings, refresh_interval=0.3,
- color_depth=self.color_depth, output=self.output, input=self.input)
-
- def run() ->None:
+
+ progress_controls = [
+ Window(
+ content=_ProgressControl(self, f, self.cancel_callback),
+ width=functools.partial(width_for_formatter, f),
+ )
+ for f in self.formatters
+ ]
+
+ self.app: Application[None] = Application(
+ min_redraw_interval=0.05,
+ layout=Layout(
+ HSplit(
+ [
+ title_toolbar,
+ VSplit(
+ progress_controls,
+ height=lambda: D(
+ preferred=len(self.counters), max=len(self.counters)
+ ),
+ ),
+ Window(),
+ bottom_toolbar,
+ ]
+ )
+ ),
+ style=self.style,
+ key_bindings=self.key_bindings,
+ refresh_interval=0.3,
+ color_depth=self.color_depth,
+ output=self.output,
+ input=self.input,
+ )
+
+ # Run application in different thread.
+ def run() -> None:
try:
self.app.run(pre_run=self._app_started.set)
except BaseException as e:
traceback.print_exc()
print(e)
+
ctx: contextvars.Context = contextvars.copy_context()
+
self._thread = threading.Thread(target=ctx.run, args=(run,))
self._thread.start()
+
return self
- def __exit__(self, *a: object) ->None:
+ def __exit__(self, *a: object) -> None:
+ # Wait for the app to be started. Make sure we don't quit earlier,
+ # otherwise `self.app.exit` won't terminate the app because
+ # `self.app.future` has not yet been set.
self._app_started.wait()
+
+ # Quit UI application.
if self.app.is_running and self.app.loop is not None:
self.app.loop.call_soon_threadsafe(self.app.exit)
+
if self._thread is not None:
self._thread.join()
- def __call__(self, data: (Iterable[_T] | None)=None, label:
- AnyFormattedText='', remove_when_done: bool=False, total: (int |
- None)=None) ->ProgressBarCounter[_T]:
+ def __call__(
+ self,
+ data: Iterable[_T] | None = None,
+ label: AnyFormattedText = "",
+ remove_when_done: bool = False,
+ total: int | None = None,
+ ) -> ProgressBarCounter[_T]:
"""
Start a new counter.
@@ -151,25 +262,56 @@ class ProgressBar:
:param total: Specify the maximum value if it can't be calculated by
calling ``len``.
"""
- counter = ProgressBarCounter(self, data, label=label,
- remove_when_done=remove_when_done, total=total)
+ counter = ProgressBarCounter(
+ self, data, label=label, remove_when_done=remove_when_done, total=total
+ )
self.counters.append(counter)
return counter
+ def invalidate(self) -> None:
+ self.app.invalidate()
+
class _ProgressControl(UIControl):
"""
User control for the progress bar.
"""
- def __init__(self, progress_bar: ProgressBar, formatter: Formatter,
- cancel_callback: (Callable[[], None] | None)) ->None:
+ def __init__(
+ self,
+ progress_bar: ProgressBar,
+ formatter: Formatter,
+ cancel_callback: Callable[[], None] | None,
+ ) -> None:
self.progress_bar = progress_bar
self.formatter = formatter
self._key_bindings = create_key_bindings(cancel_callback)
+ def create_content(self, width: int, height: int) -> UIContent:
+ items: list[StyleAndTextTuples] = []
+
+ for pr in self.progress_bar.counters:
+ try:
+ text = self.formatter.format(self.progress_bar, pr, width)
+ except BaseException:
+ traceback.print_exc()
+ text = "ERROR"
+
+ items.append(to_formatted_text(text))
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ return items[i]
+
+ return UIContent(get_line=get_line, line_count=len(items), show_cursor=False)
+
+ def is_focusable(self) -> bool:
+ return True # Make sure that the key bindings work.
+
+ def get_key_bindings(self) -> KeyBindings:
+ return self._key_bindings
+
-_CounterItem = TypeVar('_CounterItem', covariant=True)
+_CounterItem = TypeVar("_CounterItem", covariant=True)
class ProgressBarCounter(Generic[_CounterItem]):
@@ -177,9 +319,14 @@ class ProgressBarCounter(Generic[_CounterItem]):
An individual counter (A progress bar can have multiple counters).
"""
- def __init__(self, progress_bar: ProgressBar, data: (Iterable[
- _CounterItem] | None)=None, label: AnyFormattedText='',
- remove_when_done: bool=False, total: (int | None)=None) ->None:
+ def __init__(
+ self,
+ progress_bar: ProgressBar,
+ data: Iterable[_CounterItem] | None = None,
+ label: AnyFormattedText = "",
+ remove_when_done: bool = False,
+ total: int | None = None,
+ ) -> None:
self.start_time = datetime.datetime.now()
self.stop_time: datetime.datetime | None = None
self.progress_bar = progress_bar
@@ -189,36 +336,42 @@ class ProgressBarCounter(Generic[_CounterItem]):
self.remove_when_done = remove_when_done
self._done = False
self.total: int | None
+
if total is None:
try:
self.total = len(cast(Sized, data))
except TypeError:
- self.total = None
+ self.total = None # We don't know the total length.
else:
self.total = total
- def __iter__(self) ->Iterator[_CounterItem]:
+ def __iter__(self) -> Iterator[_CounterItem]:
if self.data is not None:
try:
for item in self.data:
yield item
self.item_completed()
+
+ # Only done if we iterate to the very end.
self.done = True
finally:
+ # Ensure counter has stopped even if we did not iterate to the
+ # end (e.g. break or exceptions).
self.stopped = True
else:
- raise NotImplementedError('No data defined to iterate over.')
+ raise NotImplementedError("No data defined to iterate over.")
- def item_completed(self) ->None:
+ def item_completed(self) -> None:
"""
Start handling the next item.
(Can be called manually in case we don't have a collection to loop through.)
"""
- pass
+ self.items_completed += 1
+ self.progress_bar.invalidate()
@property
- def done(self) ->bool:
+ def done(self) -> bool:
"""Whether a counter has been completed.
Done counter have been stopped (see stopped) and removed depending on
@@ -227,10 +380,18 @@ class ProgressBarCounter(Generic[_CounterItem]):
Contrast this with stopped. A stopped counter may be terminated before
100% completion. A done counter has reached its 100% completion.
"""
- pass
+ return self._done
+
+ @done.setter
+ def done(self, value: bool) -> None:
+ self._done = value
+ self.stopped = value
+
+ if value and self.remove_when_done:
+ self.progress_bar.counters.remove(self)
@property
- def stopped(self) ->bool:
+ def stopped(self) -> bool:
"""Whether a counter has been stopped.
Stopped counters no longer have increasing time_elapsed. This distinction is
@@ -245,18 +406,43 @@ class ProgressBarCounter(Generic[_CounterItem]):
Contrast this with done. A done counter has reached its 100% completion.
A stopped counter may be terminated before 100% completion.
"""
- pass
+ return self.stop_time is not None
+
+ @stopped.setter
+ def stopped(self, value: bool) -> None:
+ if value:
+ # This counter has not already been stopped.
+ if not self.stop_time:
+ self.stop_time = datetime.datetime.now()
+ else:
+ # Clearing any previously set stop_time.
+ self.stop_time = None
@property
- def time_elapsed(self) ->datetime.timedelta:
+ def percentage(self) -> float:
+ if self.total is None:
+ return 0
+ else:
+ return self.items_completed * 100 / max(self.total, 1)
+
+ @property
+ def time_elapsed(self) -> datetime.timedelta:
"""
Return how much time has been elapsed since the start.
"""
- pass
+ if self.stop_time is None:
+ return datetime.datetime.now() - self.start_time
+ else:
+ return self.stop_time - self.start_time
@property
- def time_left(self) ->(datetime.timedelta | None):
+ def time_left(self) -> datetime.timedelta | None:
"""
Timedelta representing the time left.
"""
- pass
+ if self.total is None or not self.percentage:
+ return None
+ elif self.done or self.stopped:
+ return datetime.timedelta(0)
+ else:
+ return self.time_elapsed * (100 - self.percentage) / self.percentage
diff --git a/src/prompt_toolkit/shortcuts/progress_bar/formatters.py b/src/prompt_toolkit/shortcuts/progress_bar/formatters.py
index 8ff11a85..dd0339c3 100644
--- a/src/prompt_toolkit/shortcuts/progress_bar/formatters.py
+++ b/src/prompt_toolkit/shortcuts/progress_bar/formatters.py
@@ -3,20 +3,40 @@ Formatter classes for the progress bar.
Each progress bar consists of a list of these formatters.
"""
from __future__ import annotations
+
import datetime
import time
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING
-from prompt_toolkit.formatted_text import HTML, AnyFormattedText, StyleAndTextTuples, to_formatted_text
+
+from prompt_toolkit.formatted_text import (
+ HTML,
+ AnyFormattedText,
+ StyleAndTextTuples,
+ to_formatted_text,
+)
from prompt_toolkit.formatted_text.utils import fragment_list_width
from prompt_toolkit.layout.dimension import AnyDimension, D
from prompt_toolkit.layout.utils import explode_text_fragments
from prompt_toolkit.utils import get_cwidth
+
if TYPE_CHECKING:
from .base import ProgressBar, ProgressBarCounter
-__all__ = ['Formatter', 'Text', 'Label', 'Percentage', 'Bar', 'Progress',
- 'TimeElapsed', 'TimeLeft', 'IterationsPerSecond', 'SpinningWheel',
- 'Rainbow', 'create_default_formatters']
+
+__all__ = [
+ "Formatter",
+ "Text",
+ "Label",
+ "Percentage",
+ "Bar",
+ "Progress",
+ "TimeElapsed",
+ "TimeLeft",
+ "IterationsPerSecond",
+ "SpinningWheel",
+ "Rainbow",
+ "create_default_formatters",
+]
class Formatter(metaclass=ABCMeta):
@@ -24,15 +44,38 @@ class Formatter(metaclass=ABCMeta):
Base class for any formatter.
"""
+ @abstractmethod
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ pass
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ return D()
+
class Text(Formatter):
"""
Display plain text.
"""
- def __init__(self, text: AnyFormattedText, style: str='') ->None:
+ def __init__(self, text: AnyFormattedText, style: str = "") -> None:
self.text = to_formatted_text(text, style=style)
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ return self.text
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ return fragment_list_width(self.text)
+
class Label(Formatter):
"""
@@ -44,30 +87,82 @@ class Label(Formatter):
If no task name was given, no suffix will be added.
"""
- def __init__(self, width: AnyDimension=None, suffix: str='') ->None:
+ def __init__(self, width: AnyDimension = None, suffix: str = "") -> None:
self.width = width
self.suffix = suffix
+ def _add_suffix(self, label: AnyFormattedText) -> StyleAndTextTuples:
+ label = to_formatted_text(label, style="class:label")
+ return label + [("", self.suffix)]
+
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ label = self._add_suffix(progress.label)
+ cwidth = fragment_list_width(label)
+
+ if cwidth > width:
+ # It doesn't fit -> scroll task name.
+ label = explode_text_fragments(label)
+ max_scroll = cwidth - width
+ current_scroll = int(time.time() * 3 % max_scroll)
+ label = label[current_scroll:]
+
+ return label
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ if self.width:
+ return self.width
+
+ all_labels = [self._add_suffix(c.label) for c in progress_bar.counters]
+ if all_labels:
+ max_widths = max(fragment_list_width(l) for l in all_labels)
+ return D(preferred=max_widths, max=max_widths)
+ else:
+ return D()
+
class Percentage(Formatter):
"""
Display the progress as a percentage.
"""
- template = '<percentage>{percentage:>5}%</percentage>'
+
+ template = "<percentage>{percentage:>5}%</percentage>"
+
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ return HTML(self.template).format(percentage=round(progress.percentage, 1))
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ return D.exact(6)
class Bar(Formatter):
"""
Display the progress bar itself.
"""
- template = (
- '<bar>{start}<bar-a>{bar_a}</bar-a><bar-b>{bar_b}</bar-b><bar-c>{bar_c}</bar-c>{end}</bar>'
- )
- def __init__(self, start: str='[', end: str=']', sym_a: str='=', sym_b:
- str='>', sym_c: str=' ', unknown: str='#') ->None:
+ template = "<bar>{start}<bar-a>{bar_a}</bar-a><bar-b>{bar_b}</bar-b><bar-c>{bar_c}</bar-c>{end}</bar>"
+
+ def __init__(
+ self,
+ start: str = "[",
+ end: str = "]",
+ sym_a: str = "=",
+ sym_b: str = ">",
+ sym_c: str = " ",
+ unknown: str = "#",
+ ) -> None:
assert len(sym_a) == 1 and get_cwidth(sym_a) == 1
assert len(sym_c) == 1 and get_cwidth(sym_c) == 1
+
self.start = start
self.end = end
self.sym_a = sym_a
@@ -75,19 +170,79 @@ class Bar(Formatter):
self.sym_c = sym_c
self.unknown = unknown
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ if progress.done or progress.total or progress.stopped:
+ sym_a, sym_b, sym_c = self.sym_a, self.sym_b, self.sym_c
+
+ # Compute pb_a based on done, total, or stopped states.
+ if progress.done:
+ # 100% completed irrelevant of how much was actually marked as completed.
+ percent = 1.0
+ else:
+ # Show percentage completed.
+ percent = progress.percentage / 100
+ else:
+ # Total is unknown and bar is still running.
+ sym_a, sym_b, sym_c = self.sym_c, self.unknown, self.sym_c
+
+ # Compute percent based on the time.
+ percent = time.time() * 20 % 100 / 100
+
+ # Subtract left, sym_b, and right.
+ width -= get_cwidth(self.start + sym_b + self.end)
+
+ # Scale percent by width
+ pb_a = int(percent * width)
+ bar_a = sym_a * pb_a
+ bar_b = sym_b
+ bar_c = sym_c * (width - pb_a)
+
+ return HTML(self.template).format(
+ start=self.start, end=self.end, bar_a=bar_a, bar_b=bar_b, bar_c=bar_c
+ )
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ return D(min=9)
+
class Progress(Formatter):
"""
Display the progress as text. E.g. "8/20"
"""
- template = '<current>{current:>3}</current>/<total>{total:>3}</total>'
+ template = "<current>{current:>3}</current>/<total>{total:>3}</total>"
+
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ return HTML(self.template).format(
+ current=progress.items_completed, total=progress.total or "?"
+ )
-def _format_timedelta(timedelta: datetime.timedelta) ->str:
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ all_lengths = [
+ len("{:>3}".format(c.total or "?")) for c in progress_bar.counters
+ ]
+ all_lengths.append(1)
+ return D.exact(max(all_lengths) * 2 + 1)
+
+
+def _format_timedelta(timedelta: datetime.timedelta) -> str:
"""
Return hh:mm:ss, or mm:ss if the amount of hours is zero.
"""
- pass
+ result = f"{timedelta}".split(".")[0]
+ if result.startswith("0:"):
+ result = result[2:]
+ return result
class TimeElapsed(Formatter):
@@ -95,51 +250,180 @@ class TimeElapsed(Formatter):
Display the elapsed time.
"""
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ text = _format_timedelta(progress.time_elapsed).rjust(width)
+ return HTML("<time-elapsed>{time_elapsed}</time-elapsed>").format(
+ time_elapsed=text
+ )
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ all_values = [
+ len(_format_timedelta(c.time_elapsed)) for c in progress_bar.counters
+ ]
+ if all_values:
+ return max(all_values)
+ return 0
+
class TimeLeft(Formatter):
"""
Display the time left.
"""
- template = '<time-left>{time_left}</time-left>'
- unknown = '?:??:??'
+
+ template = "<time-left>{time_left}</time-left>"
+ unknown = "?:??:??"
+
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ time_left = progress.time_left
+ if time_left is not None:
+ formatted_time_left = _format_timedelta(time_left)
+ else:
+ formatted_time_left = self.unknown
+
+ return HTML(self.template).format(time_left=formatted_time_left.rjust(width))
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ all_values = [
+ len(_format_timedelta(c.time_left)) if c.time_left is not None else 7
+ for c in progress_bar.counters
+ ]
+ if all_values:
+ return max(all_values)
+ return 0
class IterationsPerSecond(Formatter):
"""
Display the iterations per second.
"""
+
template = (
- '<iterations-per-second>{iterations_per_second:.2f}</iterations-per-second>'
- )
+ "<iterations-per-second>{iterations_per_second:.2f}</iterations-per-second>"
+ )
+
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ value = progress.items_completed / progress.time_elapsed.total_seconds()
+ return HTML(self.template.format(iterations_per_second=value))
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ all_values = [
+ len(f"{c.items_completed / c.time_elapsed.total_seconds():.2f}")
+ for c in progress_bar.counters
+ ]
+ if all_values:
+ return max(all_values)
+ return 0
class SpinningWheel(Formatter):
"""
Display a spinning wheel.
"""
- characters = '/-\\|'
+
+ characters = r"/-\|"
+
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ index = int(time.time() * 3) % len(self.characters)
+ return HTML("<spinning-wheel>{0}</spinning-wheel>").format(
+ self.characters[index]
+ )
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ return D.exact(1)
-def _hue_to_rgb(hue: float) ->tuple[int, int, int]:
+def _hue_to_rgb(hue: float) -> tuple[int, int, int]:
"""
Take hue between 0 and 1, return (r, g, b).
"""
- pass
+ i = int(hue * 6.0)
+ f = (hue * 6.0) - i
+
+ q = int(255 * (1.0 - f))
+ t = int(255 * (1.0 - (1.0 - f)))
+
+ i %= 6
+
+ return [
+ (255, t, 0),
+ (q, 255, 0),
+ (0, 255, t),
+ (0, q, 255),
+ (t, 0, 255),
+ (255, 0, q),
+ ][i]
class Rainbow(Formatter):
"""
For the fun. Add rainbow colors to any of the other formatters.
"""
- colors = [('#%.2x%.2x%.2x' % _hue_to_rgb(h / 100.0)) for h in range(0, 100)
- ]
- def __init__(self, formatter: Formatter) ->None:
+ colors = ["#%.2x%.2x%.2x" % _hue_to_rgb(h / 100.0) for h in range(0, 100)]
+
+ def __init__(self, formatter: Formatter) -> None:
self.formatter = formatter
+ def format(
+ self,
+ progress_bar: ProgressBar,
+ progress: ProgressBarCounter[object],
+ width: int,
+ ) -> AnyFormattedText:
+ # Get formatted text from nested formatter, and explode it in
+ # text/style tuples.
+ result = self.formatter.format(progress_bar, progress, width)
+ result = explode_text_fragments(to_formatted_text(result))
+
+ # Insert colors.
+ result2: StyleAndTextTuples = []
+ shift = int(time.time() * 3) % len(self.colors)
+
+ for i, (style, text, *_) in enumerate(result):
+ result2.append(
+ (style + " " + self.colors[(i + shift) % len(self.colors)], text)
+ )
+ return result2
+
+ def get_width(self, progress_bar: ProgressBar) -> AnyDimension:
+ return self.formatter.get_width(progress_bar)
+
-def create_default_formatters() ->list[Formatter]:
+def create_default_formatters() -> list[Formatter]:
"""
Return the list of default formatters.
"""
- pass
+ return [
+ Label(),
+ Text(" "),
+ Percentage(),
+ Text(" "),
+ Bar(),
+ Text(" "),
+ Progress(),
+ Text(" "),
+ Text("eta [", style="class:time-left"),
+ TimeLeft(),
+ Text("]", style="class:time-left"),
+ Text(" "),
+ ]
diff --git a/src/prompt_toolkit/shortcuts/prompt.py b/src/prompt_toolkit/shortcuts/prompt.py
index 452ef1a4..7274b5f0 100644
--- a/src/prompt_toolkit/shortcuts/prompt.py
+++ b/src/prompt_toolkit/shortcuts/prompt.py
@@ -25,63 +25,163 @@ Example::
result = s.prompt('Say something: ')
"""
from __future__ import annotations
+
from asyncio import get_running_loop
from contextlib import contextmanager
from enum import Enum
from functools import partial
from typing import TYPE_CHECKING, Callable, Generic, Iterator, TypeVar, Union, cast
+
from prompt_toolkit.application import Application
from prompt_toolkit.application.current import get_app
from prompt_toolkit.auto_suggest import AutoSuggest, DynamicAutoSuggest
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.clipboard import Clipboard, DynamicClipboard, InMemoryClipboard
from prompt_toolkit.completion import Completer, DynamicCompleter, ThreadedCompleter
-from prompt_toolkit.cursor_shapes import AnyCursorShapeConfig, CursorShapeConfig, DynamicCursorShapeConfig
+from prompt_toolkit.cursor_shapes import (
+ AnyCursorShapeConfig,
+ CursorShapeConfig,
+ DynamicCursorShapeConfig,
+)
from prompt_toolkit.document import Document
from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER, EditingMode
from prompt_toolkit.eventloop import InputHook
-from prompt_toolkit.filters import Condition, FilterOrBool, has_arg, has_focus, is_done, is_true, renderer_height_is_known, to_filter
-from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples, fragment_list_to_text, merge_formatted_text, to_formatted_text
+from prompt_toolkit.filters import (
+ Condition,
+ FilterOrBool,
+ has_arg,
+ has_focus,
+ is_done,
+ is_true,
+ renderer_height_is_known,
+ to_filter,
+)
+from prompt_toolkit.formatted_text import (
+ AnyFormattedText,
+ StyleAndTextTuples,
+ fragment_list_to_text,
+ merge_formatted_text,
+ to_formatted_text,
+)
from prompt_toolkit.history import History, InMemoryHistory
from prompt_toolkit.input.base import Input
from prompt_toolkit.key_binding.bindings.auto_suggest import load_auto_suggest_bindings
-from prompt_toolkit.key_binding.bindings.completion import display_completions_like_readline
-from prompt_toolkit.key_binding.bindings.open_in_editor import load_open_in_editor_bindings
-from prompt_toolkit.key_binding.key_bindings import ConditionalKeyBindings, DynamicKeyBindings, KeyBindings, KeyBindingsBase, merge_key_bindings
+from prompt_toolkit.key_binding.bindings.completion import (
+ display_completions_like_readline,
+)
+from prompt_toolkit.key_binding.bindings.open_in_editor import (
+ load_open_in_editor_bindings,
+)
+from prompt_toolkit.key_binding.key_bindings import (
+ ConditionalKeyBindings,
+ DynamicKeyBindings,
+ KeyBindings,
+ KeyBindingsBase,
+ merge_key_bindings,
+)
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import Float, FloatContainer, HSplit, Window
from prompt_toolkit.layout.containers import ConditionalContainer, WindowAlign
-from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl, SearchBufferControl
+from prompt_toolkit.layout.controls import (
+ BufferControl,
+ FormattedTextControl,
+ SearchBufferControl,
+)
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu
-from prompt_toolkit.layout.processors import AfterInput, AppendAutoSuggestion, ConditionalProcessor, DisplayMultipleCursors, DynamicProcessor, HighlightIncrementalSearchProcessor, HighlightSelectionProcessor, PasswordProcessor, Processor, ReverseSearchProcessor, merge_processors
+from prompt_toolkit.layout.processors import (
+ AfterInput,
+ AppendAutoSuggestion,
+ ConditionalProcessor,
+ DisplayMultipleCursors,
+ DynamicProcessor,
+ HighlightIncrementalSearchProcessor,
+ HighlightSelectionProcessor,
+ PasswordProcessor,
+ Processor,
+ ReverseSearchProcessor,
+ merge_processors,
+)
from prompt_toolkit.layout.utils import explode_text_fragments
from prompt_toolkit.lexers import DynamicLexer, Lexer
from prompt_toolkit.output import ColorDepth, DummyOutput, Output
-from prompt_toolkit.styles import BaseStyle, ConditionalStyleTransformation, DynamicStyle, DynamicStyleTransformation, StyleTransformation, SwapLightAndDarkStyleTransformation, merge_style_transformations
-from prompt_toolkit.utils import get_cwidth, is_dumb_terminal, suspend_to_background_supported, to_str
+from prompt_toolkit.styles import (
+ BaseStyle,
+ ConditionalStyleTransformation,
+ DynamicStyle,
+ DynamicStyleTransformation,
+ StyleTransformation,
+ SwapLightAndDarkStyleTransformation,
+ merge_style_transformations,
+)
+from prompt_toolkit.utils import (
+ get_cwidth,
+ is_dumb_terminal,
+ suspend_to_background_supported,
+ to_str,
+)
from prompt_toolkit.validation import DynamicValidator, Validator
-from prompt_toolkit.widgets.toolbars import SearchToolbar, SystemToolbar, ValidationToolbar
+from prompt_toolkit.widgets.toolbars import (
+ SearchToolbar,
+ SystemToolbar,
+ ValidationToolbar,
+)
+
if TYPE_CHECKING:
from prompt_toolkit.formatted_text.base import MagicFormattedText
-__all__ = ['PromptSession', 'prompt', 'confirm', 'create_confirm_session',
- 'CompleteStyle']
+
+__all__ = [
+ "PromptSession",
+ "prompt",
+ "confirm",
+ "create_confirm_session", # Used by '_display_completions_like_readline'.
+ "CompleteStyle",
+]
+
_StyleAndTextTuplesCallable = Callable[[], StyleAndTextTuples]
E = KeyPressEvent
-def _split_multiline_prompt(get_prompt_text: _StyleAndTextTuplesCallable
- ) ->tuple[Callable[[], bool], _StyleAndTextTuplesCallable,
- _StyleAndTextTuplesCallable]:
+def _split_multiline_prompt(
+ get_prompt_text: _StyleAndTextTuplesCallable,
+) -> tuple[
+ Callable[[], bool], _StyleAndTextTuplesCallable, _StyleAndTextTuplesCallable
+]:
"""
Take a `get_prompt_text` function and return three new functions instead.
One that tells whether this prompt consists of multiple lines; one that
returns the fragments to be shown on the lines above the input; and another
one with the fragments to be shown at the first line of the input.
"""
- pass
+
+ def has_before_fragments() -> bool:
+ for fragment, char, *_ in get_prompt_text():
+ if "\n" in char:
+ return True
+ return False
+
+ def before() -> StyleAndTextTuples:
+ result: StyleAndTextTuples = []
+ found_nl = False
+ for fragment, char, *_ in reversed(explode_text_fragments(get_prompt_text())):
+ if found_nl:
+ result.insert(0, (fragment, char))
+ elif char == "\n":
+ found_nl = True
+ return result
+
+ def first_input_line() -> StyleAndTextTuples:
+ result: StyleAndTextTuples = []
+ for fragment, char, *_ in reversed(explode_text_fragments(get_prompt_text())):
+ if char == "\n":
+ break
+ else:
+ result.insert(0, (fragment, char))
+ return result
+
+ return has_before_fragments, before, first_input_line
class _RPrompt(Window):
@@ -89,24 +189,37 @@ class _RPrompt(Window):
The prompt that is displayed on the right side of the Window.
"""
- def __init__(self, text: AnyFormattedText) ->None:
- super().__init__(FormattedTextControl(text=text), align=WindowAlign
- .RIGHT, style='class:rprompt')
+ def __init__(self, text: AnyFormattedText) -> None:
+ super().__init__(
+ FormattedTextControl(text=text),
+ align=WindowAlign.RIGHT,
+ style="class:rprompt",
+ )
class CompleteStyle(str, Enum):
"""
How to display autocompletions for the prompt.
"""
+
value: str
- COLUMN = 'COLUMN'
- MULTI_COLUMN = 'MULTI_COLUMN'
- READLINE_LIKE = 'READLINE_LIKE'
+
+ COLUMN = "COLUMN"
+ MULTI_COLUMN = "MULTI_COLUMN"
+ READLINE_LIKE = "READLINE_LIKE"
-PromptContinuationText = Union[str, 'MagicFormattedText',
- StyleAndTextTuples, Callable[[int, int, int], AnyFormattedText]]
-_T = TypeVar('_T')
+# Formatted text for the continuation prompt. It's the same like other
+# formatted text, except that if it's a callable, it takes three arguments.
+PromptContinuationText = Union[
+ str,
+ "MagicFormattedText",
+ StyleAndTextTuples,
+ # (prompt_width, line_number, wrap_count) -> AnyFormattedText.
+ Callable[[int, int, int], AnyFormattedText],
+]
+
+_T = TypeVar("_T")
class PromptSession(Generic[_T]):
@@ -211,51 +324,105 @@ class PromptSession(Generic[_T]):
input/output is by creating an `AppSession`.)
:param output: `Output` object.
"""
- _fields = ('message', 'lexer', 'completer', 'complete_in_thread',
- 'is_password', 'editing_mode', 'key_bindings', 'is_password',
- 'bottom_toolbar', 'style', 'style_transformation',
- 'swap_light_and_dark_colors', 'color_depth', 'cursor',
- 'include_default_pygments_style', 'rprompt', 'multiline',
- 'prompt_continuation', 'wrap_lines', 'enable_history_search',
- 'search_ignore_case', 'complete_while_typing',
- 'validate_while_typing', 'complete_style', 'mouse_support',
- 'auto_suggest', 'clipboard', 'validator', 'refresh_interval',
- 'input_processors', 'placeholder', 'enable_system_prompt',
- 'enable_suspend', 'enable_open_in_editor', 'reserve_space_for_menu',
- 'tempfile_suffix', 'tempfile')
-
- def __init__(self, message: AnyFormattedText='', *, multiline:
- FilterOrBool=False, wrap_lines: FilterOrBool=True, is_password:
- FilterOrBool=False, vi_mode: bool=False, editing_mode: EditingMode=
- EditingMode.EMACS, complete_while_typing: FilterOrBool=True,
- validate_while_typing: FilterOrBool=True, enable_history_search:
- FilterOrBool=False, search_ignore_case: FilterOrBool=False, lexer:
- (Lexer | None)=None, enable_system_prompt: FilterOrBool=False,
- enable_suspend: FilterOrBool=False, enable_open_in_editor:
- FilterOrBool=False, validator: (Validator | None)=None, completer:
- (Completer | None)=None, complete_in_thread: bool=False,
- reserve_space_for_menu: int=8, complete_style: CompleteStyle=
- CompleteStyle.COLUMN, auto_suggest: (AutoSuggest | None)=None,
- style: (BaseStyle | None)=None, style_transformation: (
- StyleTransformation | None)=None, swap_light_and_dark_colors:
- FilterOrBool=False, color_depth: (ColorDepth | None)=None, cursor:
- AnyCursorShapeConfig=None, include_default_pygments_style:
- FilterOrBool=True, history: (History | None)=None, clipboard: (
- Clipboard | None)=None, prompt_continuation: (
- PromptContinuationText | None)=None, rprompt: AnyFormattedText=None,
- bottom_toolbar: AnyFormattedText=None, mouse_support: FilterOrBool=
- False, input_processors: (list[Processor] | None)=None, placeholder:
- (AnyFormattedText | None)=None, key_bindings: (KeyBindingsBase |
- None)=None, erase_when_done: bool=False, tempfile_suffix: (str |
- Callable[[], str] | None)='.txt', tempfile: (str | Callable[[], str
- ] | None)=None, refresh_interval: float=0, input: (Input | None)=
- None, output: (Output | None)=None) ->None:
+
+ _fields = (
+ "message",
+ "lexer",
+ "completer",
+ "complete_in_thread",
+ "is_password",
+ "editing_mode",
+ "key_bindings",
+ "is_password",
+ "bottom_toolbar",
+ "style",
+ "style_transformation",
+ "swap_light_and_dark_colors",
+ "color_depth",
+ "cursor",
+ "include_default_pygments_style",
+ "rprompt",
+ "multiline",
+ "prompt_continuation",
+ "wrap_lines",
+ "enable_history_search",
+ "search_ignore_case",
+ "complete_while_typing",
+ "validate_while_typing",
+ "complete_style",
+ "mouse_support",
+ "auto_suggest",
+ "clipboard",
+ "validator",
+ "refresh_interval",
+ "input_processors",
+ "placeholder",
+ "enable_system_prompt",
+ "enable_suspend",
+ "enable_open_in_editor",
+ "reserve_space_for_menu",
+ "tempfile_suffix",
+ "tempfile",
+ )
+
+ def __init__(
+ self,
+ message: AnyFormattedText = "",
+ *,
+ multiline: FilterOrBool = False,
+ wrap_lines: FilterOrBool = True,
+ is_password: FilterOrBool = False,
+ vi_mode: bool = False,
+ editing_mode: EditingMode = EditingMode.EMACS,
+ complete_while_typing: FilterOrBool = True,
+ validate_while_typing: FilterOrBool = True,
+ enable_history_search: FilterOrBool = False,
+ search_ignore_case: FilterOrBool = False,
+ lexer: Lexer | None = None,
+ enable_system_prompt: FilterOrBool = False,
+ enable_suspend: FilterOrBool = False,
+ enable_open_in_editor: FilterOrBool = False,
+ validator: Validator | None = None,
+ completer: Completer | None = None,
+ complete_in_thread: bool = False,
+ reserve_space_for_menu: int = 8,
+ complete_style: CompleteStyle = CompleteStyle.COLUMN,
+ auto_suggest: AutoSuggest | None = None,
+ style: BaseStyle | None = None,
+ style_transformation: StyleTransformation | None = None,
+ swap_light_and_dark_colors: FilterOrBool = False,
+ color_depth: ColorDepth | None = None,
+ cursor: AnyCursorShapeConfig = None,
+ include_default_pygments_style: FilterOrBool = True,
+ history: History | None = None,
+ clipboard: Clipboard | None = None,
+ prompt_continuation: PromptContinuationText | None = None,
+ rprompt: AnyFormattedText = None,
+ bottom_toolbar: AnyFormattedText = None,
+ mouse_support: FilterOrBool = False,
+ input_processors: list[Processor] | None = None,
+ placeholder: AnyFormattedText | None = None,
+ key_bindings: KeyBindingsBase | None = None,
+ erase_when_done: bool = False,
+ tempfile_suffix: str | Callable[[], str] | None = ".txt",
+ tempfile: str | Callable[[], str] | None = None,
+ refresh_interval: float = 0,
+ input: Input | None = None,
+ output: Output | None = None,
+ ) -> None:
history = history or InMemoryHistory()
clipboard = clipboard or InMemoryClipboard()
+
+ # Ensure backwards-compatibility, when `vi_mode` is passed.
if vi_mode:
editing_mode = EditingMode.VI
+
+ # Store all settings in this class.
self._input = input
self._output = output
+
+ # Store attributes.
+ # (All except 'editing_mode'.)
self.message = message
self.lexer = lexer
self.completer = completer
@@ -291,13 +458,15 @@ class PromptSession(Generic[_T]):
self.reserve_space_for_menu = reserve_space_for_menu
self.tempfile_suffix = tempfile_suffix
self.tempfile = tempfile
+
+ # Create buffers, layout and Application.
self.history = history
self.default_buffer = self._create_default_buffer()
self.search_buffer = self._create_search_buffer()
self.layout = self._create_layout()
self.app = self._create_application(editing_mode, erase_when_done)
- def _dyncond(self, attr_name: str) ->Condition:
+ def _dyncond(self, attr_name: str) -> Condition:
"""
Dynamically take this setting from this 'PromptSession' class.
`attr_name` represents an attribute name of this class. Its value
@@ -306,63 +475,426 @@ class PromptSession(Generic[_T]):
This returns something that can be used as either a `Filter`
or `Filter`.
"""
- pass
- def _create_default_buffer(self) ->Buffer:
+ @Condition
+ def dynamic() -> bool:
+ value = cast(FilterOrBool, getattr(self, attr_name))
+ return to_filter(value)()
+
+ return dynamic
+
+ def _create_default_buffer(self) -> Buffer:
"""
Create and return the default input buffer.
"""
- pass
+ dyncond = self._dyncond
+
+ # Create buffers list.
+ def accept(buff: Buffer) -> bool:
+ """Accept the content of the default buffer. This is called when
+ the validation succeeds."""
+ cast(Application[str], get_app()).exit(result=buff.document.text)
+ return True # Keep text, we call 'reset' later on.
+
+ return Buffer(
+ name=DEFAULT_BUFFER,
+ # Make sure that complete_while_typing is disabled when
+ # enable_history_search is enabled. (First convert to Filter,
+ # to avoid doing bitwise operations on bool objects.)
+ complete_while_typing=Condition(
+ lambda: is_true(self.complete_while_typing)
+ and not is_true(self.enable_history_search)
+ and not self.complete_style == CompleteStyle.READLINE_LIKE
+ ),
+ validate_while_typing=dyncond("validate_while_typing"),
+ enable_history_search=dyncond("enable_history_search"),
+ validator=DynamicValidator(lambda: self.validator),
+ completer=DynamicCompleter(
+ lambda: ThreadedCompleter(self.completer)
+ if self.complete_in_thread and self.completer
+ else self.completer
+ ),
+ history=self.history,
+ auto_suggest=DynamicAutoSuggest(lambda: self.auto_suggest),
+ accept_handler=accept,
+ tempfile_suffix=lambda: to_str(self.tempfile_suffix or ""),
+ tempfile=lambda: to_str(self.tempfile or ""),
+ )
+
+ def _create_search_buffer(self) -> Buffer:
+ return Buffer(name=SEARCH_BUFFER)
- def _create_layout(self) ->Layout:
+ def _create_layout(self) -> Layout:
"""
Create `Layout` for this prompt.
"""
- pass
+ dyncond = self._dyncond
+
+ # Create functions that will dynamically split the prompt. (If we have
+ # a multiline prompt.)
+ (
+ has_before_fragments,
+ get_prompt_text_1,
+ get_prompt_text_2,
+ ) = _split_multiline_prompt(self._get_prompt)
+
+ default_buffer = self.default_buffer
+ search_buffer = self.search_buffer
- def _create_application(self, editing_mode: EditingMode,
- erase_when_done: bool) ->Application[_T]:
+ # Create processors list.
+ @Condition
+ def display_placeholder() -> bool:
+ return self.placeholder is not None and self.default_buffer.text == ""
+
+ all_input_processors = [
+ HighlightIncrementalSearchProcessor(),
+ HighlightSelectionProcessor(),
+ ConditionalProcessor(
+ AppendAutoSuggestion(), has_focus(default_buffer) & ~is_done
+ ),
+ ConditionalProcessor(PasswordProcessor(), dyncond("is_password")),
+ DisplayMultipleCursors(),
+ # Users can insert processors here.
+ DynamicProcessor(lambda: merge_processors(self.input_processors or [])),
+ ConditionalProcessor(
+ AfterInput(lambda: self.placeholder),
+ filter=display_placeholder,
+ ),
+ ]
+
+ # Create bottom toolbars.
+ bottom_toolbar = ConditionalContainer(
+ Window(
+ FormattedTextControl(
+ lambda: self.bottom_toolbar, style="class:bottom-toolbar.text"
+ ),
+ style="class:bottom-toolbar",
+ dont_extend_height=True,
+ height=Dimension(min=1),
+ ),
+ filter=Condition(lambda: self.bottom_toolbar is not None)
+ & ~is_done
+ & renderer_height_is_known,
+ )
+
+ search_toolbar = SearchToolbar(
+ search_buffer, ignore_case=dyncond("search_ignore_case")
+ )
+
+ search_buffer_control = SearchBufferControl(
+ buffer=search_buffer,
+ input_processors=[ReverseSearchProcessor()],
+ ignore_case=dyncond("search_ignore_case"),
+ )
+
+ system_toolbar = SystemToolbar(
+ enable_global_bindings=dyncond("enable_system_prompt")
+ )
+
+ def get_search_buffer_control() -> SearchBufferControl:
+ "Return the UIControl to be focused when searching start."
+ if is_true(self.multiline):
+ return search_toolbar.control
+ else:
+ return search_buffer_control
+
+ default_buffer_control = BufferControl(
+ buffer=default_buffer,
+ search_buffer_control=get_search_buffer_control,
+ input_processors=all_input_processors,
+ include_default_input_processors=False,
+ lexer=DynamicLexer(lambda: self.lexer),
+ preview_search=True,
+ )
+
+ default_buffer_window = Window(
+ default_buffer_control,
+ height=self._get_default_buffer_control_height,
+ get_line_prefix=partial(
+ self._get_line_prefix, get_prompt_text_2=get_prompt_text_2
+ ),
+ wrap_lines=dyncond("wrap_lines"),
+ )
+
+ @Condition
+ def multi_column_complete_style() -> bool:
+ return self.complete_style == CompleteStyle.MULTI_COLUMN
+
+ # Build the layout.
+ layout = HSplit(
+ [
+ # The main input, with completion menus floating on top of it.
+ FloatContainer(
+ HSplit(
+ [
+ ConditionalContainer(
+ Window(
+ FormattedTextControl(get_prompt_text_1),
+ dont_extend_height=True,
+ ),
+ Condition(has_before_fragments),
+ ),
+ ConditionalContainer(
+ default_buffer_window,
+ Condition(
+ lambda: get_app().layout.current_control
+ != search_buffer_control
+ ),
+ ),
+ ConditionalContainer(
+ Window(search_buffer_control),
+ Condition(
+ lambda: get_app().layout.current_control
+ == search_buffer_control
+ ),
+ ),
+ ]
+ ),
+ [
+ # Completion menus.
+ # NOTE: Especially the multi-column menu needs to be
+ # transparent, because the shape is not always
+ # rectangular due to the meta-text below the menu.
+ Float(
+ xcursor=True,
+ ycursor=True,
+ transparent=True,
+ content=CompletionsMenu(
+ max_height=16,
+ scroll_offset=1,
+ extra_filter=has_focus(default_buffer)
+ & ~multi_column_complete_style,
+ ),
+ ),
+ Float(
+ xcursor=True,
+ ycursor=True,
+ transparent=True,
+ content=MultiColumnCompletionsMenu(
+ show_meta=True,
+ extra_filter=has_focus(default_buffer)
+ & multi_column_complete_style,
+ ),
+ ),
+ # The right prompt.
+ Float(
+ right=0,
+ top=0,
+ hide_when_covering_content=True,
+ content=_RPrompt(lambda: self.rprompt),
+ ),
+ ],
+ ),
+ ConditionalContainer(ValidationToolbar(), filter=~is_done),
+ ConditionalContainer(
+ system_toolbar, dyncond("enable_system_prompt") & ~is_done
+ ),
+ # In multiline mode, we use two toolbars for 'arg' and 'search'.
+ ConditionalContainer(
+ Window(FormattedTextControl(self._get_arg_text), height=1),
+ dyncond("multiline") & has_arg,
+ ),
+ ConditionalContainer(search_toolbar, dyncond("multiline") & ~is_done),
+ bottom_toolbar,
+ ]
+ )
+
+ return Layout(layout, default_buffer_window)
+
+ def _create_application(
+ self, editing_mode: EditingMode, erase_when_done: bool
+ ) -> Application[_T]:
"""
Create the `Application` object.
"""
- pass
+ dyncond = self._dyncond
+
+ # Default key bindings.
+ auto_suggest_bindings = load_auto_suggest_bindings()
+ open_in_editor_bindings = load_open_in_editor_bindings()
+ prompt_bindings = self._create_prompt_bindings()
- def _create_prompt_bindings(self) ->KeyBindings:
+ # Create application
+ application: Application[_T] = Application(
+ layout=self.layout,
+ style=DynamicStyle(lambda: self.style),
+ style_transformation=merge_style_transformations(
+ [
+ DynamicStyleTransformation(lambda: self.style_transformation),
+ ConditionalStyleTransformation(
+ SwapLightAndDarkStyleTransformation(),
+ dyncond("swap_light_and_dark_colors"),
+ ),
+ ]
+ ),
+ include_default_pygments_style=dyncond("include_default_pygments_style"),
+ clipboard=DynamicClipboard(lambda: self.clipboard),
+ key_bindings=merge_key_bindings(
+ [
+ merge_key_bindings(
+ [
+ auto_suggest_bindings,
+ ConditionalKeyBindings(
+ open_in_editor_bindings,
+ dyncond("enable_open_in_editor")
+ & has_focus(DEFAULT_BUFFER),
+ ),
+ prompt_bindings,
+ ]
+ ),
+ DynamicKeyBindings(lambda: self.key_bindings),
+ ]
+ ),
+ mouse_support=dyncond("mouse_support"),
+ editing_mode=editing_mode,
+ erase_when_done=erase_when_done,
+ reverse_vi_search_direction=True,
+ color_depth=lambda: self.color_depth,
+ cursor=DynamicCursorShapeConfig(lambda: self.cursor),
+ refresh_interval=self.refresh_interval,
+ input=self._input,
+ output=self._output,
+ )
+
+ # During render time, make sure that we focus the right search control
+ # (if we are searching). - This could be useful if people make the
+ # 'multiline' property dynamic.
+ """
+ def on_render(app):
+ multiline = is_true(self.multiline)
+ current_control = app.layout.current_control
+
+ if multiline:
+ if current_control == search_buffer_control:
+ app.layout.current_control = search_toolbar.control
+ app.invalidate()
+ else:
+ if current_control == search_toolbar.control:
+ app.layout.current_control = search_buffer_control
+ app.invalidate()
+
+ app.on_render += on_render
+ """
+
+ return application
+
+ def _create_prompt_bindings(self) -> KeyBindings:
"""
Create the KeyBindings for a prompt application.
"""
- pass
+ kb = KeyBindings()
+ handle = kb.add
+ default_focused = has_focus(DEFAULT_BUFFER)
+
+ @Condition
+ def do_accept() -> bool:
+ return not is_true(self.multiline) and self.app.layout.has_focus(
+ DEFAULT_BUFFER
+ )
+
+ @handle("enter", filter=do_accept & default_focused)
+ def _accept_input(event: E) -> None:
+ "Accept input when enter has been pressed."
+ self.default_buffer.validate_and_handle()
+
+ @Condition
+ def readline_complete_style() -> bool:
+ return self.complete_style == CompleteStyle.READLINE_LIKE
+
+ @handle("tab", filter=readline_complete_style & default_focused)
+ def _complete_like_readline(event: E) -> None:
+ "Display completions (like Readline)."
+ display_completions_like_readline(event)
+
+ @handle("c-c", filter=default_focused)
+ @handle("<sigint>")
+ def _keyboard_interrupt(event: E) -> None:
+ "Abort when Control-C has been pressed."
+ event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
- def prompt(self, message: (AnyFormattedText | None)=None, *,
- editing_mode: (EditingMode | None)=None, refresh_interval: (float |
- None)=None, vi_mode: (bool | None)=None, lexer: (Lexer | None)=None,
- completer: (Completer | None)=None, complete_in_thread: (bool |
- None)=None, is_password: (bool | None)=None, key_bindings: (
- KeyBindingsBase | None)=None, bottom_toolbar: (AnyFormattedText |
- None)=None, style: (BaseStyle | None)=None, color_depth: (
- ColorDepth | None)=None, cursor: (AnyCursorShapeConfig | None)=None,
- include_default_pygments_style: (FilterOrBool | None)=None,
- style_transformation: (StyleTransformation | None)=None,
- swap_light_and_dark_colors: (FilterOrBool | None)=None, rprompt: (
- AnyFormattedText | None)=None, multiline: (FilterOrBool | None)=
- None, prompt_continuation: (PromptContinuationText | None)=None,
- wrap_lines: (FilterOrBool | None)=None, enable_history_search: (
- FilterOrBool | None)=None, search_ignore_case: (FilterOrBool | None
- )=None, complete_while_typing: (FilterOrBool | None)=None,
- validate_while_typing: (FilterOrBool | None)=None, complete_style:
- (CompleteStyle | None)=None, auto_suggest: (AutoSuggest | None)=
- None, validator: (Validator | None)=None, clipboard: (Clipboard |
- None)=None, mouse_support: (FilterOrBool | None)=None,
- input_processors: (list[Processor] | None)=None, placeholder: (
- AnyFormattedText | None)=None, reserve_space_for_menu: (int | None)
- =None, enable_system_prompt: (FilterOrBool | None)=None,
- enable_suspend: (FilterOrBool | None)=None, enable_open_in_editor:
- (FilterOrBool | None)=None, tempfile_suffix: (str | Callable[[],
- str] | None)=None, tempfile: (str | Callable[[], str] | None)=None,
- default: (str | Document)='', accept_default: bool=False, pre_run:
- (Callable[[], None] | None)=None, set_exception_handler: bool=True,
- handle_sigint: bool=True, in_thread: bool=False, inputhook: (
- InputHook | None)=None) ->_T:
+ @Condition
+ def ctrl_d_condition() -> bool:
+ """Ctrl-D binding is only active when the default buffer is selected
+ and empty."""
+ app = get_app()
+ return (
+ app.current_buffer.name == DEFAULT_BUFFER
+ and not app.current_buffer.text
+ )
+
+ @handle("c-d", filter=ctrl_d_condition & default_focused)
+ def _eof(event: E) -> None:
+ "Exit when Control-D has been pressed."
+ event.app.exit(exception=EOFError, style="class:exiting")
+
+ suspend_supported = Condition(suspend_to_background_supported)
+
+ @Condition
+ def enable_suspend() -> bool:
+ return to_filter(self.enable_suspend)()
+
+ @handle("c-z", filter=suspend_supported & enable_suspend)
+ def _suspend(event: E) -> None:
+ """
+ Suspend process to background.
+ """
+ event.app.suspend_to_background()
+
+ return kb
+
+ def prompt(
+ self,
+ # When any of these arguments are passed, this value is overwritten
+ # in this PromptSession.
+ message: AnyFormattedText | None = None,
+ # `message` should go first, because people call it as
+ # positional argument.
+ *,
+ editing_mode: EditingMode | None = None,
+ refresh_interval: float | None = None,
+ vi_mode: bool | None = None,
+ lexer: Lexer | None = None,
+ completer: Completer | None = None,
+ complete_in_thread: bool | None = None,
+ is_password: bool | None = None,
+ key_bindings: KeyBindingsBase | None = None,
+ bottom_toolbar: AnyFormattedText | None = None,
+ style: BaseStyle | None = None,
+ color_depth: ColorDepth | None = None,
+ cursor: AnyCursorShapeConfig | None = None,
+ include_default_pygments_style: FilterOrBool | None = None,
+ style_transformation: StyleTransformation | None = None,
+ swap_light_and_dark_colors: FilterOrBool | None = None,
+ rprompt: AnyFormattedText | None = None,
+ multiline: FilterOrBool | None = None,
+ prompt_continuation: PromptContinuationText | None = None,
+ wrap_lines: FilterOrBool | None = None,
+ enable_history_search: FilterOrBool | None = None,
+ search_ignore_case: FilterOrBool | None = None,
+ complete_while_typing: FilterOrBool | None = None,
+ validate_while_typing: FilterOrBool | None = None,
+ complete_style: CompleteStyle | None = None,
+ auto_suggest: AutoSuggest | None = None,
+ validator: Validator | None = None,
+ clipboard: Clipboard | None = None,
+ mouse_support: FilterOrBool | None = None,
+ input_processors: list[Processor] | None = None,
+ placeholder: AnyFormattedText | None = None,
+ reserve_space_for_menu: int | None = None,
+ enable_system_prompt: FilterOrBool | None = None,
+ enable_suspend: FilterOrBool | None = None,
+ enable_open_in_editor: FilterOrBool | None = None,
+ tempfile_suffix: str | Callable[[], str] | None = None,
+ tempfile: str | Callable[[], str] | None = None,
+ # Following arguments are specific to the current `prompt()` call.
+ default: str | Document = "",
+ accept_default: bool = False,
+ pre_run: Callable[[], None] | None = None,
+ set_exception_handler: bool = True,
+ handle_sigint: bool = True,
+ in_thread: bool = False,
+ inputhook: InputHook | None = None,
+ ) -> _T:
"""
Display the prompt.
@@ -392,11 +924,114 @@ class PromptSession(Generic[_T]):
pressed (for abort) and ``EOFError`` when control-d has been pressed
(for exit).
"""
- pass
+ # NOTE: We used to create a backup of the PromptSession attributes and
+ # restore them after exiting the prompt. This code has been
+ # removed, because it was confusing and didn't really serve a use
+ # case. (People were changing `Application.editing_mode`
+ # dynamically and surprised that it was reset after every call.)
+
+ # NOTE 2: YES, this is a lot of repeation below...
+ # However, it is a very convenient for a user to accept all
+ # these parameters in this `prompt` method as well. We could
+ # use `locals()` and `setattr` to avoid the repetition, but
+ # then we loose the advantage of mypy and pyflakes to be able
+ # to verify the code.
+ if message is not None:
+ self.message = message
+ if editing_mode is not None:
+ self.editing_mode = editing_mode
+ if refresh_interval is not None:
+ self.refresh_interval = refresh_interval
+ if vi_mode:
+ self.editing_mode = EditingMode.VI
+ if lexer is not None:
+ self.lexer = lexer
+ if completer is not None:
+ self.completer = completer
+ if complete_in_thread is not None:
+ self.complete_in_thread = complete_in_thread
+ if is_password is not None:
+ self.is_password = is_password
+ if key_bindings is not None:
+ self.key_bindings = key_bindings
+ if bottom_toolbar is not None:
+ self.bottom_toolbar = bottom_toolbar
+ if style is not None:
+ self.style = style
+ if color_depth is not None:
+ self.color_depth = color_depth
+ if cursor is not None:
+ self.cursor = cursor
+ if include_default_pygments_style is not None:
+ self.include_default_pygments_style = include_default_pygments_style
+ if style_transformation is not None:
+ self.style_transformation = style_transformation
+ if swap_light_and_dark_colors is not None:
+ self.swap_light_and_dark_colors = swap_light_and_dark_colors
+ if rprompt is not None:
+ self.rprompt = rprompt
+ if multiline is not None:
+ self.multiline = multiline
+ if prompt_continuation is not None:
+ self.prompt_continuation = prompt_continuation
+ if wrap_lines is not None:
+ self.wrap_lines = wrap_lines
+ if enable_history_search is not None:
+ self.enable_history_search = enable_history_search
+ if search_ignore_case is not None:
+ self.search_ignore_case = search_ignore_case
+ if complete_while_typing is not None:
+ self.complete_while_typing = complete_while_typing
+ if validate_while_typing is not None:
+ self.validate_while_typing = validate_while_typing
+ if complete_style is not None:
+ self.complete_style = complete_style
+ if auto_suggest is not None:
+ self.auto_suggest = auto_suggest
+ if validator is not None:
+ self.validator = validator
+ if clipboard is not None:
+ self.clipboard = clipboard
+ if mouse_support is not None:
+ self.mouse_support = mouse_support
+ if input_processors is not None:
+ self.input_processors = input_processors
+ if placeholder is not None:
+ self.placeholder = placeholder
+ if reserve_space_for_menu is not None:
+ self.reserve_space_for_menu = reserve_space_for_menu
+ if enable_system_prompt is not None:
+ self.enable_system_prompt = enable_system_prompt
+ if enable_suspend is not None:
+ self.enable_suspend = enable_suspend
+ if enable_open_in_editor is not None:
+ self.enable_open_in_editor = enable_open_in_editor
+ if tempfile_suffix is not None:
+ self.tempfile_suffix = tempfile_suffix
+ if tempfile is not None:
+ self.tempfile = tempfile
+
+ self._add_pre_run_callables(pre_run, accept_default)
+ self.default_buffer.reset(
+ default if isinstance(default, Document) else Document(default)
+ )
+ self.app.refresh_interval = self.refresh_interval # This is not reactive.
+
+ # If we are using the default output, and have a dumb terminal. Use the
+ # dumb prompt.
+ if self._output is None and is_dumb_terminal():
+ with self._dumb_prompt(self.message) as dump_app:
+ return dump_app.run(in_thread=in_thread, handle_sigint=handle_sigint)
+
+ return self.app.run(
+ set_exception_handler=set_exception_handler,
+ in_thread=in_thread,
+ handle_sigint=handle_sigint,
+ inputhook=inputhook,
+ )
@contextmanager
- def _dumb_prompt(self, message: AnyFormattedText='') ->Iterator[Application
- [_T]]:
+ def _dumb_prompt(self, message: AnyFormattedText = "") -> Iterator[Application[_T]]:
"""
Create prompt `Application` for prompt function for dumb terminals.
@@ -409,10 +1044,235 @@ class PromptSession(Generic[_T]):
cursor movements. Instead we only print the typed character that's
right before the cursor.
"""
- pass
+ # Send prompt to output.
+ self.output.write(fragment_list_to_text(to_formatted_text(self.message)))
+ self.output.flush()
+
+ # Key bindings for the dumb prompt: mostly the same as the full prompt.
+ key_bindings: KeyBindingsBase = self._create_prompt_bindings()
+ if self.key_bindings:
+ key_bindings = merge_key_bindings([self.key_bindings, key_bindings])
+
+ # Create and run application.
+ application = cast(
+ Application[_T],
+ Application(
+ input=self.input,
+ output=DummyOutput(),
+ layout=self.layout,
+ key_bindings=key_bindings,
+ ),
+ )
+
+ def on_text_changed(_: object) -> None:
+ self.output.write(self.default_buffer.document.text_before_cursor[-1:])
+ self.output.flush()
+
+ self.default_buffer.on_text_changed += on_text_changed
+
+ try:
+ yield application
+ finally:
+ # Render line ending.
+ self.output.write("\r\n")
+ self.output.flush()
+
+ self.default_buffer.on_text_changed -= on_text_changed
+
+ async def prompt_async(
+ self,
+ # When any of these arguments are passed, this value is overwritten
+ # in this PromptSession.
+ message: AnyFormattedText | None = None,
+ # `message` should go first, because people call it as
+ # positional argument.
+ *,
+ editing_mode: EditingMode | None = None,
+ refresh_interval: float | None = None,
+ vi_mode: bool | None = None,
+ lexer: Lexer | None = None,
+ completer: Completer | None = None,
+ complete_in_thread: bool | None = None,
+ is_password: bool | None = None,
+ key_bindings: KeyBindingsBase | None = None,
+ bottom_toolbar: AnyFormattedText | None = None,
+ style: BaseStyle | None = None,
+ color_depth: ColorDepth | None = None,
+ cursor: CursorShapeConfig | None = None,
+ include_default_pygments_style: FilterOrBool | None = None,
+ style_transformation: StyleTransformation | None = None,
+ swap_light_and_dark_colors: FilterOrBool | None = None,
+ rprompt: AnyFormattedText | None = None,
+ multiline: FilterOrBool | None = None,
+ prompt_continuation: PromptContinuationText | None = None,
+ wrap_lines: FilterOrBool | None = None,
+ enable_history_search: FilterOrBool | None = None,
+ search_ignore_case: FilterOrBool | None = None,
+ complete_while_typing: FilterOrBool | None = None,
+ validate_while_typing: FilterOrBool | None = None,
+ complete_style: CompleteStyle | None = None,
+ auto_suggest: AutoSuggest | None = None,
+ validator: Validator | None = None,
+ clipboard: Clipboard | None = None,
+ mouse_support: FilterOrBool | None = None,
+ input_processors: list[Processor] | None = None,
+ placeholder: AnyFormattedText | None = None,
+ reserve_space_for_menu: int | None = None,
+ enable_system_prompt: FilterOrBool | None = None,
+ enable_suspend: FilterOrBool | None = None,
+ enable_open_in_editor: FilterOrBool | None = None,
+ tempfile_suffix: str | Callable[[], str] | None = None,
+ tempfile: str | Callable[[], str] | None = None,
+ # Following arguments are specific to the current `prompt()` call.
+ default: str | Document = "",
+ accept_default: bool = False,
+ pre_run: Callable[[], None] | None = None,
+ set_exception_handler: bool = True,
+ handle_sigint: bool = True,
+ ) -> _T:
+ if message is not None:
+ self.message = message
+ if editing_mode is not None:
+ self.editing_mode = editing_mode
+ if refresh_interval is not None:
+ self.refresh_interval = refresh_interval
+ if vi_mode:
+ self.editing_mode = EditingMode.VI
+ if lexer is not None:
+ self.lexer = lexer
+ if completer is not None:
+ self.completer = completer
+ if complete_in_thread is not None:
+ self.complete_in_thread = complete_in_thread
+ if is_password is not None:
+ self.is_password = is_password
+ if key_bindings is not None:
+ self.key_bindings = key_bindings
+ if bottom_toolbar is not None:
+ self.bottom_toolbar = bottom_toolbar
+ if style is not None:
+ self.style = style
+ if color_depth is not None:
+ self.color_depth = color_depth
+ if cursor is not None:
+ self.cursor = cursor
+ if include_default_pygments_style is not None:
+ self.include_default_pygments_style = include_default_pygments_style
+ if style_transformation is not None:
+ self.style_transformation = style_transformation
+ if swap_light_and_dark_colors is not None:
+ self.swap_light_and_dark_colors = swap_light_and_dark_colors
+ if rprompt is not None:
+ self.rprompt = rprompt
+ if multiline is not None:
+ self.multiline = multiline
+ if prompt_continuation is not None:
+ self.prompt_continuation = prompt_continuation
+ if wrap_lines is not None:
+ self.wrap_lines = wrap_lines
+ if enable_history_search is not None:
+ self.enable_history_search = enable_history_search
+ if search_ignore_case is not None:
+ self.search_ignore_case = search_ignore_case
+ if complete_while_typing is not None:
+ self.complete_while_typing = complete_while_typing
+ if validate_while_typing is not None:
+ self.validate_while_typing = validate_while_typing
+ if complete_style is not None:
+ self.complete_style = complete_style
+ if auto_suggest is not None:
+ self.auto_suggest = auto_suggest
+ if validator is not None:
+ self.validator = validator
+ if clipboard is not None:
+ self.clipboard = clipboard
+ if mouse_support is not None:
+ self.mouse_support = mouse_support
+ if input_processors is not None:
+ self.input_processors = input_processors
+ if placeholder is not None:
+ self.placeholder = placeholder
+ if reserve_space_for_menu is not None:
+ self.reserve_space_for_menu = reserve_space_for_menu
+ if enable_system_prompt is not None:
+ self.enable_system_prompt = enable_system_prompt
+ if enable_suspend is not None:
+ self.enable_suspend = enable_suspend
+ if enable_open_in_editor is not None:
+ self.enable_open_in_editor = enable_open_in_editor
+ if tempfile_suffix is not None:
+ self.tempfile_suffix = tempfile_suffix
+ if tempfile is not None:
+ self.tempfile = tempfile
+
+ self._add_pre_run_callables(pre_run, accept_default)
+ self.default_buffer.reset(
+ default if isinstance(default, Document) else Document(default)
+ )
+ self.app.refresh_interval = self.refresh_interval # This is not reactive.
+
+ # If we are using the default output, and have a dumb terminal. Use the
+ # dumb prompt.
+ if self._output is None and is_dumb_terminal():
+ with self._dumb_prompt(self.message) as dump_app:
+ return await dump_app.run_async(handle_sigint=handle_sigint)
+
+ return await self.app.run_async(
+ set_exception_handler=set_exception_handler, handle_sigint=handle_sigint
+ )
+
+ def _add_pre_run_callables(
+ self, pre_run: Callable[[], None] | None, accept_default: bool
+ ) -> None:
+ def pre_run2() -> None:
+ if pre_run:
+ pre_run()
+
+ if accept_default:
+ # Validate and handle input. We use `call_from_executor` in
+ # order to run it "soon" (during the next iteration of the
+ # event loop), instead of right now. Otherwise, it won't
+ # display the default value.
+ get_running_loop().call_soon(self.default_buffer.validate_and_handle)
+
+ self.app.pre_run_callables.append(pre_run2)
- def _get_continuation(self, width: int, line_number: int, wrap_count: int
- ) ->StyleAndTextTuples:
+ @property
+ def editing_mode(self) -> EditingMode:
+ return self.app.editing_mode
+
+ @editing_mode.setter
+ def editing_mode(self, value: EditingMode) -> None:
+ self.app.editing_mode = value
+
+ def _get_default_buffer_control_height(self) -> Dimension:
+ # If there is an autocompletion menu to be shown, make sure that our
+ # layout has at least a minimal height in order to display it.
+ if (
+ self.completer is not None
+ and self.complete_style != CompleteStyle.READLINE_LIKE
+ ):
+ space = self.reserve_space_for_menu
+ else:
+ space = 0
+
+ if space and not get_app().is_done:
+ buff = self.default_buffer
+
+ # Reserve the space, either when there are completions, or when
+ # `complete_while_typing` is true and we expect completions very
+ # soon.
+ if buff.complete_while_typing() or buff.complete_state is not None:
+ return Dimension(min=space)
+
+ return Dimension()
+
+ def _get_prompt(self) -> StyleAndTextTuples:
+ return to_formatted_text(self.message, style="class:prompt")
+
+ def _get_continuation(
+ self, width: int, line_number: int, wrap_count: int
+ ) -> StyleAndTextTuples:
"""
Insert the prompt continuation.
@@ -421,74 +1281,224 @@ class PromptSession(Generic[_T]):
:param line_number:
:param wrap_count: Amount of times that the line has been wrapped.
"""
- pass
+ prompt_continuation = self.prompt_continuation
+
+ if callable(prompt_continuation):
+ continuation: AnyFormattedText = prompt_continuation(
+ width, line_number, wrap_count
+ )
+ else:
+ continuation = prompt_continuation
+
+ # When the continuation prompt is not given, choose the same width as
+ # the actual prompt.
+ if continuation is None and is_true(self.multiline):
+ continuation = " " * width
+
+ return to_formatted_text(continuation, style="class:prompt-continuation")
- def _get_line_prefix(self, line_number: int, wrap_count: int,
- get_prompt_text_2: _StyleAndTextTuplesCallable) ->StyleAndTextTuples:
+ def _get_line_prefix(
+ self,
+ line_number: int,
+ wrap_count: int,
+ get_prompt_text_2: _StyleAndTextTuplesCallable,
+ ) -> StyleAndTextTuples:
"""
Return whatever needs to be inserted before every line.
(the prompt, or a line continuation.)
"""
- pass
+ # First line: display the "arg" or the prompt.
+ if line_number == 0 and wrap_count == 0:
+ if not is_true(self.multiline) and get_app().key_processor.arg is not None:
+ return self._inline_arg()
+ else:
+ return get_prompt_text_2()
- def _get_arg_text(self) ->StyleAndTextTuples:
- """'arg' toolbar, for in multiline mode."""
- pass
+ # For the next lines, display the appropriate continuation.
+ prompt_width = get_cwidth(fragment_list_to_text(get_prompt_text_2()))
+ return self._get_continuation(prompt_width, line_number, wrap_count)
- def _inline_arg(self) ->StyleAndTextTuples:
- """'arg' prefix, for in single line mode."""
- pass
+ def _get_arg_text(self) -> StyleAndTextTuples:
+ "'arg' toolbar, for in multiline mode."
+ arg = self.app.key_processor.arg
+ if arg is None:
+ # Should not happen because of the `has_arg` filter in the layout.
+ return []
+ if arg == "-":
+ arg = "-1"
-def prompt(message: (AnyFormattedText | None)=None, *, history: (History |
- None)=None, editing_mode: (EditingMode | None)=None, refresh_interval:
- (float | None)=None, vi_mode: (bool | None)=None, lexer: (Lexer | None)
- =None, completer: (Completer | None)=None, complete_in_thread: (bool |
- None)=None, is_password: (bool | None)=None, key_bindings: (
- KeyBindingsBase | None)=None, bottom_toolbar: (AnyFormattedText | None)
- =None, style: (BaseStyle | None)=None, color_depth: (ColorDepth | None)
- =None, cursor: AnyCursorShapeConfig=None,
- include_default_pygments_style: (FilterOrBool | None)=None,
- style_transformation: (StyleTransformation | None)=None,
- swap_light_and_dark_colors: (FilterOrBool | None)=None, rprompt: (
- AnyFormattedText | None)=None, multiline: (FilterOrBool | None)=None,
- prompt_continuation: (PromptContinuationText | None)=None, wrap_lines:
- (FilterOrBool | None)=None, enable_history_search: (FilterOrBool | None
- )=None, search_ignore_case: (FilterOrBool | None)=None,
- complete_while_typing: (FilterOrBool | None)=None,
- validate_while_typing: (FilterOrBool | None)=None, complete_style: (
- CompleteStyle | None)=None, auto_suggest: (AutoSuggest | None)=None,
- validator: (Validator | None)=None, clipboard: (Clipboard | None)=None,
- mouse_support: (FilterOrBool | None)=None, input_processors: (list[
- Processor] | None)=None, placeholder: (AnyFormattedText | None)=None,
- reserve_space_for_menu: (int | None)=None, enable_system_prompt: (
- FilterOrBool | None)=None, enable_suspend: (FilterOrBool | None)=None,
- enable_open_in_editor: (FilterOrBool | None)=None, tempfile_suffix: (
- str | Callable[[], str] | None)=None, tempfile: (str | Callable[[], str
- ] | None)=None, default: str='', accept_default: bool=False, pre_run: (
- Callable[[], None] | None)=None, set_exception_handler: bool=True,
- handle_sigint: bool=True, in_thread: bool=False, inputhook: (InputHook |
- None)=None) ->str:
+ return [("class:arg-toolbar", "Repeat: "), ("class:arg-toolbar.text", arg)]
+
+ def _inline_arg(self) -> StyleAndTextTuples:
+ "'arg' prefix, for in single line mode."
+ app = get_app()
+ if app.key_processor.arg is None:
+ return []
+ else:
+ arg = app.key_processor.arg
+
+ return [
+ ("class:prompt.arg", "(arg: "),
+ ("class:prompt.arg.text", str(arg)),
+ ("class:prompt.arg", ") "),
+ ]
+
+ # Expose the Input and Output objects as attributes, mainly for
+ # backward-compatibility.
+
+ @property
+ def input(self) -> Input:
+ return self.app.input
+
+ @property
+ def output(self) -> Output:
+ return self.app.output
+
+
+def prompt(
+ message: AnyFormattedText | None = None,
+ *,
+ history: History | None = None,
+ editing_mode: EditingMode | None = None,
+ refresh_interval: float | None = None,
+ vi_mode: bool | None = None,
+ lexer: Lexer | None = None,
+ completer: Completer | None = None,
+ complete_in_thread: bool | None = None,
+ is_password: bool | None = None,
+ key_bindings: KeyBindingsBase | None = None,
+ bottom_toolbar: AnyFormattedText | None = None,
+ style: BaseStyle | None = None,
+ color_depth: ColorDepth | None = None,
+ cursor: AnyCursorShapeConfig = None,
+ include_default_pygments_style: FilterOrBool | None = None,
+ style_transformation: StyleTransformation | None = None,
+ swap_light_and_dark_colors: FilterOrBool | None = None,
+ rprompt: AnyFormattedText | None = None,
+ multiline: FilterOrBool | None = None,
+ prompt_continuation: PromptContinuationText | None = None,
+ wrap_lines: FilterOrBool | None = None,
+ enable_history_search: FilterOrBool | None = None,
+ search_ignore_case: FilterOrBool | None = None,
+ complete_while_typing: FilterOrBool | None = None,
+ validate_while_typing: FilterOrBool | None = None,
+ complete_style: CompleteStyle | None = None,
+ auto_suggest: AutoSuggest | None = None,
+ validator: Validator | None = None,
+ clipboard: Clipboard | None = None,
+ mouse_support: FilterOrBool | None = None,
+ input_processors: list[Processor] | None = None,
+ placeholder: AnyFormattedText | None = None,
+ reserve_space_for_menu: int | None = None,
+ enable_system_prompt: FilterOrBool | None = None,
+ enable_suspend: FilterOrBool | None = None,
+ enable_open_in_editor: FilterOrBool | None = None,
+ tempfile_suffix: str | Callable[[], str] | None = None,
+ tempfile: str | Callable[[], str] | None = None,
+ # Following arguments are specific to the current `prompt()` call.
+ default: str = "",
+ accept_default: bool = False,
+ pre_run: Callable[[], None] | None = None,
+ set_exception_handler: bool = True,
+ handle_sigint: bool = True,
+ in_thread: bool = False,
+ inputhook: InputHook | None = None,
+) -> str:
"""
The global `prompt` function. This will create a new `PromptSession`
instance for every call.
"""
- pass
+ # The history is the only attribute that has to be passed to the
+ # `PromptSession`, it can't be passed into the `prompt()` method.
+ session: PromptSession[str] = PromptSession(history=history)
+
+ return session.prompt(
+ message,
+ editing_mode=editing_mode,
+ refresh_interval=refresh_interval,
+ vi_mode=vi_mode,
+ lexer=lexer,
+ completer=completer,
+ complete_in_thread=complete_in_thread,
+ is_password=is_password,
+ key_bindings=key_bindings,
+ bottom_toolbar=bottom_toolbar,
+ style=style,
+ color_depth=color_depth,
+ cursor=cursor,
+ include_default_pygments_style=include_default_pygments_style,
+ style_transformation=style_transformation,
+ swap_light_and_dark_colors=swap_light_and_dark_colors,
+ rprompt=rprompt,
+ multiline=multiline,
+ prompt_continuation=prompt_continuation,
+ wrap_lines=wrap_lines,
+ enable_history_search=enable_history_search,
+ search_ignore_case=search_ignore_case,
+ complete_while_typing=complete_while_typing,
+ validate_while_typing=validate_while_typing,
+ complete_style=complete_style,
+ auto_suggest=auto_suggest,
+ validator=validator,
+ clipboard=clipboard,
+ mouse_support=mouse_support,
+ input_processors=input_processors,
+ placeholder=placeholder,
+ reserve_space_for_menu=reserve_space_for_menu,
+ enable_system_prompt=enable_system_prompt,
+ enable_suspend=enable_suspend,
+ enable_open_in_editor=enable_open_in_editor,
+ tempfile_suffix=tempfile_suffix,
+ tempfile=tempfile,
+ default=default,
+ accept_default=accept_default,
+ pre_run=pre_run,
+ set_exception_handler=set_exception_handler,
+ handle_sigint=handle_sigint,
+ in_thread=in_thread,
+ inputhook=inputhook,
+ )
prompt.__doc__ = PromptSession.prompt.__doc__
-def create_confirm_session(message: str, suffix: str=' (y/n) '
- ) ->PromptSession[bool]:
+def create_confirm_session(
+ message: str, suffix: str = " (y/n) "
+) -> PromptSession[bool]:
"""
Create a `PromptSession` object for the 'confirm' function.
"""
- pass
+ bindings = KeyBindings()
+
+ @bindings.add("y")
+ @bindings.add("Y")
+ def yes(event: E) -> None:
+ session.default_buffer.text = "y"
+ event.app.exit(result=True)
+
+ @bindings.add("n")
+ @bindings.add("N")
+ def no(event: E) -> None:
+ session.default_buffer.text = "n"
+ event.app.exit(result=False)
+
+ @bindings.add(Keys.Any)
+ def _(event: E) -> None:
+ "Disallow inserting other text."
+ pass
+
+ complete_message = merge_formatted_text([message, suffix])
+ session: PromptSession[bool] = PromptSession(
+ complete_message, key_bindings=bindings
+ )
+ return session
-def confirm(message: str='Confirm?', suffix: str=' (y/n) ') ->bool:
+def confirm(message: str = "Confirm?", suffix: str = " (y/n) ") -> bool:
"""
Display a confirmation prompt that returns True/False.
"""
- pass
+ session = create_confirm_session(message, suffix)
+ return session.prompt()
diff --git a/src/prompt_toolkit/shortcuts/utils.py b/src/prompt_toolkit/shortcuts/utils.py
index 44028b38..abf4fd2a 100644
--- a/src/prompt_toolkit/shortcuts/utils.py
+++ b/src/prompt_toolkit/shortcuts/utils.py
@@ -1,27 +1,55 @@
from __future__ import annotations
+
from asyncio.events import AbstractEventLoop
from typing import TYPE_CHECKING, Any, TextIO
+
from prompt_toolkit.application import Application
from prompt_toolkit.application.current import get_app_or_none, get_app_session
from prompt_toolkit.application.run_in_terminal import run_in_terminal
-from prompt_toolkit.formatted_text import FormattedText, StyleAndTextTuples, to_formatted_text
+from prompt_toolkit.formatted_text import (
+ FormattedText,
+ StyleAndTextTuples,
+ to_formatted_text,
+)
from prompt_toolkit.input import DummyInput
from prompt_toolkit.layout import Layout
from prompt_toolkit.output import ColorDepth, Output
from prompt_toolkit.output.defaults import create_output
-from prompt_toolkit.renderer import print_formatted_text as renderer_print_formatted_text
-from prompt_toolkit.styles import BaseStyle, StyleTransformation, default_pygments_style, default_ui_style, merge_styles
+from prompt_toolkit.renderer import (
+ print_formatted_text as renderer_print_formatted_text,
+)
+from prompt_toolkit.styles import (
+ BaseStyle,
+ StyleTransformation,
+ default_pygments_style,
+ default_ui_style,
+ merge_styles,
+)
+
if TYPE_CHECKING:
from prompt_toolkit.layout.containers import AnyContainer
-__all__ = ['print_formatted_text', 'print_container', 'clear', 'set_title',
- 'clear_title']
-
-def print_formatted_text(*values: Any, sep: str=' ', end: str='\n', file: (
- TextIO | None)=None, flush: bool=False, style: (BaseStyle | None)=None,
- output: (Output | None)=None, color_depth: (ColorDepth | None)=None,
- style_transformation: (StyleTransformation | None)=None,
- include_default_pygments_style: bool=True) ->None:
+__all__ = [
+ "print_formatted_text",
+ "print_container",
+ "clear",
+ "set_title",
+ "clear_title",
+]
+
+
+def print_formatted_text(
+ *values: Any,
+ sep: str = " ",
+ end: str = "\n",
+ file: TextIO | None = None,
+ flush: bool = False,
+ style: BaseStyle | None = None,
+ output: Output | None = None,
+ color_depth: ColorDepth | None = None,
+ style_transformation: StyleTransformation | None = None,
+ include_default_pygments_style: bool = True,
+) -> None:
"""
::
@@ -73,12 +101,75 @@ def print_formatted_text(*values: Any, sep: str=' ', end: str='\n', file: (
:param include_default_pygments_style: `bool`. Include the default Pygments
style when set to `True` (the default).
"""
- pass
-
-
-def print_container(container: AnyContainer, file: (TextIO | None)=None,
- style: (BaseStyle | None)=None, include_default_pygments_style: bool=True
- ) ->None:
+ assert not (output and file)
+
+ # Create Output object.
+ if output is None:
+ if file:
+ output = create_output(stdout=file)
+ else:
+ output = get_app_session().output
+
+ assert isinstance(output, Output)
+
+ # Get color depth.
+ color_depth = color_depth or output.get_default_color_depth()
+
+ # Merges values.
+ def to_text(val: Any) -> StyleAndTextTuples:
+ # Normal lists which are not instances of `FormattedText` are
+ # considered plain text.
+ if isinstance(val, list) and not isinstance(val, FormattedText):
+ return to_formatted_text(f"{val}")
+ return to_formatted_text(val, auto_convert=True)
+
+ fragments = []
+ for i, value in enumerate(values):
+ fragments.extend(to_text(value))
+
+ if sep and i != len(values) - 1:
+ fragments.extend(to_text(sep))
+
+ fragments.extend(to_text(end))
+
+ # Print output.
+ def render() -> None:
+ assert isinstance(output, Output)
+
+ renderer_print_formatted_text(
+ output,
+ fragments,
+ _create_merged_style(
+ style, include_default_pygments_style=include_default_pygments_style
+ ),
+ color_depth=color_depth,
+ style_transformation=style_transformation,
+ )
+
+ # Flush the output stream.
+ if flush:
+ output.flush()
+
+ # If an application is running, print above the app. This does not require
+ # `patch_stdout`.
+ loop: AbstractEventLoop | None = None
+
+ app = get_app_or_none()
+ if app is not None:
+ loop = app.loop
+
+ if loop is not None:
+ loop.call_soon_threadsafe(lambda: run_in_terminal(render))
+ else:
+ render()
+
+
+def print_container(
+ container: AnyContainer,
+ file: TextIO | None = None,
+ style: BaseStyle | None = None,
+ include_default_pygments_style: bool = True,
+) -> None:
"""
Print any layout to the output in a non-interactive way.
@@ -88,33 +179,61 @@ def print_container(container: AnyContainer, file: (TextIO | None)=None,
print_container(
Frame(TextArea(text='Hello world!')))
"""
- pass
-
-
-def _create_merged_style(style: (BaseStyle | None),
- include_default_pygments_style: bool) ->BaseStyle:
+ if file:
+ output = create_output(stdout=file)
+ else:
+ output = get_app_session().output
+
+ app: Application[None] = Application(
+ layout=Layout(container=container),
+ output=output,
+ # `DummyInput` will cause the application to terminate immediately.
+ input=DummyInput(),
+ style=_create_merged_style(
+ style, include_default_pygments_style=include_default_pygments_style
+ ),
+ )
+ try:
+ app.run(in_thread=True)
+ except EOFError:
+ pass
+
+
+def _create_merged_style(
+ style: BaseStyle | None, include_default_pygments_style: bool
+) -> BaseStyle:
"""
Merge user defined style with built-in style.
"""
- pass
+ styles = [default_ui_style()]
+ if include_default_pygments_style:
+ styles.append(default_pygments_style())
+ if style:
+ styles.append(style)
+
+ return merge_styles(styles)
-def clear() ->None:
+def clear() -> None:
"""
Clear the screen.
"""
- pass
+ output = get_app_session().output
+ output.erase_screen()
+ output.cursor_goto(0, 0)
+ output.flush()
-def set_title(text: str) ->None:
+def set_title(text: str) -> None:
"""
Set the terminal title.
"""
- pass
+ output = get_app_session().output
+ output.set_title(text)
-def clear_title() ->None:
+def clear_title() -> None:
"""
Erase the current title.
"""
- pass
+ set_title("")
diff --git a/src/prompt_toolkit/styles/base.py b/src/prompt_toolkit/styles/base.py
index a78d7080..b50f3b0e 100644
--- a/src/prompt_toolkit/styles/base.py
+++ b/src/prompt_toolkit/styles/base.py
@@ -2,12 +2,22 @@
The base classes for the styling.
"""
from __future__ import annotations
+
from abc import ABCMeta, abstractmethod, abstractproperty
from typing import Callable, Hashable, NamedTuple
-__all__ = ['Attrs', 'DEFAULT_ATTRS', 'ANSI_COLOR_NAMES',
- 'ANSI_COLOR_NAMES_ALIASES', 'BaseStyle', 'DummyStyle', 'DynamicStyle']
+
+__all__ = [
+ "Attrs",
+ "DEFAULT_ATTRS",
+ "ANSI_COLOR_NAMES",
+ "ANSI_COLOR_NAMES_ALIASES",
+ "BaseStyle",
+ "DummyStyle",
+ "DynamicStyle",
+]
+#: Style attributes.
class Attrs(NamedTuple):
color: str | None
bgcolor: str | None
@@ -31,21 +41,68 @@ class Attrs(NamedTuple):
:param reverse: Boolean
:param hidden: Boolean
"""
-DEFAULT_ATTRS = Attrs(color='', bgcolor='', bold=False, underline=False,
- strike=False, italic=False, blink=False, reverse=False, hidden=False)
-ANSI_COLOR_NAMES = ['ansidefault', 'ansiblack', 'ansired', 'ansigreen',
- 'ansiyellow', 'ansiblue', 'ansimagenta', 'ansicyan', 'ansigray',
- 'ansibrightblack', 'ansibrightred', 'ansibrightgreen',
- 'ansibrightyellow', 'ansibrightblue', 'ansibrightmagenta',
- 'ansibrightcyan', 'ansiwhite']
-ANSI_COLOR_NAMES_ALIASES: dict[str, str] = {'ansidarkgray':
- 'ansibrightblack', 'ansiteal': 'ansicyan', 'ansiturquoise':
- 'ansibrightcyan', 'ansibrown': 'ansiyellow', 'ansipurple':
- 'ansimagenta', 'ansifuchsia': 'ansibrightmagenta', 'ansilightgray':
- 'ansigray', 'ansidarkred': 'ansired', 'ansidarkgreen': 'ansigreen',
- 'ansidarkblue': 'ansiblue'}
+
+#: The default `Attrs`.
+DEFAULT_ATTRS = Attrs(
+ color="",
+ bgcolor="",
+ bold=False,
+ underline=False,
+ strike=False,
+ italic=False,
+ blink=False,
+ reverse=False,
+ hidden=False,
+)
+
+
+#: ``Attrs.bgcolor/fgcolor`` can be in either 'ffffff' format, or can be any of
+#: the following in case we want to take colors from the 8/16 color palette.
+#: Usually, in that case, the terminal application allows to configure the RGB
+#: values for these names.
+#: ISO 6429 colors
+ANSI_COLOR_NAMES = [
+ "ansidefault",
+ # Low intensity, dark. (One or two components 0x80, the other 0x00.)
+ "ansiblack",
+ "ansired",
+ "ansigreen",
+ "ansiyellow",
+ "ansiblue",
+ "ansimagenta",
+ "ansicyan",
+ "ansigray",
+ # High intensity, bright. (One or two components 0xff, the other 0x00. Not supported everywhere.)
+ "ansibrightblack",
+ "ansibrightred",
+ "ansibrightgreen",
+ "ansibrightyellow",
+ "ansibrightblue",
+ "ansibrightmagenta",
+ "ansibrightcyan",
+ "ansiwhite",
+]
+
+
+# People don't use the same ANSI color names everywhere. In prompt_toolkit 1.0
+# we used some unconventional names (which were contributed like that to
+# Pygments). This is fixed now, but we still support the old names.
+
+# The table below maps the old aliases to the current names.
+ANSI_COLOR_NAMES_ALIASES: dict[str, str] = {
+ "ansidarkgray": "ansibrightblack",
+ "ansiteal": "ansicyan",
+ "ansiturquoise": "ansibrightcyan",
+ "ansibrown": "ansiyellow",
+ "ansipurple": "ansimagenta",
+ "ansifuchsia": "ansibrightmagenta",
+ "ansilightgray": "ansigray",
+ "ansidarkred": "ansired",
+ "ansidarkgreen": "ansigreen",
+ "ansidarkblue": "ansiblue",
+}
assert set(ANSI_COLOR_NAMES_ALIASES.values()).issubset(set(ANSI_COLOR_NAMES))
-assert not set(ANSI_COLOR_NAMES_ALIASES.keys()) & set(ANSI_COLOR_NAMES)
+assert not (set(ANSI_COLOR_NAMES_ALIASES.keys()) & set(ANSI_COLOR_NAMES))
class BaseStyle(metaclass=ABCMeta):
@@ -54,8 +111,9 @@ class BaseStyle(metaclass=ABCMeta):
"""
@abstractmethod
- def get_attrs_for_style_str(self, style_str: str, default: Attrs=
- DEFAULT_ATTRS) ->Attrs:
+ def get_attrs_for_style_str(
+ self, style_str: str, default: Attrs = DEFAULT_ATTRS
+ ) -> Attrs:
"""
Return :class:`.Attrs` for the given style string.
@@ -63,24 +121,22 @@ class BaseStyle(metaclass=ABCMeta):
well as classnames (e.g. "class:title").
:param default: `Attrs` to be used if no styling was defined.
"""
- pass
@abstractproperty
- def style_rules(self) ->list[tuple[str, str]]:
+ def style_rules(self) -> list[tuple[str, str]]:
"""
The list of style rules, used to create this style.
(Required for `DynamicStyle` and `_MergedStyle` to work.)
"""
- pass
+ return []
@abstractmethod
- def invalidation_hash(self) ->Hashable:
+ def invalidation_hash(self) -> Hashable:
"""
Invalidation hash for the style. When this changes over time, the
renderer knows that something in the style changed, and that everything
has to be redrawn.
"""
- pass
class DummyStyle(BaseStyle):
@@ -88,6 +144,18 @@ class DummyStyle(BaseStyle):
A style that doesn't style anything.
"""
+ def get_attrs_for_style_str(
+ self, style_str: str, default: Attrs = DEFAULT_ATTRS
+ ) -> Attrs:
+ return default
+
+ def invalidation_hash(self) -> Hashable:
+ return 1 # Always the same value.
+
+ @property
+ def style_rules(self) -> list[tuple[str, str]]:
+ return []
+
class DynamicStyle(BaseStyle):
"""
@@ -99,3 +167,17 @@ class DynamicStyle(BaseStyle):
def __init__(self, get_style: Callable[[], BaseStyle | None]):
self.get_style = get_style
self._dummy = DummyStyle()
+
+ def get_attrs_for_style_str(
+ self, style_str: str, default: Attrs = DEFAULT_ATTRS
+ ) -> Attrs:
+ style = self.get_style() or self._dummy
+
+ return style.get_attrs_for_style_str(style_str, default)
+
+ def invalidation_hash(self) -> Hashable:
+ return (self.get_style() or self._dummy).invalidation_hash()
+
+ @property
+ def style_rules(self) -> list[tuple[str, str]]:
+ return (self.get_style() or self._dummy).style_rules
diff --git a/src/prompt_toolkit/styles/defaults.py b/src/prompt_toolkit/styles/defaults.py
index 21bda00d..75b8dd21 100644
--- a/src/prompt_toolkit/styles/defaults.py
+++ b/src/prompt_toolkit/styles/defaults.py
@@ -2,112 +2,234 @@
The default styling.
"""
from __future__ import annotations
+
from prompt_toolkit.cache import memoized
+
from .base import ANSI_COLOR_NAMES, BaseStyle
from .named_colors import NAMED_COLORS
from .style import Style, merge_styles
-__all__ = ['default_ui_style', 'default_pygments_style']
-PROMPT_TOOLKIT_STYLE = [('search', 'bg:ansibrightyellow ansiblack'), (
- 'search.current', ''), ('incsearch', ''), ('incsearch.current',
- 'reverse'), ('selected', 'reverse'), ('cursor-column', 'bg:#dddddd'), (
- 'cursor-line', 'underline'), ('color-column', 'bg:#ccaacc'), (
- 'matching-bracket', ''), ('matching-bracket.other',
- '#000000 bg:#aacccc'), ('matching-bracket.cursor', '#ff8888 bg:#880000'
- ), ('multiple-cursors', '#000000 bg:#ccccaa'), ('line-number',
- '#888888'), ('line-number.current', 'bold'), ('tilde', '#8888ff'), (
- 'prompt', ''), ('prompt.arg', 'noinherit'), ('prompt.arg.text', ''), (
- 'prompt.search', 'noinherit'), ('prompt.search.text', ''), (
- 'search-toolbar', 'bold'), ('search-toolbar.text', 'nobold'), (
- 'system-toolbar', 'bold'), ('system-toolbar.text', 'nobold'), (
- 'arg-toolbar', 'bold'), ('arg-toolbar.text', 'nobold'), (
- 'validation-toolbar', 'bg:#550000 #ffffff'), ('window-too-small',
- 'bg:#550000 #ffffff'), ('completion-toolbar', 'bg:#bbbbbb #000000'), (
- 'completion-toolbar.arrow', 'bg:#bbbbbb #000000 bold'), (
- 'completion-toolbar.completion', 'bg:#bbbbbb #000000'), (
- 'completion-toolbar.completion.current', 'bg:#444444 #ffffff'), (
- 'completion-menu', 'bg:#bbbbbb #000000'), ('completion-menu.completion',
- ''), ('completion-menu.completion.current',
- 'fg:#888888 bg:#ffffff reverse'), ('completion-menu.meta.completion',
- 'bg:#999999 #000000'), ('completion-menu.meta.completion.current',
- 'bg:#aaaaaa #000000'), ('completion-menu.multi-column-meta',
- 'bg:#aaaaaa #000000'), ('completion-menu.completion fuzzymatch.outside',
- 'fg:#444444'), ('completion-menu.completion fuzzymatch.inside', 'bold'),
- ('completion-menu.completion fuzzymatch.inside.character', 'underline'),
- ('completion-menu.completion.current fuzzymatch.outside', 'fg:default'),
- ('completion-menu.completion.current fuzzymatch.inside', 'nobold'), (
- 'readline-like-completions', ''), (
- 'readline-like-completions.completion', ''), (
- 'readline-like-completions.completion fuzzymatch.outside', '#888888'),
- ('readline-like-completions.completion fuzzymatch.inside', ''), (
- 'readline-like-completions.completion fuzzymatch.inside.character',
- 'underline'), ('scrollbar.background', 'bg:#aaaaaa'), (
- 'scrollbar.button', 'bg:#444444'), ('scrollbar.arrow', 'noinherit bold'
- ), ('auto-suggestion', '#666666'), ('trailing-whitespace', '#999999'),
- ('tab', '#999999'), ('aborting',
- '#888888 bg:default noreverse noitalic nounderline noblink'), (
- 'exiting', '#888888 bg:default noreverse noitalic nounderline noblink'),
- ('digraph', '#4444ff'), ('control-character', 'ansiblue'), ('nbsp',
- 'underline ansiyellow'), ('i', 'italic'), ('u', 'underline'), ('s',
- 'strike'), ('b', 'bold'), ('em', 'italic'), ('strong', 'bold'), ('del',
- 'strike'), ('hidden', 'hidden'), ('italic', 'italic'), ('underline',
- 'underline'), ('strike', 'strike'), ('bold', 'bold'), ('reverse',
- 'reverse'), ('noitalic', 'noitalic'), ('nounderline', 'nounderline'), (
- 'nostrike', 'nostrike'), ('nobold', 'nobold'), ('noreverse',
- 'noreverse'), ('bottom-toolbar', 'reverse')]
-COLORS_STYLE = [(name, 'fg:' + name) for name in ANSI_COLOR_NAMES] + [(name
- .lower(), 'fg:' + name) for name in NAMED_COLORS]
-WIDGETS_STYLE = [('dialog', 'bg:#4444ff'), ('dialog.body',
- 'bg:#ffffff #000000'), ('dialog.body text-area', 'bg:#cccccc'), (
- 'dialog.body text-area last-line', 'underline'), ('dialog frame.label',
- '#ff0000 bold'), ('dialog.body scrollbar.background', ''), (
- 'dialog.body scrollbar.button', 'bg:#000000'), (
- 'dialog.body scrollbar.arrow', ''), ('dialog.body scrollbar.start',
- 'nounderline'), ('dialog.body scrollbar.end', 'nounderline'), ('button',
- ''), ('button.arrow', 'bold'), ('button.focused', 'bg:#aa0000 #ffffff'),
- ('menu-bar', 'bg:#aaaaaa #000000'), ('menu-bar.selected-item',
- 'bg:#ffffff #000000'), ('menu', 'bg:#888888 #ffffff'), ('menu.border',
- '#aaaaaa'), ('menu.border shadow', '#444444'), ('dialog shadow',
- 'bg:#000088'), ('dialog.body shadow', 'bg:#aaaaaa'), ('progress-bar',
- 'bg:#000088'), ('progress-bar.used', 'bg:#ff0000')]
-PYGMENTS_DEFAULT_STYLE = {'pygments.whitespace': '#bbbbbb',
- 'pygments.comment': 'italic #408080', 'pygments.comment.preproc':
- 'noitalic #bc7a00', 'pygments.keyword': 'bold #008000',
- 'pygments.keyword.pseudo': 'nobold', 'pygments.keyword.type':
- 'nobold #b00040', 'pygments.operator': '#666666',
- 'pygments.operator.word': 'bold #aa22ff', 'pygments.name.builtin':
- '#008000', 'pygments.name.function': '#0000ff', 'pygments.name.class':
- 'bold #0000ff', 'pygments.name.namespace': 'bold #0000ff',
- 'pygments.name.exception': 'bold #d2413a', 'pygments.name.variable':
- '#19177c', 'pygments.name.constant': '#880000', 'pygments.name.label':
- '#a0a000', 'pygments.name.entity': 'bold #999999',
- 'pygments.name.attribute': '#7d9029', 'pygments.name.tag':
- 'bold #008000', 'pygments.name.decorator': '#aa22ff',
- 'pygments.literal.string': '#ba2121', 'pygments.literal.string.doc':
- 'italic', 'pygments.literal.string.interpol': 'bold #bb6688',
- 'pygments.literal.string.escape': 'bold #bb6622',
- 'pygments.literal.string.regex': '#bb6688',
- 'pygments.literal.string.symbol': '#19177c',
- 'pygments.literal.string.other': '#008000', 'pygments.literal.number':
- '#666666', 'pygments.generic.heading': 'bold #000080',
- 'pygments.generic.subheading': 'bold #800080',
- 'pygments.generic.deleted': '#a00000', 'pygments.generic.inserted':
- '#00a000', 'pygments.generic.error': '#ff0000', 'pygments.generic.emph':
- 'italic', 'pygments.generic.strong': 'bold', 'pygments.generic.prompt':
- 'bold #000080', 'pygments.generic.output': '#888',
- 'pygments.generic.traceback': '#04d', 'pygments.error': 'border:#ff0000'}
+
+__all__ = [
+ "default_ui_style",
+ "default_pygments_style",
+]
+
+#: Default styling. Mapping from classnames to their style definition.
+PROMPT_TOOLKIT_STYLE = [
+ # Highlighting of search matches in document.
+ ("search", "bg:ansibrightyellow ansiblack"),
+ ("search.current", ""),
+ # Incremental search.
+ ("incsearch", ""),
+ ("incsearch.current", "reverse"),
+ # Highlighting of select text in document.
+ ("selected", "reverse"),
+ ("cursor-column", "bg:#dddddd"),
+ ("cursor-line", "underline"),
+ ("color-column", "bg:#ccaacc"),
+ # Highlighting of matching brackets.
+ ("matching-bracket", ""),
+ ("matching-bracket.other", "#000000 bg:#aacccc"),
+ ("matching-bracket.cursor", "#ff8888 bg:#880000"),
+ # Styling of other cursors, in case of block editing.
+ ("multiple-cursors", "#000000 bg:#ccccaa"),
+ # Line numbers.
+ ("line-number", "#888888"),
+ ("line-number.current", "bold"),
+ ("tilde", "#8888ff"),
+ # Default prompt.
+ ("prompt", ""),
+ ("prompt.arg", "noinherit"),
+ ("prompt.arg.text", ""),
+ ("prompt.search", "noinherit"),
+ ("prompt.search.text", ""),
+ # Search toolbar.
+ ("search-toolbar", "bold"),
+ ("search-toolbar.text", "nobold"),
+ # System toolbar
+ ("system-toolbar", "bold"),
+ ("system-toolbar.text", "nobold"),
+ # "arg" toolbar.
+ ("arg-toolbar", "bold"),
+ ("arg-toolbar.text", "nobold"),
+ # Validation toolbar.
+ ("validation-toolbar", "bg:#550000 #ffffff"),
+ ("window-too-small", "bg:#550000 #ffffff"),
+ # Completions toolbar.
+ ("completion-toolbar", "bg:#bbbbbb #000000"),
+ ("completion-toolbar.arrow", "bg:#bbbbbb #000000 bold"),
+ ("completion-toolbar.completion", "bg:#bbbbbb #000000"),
+ ("completion-toolbar.completion.current", "bg:#444444 #ffffff"),
+ # Completions menu.
+ ("completion-menu", "bg:#bbbbbb #000000"),
+ ("completion-menu.completion", ""),
+ # (Note: for the current completion, we use 'reverse' on top of fg/bg
+ # colors. This is to have proper rendering with NO_COLOR=1).
+ ("completion-menu.completion.current", "fg:#888888 bg:#ffffff reverse"),
+ ("completion-menu.meta.completion", "bg:#999999 #000000"),
+ ("completion-menu.meta.completion.current", "bg:#aaaaaa #000000"),
+ ("completion-menu.multi-column-meta", "bg:#aaaaaa #000000"),
+ # Fuzzy matches in completion menu (for FuzzyCompleter).
+ ("completion-menu.completion fuzzymatch.outside", "fg:#444444"),
+ ("completion-menu.completion fuzzymatch.inside", "bold"),
+ ("completion-menu.completion fuzzymatch.inside.character", "underline"),
+ ("completion-menu.completion.current fuzzymatch.outside", "fg:default"),
+ ("completion-menu.completion.current fuzzymatch.inside", "nobold"),
+ # Styling of readline-like completions.
+ ("readline-like-completions", ""),
+ ("readline-like-completions.completion", ""),
+ ("readline-like-completions.completion fuzzymatch.outside", "#888888"),
+ ("readline-like-completions.completion fuzzymatch.inside", ""),
+ ("readline-like-completions.completion fuzzymatch.inside.character", "underline"),
+ # Scrollbars.
+ ("scrollbar.background", "bg:#aaaaaa"),
+ ("scrollbar.button", "bg:#444444"),
+ ("scrollbar.arrow", "noinherit bold"),
+ # Start/end of scrollbars. Adding 'underline' here provides a nice little
+ # detail to the progress bar, but it doesn't look good on all terminals.
+ # ('scrollbar.start', 'underline #ffffff'),
+ # ('scrollbar.end', 'underline #000000'),
+ # Auto suggestion text.
+ ("auto-suggestion", "#666666"),
+ # Trailing whitespace and tabs.
+ ("trailing-whitespace", "#999999"),
+ ("tab", "#999999"),
+ # When Control-C/D has been pressed. Grayed.
+ ("aborting", "#888888 bg:default noreverse noitalic nounderline noblink"),
+ ("exiting", "#888888 bg:default noreverse noitalic nounderline noblink"),
+ # Entering a Vi digraph.
+ ("digraph", "#4444ff"),
+ # Control characters, like ^C, ^X.
+ ("control-character", "ansiblue"),
+ # Non-breaking space.
+ ("nbsp", "underline ansiyellow"),
+ # Default styling of HTML elements.
+ ("i", "italic"),
+ ("u", "underline"),
+ ("s", "strike"),
+ ("b", "bold"),
+ ("em", "italic"),
+ ("strong", "bold"),
+ ("del", "strike"),
+ ("hidden", "hidden"),
+ # It should be possible to use the style names in HTML.
+ # <reverse>...</reverse> or <noreverse>...</noreverse>.
+ ("italic", "italic"),
+ ("underline", "underline"),
+ ("strike", "strike"),
+ ("bold", "bold"),
+ ("reverse", "reverse"),
+ ("noitalic", "noitalic"),
+ ("nounderline", "nounderline"),
+ ("nostrike", "nostrike"),
+ ("nobold", "nobold"),
+ ("noreverse", "noreverse"),
+ # Prompt bottom toolbar
+ ("bottom-toolbar", "reverse"),
+]
+
+
+# Style that will turn for instance the class 'red' into 'red'.
+COLORS_STYLE = [(name, "fg:" + name) for name in ANSI_COLOR_NAMES] + [
+ (name.lower(), "fg:" + name) for name in NAMED_COLORS
+]
+
+
+WIDGETS_STYLE = [
+ # Dialog windows.
+ ("dialog", "bg:#4444ff"),
+ ("dialog.body", "bg:#ffffff #000000"),
+ ("dialog.body text-area", "bg:#cccccc"),
+ ("dialog.body text-area last-line", "underline"),
+ ("dialog frame.label", "#ff0000 bold"),
+ # Scrollbars in dialogs.
+ ("dialog.body scrollbar.background", ""),
+ ("dialog.body scrollbar.button", "bg:#000000"),
+ ("dialog.body scrollbar.arrow", ""),
+ ("dialog.body scrollbar.start", "nounderline"),
+ ("dialog.body scrollbar.end", "nounderline"),
+ # Buttons.
+ ("button", ""),
+ ("button.arrow", "bold"),
+ ("button.focused", "bg:#aa0000 #ffffff"),
+ # Menu bars.
+ ("menu-bar", "bg:#aaaaaa #000000"),
+ ("menu-bar.selected-item", "bg:#ffffff #000000"),
+ ("menu", "bg:#888888 #ffffff"),
+ ("menu.border", "#aaaaaa"),
+ ("menu.border shadow", "#444444"),
+ # Shadows.
+ ("dialog shadow", "bg:#000088"),
+ ("dialog.body shadow", "bg:#aaaaaa"),
+ ("progress-bar", "bg:#000088"),
+ ("progress-bar.used", "bg:#ff0000"),
+]
+
+
+# The default Pygments style, include this by default in case a Pygments lexer
+# is used.
+PYGMENTS_DEFAULT_STYLE = {
+ "pygments.whitespace": "#bbbbbb",
+ "pygments.comment": "italic #408080",
+ "pygments.comment.preproc": "noitalic #bc7a00",
+ "pygments.keyword": "bold #008000",
+ "pygments.keyword.pseudo": "nobold",
+ "pygments.keyword.type": "nobold #b00040",
+ "pygments.operator": "#666666",
+ "pygments.operator.word": "bold #aa22ff",
+ "pygments.name.builtin": "#008000",
+ "pygments.name.function": "#0000ff",
+ "pygments.name.class": "bold #0000ff",
+ "pygments.name.namespace": "bold #0000ff",
+ "pygments.name.exception": "bold #d2413a",
+ "pygments.name.variable": "#19177c",
+ "pygments.name.constant": "#880000",
+ "pygments.name.label": "#a0a000",
+ "pygments.name.entity": "bold #999999",
+ "pygments.name.attribute": "#7d9029",
+ "pygments.name.tag": "bold #008000",
+ "pygments.name.decorator": "#aa22ff",
+ # Note: In Pygments, Token.String is an alias for Token.Literal.String,
+ # and Token.Number as an alias for Token.Literal.Number.
+ "pygments.literal.string": "#ba2121",
+ "pygments.literal.string.doc": "italic",
+ "pygments.literal.string.interpol": "bold #bb6688",
+ "pygments.literal.string.escape": "bold #bb6622",
+ "pygments.literal.string.regex": "#bb6688",
+ "pygments.literal.string.symbol": "#19177c",
+ "pygments.literal.string.other": "#008000",
+ "pygments.literal.number": "#666666",
+ "pygments.generic.heading": "bold #000080",
+ "pygments.generic.subheading": "bold #800080",
+ "pygments.generic.deleted": "#a00000",
+ "pygments.generic.inserted": "#00a000",
+ "pygments.generic.error": "#ff0000",
+ "pygments.generic.emph": "italic",
+ "pygments.generic.strong": "bold",
+ "pygments.generic.prompt": "bold #000080",
+ "pygments.generic.output": "#888",
+ "pygments.generic.traceback": "#04d",
+ "pygments.error": "border:#ff0000",
+}
@memoized()
-def default_ui_style() ->BaseStyle:
+def default_ui_style() -> BaseStyle:
"""
Create a default `Style` object.
"""
- pass
+ return merge_styles(
+ [
+ Style(PROMPT_TOOLKIT_STYLE),
+ Style(COLORS_STYLE),
+ Style(WIDGETS_STYLE),
+ ]
+ )
@memoized()
-def default_pygments_style() ->Style:
+def default_pygments_style() -> Style:
"""
Create a `Style` object that contains the default Pygments style.
"""
- pass
+ return Style.from_dict(PYGMENTS_DEFAULT_STYLE)
diff --git a/src/prompt_toolkit/styles/named_colors.py b/src/prompt_toolkit/styles/named_colors.py
index 1604c298..0395c8bc 100644
--- a/src/prompt_toolkit/styles/named_colors.py
+++ b/src/prompt_toolkit/styles/named_colors.py
@@ -3,57 +3,159 @@ All modern web browsers support these 140 color names.
Taken from: https://www.w3schools.com/colors/colors_names.asp
"""
from __future__ import annotations
-__all__ = ['NAMED_COLORS']
-NAMED_COLORS: dict[str, str] = {'AliceBlue': '#f0f8ff', 'AntiqueWhite':
- '#faebd7', 'Aqua': '#00ffff', 'Aquamarine': '#7fffd4', 'Azure':
- '#f0ffff', 'Beige': '#f5f5dc', 'Bisque': '#ffe4c4', 'Black': '#000000',
- 'BlanchedAlmond': '#ffebcd', 'Blue': '#0000ff', 'BlueViolet': '#8a2be2',
- 'Brown': '#a52a2a', 'BurlyWood': '#deb887', 'CadetBlue': '#5f9ea0',
- 'Chartreuse': '#7fff00', 'Chocolate': '#d2691e', 'Coral': '#ff7f50',
- 'CornflowerBlue': '#6495ed', 'Cornsilk': '#fff8dc', 'Crimson':
- '#dc143c', 'Cyan': '#00ffff', 'DarkBlue': '#00008b', 'DarkCyan':
- '#008b8b', 'DarkGoldenRod': '#b8860b', 'DarkGray': '#a9a9a9',
- 'DarkGreen': '#006400', 'DarkGrey': '#a9a9a9', 'DarkKhaki': '#bdb76b',
- 'DarkMagenta': '#8b008b', 'DarkOliveGreen': '#556b2f', 'DarkOrange':
- '#ff8c00', 'DarkOrchid': '#9932cc', 'DarkRed': '#8b0000', 'DarkSalmon':
- '#e9967a', 'DarkSeaGreen': '#8fbc8f', 'DarkSlateBlue': '#483d8b',
- 'DarkSlateGray': '#2f4f4f', 'DarkSlateGrey': '#2f4f4f', 'DarkTurquoise':
- '#00ced1', 'DarkViolet': '#9400d3', 'DeepPink': '#ff1493',
- 'DeepSkyBlue': '#00bfff', 'DimGray': '#696969', 'DimGrey': '#696969',
- 'DodgerBlue': '#1e90ff', 'FireBrick': '#b22222', 'FloralWhite':
- '#fffaf0', 'ForestGreen': '#228b22', 'Fuchsia': '#ff00ff', 'Gainsboro':
- '#dcdcdc', 'GhostWhite': '#f8f8ff', 'Gold': '#ffd700', 'GoldenRod':
- '#daa520', 'Gray': '#808080', 'Green': '#008000', 'GreenYellow':
- '#adff2f', 'Grey': '#808080', 'HoneyDew': '#f0fff0', 'HotPink':
- '#ff69b4', 'IndianRed': '#cd5c5c', 'Indigo': '#4b0082', 'Ivory':
- '#fffff0', 'Khaki': '#f0e68c', 'Lavender': '#e6e6fa', 'LavenderBlush':
- '#fff0f5', 'LawnGreen': '#7cfc00', 'LemonChiffon': '#fffacd',
- 'LightBlue': '#add8e6', 'LightCoral': '#f08080', 'LightCyan': '#e0ffff',
- 'LightGoldenRodYellow': '#fafad2', 'LightGray': '#d3d3d3', 'LightGreen':
- '#90ee90', 'LightGrey': '#d3d3d3', 'LightPink': '#ffb6c1',
- 'LightSalmon': '#ffa07a', 'LightSeaGreen': '#20b2aa', 'LightSkyBlue':
- '#87cefa', 'LightSlateGray': '#778899', 'LightSlateGrey': '#778899',
- 'LightSteelBlue': '#b0c4de', 'LightYellow': '#ffffe0', 'Lime':
- '#00ff00', 'LimeGreen': '#32cd32', 'Linen': '#faf0e6', 'Magenta':
- '#ff00ff', 'Maroon': '#800000', 'MediumAquaMarine': '#66cdaa',
- 'MediumBlue': '#0000cd', 'MediumOrchid': '#ba55d3', 'MediumPurple':
- '#9370db', 'MediumSeaGreen': '#3cb371', 'MediumSlateBlue': '#7b68ee',
- 'MediumSpringGreen': '#00fa9a', 'MediumTurquoise': '#48d1cc',
- 'MediumVioletRed': '#c71585', 'MidnightBlue': '#191970', 'MintCream':
- '#f5fffa', 'MistyRose': '#ffe4e1', 'Moccasin': '#ffe4b5', 'NavajoWhite':
- '#ffdead', 'Navy': '#000080', 'OldLace': '#fdf5e6', 'Olive': '#808000',
- 'OliveDrab': '#6b8e23', 'Orange': '#ffa500', 'OrangeRed': '#ff4500',
- 'Orchid': '#da70d6', 'PaleGoldenRod': '#eee8aa', 'PaleGreen': '#98fb98',
- 'PaleTurquoise': '#afeeee', 'PaleVioletRed': '#db7093', 'PapayaWhip':
- '#ffefd5', 'PeachPuff': '#ffdab9', 'Peru': '#cd853f', 'Pink': '#ffc0cb',
- 'Plum': '#dda0dd', 'PowderBlue': '#b0e0e6', 'Purple': '#800080',
- 'RebeccaPurple': '#663399', 'Red': '#ff0000', 'RosyBrown': '#bc8f8f',
- 'RoyalBlue': '#4169e1', 'SaddleBrown': '#8b4513', 'Salmon': '#fa8072',
- 'SandyBrown': '#f4a460', 'SeaGreen': '#2e8b57', 'SeaShell': '#fff5ee',
- 'Sienna': '#a0522d', 'Silver': '#c0c0c0', 'SkyBlue': '#87ceeb',
- 'SlateBlue': '#6a5acd', 'SlateGray': '#708090', 'SlateGrey': '#708090',
- 'Snow': '#fffafa', 'SpringGreen': '#00ff7f', 'SteelBlue': '#4682b4',
- 'Tan': '#d2b48c', 'Teal': '#008080', 'Thistle': '#d8bfd8', 'Tomato':
- '#ff6347', 'Turquoise': '#40e0d0', 'Violet': '#ee82ee', 'Wheat':
- '#f5deb3', 'White': '#ffffff', 'WhiteSmoke': '#f5f5f5', 'Yellow':
- '#ffff00', 'YellowGreen': '#9acd32'}
+
+__all__ = [
+ "NAMED_COLORS",
+]
+
+
+NAMED_COLORS: dict[str, str] = {
+ "AliceBlue": "#f0f8ff",
+ "AntiqueWhite": "#faebd7",
+ "Aqua": "#00ffff",
+ "Aquamarine": "#7fffd4",
+ "Azure": "#f0ffff",
+ "Beige": "#f5f5dc",
+ "Bisque": "#ffe4c4",
+ "Black": "#000000",
+ "BlanchedAlmond": "#ffebcd",
+ "Blue": "#0000ff",
+ "BlueViolet": "#8a2be2",
+ "Brown": "#a52a2a",
+ "BurlyWood": "#deb887",
+ "CadetBlue": "#5f9ea0",
+ "Chartreuse": "#7fff00",
+ "Chocolate": "#d2691e",
+ "Coral": "#ff7f50",
+ "CornflowerBlue": "#6495ed",
+ "Cornsilk": "#fff8dc",
+ "Crimson": "#dc143c",
+ "Cyan": "#00ffff",
+ "DarkBlue": "#00008b",
+ "DarkCyan": "#008b8b",
+ "DarkGoldenRod": "#b8860b",
+ "DarkGray": "#a9a9a9",
+ "DarkGreen": "#006400",
+ "DarkGrey": "#a9a9a9",
+ "DarkKhaki": "#bdb76b",
+ "DarkMagenta": "#8b008b",
+ "DarkOliveGreen": "#556b2f",
+ "DarkOrange": "#ff8c00",
+ "DarkOrchid": "#9932cc",
+ "DarkRed": "#8b0000",
+ "DarkSalmon": "#e9967a",
+ "DarkSeaGreen": "#8fbc8f",
+ "DarkSlateBlue": "#483d8b",
+ "DarkSlateGray": "#2f4f4f",
+ "DarkSlateGrey": "#2f4f4f",
+ "DarkTurquoise": "#00ced1",
+ "DarkViolet": "#9400d3",
+ "DeepPink": "#ff1493",
+ "DeepSkyBlue": "#00bfff",
+ "DimGray": "#696969",
+ "DimGrey": "#696969",
+ "DodgerBlue": "#1e90ff",
+ "FireBrick": "#b22222",
+ "FloralWhite": "#fffaf0",
+ "ForestGreen": "#228b22",
+ "Fuchsia": "#ff00ff",
+ "Gainsboro": "#dcdcdc",
+ "GhostWhite": "#f8f8ff",
+ "Gold": "#ffd700",
+ "GoldenRod": "#daa520",
+ "Gray": "#808080",
+ "Green": "#008000",
+ "GreenYellow": "#adff2f",
+ "Grey": "#808080",
+ "HoneyDew": "#f0fff0",
+ "HotPink": "#ff69b4",
+ "IndianRed": "#cd5c5c",
+ "Indigo": "#4b0082",
+ "Ivory": "#fffff0",
+ "Khaki": "#f0e68c",
+ "Lavender": "#e6e6fa",
+ "LavenderBlush": "#fff0f5",
+ "LawnGreen": "#7cfc00",
+ "LemonChiffon": "#fffacd",
+ "LightBlue": "#add8e6",
+ "LightCoral": "#f08080",
+ "LightCyan": "#e0ffff",
+ "LightGoldenRodYellow": "#fafad2",
+ "LightGray": "#d3d3d3",
+ "LightGreen": "#90ee90",
+ "LightGrey": "#d3d3d3",
+ "LightPink": "#ffb6c1",
+ "LightSalmon": "#ffa07a",
+ "LightSeaGreen": "#20b2aa",
+ "LightSkyBlue": "#87cefa",
+ "LightSlateGray": "#778899",
+ "LightSlateGrey": "#778899",
+ "LightSteelBlue": "#b0c4de",
+ "LightYellow": "#ffffe0",
+ "Lime": "#00ff00",
+ "LimeGreen": "#32cd32",
+ "Linen": "#faf0e6",
+ "Magenta": "#ff00ff",
+ "Maroon": "#800000",
+ "MediumAquaMarine": "#66cdaa",
+ "MediumBlue": "#0000cd",
+ "MediumOrchid": "#ba55d3",
+ "MediumPurple": "#9370db",
+ "MediumSeaGreen": "#3cb371",
+ "MediumSlateBlue": "#7b68ee",
+ "MediumSpringGreen": "#00fa9a",
+ "MediumTurquoise": "#48d1cc",
+ "MediumVioletRed": "#c71585",
+ "MidnightBlue": "#191970",
+ "MintCream": "#f5fffa",
+ "MistyRose": "#ffe4e1",
+ "Moccasin": "#ffe4b5",
+ "NavajoWhite": "#ffdead",
+ "Navy": "#000080",
+ "OldLace": "#fdf5e6",
+ "Olive": "#808000",
+ "OliveDrab": "#6b8e23",
+ "Orange": "#ffa500",
+ "OrangeRed": "#ff4500",
+ "Orchid": "#da70d6",
+ "PaleGoldenRod": "#eee8aa",
+ "PaleGreen": "#98fb98",
+ "PaleTurquoise": "#afeeee",
+ "PaleVioletRed": "#db7093",
+ "PapayaWhip": "#ffefd5",
+ "PeachPuff": "#ffdab9",
+ "Peru": "#cd853f",
+ "Pink": "#ffc0cb",
+ "Plum": "#dda0dd",
+ "PowderBlue": "#b0e0e6",
+ "Purple": "#800080",
+ "RebeccaPurple": "#663399",
+ "Red": "#ff0000",
+ "RosyBrown": "#bc8f8f",
+ "RoyalBlue": "#4169e1",
+ "SaddleBrown": "#8b4513",
+ "Salmon": "#fa8072",
+ "SandyBrown": "#f4a460",
+ "SeaGreen": "#2e8b57",
+ "SeaShell": "#fff5ee",
+ "Sienna": "#a0522d",
+ "Silver": "#c0c0c0",
+ "SkyBlue": "#87ceeb",
+ "SlateBlue": "#6a5acd",
+ "SlateGray": "#708090",
+ "SlateGrey": "#708090",
+ "Snow": "#fffafa",
+ "SpringGreen": "#00ff7f",
+ "SteelBlue": "#4682b4",
+ "Tan": "#d2b48c",
+ "Teal": "#008080",
+ "Thistle": "#d8bfd8",
+ "Tomato": "#ff6347",
+ "Turquoise": "#40e0d0",
+ "Violet": "#ee82ee",
+ "Wheat": "#f5deb3",
+ "White": "#ffffff",
+ "WhiteSmoke": "#f5f5f5",
+ "Yellow": "#ffff00",
+ "YellowGreen": "#9acd32",
+}
diff --git a/src/prompt_toolkit/styles/pygments.py b/src/prompt_toolkit/styles/pygments.py
index 07c41282..3e101f1d 100644
--- a/src/prompt_toolkit/styles/pygments.py
+++ b/src/prompt_toolkit/styles/pygments.py
@@ -7,16 +7,24 @@ Usage::
style = style_from_pygments_cls(pygments_style_cls=TangoStyle)
"""
from __future__ import annotations
+
from typing import TYPE_CHECKING
+
from .style import Style
+
if TYPE_CHECKING:
from pygments.style import Style as PygmentsStyle
from pygments.token import Token
-__all__ = ['style_from_pygments_cls', 'style_from_pygments_dict',
- 'pygments_token_to_classname']
-def style_from_pygments_cls(pygments_style_cls: type[PygmentsStyle]) ->Style:
+__all__ = [
+ "style_from_pygments_cls",
+ "style_from_pygments_dict",
+ "pygments_token_to_classname",
+]
+
+
+def style_from_pygments_cls(pygments_style_cls: type[PygmentsStyle]) -> Style:
"""
Shortcut to create a :class:`.Style` instance from a Pygments style class
and a style dictionary.
@@ -29,22 +37,33 @@ def style_from_pygments_cls(pygments_style_cls: type[PygmentsStyle]) ->Style:
:param pygments_style_cls: Pygments style class to start from.
"""
- pass
+ # Import inline.
+ from pygments.style import Style as PygmentsStyle
+
+ assert issubclass(pygments_style_cls, PygmentsStyle)
+
+ return style_from_pygments_dict(pygments_style_cls.styles)
-def style_from_pygments_dict(pygments_dict: dict[Token, str]) ->Style:
+def style_from_pygments_dict(pygments_dict: dict[Token, str]) -> Style:
"""
Create a :class:`.Style` instance from a Pygments style dictionary.
(One that maps Token objects to style strings.)
"""
- pass
+ pygments_style = []
+
+ for token, style in pygments_dict.items():
+ pygments_style.append((pygments_token_to_classname(token), style))
+
+ return Style(pygments_style)
-def pygments_token_to_classname(token: Token) ->str:
+def pygments_token_to_classname(token: Token) -> str:
"""
Turn e.g. `Token.Name.Exception` into `'pygments.name.exception'`.
(Our Pygments lexer will also turn the tokens that pygments produces in a
prompt_toolkit list of fragments that match these styling rules.)
"""
- pass
+ parts = ("pygments",) + token
+ return ".".join(parts).lower()
diff --git a/src/prompt_toolkit/styles/style.py b/src/prompt_toolkit/styles/style.py
index ddd7ee70..1abee0f5 100644
--- a/src/prompt_toolkit/styles/style.py
+++ b/src/prompt_toolkit/styles/style.py
@@ -2,50 +2,178 @@
Tool for creating styles from a dictionary.
"""
from __future__ import annotations
+
import itertools
import re
from enum import Enum
from typing import Hashable, TypeVar
+
from prompt_toolkit.cache import SimpleCache
-from .base import ANSI_COLOR_NAMES, ANSI_COLOR_NAMES_ALIASES, DEFAULT_ATTRS, Attrs, BaseStyle
+
+from .base import (
+ ANSI_COLOR_NAMES,
+ ANSI_COLOR_NAMES_ALIASES,
+ DEFAULT_ATTRS,
+ Attrs,
+ BaseStyle,
+)
from .named_colors import NAMED_COLORS
-__all__ = ['Style', 'parse_color', 'Priority', 'merge_styles']
-_named_colors_lowercase = {k.lower(): v.lstrip('#') for k, v in
- NAMED_COLORS.items()}
+__all__ = [
+ "Style",
+ "parse_color",
+ "Priority",
+ "merge_styles",
+]
-def parse_color(text: str) ->str:
+_named_colors_lowercase = {k.lower(): v.lstrip("#") for k, v in NAMED_COLORS.items()}
+
+
+def parse_color(text: str) -> str:
"""
Parse/validate color format.
Like in Pygments, but also support the ANSI color names.
(These will map to the colors of the 16 color palette.)
"""
- pass
+ # ANSI color names.
+ if text in ANSI_COLOR_NAMES:
+ return text
+ if text in ANSI_COLOR_NAMES_ALIASES:
+ return ANSI_COLOR_NAMES_ALIASES[text]
+
+ # 140 named colors.
+ try:
+ # Replace by 'hex' value.
+ return _named_colors_lowercase[text.lower()]
+ except KeyError:
+ pass
+
+ # Hex codes.
+ if text[0:1] == "#":
+ col = text[1:]
+
+ # Keep this for backwards-compatibility (Pygments does it).
+ # I don't like the '#' prefix for named colors.
+ if col in ANSI_COLOR_NAMES:
+ return col
+ elif col in ANSI_COLOR_NAMES_ALIASES:
+ return ANSI_COLOR_NAMES_ALIASES[col]
+
+ # 6 digit hex color.
+ elif len(col) == 6:
+ return col
+
+ # 3 digit hex color.
+ elif len(col) == 3:
+ return col[0] * 2 + col[1] * 2 + col[2] * 2
+
+ # Default.
+ elif text in ("", "default"):
+ return text
+ raise ValueError("Wrong color format %r" % text)
-_EMPTY_ATTRS = Attrs(color=None, bgcolor=None, bold=None, underline=None,
- strike=None, italic=None, blink=None, reverse=None, hidden=None)
+# Attributes, when they are not filled in by a style. None means that we take
+# the value from the parent.
+_EMPTY_ATTRS = Attrs(
+ color=None,
+ bgcolor=None,
+ bold=None,
+ underline=None,
+ strike=None,
+ italic=None,
+ blink=None,
+ reverse=None,
+ hidden=None,
+)
-def _expand_classname(classname: str) ->list[str]:
+
+def _expand_classname(classname: str) -> list[str]:
"""
Split a single class name at the `.` operator, and build a list of classes.
E.g. 'a.b.c' becomes ['a', 'a.b', 'a.b.c']
"""
- pass
+ result = []
+ parts = classname.split(".")
+
+ for i in range(1, len(parts) + 1):
+ result.append(".".join(parts[:i]).lower())
+
+ return result
-def _parse_style_str(style_str: str) ->Attrs:
+def _parse_style_str(style_str: str) -> Attrs:
"""
Take a style string, e.g. 'bg:red #88ff00 class:title'
and return a `Attrs` instance.
"""
- pass
-
-
-CLASS_NAMES_RE = re.compile('^[a-z0-9.\\s_-]*$')
+ # Start from default Attrs.
+ if "noinherit" in style_str:
+ attrs = DEFAULT_ATTRS
+ else:
+ attrs = _EMPTY_ATTRS
+
+ # Now update with the given attributes.
+ for part in style_str.split():
+ if part == "noinherit":
+ pass
+ elif part == "bold":
+ attrs = attrs._replace(bold=True)
+ elif part == "nobold":
+ attrs = attrs._replace(bold=False)
+ elif part == "italic":
+ attrs = attrs._replace(italic=True)
+ elif part == "noitalic":
+ attrs = attrs._replace(italic=False)
+ elif part == "underline":
+ attrs = attrs._replace(underline=True)
+ elif part == "nounderline":
+ attrs = attrs._replace(underline=False)
+ elif part == "strike":
+ attrs = attrs._replace(strike=True)
+ elif part == "nostrike":
+ attrs = attrs._replace(strike=False)
+
+ # prompt_toolkit extensions. Not in Pygments.
+ elif part == "blink":
+ attrs = attrs._replace(blink=True)
+ elif part == "noblink":
+ attrs = attrs._replace(blink=False)
+ elif part == "reverse":
+ attrs = attrs._replace(reverse=True)
+ elif part == "noreverse":
+ attrs = attrs._replace(reverse=False)
+ elif part == "hidden":
+ attrs = attrs._replace(hidden=True)
+ elif part == "nohidden":
+ attrs = attrs._replace(hidden=False)
+
+ # Pygments properties that we ignore.
+ elif part in ("roman", "sans", "mono"):
+ pass
+ elif part.startswith("border:"):
+ pass
+
+ # Ignore pieces in between square brackets. This is internal stuff.
+ # Like '[transparent]' or '[set-cursor-position]'.
+ elif part.startswith("[") and part.endswith("]"):
+ pass
+
+ # Colors.
+ elif part.startswith("bg:"):
+ attrs = attrs._replace(bgcolor=parse_color(part[3:]))
+ elif part.startswith("fg:"): # The 'fg:' prefix is optional.
+ attrs = attrs._replace(color=parse_color(part[3:]))
+ else:
+ attrs = attrs._replace(color=parse_color(part))
+
+ return attrs
+
+
+CLASS_NAMES_RE = re.compile(r"^[a-z0-9.\s_-]*$") # This one can't contain a comma!
class Priority(Enum):
@@ -64,10 +192,13 @@ class Priority(Enum):
- `MOST_PRECISE`: keys that are defined with most precision will get higher
priority. (More precise means: more elements.)
"""
- DICT_KEY_ORDER = 'KEY_ORDER'
- MOST_PRECISE = 'MOST_PRECISE'
+ DICT_KEY_ORDER = "KEY_ORDER"
+ MOST_PRECISE = "MOST_PRECISE"
+
+# We don't support Python versions older than 3.6 anymore, so we can always
+# depend on dictionary ordering. This is the default.
default_priority = Priority.DICT_KEY_ORDER
@@ -93,50 +224,135 @@ class Style(BaseStyle):
The ``from_dict`` classmethod is similar, but takes a dictionary as input.
"""
- def __init__(self, style_rules: list[tuple[str, str]]) ->None:
+ def __init__(self, style_rules: list[tuple[str, str]]) -> None:
class_names_and_attrs = []
+
+ # Loop through the rules in the order they were defined.
+ # Rules that are defined later get priority.
for class_names, style_str in style_rules:
assert CLASS_NAMES_RE.match(class_names), repr(class_names)
+
+ # The order of the class names doesn't matter.
+ # (But the order of rules does matter.)
class_names_set = frozenset(class_names.lower().split())
attrs = _parse_style_str(style_str)
+
class_names_and_attrs.append((class_names_set, attrs))
+
self._style_rules = style_rules
self.class_names_and_attrs = class_names_and_attrs
+ @property
+ def style_rules(self) -> list[tuple[str, str]]:
+ return self._style_rules
+
@classmethod
- def from_dict(cls, style_dict: dict[str, str], priority: Priority=
- default_priority) ->Style:
+ def from_dict(
+ cls, style_dict: dict[str, str], priority: Priority = default_priority
+ ) -> Style:
"""
:param style_dict: Style dictionary.
:param priority: `Priority` value.
"""
- pass
+ if priority == Priority.MOST_PRECISE:
- def get_attrs_for_style_str(self, style_str: str, default: Attrs=
- DEFAULT_ATTRS) ->Attrs:
+ def key(item: tuple[str, str]) -> int:
+ # Split on '.' and whitespace. Count elements.
+ return sum(len(i.split(".")) for i in item[0].split())
+
+ return cls(sorted(style_dict.items(), key=key))
+ else:
+ return cls(list(style_dict.items()))
+
+ def get_attrs_for_style_str(
+ self, style_str: str, default: Attrs = DEFAULT_ATTRS
+ ) -> Attrs:
"""
Get `Attrs` for the given style string.
"""
- pass
+ list_of_attrs = [default]
+ class_names: set[str] = set()
+
+ # Apply default styling.
+ for names, attr in self.class_names_and_attrs:
+ if not names:
+ list_of_attrs.append(attr)
+
+ # Go from left to right through the style string. Things on the right
+ # take precedence.
+ for part in style_str.split():
+ # This part represents a class.
+ # Do lookup of this class name in the style definition, as well
+ # as all class combinations that we have so far.
+ if part.startswith("class:"):
+ # Expand all class names (comma separated list).
+ new_class_names = []
+ for p in part[6:].lower().split(","):
+ new_class_names.extend(_expand_classname(p))
+
+ for new_name in new_class_names:
+ # Build a set of all possible class combinations to be applied.
+ combos = set()
+ combos.add(frozenset([new_name]))
+
+ for count in range(1, len(class_names) + 1):
+ for c2 in itertools.combinations(class_names, count):
+ combos.add(frozenset(c2 + (new_name,)))
+
+ # Apply the styles that match these class names.
+ for names, attr in self.class_names_and_attrs:
+ if names in combos:
+ list_of_attrs.append(attr)
+ class_names.add(new_name)
-_T = TypeVar('_T')
+ # Process inline style.
+ else:
+ inline_attrs = _parse_style_str(part)
+ list_of_attrs.append(inline_attrs)
+ return _merge_attrs(list_of_attrs)
-def _merge_attrs(list_of_attrs: list[Attrs]) ->Attrs:
+ def invalidation_hash(self) -> Hashable:
+ return id(self.class_names_and_attrs)
+
+
+_T = TypeVar("_T")
+
+
+def _merge_attrs(list_of_attrs: list[Attrs]) -> Attrs:
"""
Take a list of :class:`.Attrs` instances and merge them into one.
Every `Attr` in the list can override the styling of the previous one. So,
the last one has highest priority.
"""
- pass
-
-def merge_styles(styles: list[BaseStyle]) ->_MergedStyle:
+ def _or(*values: _T) -> _T:
+ "Take first not-None value, starting at the end."
+ for v in values[::-1]:
+ if v is not None:
+ return v
+ raise ValueError # Should not happen, there's always one non-null value.
+
+ return Attrs(
+ color=_or("", *[a.color for a in list_of_attrs]),
+ bgcolor=_or("", *[a.bgcolor for a in list_of_attrs]),
+ bold=_or(False, *[a.bold for a in list_of_attrs]),
+ underline=_or(False, *[a.underline for a in list_of_attrs]),
+ strike=_or(False, *[a.strike for a in list_of_attrs]),
+ italic=_or(False, *[a.italic for a in list_of_attrs]),
+ blink=_or(False, *[a.blink for a in list_of_attrs]),
+ reverse=_or(False, *[a.reverse for a in list_of_attrs]),
+ hidden=_or(False, *[a.hidden for a in list_of_attrs]),
+ )
+
+
+def merge_styles(styles: list[BaseStyle]) -> _MergedStyle:
"""
Merge multiple `Style` objects.
"""
- pass
+ styles = [s for s in styles if s is not None]
+ return _MergedStyle(styles)
class _MergedStyle(BaseStyle):
@@ -146,11 +362,39 @@ class _MergedStyle(BaseStyle):
then this style will be updated.
"""
- def __init__(self, styles: list[BaseStyle]) ->None:
+ # NOTE: previously, we used an algorithm where we did not generate the
+ # combined style. Instead this was a proxy that called one style
+ # after the other, passing the outcome of the previous style as the
+ # default for the next one. This did not work, because that way, the
+ # priorities like described in the `Style` class don't work.
+ # 'class:aborted' was for instance never displayed in gray, because
+ # the next style specified a default color for any text. (The
+ # explicit styling of class:aborted should have taken priority,
+ # because it was more precise.)
+ def __init__(self, styles: list[BaseStyle]) -> None:
self.styles = styles
self._style: SimpleCache[Hashable, Style] = SimpleCache(maxsize=1)
@property
- def _merged_style(self) ->Style:
- """The `Style` object that has the other styles merged together."""
- pass
+ def _merged_style(self) -> Style:
+ "The `Style` object that has the other styles merged together."
+
+ def get() -> Style:
+ return Style(self.style_rules)
+
+ return self._style.get(self.invalidation_hash(), get)
+
+ @property
+ def style_rules(self) -> list[tuple[str, str]]:
+ style_rules = []
+ for s in self.styles:
+ style_rules.extend(s.style_rules)
+ return style_rules
+
+ def get_attrs_for_style_str(
+ self, style_str: str, default: Attrs = DEFAULT_ATTRS
+ ) -> Attrs:
+ return self._merged_style.get_attrs_for_style_str(style_str, default)
+
+ def invalidation_hash(self) -> Hashable:
+ return tuple(s.invalidation_hash() for s in self.styles)
diff --git a/src/prompt_toolkit/styles/style_transformation.py b/src/prompt_toolkit/styles/style_transformation.py
index d15adc5c..fbb5a639 100644
--- a/src/prompt_toolkit/styles/style_transformation.py
+++ b/src/prompt_toolkit/styles/style_transformation.py
@@ -10,19 +10,29 @@ style strings are turned into `Attrs` objects that represent the actual
formatting.
"""
from __future__ import annotations
+
from abc import ABCMeta, abstractmethod
from colorsys import hls_to_rgb, rgb_to_hls
from typing import Callable, Hashable, Sequence
+
from prompt_toolkit.cache import memoized
from prompt_toolkit.filters import FilterOrBool, to_filter
from prompt_toolkit.utils import AnyFloat, to_float, to_str
+
from .base import ANSI_COLOR_NAMES, Attrs
from .style import parse_color
-__all__ = ['StyleTransformation', 'SwapLightAndDarkStyleTransformation',
- 'ReverseStyleTransformation', 'SetDefaultColorStyleTransformation',
- 'AdjustBrightnessStyleTransformation', 'DummyStyleTransformation',
- 'ConditionalStyleTransformation', 'DynamicStyleTransformation',
- 'merge_style_transformations']
+
+__all__ = [
+ "StyleTransformation",
+ "SwapLightAndDarkStyleTransformation",
+ "ReverseStyleTransformation",
+ "SetDefaultColorStyleTransformation",
+ "AdjustBrightnessStyleTransformation",
+ "DummyStyleTransformation",
+ "ConditionalStyleTransformation",
+ "DynamicStyleTransformation",
+ "merge_style_transformations",
+]
class StyleTransformation(metaclass=ABCMeta):
@@ -31,20 +41,19 @@ class StyleTransformation(metaclass=ABCMeta):
"""
@abstractmethod
- def transform_attrs(self, attrs: Attrs) ->Attrs:
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
"""
Take an `Attrs` object and return a new `Attrs` object.
Remember that the color formats can be either "ansi..." or a 6 digit
lowercase hexadecimal color (without '#' prefix).
"""
- pass
- def invalidation_hash(self) ->Hashable:
+ def invalidation_hash(self) -> Hashable:
"""
When this changes, the cache should be invalidated.
"""
- pass
+ return f"{self.__class__.__name__}-{id(self)}"
class SwapLightAndDarkStyleTransformation(StyleTransformation):
@@ -65,11 +74,15 @@ class SwapLightAndDarkStyleTransformation(StyleTransformation):
reverse works good with that.
"""
- def transform_attrs(self, attrs: Attrs) ->Attrs:
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
"""
Return the `Attrs` used when opposite luminosity should be used.
"""
- pass
+ # Reverse colors.
+ attrs = attrs._replace(color=get_opposite_color(attrs.color))
+ attrs = attrs._replace(bgcolor=get_opposite_color(attrs.bgcolor))
+
+ return attrs
class ReverseStyleTransformation(StyleTransformation):
@@ -79,6 +92,9 @@ class ReverseStyleTransformation(StyleTransformation):
(This is still experimental.)
"""
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
+ return attrs._replace(reverse=not attrs.reverse)
+
class SetDefaultColorStyleTransformation(StyleTransformation):
"""
@@ -90,11 +106,28 @@ class SetDefaultColorStyleTransformation(StyleTransformation):
:param bg: Like `fg`, but for the background.
"""
- def __init__(self, fg: (str | Callable[[], str]), bg: (str | Callable[[
- ], str])) ->None:
+ def __init__(
+ self, fg: str | Callable[[], str], bg: str | Callable[[], str]
+ ) -> None:
self.fg = fg
self.bg = bg
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
+ if attrs.bgcolor in ("", "default"):
+ attrs = attrs._replace(bgcolor=parse_color(to_str(self.bg)))
+
+ if attrs.color in ("", "default"):
+ attrs = attrs._replace(color=parse_color(to_str(self.fg)))
+
+ return attrs
+
+ def invalidation_hash(self) -> Hashable:
+ return (
+ "set-default-color",
+ to_str(self.fg),
+ to_str(self.bg),
+ )
+
class AdjustBrightnessStyleTransformation(StyleTransformation):
"""
@@ -120,23 +153,78 @@ class AdjustBrightnessStyleTransformation(StyleTransformation):
a float.
"""
- def __init__(self, min_brightness: AnyFloat=0.0, max_brightness:
- AnyFloat=1.0) ->None:
+ def __init__(
+ self, min_brightness: AnyFloat = 0.0, max_brightness: AnyFloat = 1.0
+ ) -> None:
self.min_brightness = min_brightness
self.max_brightness = max_brightness
- def _color_to_rgb(self, color: str) ->tuple[float, float, float]:
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
+ min_brightness = to_float(self.min_brightness)
+ max_brightness = to_float(self.max_brightness)
+ assert 0 <= min_brightness <= 1
+ assert 0 <= max_brightness <= 1
+
+ # Don't do anything if the whole brightness range is acceptable.
+ # This also avoids turning ansi colors into RGB sequences.
+ if min_brightness == 0.0 and max_brightness == 1.0:
+ return attrs
+
+ # If a foreground color is given without a background color.
+ no_background = not attrs.bgcolor or attrs.bgcolor == "default"
+ has_fgcolor = attrs.color and attrs.color != "ansidefault"
+
+ if has_fgcolor and no_background:
+ # Calculate new RGB values.
+ r, g, b = self._color_to_rgb(attrs.color or "")
+ hue, brightness, saturation = rgb_to_hls(r, g, b)
+ brightness = self._interpolate_brightness(
+ brightness, min_brightness, max_brightness
+ )
+ r, g, b = hls_to_rgb(hue, brightness, saturation)
+ new_color = f"{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
+
+ attrs = attrs._replace(color=new_color)
+
+ return attrs
+
+ def _color_to_rgb(self, color: str) -> tuple[float, float, float]:
"""
Parse `style.Attrs` color into RGB tuple.
"""
- pass
-
- def _interpolate_brightness(self, value: float, min_brightness: float,
- max_brightness: float) ->float:
+ # Do RGB lookup for ANSI colors.
+ try:
+ from prompt_toolkit.output.vt100 import ANSI_COLORS_TO_RGB
+
+ r, g, b = ANSI_COLORS_TO_RGB[color]
+ return r / 255.0, g / 255.0, b / 255.0
+ except KeyError:
+ pass
+
+ # Parse RRGGBB format.
+ return (
+ int(color[0:2], 16) / 255.0,
+ int(color[2:4], 16) / 255.0,
+ int(color[4:6], 16) / 255.0,
+ )
+
+ # NOTE: we don't have to support named colors here. They are already
+ # transformed into RGB values in `style.parse_color`.
+
+ def _interpolate_brightness(
+ self, value: float, min_brightness: float, max_brightness: float
+ ) -> float:
"""
Map the brightness to the (min_brightness..max_brightness) range.
"""
- pass
+ return min_brightness + (max_brightness - min_brightness) * value
+
+ def invalidation_hash(self) -> Hashable:
+ return (
+ "adjust-brightness",
+ to_float(self.min_brightness),
+ to_float(self.max_brightness),
+ )
class DummyStyleTransformation(StyleTransformation):
@@ -144,6 +232,13 @@ class DummyStyleTransformation(StyleTransformation):
Don't transform anything at all.
"""
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
+ return attrs
+
+ def invalidation_hash(self) -> Hashable:
+ # Always return the same hash for these dummy instances.
+ return "dummy-style-transformation"
+
class DynamicStyleTransformation(StyleTransformation):
"""
@@ -154,52 +249,94 @@ class DynamicStyleTransformation(StyleTransformation):
:class:`.StyleTransformation` instance.
"""
- def __init__(self, get_style_transformation: Callable[[],
- StyleTransformation | None]) ->None:
+ def __init__(
+ self, get_style_transformation: Callable[[], StyleTransformation | None]
+ ) -> None:
self.get_style_transformation = get_style_transformation
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
+ style_transformation = (
+ self.get_style_transformation() or DummyStyleTransformation()
+ )
+ return style_transformation.transform_attrs(attrs)
+
+ def invalidation_hash(self) -> Hashable:
+ style_transformation = (
+ self.get_style_transformation() or DummyStyleTransformation()
+ )
+ return style_transformation.invalidation_hash()
+
class ConditionalStyleTransformation(StyleTransformation):
"""
Apply the style transformation depending on a condition.
"""
- def __init__(self, style_transformation: StyleTransformation, filter:
- FilterOrBool) ->None:
+ def __init__(
+ self, style_transformation: StyleTransformation, filter: FilterOrBool
+ ) -> None:
self.style_transformation = style_transformation
self.filter = to_filter(filter)
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
+ if self.filter():
+ return self.style_transformation.transform_attrs(attrs)
+ return attrs
-class _MergedStyleTransformation(StyleTransformation):
+ def invalidation_hash(self) -> Hashable:
+ return (self.filter(), self.style_transformation.invalidation_hash())
- def __init__(self, style_transformations: Sequence[StyleTransformation]
- ) ->None:
+
+class _MergedStyleTransformation(StyleTransformation):
+ def __init__(self, style_transformations: Sequence[StyleTransformation]) -> None:
self.style_transformations = style_transformations
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
+ for transformation in self.style_transformations:
+ attrs = transformation.transform_attrs(attrs)
+ return attrs
+
+ def invalidation_hash(self) -> Hashable:
+ return tuple(t.invalidation_hash() for t in self.style_transformations)
+
-def merge_style_transformations(style_transformations: Sequence[
- StyleTransformation]) ->StyleTransformation:
+def merge_style_transformations(
+ style_transformations: Sequence[StyleTransformation],
+) -> StyleTransformation:
"""
Merge multiple transformations together.
"""
- pass
-
-
-OPPOSITE_ANSI_COLOR_NAMES = {'ansidefault': 'ansidefault', 'ansiblack':
- 'ansiwhite', 'ansired': 'ansibrightred', 'ansigreen': 'ansibrightgreen',
- 'ansiyellow': 'ansibrightyellow', 'ansiblue': 'ansibrightblue',
- 'ansimagenta': 'ansibrightmagenta', 'ansicyan': 'ansibrightcyan',
- 'ansigray': 'ansibrightblack', 'ansiwhite': 'ansiblack',
- 'ansibrightred': 'ansired', 'ansibrightgreen': 'ansigreen',
- 'ansibrightyellow': 'ansiyellow', 'ansibrightblue': 'ansiblue',
- 'ansibrightmagenta': 'ansimagenta', 'ansibrightcyan': 'ansicyan',
- 'ansibrightblack': 'ansigray'}
+ return _MergedStyleTransformation(style_transformations)
+
+
+# Dictionary that maps ANSI color names to their opposite. This is useful for
+# turning color schemes that are optimized for a black background usable for a
+# white background.
+OPPOSITE_ANSI_COLOR_NAMES = {
+ "ansidefault": "ansidefault",
+ "ansiblack": "ansiwhite",
+ "ansired": "ansibrightred",
+ "ansigreen": "ansibrightgreen",
+ "ansiyellow": "ansibrightyellow",
+ "ansiblue": "ansibrightblue",
+ "ansimagenta": "ansibrightmagenta",
+ "ansicyan": "ansibrightcyan",
+ "ansigray": "ansibrightblack",
+ "ansiwhite": "ansiblack",
+ "ansibrightred": "ansired",
+ "ansibrightgreen": "ansigreen",
+ "ansibrightyellow": "ansiyellow",
+ "ansibrightblue": "ansiblue",
+ "ansibrightmagenta": "ansimagenta",
+ "ansibrightcyan": "ansicyan",
+ "ansibrightblack": "ansigray",
+}
assert set(OPPOSITE_ANSI_COLOR_NAMES.keys()) == set(ANSI_COLOR_NAMES)
assert set(OPPOSITE_ANSI_COLOR_NAMES.values()) == set(ANSI_COLOR_NAMES)
@memoized()
-def get_opposite_color(colorname: (str | None)) ->(str | None):
+def get_opposite_color(colorname: str | None) -> str | None:
"""
Take a color name in either 'ansi...' format or 6 digit RGB, return the
color of opposite luminosity (same hue/saturation).
@@ -207,4 +344,30 @@ def get_opposite_color(colorname: (str | None)) ->(str | None):
This is used for turning color schemes that work on a light background
usable on a dark background.
"""
- pass
+ if colorname is None: # Because color/bgcolor can be None in `Attrs`.
+ return None
+
+ # Special values.
+ if colorname in ("", "default"):
+ return colorname
+
+ # Try ANSI color names.
+ try:
+ return OPPOSITE_ANSI_COLOR_NAMES[colorname]
+ except KeyError:
+ # Try 6 digit RGB colors.
+ r = int(colorname[:2], 16) / 255.0
+ g = int(colorname[2:4], 16) / 255.0
+ b = int(colorname[4:6], 16) / 255.0
+
+ h, l, s = rgb_to_hls(r, g, b)
+
+ l = 1 - l
+
+ r, g, b = hls_to_rgb(h, l, s)
+
+ r = int(r * 255)
+ g = int(g * 255)
+ b = int(b * 255)
+
+ return f"{r:02x}{g:02x}{b:02x}"
diff --git a/src/prompt_toolkit/token.py b/src/prompt_toolkit/token.py
index cd41734d..a2c80e54 100644
--- a/src/prompt_toolkit/token.py
+++ b/src/prompt_toolkit/token.py
@@ -1,5 +1,10 @@
"""
"""
+
from __future__ import annotations
-__all__ = ['ZeroWidthEscape']
-ZeroWidthEscape = '[ZeroWidthEscape]'
+
+__all__ = [
+ "ZeroWidthEscape",
+]
+
+ZeroWidthEscape = "[ZeroWidthEscape]"
diff --git a/src/prompt_toolkit/utils.py b/src/prompt_toolkit/utils.py
index 32a37c22..1a99a286 100644
--- a/src/prompt_toolkit/utils.py
+++ b/src/prompt_toolkit/utils.py
@@ -1,18 +1,45 @@
from __future__ import annotations
+
import os
import signal
import sys
import threading
from collections import deque
-from typing import Callable, ContextManager, Dict, Generator, Generic, TypeVar, Union
+from typing import (
+ Callable,
+ ContextManager,
+ Dict,
+ Generator,
+ Generic,
+ TypeVar,
+ Union,
+)
+
from wcwidth import wcwidth
-__all__ = ['Event', 'DummyContext', 'get_cwidth',
- 'suspend_to_background_supported', 'is_conemu_ansi', 'is_windows',
- 'in_main_thread', 'get_bell_environment_variable',
- 'get_term_environment_variable', 'take_using_weights', 'to_str',
- 'to_int', 'AnyFloat', 'to_float', 'is_dumb_terminal']
-SPHINX_AUTODOC_RUNNING = 'sphinx.ext.autodoc' in sys.modules
-_Sender = TypeVar('_Sender', covariant=True)
+
+__all__ = [
+ "Event",
+ "DummyContext",
+ "get_cwidth",
+ "suspend_to_background_supported",
+ "is_conemu_ansi",
+ "is_windows",
+ "in_main_thread",
+ "get_bell_environment_variable",
+ "get_term_environment_variable",
+ "take_using_weights",
+ "to_str",
+ "to_int",
+ "AnyFloat",
+ "to_float",
+ "is_dumb_terminal",
+]
+
+# Used to ensure sphinx autodoc does not try to import platform-specific
+# stuff when documenting win32.py modules.
+SPHINX_AUTODOC_RUNNING = "sphinx.ext.autodoc" in sys.modules
+
+_Sender = TypeVar("_Sender", covariant=True)
class Event(Generic[_Sender]):
@@ -36,44 +63,48 @@ class Event(Generic[_Sender]):
obj.event()
"""
- def __init__(self, sender: _Sender, handler: (Callable[[_Sender], None] |
- None)=None) ->None:
+ def __init__(
+ self, sender: _Sender, handler: Callable[[_Sender], None] | None = None
+ ) -> None:
self.sender = sender
self._handlers: list[Callable[[_Sender], None]] = []
+
if handler is not None:
self += handler
- def __call__(self) ->None:
- """Fire event."""
+ def __call__(self) -> None:
+ "Fire event."
for handler in self._handlers:
handler(self.sender)
- def fire(self) ->None:
- """Alias for just calling the event."""
- pass
+ def fire(self) -> None:
+ "Alias for just calling the event."
+ self()
- def add_handler(self, handler: Callable[[_Sender], None]) ->None:
+ def add_handler(self, handler: Callable[[_Sender], None]) -> None:
"""
Add another handler to this callback.
(Handler should be a callable that takes exactly one parameter: the
sender object.)
"""
- pass
+ # Add to list of event handlers.
+ self._handlers.append(handler)
- def remove_handler(self, handler: Callable[[_Sender], None]) ->None:
+ def remove_handler(self, handler: Callable[[_Sender], None]) -> None:
"""
Remove a handler from this callback.
"""
- pass
+ if handler in self._handlers:
+ self._handlers.remove(handler)
- def __iadd__(self, handler: Callable[[_Sender], None]) ->Event[_Sender]:
+ def __iadd__(self, handler: Callable[[_Sender], None]) -> Event[_Sender]:
"""
`event += handler` notation for adding a handler.
"""
self.add_handler(handler)
return self
- def __isub__(self, handler: Callable[[_Sender], None]) ->Event[_Sender]:
+ def __isub__(self, handler: Callable[[_Sender], None]) -> Event[_Sender]:
"""
`event -= handler` notation for removing a handler.
"""
@@ -86,10 +117,10 @@ class DummyContext(ContextManager[None]):
(contextlib.nested is not available on Py3)
"""
- def __enter__(self) ->None:
+ def __enter__(self) -> None:
pass
- def __exit__(self, *a: object) ->None:
+ def __exit__(self, *a: object) -> None:
pass
@@ -97,93 +128,114 @@ class _CharSizesCache(Dict[str, int]):
"""
Cache for wcwidth sizes.
"""
- LONG_STRING_MIN_LEN = 64
- MAX_LONG_STRINGS = 16
- def __init__(self) ->None:
+ LONG_STRING_MIN_LEN = 64 # Minimum string length for considering it long.
+ MAX_LONG_STRINGS = 16 # Maximum number of long strings to remember.
+
+ def __init__(self) -> None:
super().__init__()
+ # Keep track of the "long" strings in this cache.
self._long_strings: deque[str] = deque()
- def __missing__(self, string: str) ->int:
+ def __missing__(self, string: str) -> int:
+ # Note: We use the `max(0, ...` because some non printable control
+ # characters, like e.g. Ctrl-underscore get a -1 wcwidth value.
+ # It can be possible that these characters end up in the input
+ # text.
result: int
if len(string) == 1:
result = max(0, wcwidth(string))
else:
result = sum(self[c] for c in string)
+
+ # Store in cache.
self[string] = result
+
+ # Rotate long strings.
+ # (It's hard to tell what we can consider short...)
if len(string) > self.LONG_STRING_MIN_LEN:
long_strings = self._long_strings
long_strings.append(string)
+
if len(long_strings) > self.MAX_LONG_STRINGS:
key_to_remove = long_strings.popleft()
if key_to_remove in self:
del self[key_to_remove]
+
return result
_CHAR_SIZES_CACHE = _CharSizesCache()
-def get_cwidth(string: str) ->int:
+def get_cwidth(string: str) -> int:
"""
Return width of a string. Wrapper around ``wcwidth``.
"""
- pass
+ return _CHAR_SIZES_CACHE[string]
-def suspend_to_background_supported() ->bool:
+def suspend_to_background_supported() -> bool:
"""
Returns `True` when the Python implementation supports
suspend-to-background. This is typically `False' on Windows systems.
"""
- pass
+ return hasattr(signal, "SIGTSTP")
-def is_windows() ->bool:
+def is_windows() -> bool:
"""
True when we are using Windows.
"""
- pass
+ return sys.platform == "win32" # Not 'darwin' or 'linux2'
-def is_windows_vt100_supported() ->bool:
+def is_windows_vt100_supported() -> bool:
"""
True when we are using Windows, but VT100 escape sequences are supported.
"""
- pass
+ if sys.platform == "win32":
+ # Import needs to be inline. Windows libraries are not always available.
+ from prompt_toolkit.output.windows10 import is_win_vt100_enabled
+
+ return is_win_vt100_enabled()
+
+ return False
-def is_conemu_ansi() ->bool:
+def is_conemu_ansi() -> bool:
"""
True when the ConEmu Windows console is used.
"""
- pass
+ return sys.platform == "win32" and os.environ.get("ConEmuANSI", "OFF") == "ON"
-def in_main_thread() ->bool:
+def in_main_thread() -> bool:
"""
True when the current thread is the main thread.
"""
- pass
+ return threading.current_thread().__class__.__name__ == "_MainThread"
-def get_bell_environment_variable() ->bool:
+def get_bell_environment_variable() -> bool:
"""
True if env variable is set to true (true, TRUE, True, 1).
"""
- pass
+ value = os.environ.get("PROMPT_TOOLKIT_BELL", "true")
+ return value.lower() in ("1", "true")
-def get_term_environment_variable() ->str:
- """Return the $TERM environment variable."""
- pass
+def get_term_environment_variable() -> str:
+ "Return the $TERM environment variable."
+ return os.environ.get("TERM", "")
-_T = TypeVar('_T')
+_T = TypeVar("_T")
-def take_using_weights(items: list[_T], weights: list[int]) ->Generator[_T,
- None, None]:
+def take_using_weights(
+ items: list[_T], weights: list[int]
+) -> Generator[_T, None, None]:
"""
Generator that keeps yielding items from the items list, in proportion to
their weight. For instance::
@@ -196,32 +248,80 @@ def take_using_weights(items: list[_T], weights: list[int]) ->Generator[_T,
:param weights: Integers representing the weight. (Numbers have to be
integers, not floats.)
"""
- pass
+ assert len(items) == len(weights)
+ assert len(items) > 0
+
+ # Remove items with zero-weight.
+ items2 = []
+ weights2 = []
+ for item, w in zip(items, weights):
+ if w > 0:
+ items2.append(item)
+ weights2.append(w)
+
+ items = items2
+ weights = weights2
+
+ # Make sure that we have some items left.
+ if not items:
+ raise ValueError("Did't got any items with a positive weight.")
+ #
+ already_taken = [0 for i in items]
+ item_count = len(items)
+ max_weight = max(weights)
-def to_str(value: (Callable[[], str] | str)) ->str:
- """Turn callable or string into string."""
- pass
+ i = 0
+ while True:
+ # Each iteration of this loop, we fill up until by (total_weight/max_weight).
+ adding = True
+ while adding:
+ adding = False
+ for item_i, item, weight in zip(range(item_count), items, weights):
+ if already_taken[item_i] < i * weight / float(max_weight):
+ yield item
+ already_taken[item_i] += 1
+ adding = True
-def to_int(value: (Callable[[], int] | int)) ->int:
- """Turn callable or int into int."""
- pass
+ i += 1
+
+
+def to_str(value: Callable[[], str] | str) -> str:
+ "Turn callable or string into string."
+ if callable(value):
+ return to_str(value())
+ else:
+ return str(value)
+
+
+def to_int(value: Callable[[], int] | int) -> int:
+ "Turn callable or int into int."
+ if callable(value):
+ return to_int(value())
+ else:
+ return int(value)
AnyFloat = Union[Callable[[], float], float]
-def to_float(value: AnyFloat) ->float:
- """Turn callable or float into float."""
- pass
+def to_float(value: AnyFloat) -> float:
+ "Turn callable or float into float."
+ if callable(value):
+ return to_float(value())
+ else:
+ return float(value)
-def is_dumb_terminal(term: (str | None)=None) ->bool:
+def is_dumb_terminal(term: str | None = None) -> bool:
"""
True if this terminal type is considered "dumb".
If so, we should fall back to the simplest possible form of line editing,
without cursor positioning and color support.
"""
- pass
+ if term is None:
+ return is_dumb_terminal(os.environ.get("TERM", ""))
+
+ return term.lower() in ["dumb", "unknown"]
diff --git a/src/prompt_toolkit/validation.py b/src/prompt_toolkit/validation.py
index 76ed1cda..127445e8 100644
--- a/src/prompt_toolkit/validation.py
+++ b/src/prompt_toolkit/validation.py
@@ -3,13 +3,23 @@ Input validation for a `Buffer`.
(Validators will be called before accepting input.)
"""
from __future__ import annotations
+
from abc import ABCMeta, abstractmethod
from typing import Callable
+
from prompt_toolkit.eventloop import run_in_executor_with_context
+
from .document import Document
from .filters import FilterOrBool, to_filter
-__all__ = ['ConditionalValidator', 'ValidationError', 'Validator',
- 'ThreadedValidator', 'DummyValidator', 'DynamicValidator']
+
+__all__ = [
+ "ConditionalValidator",
+ "ValidationError",
+ "Validator",
+ "ThreadedValidator",
+ "DummyValidator",
+ "DynamicValidator",
+]
class ValidationError(Exception):
@@ -20,14 +30,17 @@ class ValidationError(Exception):
:param message: Text.
"""
- def __init__(self, cursor_position: int=0, message: str='') ->None:
+ def __init__(self, cursor_position: int = 0, message: str = "") -> None:
super().__init__(message)
self.cursor_position = cursor_position
self.message = message
- def __repr__(self) ->str:
- return '{}(cursor_position={!r}, message={!r})'.format(self.
- __class__.__name__, self.cursor_position, self.message)
+ def __repr__(self) -> str:
+ return "{}(cursor_position={!r}, message={!r})".format(
+ self.__class__.__name__,
+ self.cursor_position,
+ self.message,
+ )
class Validator(metaclass=ABCMeta):
@@ -44,7 +57,7 @@ class Validator(metaclass=ABCMeta):
"""
@abstractmethod
- def validate(self, document: Document) ->None:
+ def validate(self, document: Document) -> None:
"""
Validate the input.
If invalid, this should raise a :class:`.ValidationError`.
@@ -53,18 +66,24 @@ class Validator(metaclass=ABCMeta):
"""
pass
- async def validate_async(self, document: Document) ->None:
+ async def validate_async(self, document: Document) -> None:
"""
Return a `Future` which is set when the validation is ready.
This function can be overloaded in order to provide an asynchronous
implementation.
"""
- pass
+ try:
+ self.validate(document)
+ except ValidationError:
+ raise
@classmethod
- def from_callable(cls, validate_func: Callable[[str], bool],
- error_message: str='Invalid input', move_cursor_to_end: bool=False
- ) ->Validator:
+ def from_callable(
+ cls,
+ validate_func: Callable[[str], bool],
+ error_message: str = "Invalid input",
+ move_cursor_to_end: bool = False,
+ ) -> Validator:
"""
Create a validator from a simple validate callable. E.g.:
@@ -80,7 +99,7 @@ class Validator(metaclass=ABCMeta):
:param move_cursor_to_end: Move the cursor to the end of the input, if
the input is invalid.
"""
- pass
+ return _ValidatorFromCallable(validate_func, error_message, move_cursor_to_end)
class _ValidatorFromCallable(Validator):
@@ -88,14 +107,24 @@ class _ValidatorFromCallable(Validator):
Validate input from a simple callable.
"""
- def __init__(self, func: Callable[[str], bool], error_message: str,
- move_cursor_to_end: bool) ->None:
+ def __init__(
+ self, func: Callable[[str], bool], error_message: str, move_cursor_to_end: bool
+ ) -> None:
self.func = func
self.error_message = error_message
self.move_cursor_to_end = move_cursor_to_end
- def __repr__(self) ->str:
- return f'Validator.from_callable({self.func!r})'
+ def __repr__(self) -> str:
+ return f"Validator.from_callable({self.func!r})"
+
+ def validate(self, document: Document) -> None:
+ if not self.func(document.text):
+ if self.move_cursor_to_end:
+ index = len(document.text)
+ else:
+ index = 0
+
+ raise ValidationError(cursor_position=index, message=self.error_message)
class ThreadedValidator(Validator):
@@ -105,14 +134,21 @@ class ThreadedValidator(Validator):
input validation takes too much time.)
"""
- def __init__(self, validator: Validator) ->None:
+ def __init__(self, validator: Validator) -> None:
self.validator = validator
- async def validate_async(self, document: Document) ->None:
+ def validate(self, document: Document) -> None:
+ self.validator.validate(document)
+
+ async def validate_async(self, document: Document) -> None:
"""
Run the `validate` function in a thread.
"""
- pass
+
+ def run_validation_thread() -> None:
+ return self.validate(document)
+
+ await run_in_executor_with_context(run_validation_thread)
class DummyValidator(Validator):
@@ -120,6 +156,9 @@ class DummyValidator(Validator):
Validator class that accepts any input.
"""
+ def validate(self, document: Document) -> None:
+ pass # Don't raise any exception.
+
class ConditionalValidator(Validator):
"""
@@ -127,10 +166,15 @@ class ConditionalValidator(Validator):
a filter. (This wraps around another validator.)
"""
- def __init__(self, validator: Validator, filter: FilterOrBool) ->None:
+ def __init__(self, validator: Validator, filter: FilterOrBool) -> None:
self.validator = validator
self.filter = to_filter(filter)
+ def validate(self, document: Document) -> None:
+ # Call the validator only if the filter is active.
+ if self.filter():
+ self.validator.validate(document)
+
class DynamicValidator(Validator):
"""
@@ -139,5 +183,13 @@ class DynamicValidator(Validator):
:param get_validator: Callable that returns a :class:`.Validator` instance.
"""
- def __init__(self, get_validator: Callable[[], Validator | None]) ->None:
+ def __init__(self, get_validator: Callable[[], Validator | None]) -> None:
self.get_validator = get_validator
+
+ def validate(self, document: Document) -> None:
+ validator = self.get_validator() or DummyValidator()
+ validator.validate(document)
+
+ async def validate_async(self, document: Document) -> None:
+ validator = self.get_validator() or DummyValidator()
+ await validator.validate_async(document)
diff --git a/src/prompt_toolkit/widgets/base.py b/src/prompt_toolkit/widgets/base.py
index dbba3ba3..f36a5457 100644
--- a/src/prompt_toolkit/widgets/base.py
+++ b/src/prompt_toolkit/widgets/base.py
@@ -13,45 +13,99 @@ container object.
`prompt_toolkit.shortcuts.dialogs` on the other hand is considered stable.
"""
from __future__ import annotations
+
from functools import partial
from typing import Callable, Generic, Sequence, TypeVar
+
from prompt_toolkit.application.current import get_app
from prompt_toolkit.auto_suggest import AutoSuggest, DynamicAutoSuggest
from prompt_toolkit.buffer import Buffer, BufferAcceptHandler
from prompt_toolkit.completion import Completer, DynamicCompleter
from prompt_toolkit.document import Document
-from prompt_toolkit.filters import Condition, FilterOrBool, has_focus, is_done, is_true, to_filter
-from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples, Template, to_formatted_text
+from prompt_toolkit.filters import (
+ Condition,
+ FilterOrBool,
+ has_focus,
+ is_done,
+ is_true,
+ to_filter,
+)
+from prompt_toolkit.formatted_text import (
+ AnyFormattedText,
+ StyleAndTextTuples,
+ Template,
+ to_formatted_text,
+)
from prompt_toolkit.formatted_text.utils import fragment_list_to_text
from prompt_toolkit.history import History
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.layout.containers import AnyContainer, ConditionalContainer, Container, DynamicContainer, Float, FloatContainer, HSplit, VSplit, Window, WindowAlign
-from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl, GetLinePrefixCallable
+from prompt_toolkit.layout.containers import (
+ AnyContainer,
+ ConditionalContainer,
+ Container,
+ DynamicContainer,
+ Float,
+ FloatContainer,
+ HSplit,
+ VSplit,
+ Window,
+ WindowAlign,
+)
+from prompt_toolkit.layout.controls import (
+ BufferControl,
+ FormattedTextControl,
+ GetLinePrefixCallable,
+)
from prompt_toolkit.layout.dimension import AnyDimension, to_dimension
from prompt_toolkit.layout.dimension import Dimension as D
-from prompt_toolkit.layout.margins import ConditionalMargin, NumberedMargin, ScrollbarMargin
-from prompt_toolkit.layout.processors import AppendAutoSuggestion, BeforeInput, ConditionalProcessor, PasswordProcessor, Processor
+from prompt_toolkit.layout.margins import (
+ ConditionalMargin,
+ NumberedMargin,
+ ScrollbarMargin,
+)
+from prompt_toolkit.layout.processors import (
+ AppendAutoSuggestion,
+ BeforeInput,
+ ConditionalProcessor,
+ PasswordProcessor,
+ Processor,
+)
from prompt_toolkit.lexers import DynamicLexer, Lexer
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from prompt_toolkit.utils import get_cwidth
from prompt_toolkit.validation import DynamicValidator, Validator
+
from .toolbars import SearchToolbar
-__all__ = ['TextArea', 'Label', 'Button', 'Frame', 'Shadow', 'Box',
- 'VerticalLine', 'HorizontalLine', 'RadioList', 'CheckboxList',
- 'Checkbox', 'ProgressBar']
+
+__all__ = [
+ "TextArea",
+ "Label",
+ "Button",
+ "Frame",
+ "Shadow",
+ "Box",
+ "VerticalLine",
+ "HorizontalLine",
+ "RadioList",
+ "CheckboxList",
+ "Checkbox", # backward compatibility
+ "ProgressBar",
+]
+
E = KeyPressEvent
class Border:
- """Box drawing characters. (Thin)"""
- HORIZONTAL = '─'
- VERTICAL = '│'
- TOP_LEFT = '┌'
- TOP_RIGHT = '┐'
- BOTTOM_LEFT = '└'
- BOTTOM_RIGHT = '┘'
+ "Box drawing characters. (Thin)"
+
+ HORIZONTAL = "\u2500"
+ VERTICAL = "\u2502"
+ TOP_LEFT = "\u250c"
+ TOP_RIGHT = "\u2510"
+ BOTTOM_LEFT = "\u2514"
+ BOTTOM_RIGHT = "\u2518"
class TextArea:
@@ -115,27 +169,45 @@ class TextArea:
:param search_field: An optional `SearchToolbar` object.
"""
- def __init__(self, text: str='', multiline: FilterOrBool=True, password:
- FilterOrBool=False, lexer: (Lexer | None)=None, auto_suggest: (
- AutoSuggest | None)=None, completer: (Completer | None)=None,
- complete_while_typing: FilterOrBool=True, validator: (Validator |
- None)=None, accept_handler: (BufferAcceptHandler | None)=None,
- history: (History | None)=None, focusable: FilterOrBool=True,
- focus_on_click: FilterOrBool=False, wrap_lines: FilterOrBool=True,
- read_only: FilterOrBool=False, width: AnyDimension=None, height:
- AnyDimension=None, dont_extend_height: FilterOrBool=False,
- dont_extend_width: FilterOrBool=False, line_numbers: bool=False,
- get_line_prefix: (GetLinePrefixCallable | None)=None, scrollbar:
- bool=False, style: str='', search_field: (SearchToolbar | None)=
- None, preview_search: FilterOrBool=True, prompt: AnyFormattedText=
- '', input_processors: (list[Processor] | None)=None, name: str=''
- ) ->None:
+ def __init__(
+ self,
+ text: str = "",
+ multiline: FilterOrBool = True,
+ password: FilterOrBool = False,
+ lexer: Lexer | None = None,
+ auto_suggest: AutoSuggest | None = None,
+ completer: Completer | None = None,
+ complete_while_typing: FilterOrBool = True,
+ validator: Validator | None = None,
+ accept_handler: BufferAcceptHandler | None = None,
+ history: History | None = None,
+ focusable: FilterOrBool = True,
+ focus_on_click: FilterOrBool = False,
+ wrap_lines: FilterOrBool = True,
+ read_only: FilterOrBool = False,
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ dont_extend_height: FilterOrBool = False,
+ dont_extend_width: FilterOrBool = False,
+ line_numbers: bool = False,
+ get_line_prefix: GetLinePrefixCallable | None = None,
+ scrollbar: bool = False,
+ style: str = "",
+ search_field: SearchToolbar | None = None,
+ preview_search: FilterOrBool = True,
+ prompt: AnyFormattedText = "",
+ input_processors: list[Processor] | None = None,
+ name: str = "",
+ ) -> None:
if search_field is None:
search_control = None
elif isinstance(search_field, SearchToolbar):
search_control = search_field.control
+
if input_processors is None:
input_processors = []
+
+ # Writeable attributes.
self.completer = completer
self.complete_while_typing = complete_while_typing
self.lexer = lexer
@@ -143,22 +215,41 @@ class TextArea:
self.read_only = read_only
self.wrap_lines = wrap_lines
self.validator = validator
- self.buffer = Buffer(document=Document(text, 0), multiline=
- multiline, read_only=Condition(lambda : is_true(self.read_only)
- ), completer=DynamicCompleter(lambda : self.completer),
- complete_while_typing=Condition(lambda : is_true(self.
- complete_while_typing)), validator=DynamicValidator(lambda :
- self.validator), auto_suggest=DynamicAutoSuggest(lambda : self.
- auto_suggest), accept_handler=accept_handler, history=history,
- name=name)
- self.control = BufferControl(buffer=self.buffer, lexer=DynamicLexer
- (lambda : self.lexer), input_processors=[ConditionalProcessor(
- AppendAutoSuggestion(), has_focus(self.buffer) & ~is_done),
- ConditionalProcessor(processor=PasswordProcessor(), filter=
- to_filter(password)), BeforeInput(prompt, style=
- 'class:text-area.prompt')] + input_processors,
- search_buffer_control=search_control, preview_search=
- preview_search, focusable=focusable, focus_on_click=focus_on_click)
+
+ self.buffer = Buffer(
+ document=Document(text, 0),
+ multiline=multiline,
+ read_only=Condition(lambda: is_true(self.read_only)),
+ completer=DynamicCompleter(lambda: self.completer),
+ complete_while_typing=Condition(
+ lambda: is_true(self.complete_while_typing)
+ ),
+ validator=DynamicValidator(lambda: self.validator),
+ auto_suggest=DynamicAutoSuggest(lambda: self.auto_suggest),
+ accept_handler=accept_handler,
+ history=history,
+ name=name,
+ )
+
+ self.control = BufferControl(
+ buffer=self.buffer,
+ lexer=DynamicLexer(lambda: self.lexer),
+ input_processors=[
+ ConditionalProcessor(
+ AppendAutoSuggestion(), has_focus(self.buffer) & ~is_done
+ ),
+ ConditionalProcessor(
+ processor=PasswordProcessor(), filter=to_filter(password)
+ ),
+ BeforeInput(prompt, style="class:text-area.prompt"),
+ ]
+ + input_processors,
+ search_buffer_control=search_control,
+ preview_search=preview_search,
+ focusable=focusable,
+ focus_on_click=focus_on_click,
+ )
+
if multiline:
if scrollbar:
right_margins = [ScrollbarMargin(display_arrows=True)]
@@ -172,37 +263,60 @@ class TextArea:
height = D.exact(1)
left_margins = []
right_margins = []
- style = 'class:text-area ' + style
+
+ style = "class:text-area " + style
+
+ # If no height was given, guarantee height of at least 1.
if height is None:
height = D(min=1)
- self.window = Window(height=height, width=width, dont_extend_height
- =dont_extend_height, dont_extend_width=dont_extend_width,
- content=self.control, style=style, wrap_lines=Condition(lambda :
- is_true(self.wrap_lines)), left_margins=left_margins,
- right_margins=right_margins, get_line_prefix=get_line_prefix)
+
+ self.window = Window(
+ height=height,
+ width=width,
+ dont_extend_height=dont_extend_height,
+ dont_extend_width=dont_extend_width,
+ content=self.control,
+ style=style,
+ wrap_lines=Condition(lambda: is_true(self.wrap_lines)),
+ left_margins=left_margins,
+ right_margins=right_margins,
+ get_line_prefix=get_line_prefix,
+ )
@property
- def text(self) ->str:
+ def text(self) -> str:
"""
The `Buffer` text.
"""
- pass
+ return self.buffer.text
+
+ @text.setter
+ def text(self, value: str) -> None:
+ self.document = Document(value, 0)
@property
- def document(self) ->Document:
+ def document(self) -> Document:
"""
The `Buffer` document (text + cursor position).
"""
- pass
+ return self.buffer.document
+
+ @document.setter
+ def document(self, value: Document) -> None:
+ self.buffer.set_document(value, bypass_readonly=True)
@property
- def accept_handler(self) ->(BufferAcceptHandler | None):
+ def accept_handler(self) -> BufferAcceptHandler | None:
"""
The accept handler. Called when the user accepts the input.
"""
- pass
+ return self.buffer.accept_handler
- def __pt_container__(self) ->Container:
+ @accept_handler.setter
+ def accept_handler(self, value: BufferAcceptHandler) -> None:
+ self.buffer.accept_handler = value
+
+ def __pt_container__(self) -> Container:
return self.window
@@ -225,32 +339,46 @@ class Label:
the text. `False` by default.
"""
- def __init__(self, text: AnyFormattedText, style: str='', width:
- AnyDimension=None, dont_extend_height: bool=True, dont_extend_width:
- bool=False, align: (WindowAlign | Callable[[], WindowAlign])=
- WindowAlign.LEFT, wrap_lines: FilterOrBool=True) ->None:
+ def __init__(
+ self,
+ text: AnyFormattedText,
+ style: str = "",
+ width: AnyDimension = None,
+ dont_extend_height: bool = True,
+ dont_extend_width: bool = False,
+ align: WindowAlign | Callable[[], WindowAlign] = WindowAlign.LEFT,
+ # There is no cursor navigation in a label, so it makes sense to always
+ # wrap lines by default.
+ wrap_lines: FilterOrBool = True,
+ ) -> None:
self.text = text
- def get_width() ->AnyDimension:
+ def get_width() -> AnyDimension:
if width is None:
text_fragments = to_formatted_text(self.text)
text = fragment_list_to_text(text_fragments)
if text:
- longest_line = max(get_cwidth(line) for line in text.
- splitlines())
+ longest_line = max(get_cwidth(line) for line in text.splitlines())
else:
return D(preferred=0)
return D(preferred=longest_line)
else:
return width
- self.formatted_text_control = FormattedTextControl(text=lambda :
- self.text)
- self.window = Window(content=self.formatted_text_control, width=
- get_width, height=D(min=1), style='class:label ' + style,
- dont_extend_height=dont_extend_height, dont_extend_width=
- dont_extend_width, align=align, wrap_lines=wrap_lines)
-
- def __pt_container__(self) ->Container:
+
+ self.formatted_text_control = FormattedTextControl(text=lambda: self.text)
+
+ self.window = Window(
+ content=self.formatted_text_control,
+ width=get_width,
+ height=D(min=1),
+ style="class:label " + style,
+ dont_extend_height=dont_extend_height,
+ dont_extend_width=dont_extend_width,
+ align=align,
+ wrap_lines=wrap_lines,
+ )
+
+ def __pt_container__(self) -> Container:
return self.window
@@ -265,30 +393,80 @@ class Button:
:param width: Width of the button.
"""
- def __init__(self, text: str, handler: (Callable[[], None] | None)=None,
- width: int=12, left_symbol: str='<', right_symbol: str='>') ->None:
+ def __init__(
+ self,
+ text: str,
+ handler: Callable[[], None] | None = None,
+ width: int = 12,
+ left_symbol: str = "<",
+ right_symbol: str = ">",
+ ) -> None:
self.text = text
self.left_symbol = left_symbol
self.right_symbol = right_symbol
self.handler = handler
self.width = width
- self.control = FormattedTextControl(self._get_text_fragments,
- key_bindings=self._get_key_bindings(), focusable=True)
+ self.control = FormattedTextControl(
+ self._get_text_fragments,
+ key_bindings=self._get_key_bindings(),
+ focusable=True,
+ )
- def get_style() ->str:
+ def get_style() -> str:
if get_app().layout.has_focus(self):
- return 'class:button.focused'
+ return "class:button.focused"
else:
- return 'class:button'
- self.window = Window(self.control, align=WindowAlign.CENTER, height
- =1, width=width, style=get_style, dont_extend_width=False,
- dont_extend_height=True)
+ return "class:button"
+
+ # Note: `dont_extend_width` is False, because we want to allow buttons
+ # to take more space if the parent container provides more space.
+ # Otherwise, we will also truncate the text.
+ # Probably we need a better way here to adjust to width of the
+ # button to the text.
+
+ self.window = Window(
+ self.control,
+ align=WindowAlign.CENTER,
+ height=1,
+ width=width,
+ style=get_style,
+ dont_extend_width=False,
+ dont_extend_height=True,
+ )
+
+ def _get_text_fragments(self) -> StyleAndTextTuples:
+ width = self.width - (
+ get_cwidth(self.left_symbol) + get_cwidth(self.right_symbol)
+ )
+ text = (f"{{:^{width}}}").format(self.text)
+
+ def handler(mouse_event: MouseEvent) -> None:
+ if (
+ self.handler is not None
+ and mouse_event.event_type == MouseEventType.MOUSE_UP
+ ):
+ self.handler()
+
+ return [
+ ("class:button.arrow", self.left_symbol, handler),
+ ("[SetCursorPosition]", ""),
+ ("class:button.text", text, handler),
+ ("class:button.arrow", self.right_symbol, handler),
+ ]
+
+ def _get_key_bindings(self) -> KeyBindings:
+ "Key bindings for the Button."
+ kb = KeyBindings()
+
+ @kb.add(" ")
+ @kb.add("enter")
+ def _(event: E) -> None:
+ if self.handler is not None:
+ self.handler()
- def _get_key_bindings(self) ->KeyBindings:
- """Key bindings for the Button."""
- pass
+ return kb
- def __pt_container__(self) ->Container:
+ def __pt_container__(self) -> Container:
return self.window
@@ -304,37 +482,86 @@ class Frame:
:param style: Style string to be applied to this widget.
"""
- def __init__(self, body: AnyContainer, title: AnyFormattedText='',
- style: str='', width: AnyDimension=None, height: AnyDimension=None,
- key_bindings: (KeyBindings | None)=None, modal: bool=False) ->None:
+ def __init__(
+ self,
+ body: AnyContainer,
+ title: AnyFormattedText = "",
+ style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ key_bindings: KeyBindings | None = None,
+ modal: bool = False,
+ ) -> None:
self.title = title
self.body = body
- fill = partial(Window, style='class:frame.border')
- style = 'class:frame ' + style
- top_row_with_title = VSplit([fill(width=1, height=1, char=Border.
- TOP_LEFT), fill(char=Border.HORIZONTAL), fill(width=1, height=1,
- char='|'), Label(lambda : Template(' {} ').format(self.title),
- style='class:frame.label', dont_extend_width=True), fill(width=
- 1, height=1, char='|'), fill(char=Border.HORIZONTAL), fill(
- width=1, height=1, char=Border.TOP_RIGHT)], height=1)
- top_row_without_title = VSplit([fill(width=1, height=1, char=Border
- .TOP_LEFT), fill(char=Border.HORIZONTAL), fill(width=1, height=
- 1, char=Border.TOP_RIGHT)], height=1)
+
+ fill = partial(Window, style="class:frame.border")
+ style = "class:frame " + style
+
+ top_row_with_title = VSplit(
+ [
+ fill(width=1, height=1, char=Border.TOP_LEFT),
+ fill(char=Border.HORIZONTAL),
+ fill(width=1, height=1, char="|"),
+ # Notice: we use `Template` here, because `self.title` can be an
+ # `HTML` object for instance.
+ Label(
+ lambda: Template(" {} ").format(self.title),
+ style="class:frame.label",
+ dont_extend_width=True,
+ ),
+ fill(width=1, height=1, char="|"),
+ fill(char=Border.HORIZONTAL),
+ fill(width=1, height=1, char=Border.TOP_RIGHT),
+ ],
+ height=1,
+ )
+
+ top_row_without_title = VSplit(
+ [
+ fill(width=1, height=1, char=Border.TOP_LEFT),
+ fill(char=Border.HORIZONTAL),
+ fill(width=1, height=1, char=Border.TOP_RIGHT),
+ ],
+ height=1,
+ )
@Condition
- def has_title() ->bool:
+ def has_title() -> bool:
return bool(self.title)
- self.container = HSplit([ConditionalContainer(content=
- top_row_with_title, filter=has_title), ConditionalContainer(
- content=top_row_without_title, filter=~has_title), VSplit([fill
- (width=1, char=Border.VERTICAL), DynamicContainer(lambda : self
- .body), fill(width=1, char=Border.VERTICAL)], padding=0),
- VSplit([fill(width=1, height=1, char=Border.BOTTOM_LEFT), fill(
- char=Border.HORIZONTAL), fill(width=1, height=1, char=Border.
- BOTTOM_RIGHT)], height=1)], width=width, height=height, style=
- style, key_bindings=key_bindings, modal=modal)
-
- def __pt_container__(self) ->Container:
+
+ self.container = HSplit(
+ [
+ ConditionalContainer(content=top_row_with_title, filter=has_title),
+ ConditionalContainer(content=top_row_without_title, filter=~has_title),
+ VSplit(
+ [
+ fill(width=1, char=Border.VERTICAL),
+ DynamicContainer(lambda: self.body),
+ fill(width=1, char=Border.VERTICAL),
+ # Padding is required to make sure that if the content is
+ # too small, the right frame border is still aligned.
+ ],
+ padding=0,
+ ),
+ VSplit(
+ [
+ fill(width=1, height=1, char=Border.BOTTOM_LEFT),
+ fill(char=Border.HORIZONTAL),
+ fill(width=1, height=1, char=Border.BOTTOM_RIGHT),
+ ],
+ # specifying height here will increase the rendering speed.
+ height=1,
+ ),
+ ],
+ width=width,
+ height=height,
+ style=style,
+ key_bindings=key_bindings,
+ modal=modal,
+ )
+
+ def __pt_container__(self) -> Container:
return self.container
@@ -347,13 +574,30 @@ class Shadow:
:param body: Another container object.
"""
- def __init__(self, body: AnyContainer) ->None:
- self.container = FloatContainer(content=body, floats=[Float(bottom=
- -1, height=1, left=1, right=-1, transparent=True, content=
- Window(style='class:shadow')), Float(bottom=-1, top=1, width=1,
- right=-1, transparent=True, content=Window(style='class:shadow'))])
-
- def __pt_container__(self) ->Container:
+ def __init__(self, body: AnyContainer) -> None:
+ self.container = FloatContainer(
+ content=body,
+ floats=[
+ Float(
+ bottom=-1,
+ height=1,
+ left=1,
+ right=-1,
+ transparent=True,
+ content=Window(style="class:shadow"),
+ ),
+ Float(
+ bottom=-1,
+ top=1,
+ width=1,
+ right=-1,
+ transparent=True,
+ content=Window(style="class:shadow"),
+ ),
+ ],
+ )
+
+ def __pt_container__(self) -> Container:
return self.container
@@ -376,111 +620,220 @@ class Box:
(This is supposed to be a character with a terminal width of 1.)
"""
- def __init__(self, body: AnyContainer, padding: AnyDimension=None,
- padding_left: AnyDimension=None, padding_right: AnyDimension=None,
- padding_top: AnyDimension=None, padding_bottom: AnyDimension=None,
- width: AnyDimension=None, height: AnyDimension=None, style: str='',
- char: (None | str | Callable[[], str])=None, modal: bool=False,
- key_bindings: (KeyBindings | None)=None) ->None:
+ def __init__(
+ self,
+ body: AnyContainer,
+ padding: AnyDimension = None,
+ padding_left: AnyDimension = None,
+ padding_right: AnyDimension = None,
+ padding_top: AnyDimension = None,
+ padding_bottom: AnyDimension = None,
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ style: str = "",
+ char: None | str | Callable[[], str] = None,
+ modal: bool = False,
+ key_bindings: KeyBindings | None = None,
+ ) -> None:
if padding is None:
padding = D(preferred=0)
- def get(value: AnyDimension) ->D:
+ def get(value: AnyDimension) -> D:
if value is None:
value = padding
return to_dimension(value)
+
self.padding_left = get(padding_left)
self.padding_right = get(padding_right)
self.padding_top = get(padding_top)
self.padding_bottom = get(padding_bottom)
self.body = body
- self.container = HSplit([Window(height=self.padding_top, char=char),
- VSplit([Window(width=self.padding_left, char=char), body,
- Window(width=self.padding_right, char=char)]), Window(height=
- self.padding_bottom, char=char)], width=width, height=height,
- style=style, modal=modal, key_bindings=None)
- def __pt_container__(self) ->Container:
+ self.container = HSplit(
+ [
+ Window(height=self.padding_top, char=char),
+ VSplit(
+ [
+ Window(width=self.padding_left, char=char),
+ body,
+ Window(width=self.padding_right, char=char),
+ ]
+ ),
+ Window(height=self.padding_bottom, char=char),
+ ],
+ width=width,
+ height=height,
+ style=style,
+ modal=modal,
+ key_bindings=None,
+ )
+
+ def __pt_container__(self) -> Container:
return self.container
-_T = TypeVar('_T')
+_T = TypeVar("_T")
class _DialogList(Generic[_T]):
"""
Common code for `RadioList` and `CheckboxList`.
"""
- open_character: str = ''
- close_character: str = ''
- container_style: str = ''
- default_style: str = ''
- selected_style: str = ''
- checked_style: str = ''
+
+ open_character: str = ""
+ close_character: str = ""
+ container_style: str = ""
+ default_style: str = ""
+ selected_style: str = ""
+ checked_style: str = ""
multiple_selection: bool = False
show_scrollbar: bool = True
- def __init__(self, values: Sequence[tuple[_T, AnyFormattedText]],
- default_values: (Sequence[_T] | None)=None) ->None:
+ def __init__(
+ self,
+ values: Sequence[tuple[_T, AnyFormattedText]],
+ default_values: Sequence[_T] | None = None,
+ ) -> None:
assert len(values) > 0
default_values = default_values or []
+
self.values = values
- keys: list[_T] = [value for value, _ in values]
- self.current_values: list[_T] = [value for value in default_values if
- value in keys]
- self.current_value: _T = default_values[0] if len(default_values
- ) and default_values[0] in keys else values[0][0]
+ # current_values will be used in multiple_selection,
+ # current_value will be used otherwise.
+ keys: list[_T] = [value for (value, _) in values]
+ self.current_values: list[_T] = [
+ value for value in default_values if value in keys
+ ]
+ self.current_value: _T = (
+ default_values[0]
+ if len(default_values) and default_values[0] in keys
+ else values[0][0]
+ )
+
+ # Cursor index: take first selected item or first item otherwise.
if len(self.current_values) > 0:
self._selected_index = keys.index(self.current_values[0])
else:
self._selected_index = 0
+
+ # Key bindings.
kb = KeyBindings()
- @kb.add('up')
- def _up(event: E) ->None:
+ @kb.add("up")
+ def _up(event: E) -> None:
self._selected_index = max(0, self._selected_index - 1)
- @kb.add('down')
- def _down(event: E) ->None:
- self._selected_index = min(len(self.values) - 1, self.
- _selected_index + 1)
+ @kb.add("down")
+ def _down(event: E) -> None:
+ self._selected_index = min(len(self.values) - 1, self._selected_index + 1)
- @kb.add('pageup')
- def _pageup(event: E) ->None:
+ @kb.add("pageup")
+ def _pageup(event: E) -> None:
w = event.app.layout.current_window
if w.render_info:
- self._selected_index = max(0, self._selected_index - len(w.
- render_info.displayed_lines))
+ self._selected_index = max(
+ 0, self._selected_index - len(w.render_info.displayed_lines)
+ )
- @kb.add('pagedown')
- def _pagedown(event: E) ->None:
+ @kb.add("pagedown")
+ def _pagedown(event: E) -> None:
w = event.app.layout.current_window
if w.render_info:
- self._selected_index = min(len(self.values) - 1, self.
- _selected_index + len(w.render_info.displayed_lines))
-
- @kb.add('enter')
- @kb.add(' ')
- def _click(event: E) ->None:
+ self._selected_index = min(
+ len(self.values) - 1,
+ self._selected_index + len(w.render_info.displayed_lines),
+ )
+
+ @kb.add("enter")
+ @kb.add(" ")
+ def _click(event: E) -> None:
self._handle_enter()
@kb.add(Keys.Any)
- def _find(event: E) ->None:
+ def _find(event: E) -> None:
+ # We first check values after the selected value, then all values.
values = list(self.values)
- for value in (values[self._selected_index + 1:] + values):
- text = fragment_list_to_text(to_formatted_text(value[1])
- ).lower()
+ for value in values[self._selected_index + 1 :] + values:
+ text = fragment_list_to_text(to_formatted_text(value[1])).lower()
+
if text.startswith(event.data.lower()):
self._selected_index = self.values.index(value)
return
- self.control = FormattedTextControl(self._get_text_fragments,
- key_bindings=kb, focusable=True)
- self.window = Window(content=self.control, style=self.
- container_style, right_margins=[ConditionalMargin(margin=
- ScrollbarMargin(display_arrows=True), filter=Condition(lambda :
- self.show_scrollbar))], dont_extend_height=True)
-
- def __pt_container__(self) ->Container:
+
+ # Control and window.
+ self.control = FormattedTextControl(
+ self._get_text_fragments, key_bindings=kb, focusable=True
+ )
+
+ self.window = Window(
+ content=self.control,
+ style=self.container_style,
+ right_margins=[
+ ConditionalMargin(
+ margin=ScrollbarMargin(display_arrows=True),
+ filter=Condition(lambda: self.show_scrollbar),
+ ),
+ ],
+ dont_extend_height=True,
+ )
+
+ def _handle_enter(self) -> None:
+ if self.multiple_selection:
+ val = self.values[self._selected_index][0]
+ if val in self.current_values:
+ self.current_values.remove(val)
+ else:
+ self.current_values.append(val)
+ else:
+ self.current_value = self.values[self._selected_index][0]
+
+ def _get_text_fragments(self) -> StyleAndTextTuples:
+ def mouse_handler(mouse_event: MouseEvent) -> None:
+ """
+ Set `_selected_index` and `current_value` according to the y
+ position of the mouse click event.
+ """
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
+ self._selected_index = mouse_event.position.y
+ self._handle_enter()
+
+ result: StyleAndTextTuples = []
+ for i, value in enumerate(self.values):
+ if self.multiple_selection:
+ checked = value[0] in self.current_values
+ else:
+ checked = value[0] == self.current_value
+ selected = i == self._selected_index
+
+ style = ""
+ if checked:
+ style += " " + self.checked_style
+ if selected:
+ style += " " + self.selected_style
+
+ result.append((style, self.open_character))
+
+ if selected:
+ result.append(("[SetCursorPosition]", ""))
+
+ if checked:
+ result.append((style, "*"))
+ else:
+ result.append((style, " "))
+
+ result.append((style, self.close_character))
+ result.append((self.default_style, " "))
+ result.extend(to_formatted_text(value[1], style=self.default_style))
+ result.append(("", "\n"))
+
+ # Add mouse handler to all fragments.
+ for i in range(len(result)):
+ result[i] = (result[i][0], result[i][1], mouse_handler)
+
+ result.pop() # Remove last newline.
+ return result
+
+ def __pt_container__(self) -> Container:
return self.window
@@ -490,20 +843,25 @@ class RadioList(_DialogList[_T]):
:param values: List of (value, label) tuples.
"""
- open_character = '('
- close_character = ')'
- container_style = 'class:radio-list'
- default_style = 'class:radio'
- selected_style = 'class:radio-selected'
- checked_style = 'class:radio-checked'
+
+ open_character = "("
+ close_character = ")"
+ container_style = "class:radio-list"
+ default_style = "class:radio"
+ selected_style = "class:radio-selected"
+ checked_style = "class:radio-checked"
multiple_selection = False
- def __init__(self, values: Sequence[tuple[_T, AnyFormattedText]],
- default: (_T | None)=None) ->None:
+ def __init__(
+ self,
+ values: Sequence[tuple[_T, AnyFormattedText]],
+ default: _T | None = None,
+ ) -> None:
if default is None:
default_values = None
else:
default_values = [default]
+
super().__init__(values, default_values=default_values)
@@ -513,12 +871,13 @@ class CheckboxList(_DialogList[_T]):
:param values: List of (value, label) tuples.
"""
- open_character = '['
- close_character = ']'
- container_style = 'class:checkbox-list'
- default_style = 'class:checkbox'
- selected_style = 'class:checkbox-selected'
- checked_style = 'class:checkbox-checked'
+
+ open_character = "["
+ close_character = "]"
+ container_style = "class:checkbox-list"
+ default_style = "class:checkbox"
+ selected_style = "class:checkbox-selected"
+ checked_style = "class:checkbox-checked"
multiple_selection = True
@@ -527,24 +886,37 @@ class Checkbox(CheckboxList[str]):
:param text: the text
"""
+
show_scrollbar = False
- def __init__(self, text: AnyFormattedText='', checked: bool=False) ->None:
- values = [('value', text)]
+ def __init__(self, text: AnyFormattedText = "", checked: bool = False) -> None:
+ values = [("value", text)]
super().__init__(values=values)
self.checked = checked
+ @property
+ def checked(self) -> bool:
+ return "value" in self.current_values
+
+ @checked.setter
+ def checked(self, value: bool) -> None:
+ if value:
+ self.current_values = ["value"]
+ else:
+ self.current_values = []
+
class VerticalLine:
"""
A simple vertical line with a width of 1.
"""
- def __init__(self) ->None:
- self.window = Window(char=Border.VERTICAL, style=
- 'class:line,vertical-line', width=1)
+ def __init__(self) -> None:
+ self.window = Window(
+ char=Border.VERTICAL, style="class:line,vertical-line", width=1
+ )
- def __pt_container__(self) ->Container:
+ def __pt_container__(self) -> Container:
return self.window
@@ -553,25 +925,57 @@ class HorizontalLine:
A simple horizontal line with a height of 1.
"""
- def __init__(self) ->None:
- self.window = Window(char=Border.HORIZONTAL, style=
- 'class:line,horizontal-line', height=1)
+ def __init__(self) -> None:
+ self.window = Window(
+ char=Border.HORIZONTAL, style="class:line,horizontal-line", height=1
+ )
- def __pt_container__(self) ->Container:
+ def __pt_container__(self) -> Container:
return self.window
class ProgressBar:
-
- def __init__(self) ->None:
+ def __init__(self) -> None:
self._percentage = 60
- self.label = Label('60%')
- self.container = FloatContainer(content=Window(height=1), floats=[
- Float(content=self.label, top=0, bottom=0), Float(left=0, top=0,
- right=0, bottom=0, content=VSplit([Window(style=
- 'class:progress-bar.used', width=lambda : D(weight=int(self.
- _percentage))), Window(style='class:progress-bar', width=lambda :
- D(weight=int(100 - self._percentage)))]))])
-
- def __pt_container__(self) ->Container:
+
+ self.label = Label("60%")
+ self.container = FloatContainer(
+ content=Window(height=1),
+ floats=[
+ # We first draw the label, then the actual progress bar. Right
+ # now, this is the only way to have the colors of the progress
+ # bar appear on top of the label. The problem is that our label
+ # can't be part of any `Window` below.
+ Float(content=self.label, top=0, bottom=0),
+ Float(
+ left=0,
+ top=0,
+ right=0,
+ bottom=0,
+ content=VSplit(
+ [
+ Window(
+ style="class:progress-bar.used",
+ width=lambda: D(weight=int(self._percentage)),
+ ),
+ Window(
+ style="class:progress-bar",
+ width=lambda: D(weight=int(100 - self._percentage)),
+ ),
+ ]
+ ),
+ ),
+ ],
+ )
+
+ @property
+ def percentage(self) -> int:
+ return self._percentage
+
+ @percentage.setter
+ def percentage(self, value: int) -> None:
+ self._percentage = value
+ self.label.text = f"{value}%"
+
+ def __pt_container__(self) -> Container:
return self.container
diff --git a/src/prompt_toolkit/widgets/dialogs.py b/src/prompt_toolkit/widgets/dialogs.py
index 29550fe1..c47c15b4 100644
--- a/src/prompt_toolkit/widgets/dialogs.py
+++ b/src/prompt_toolkit/widgets/dialogs.py
@@ -2,16 +2,27 @@
Collection of reusable components for building full screen applications.
"""
from __future__ import annotations
+
from typing import Sequence
+
from prompt_toolkit.filters import has_completions, has_focus
from prompt_toolkit.formatted_text import AnyFormattedText
from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
from prompt_toolkit.key_binding.key_bindings import KeyBindings
-from prompt_toolkit.layout.containers import AnyContainer, DynamicContainer, HSplit, VSplit
+from prompt_toolkit.layout.containers import (
+ AnyContainer,
+ DynamicContainer,
+ HSplit,
+ VSplit,
+)
from prompt_toolkit.layout.dimension import AnyDimension
from prompt_toolkit.layout.dimension import Dimension as D
+
from .base import Box, Button, Frame, Shadow
-__all__ = ['Dialog']
+
+__all__ = [
+ "Dialog",
+]
class Dialog:
@@ -27,37 +38,70 @@ class Dialog:
:param buttons: A list of `Button` widgets, displayed at the bottom.
"""
- def __init__(self, body: AnyContainer, title: AnyFormattedText='',
- buttons: (Sequence[Button] | None)=None, modal: bool=True, width:
- AnyDimension=None, with_background: bool=False) ->None:
+ def __init__(
+ self,
+ body: AnyContainer,
+ title: AnyFormattedText = "",
+ buttons: Sequence[Button] | None = None,
+ modal: bool = True,
+ width: AnyDimension = None,
+ with_background: bool = False,
+ ) -> None:
self.body = body
self.title = title
+
buttons = buttons or []
+
+ # When a button is selected, handle left/right key bindings.
buttons_kb = KeyBindings()
if len(buttons) > 1:
first_selected = has_focus(buttons[0])
last_selected = has_focus(buttons[-1])
- buttons_kb.add('left', filter=~first_selected)(focus_previous)
- buttons_kb.add('right', filter=~last_selected)(focus_next)
+
+ buttons_kb.add("left", filter=~first_selected)(focus_previous)
+ buttons_kb.add("right", filter=~last_selected)(focus_next)
+
frame_body: AnyContainer
if buttons:
- frame_body = HSplit([Box(body=DynamicContainer(lambda : self.
- body), padding=D(preferred=1, max=1), padding_bottom=0),
- Box(body=VSplit(buttons, padding=1, key_bindings=buttons_kb
- ), height=D(min=1, max=3, preferred=3))])
+ frame_body = HSplit(
+ [
+ # Add optional padding around the body.
+ Box(
+ body=DynamicContainer(lambda: self.body),
+ padding=D(preferred=1, max=1),
+ padding_bottom=0,
+ ),
+ # The buttons.
+ Box(
+ body=VSplit(buttons, padding=1, key_bindings=buttons_kb),
+ height=D(min=1, max=3, preferred=3),
+ ),
+ ]
+ )
else:
frame_body = body
+
+ # Key bindings for whole dialog.
kb = KeyBindings()
- kb.add('tab', filter=~has_completions)(focus_next)
- kb.add('s-tab', filter=~has_completions)(focus_previous)
- frame = Shadow(body=Frame(title=lambda : self.title, body=
- frame_body, style='class:dialog.body', width=None if
- with_background is None else width, key_bindings=kb, modal=modal))
+ kb.add("tab", filter=~has_completions)(focus_next)
+ kb.add("s-tab", filter=~has_completions)(focus_previous)
+
+ frame = Shadow(
+ body=Frame(
+ title=lambda: self.title,
+ body=frame_body,
+ style="class:dialog.body",
+ width=(None if with_background is None else width),
+ key_bindings=kb,
+ modal=modal,
+ )
+ )
+
self.container: Box | Shadow
if with_background:
- self.container = Box(body=frame, style='class:dialog', width=width)
+ self.container = Box(body=frame, style="class:dialog", width=width)
else:
self.container = frame
- def __pt_container__(self) ->AnyContainer:
+ def __pt_container__(self) -> AnyContainer:
return self.container
diff --git a/src/prompt_toolkit/widgets/menus.py b/src/prompt_toolkit/widgets/menus.py
index 9174199f..c574c067 100644
--- a/src/prompt_toolkit/widgets/menus.py
+++ b/src/prompt_toolkit/widgets/menus.py
@@ -1,18 +1,34 @@
from __future__ import annotations
+
from typing import Callable, Iterable, Sequence
+
from prompt_toolkit.application.current import get_app
from prompt_toolkit.filters import Condition
from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple, StyleAndTextTuples
from prompt_toolkit.key_binding.key_bindings import KeyBindings, KeyBindingsBase
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.keys import Keys
-from prompt_toolkit.layout.containers import AnyContainer, ConditionalContainer, Container, Float, FloatContainer, HSplit, Window
+from prompt_toolkit.layout.containers import (
+ AnyContainer,
+ ConditionalContainer,
+ Container,
+ Float,
+ FloatContainer,
+ HSplit,
+ Window,
+)
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
from prompt_toolkit.utils import get_cwidth
from prompt_toolkit.widgets import Shadow
+
from .base import Border
-__all__ = ['MenuContainer', 'MenuItem']
+
+__all__ = [
+ "MenuContainer",
+ "MenuItem",
+]
+
E = KeyPressEvent
@@ -22,126 +38,337 @@ class MenuContainer:
:param menu_items: List of `MenuItem` objects.
"""
- def __init__(self, body: AnyContainer, menu_items: list[MenuItem],
- floats: (list[Float] | None)=None, key_bindings: (KeyBindingsBase |
- None)=None) ->None:
+ def __init__(
+ self,
+ body: AnyContainer,
+ menu_items: list[MenuItem],
+ floats: list[Float] | None = None,
+ key_bindings: KeyBindingsBase | None = None,
+ ) -> None:
self.body = body
self.menu_items = menu_items
self.selected_menu = [0]
+
+ # Key bindings.
kb = KeyBindings()
@Condition
- def in_main_menu() ->bool:
+ def in_main_menu() -> bool:
return len(self.selected_menu) == 1
@Condition
- def in_sub_menu() ->bool:
+ def in_sub_menu() -> bool:
return len(self.selected_menu) > 1
- @kb.add('left', filter=in_main_menu)
- def _left(event: E) ->None:
+ # Navigation through the main menu.
+
+ @kb.add("left", filter=in_main_menu)
+ def _left(event: E) -> None:
self.selected_menu[0] = max(0, self.selected_menu[0] - 1)
- @kb.add('right', filter=in_main_menu)
- def _right(event: E) ->None:
- self.selected_menu[0] = min(len(self.menu_items) - 1, self.
- selected_menu[0] + 1)
+ @kb.add("right", filter=in_main_menu)
+ def _right(event: E) -> None:
+ self.selected_menu[0] = min(
+ len(self.menu_items) - 1, self.selected_menu[0] + 1
+ )
- @kb.add('down', filter=in_main_menu)
- def _down(event: E) ->None:
+ @kb.add("down", filter=in_main_menu)
+ def _down(event: E) -> None:
self.selected_menu.append(0)
- @kb.add('c-c', filter=in_main_menu)
- @kb.add('c-g', filter=in_main_menu)
- def _cancel(event: E) ->None:
- """Leave menu."""
+ @kb.add("c-c", filter=in_main_menu)
+ @kb.add("c-g", filter=in_main_menu)
+ def _cancel(event: E) -> None:
+ "Leave menu."
event.app.layout.focus_last()
- @kb.add('left', filter=in_sub_menu)
- @kb.add('c-g', filter=in_sub_menu)
- @kb.add('c-c', filter=in_sub_menu)
- def _back(event: E) ->None:
- """Go back to parent menu."""
+ # Sub menu navigation.
+
+ @kb.add("left", filter=in_sub_menu)
+ @kb.add("c-g", filter=in_sub_menu)
+ @kb.add("c-c", filter=in_sub_menu)
+ def _back(event: E) -> None:
+ "Go back to parent menu."
if len(self.selected_menu) > 1:
self.selected_menu.pop()
- @kb.add('right', filter=in_sub_menu)
- def _submenu(event: E) ->None:
- """go into sub menu."""
+ @kb.add("right", filter=in_sub_menu)
+ def _submenu(event: E) -> None:
+ "go into sub menu."
if self._get_menu(len(self.selected_menu) - 1).children:
self.selected_menu.append(0)
- elif len(self.selected_menu) == 2 and self.selected_menu[0] < len(
- self.menu_items) - 1:
- self.selected_menu = [min(len(self.menu_items) - 1, self.
- selected_menu[0] + 1)]
+
+ # If This item does not have a sub menu. Go up in the parent menu.
+ elif (
+ len(self.selected_menu) == 2
+ and self.selected_menu[0] < len(self.menu_items) - 1
+ ):
+ self.selected_menu = [
+ min(len(self.menu_items) - 1, self.selected_menu[0] + 1)
+ ]
if self.menu_items[self.selected_menu[0]].children:
self.selected_menu.append(0)
- @kb.add('up', filter=in_sub_menu)
- def _up_in_submenu(event: E) ->None:
- """Select previous (enabled) menu item or return to main menu."""
+ @kb.add("up", filter=in_sub_menu)
+ def _up_in_submenu(event: E) -> None:
+ "Select previous (enabled) menu item or return to main menu."
+ # Look for previous enabled items in this sub menu.
menu = self._get_menu(len(self.selected_menu) - 2)
index = self.selected_menu[-1]
- previous_indexes = [i for i, item in enumerate(menu.children) if
- i < index and not item.disabled]
+
+ previous_indexes = [
+ i
+ for i, item in enumerate(menu.children)
+ if i < index and not item.disabled
+ ]
+
if previous_indexes:
self.selected_menu[-1] = previous_indexes[-1]
elif len(self.selected_menu) == 2:
+ # Return to main menu.
self.selected_menu.pop()
- @kb.add('down', filter=in_sub_menu)
- def _down_in_submenu(event: E) ->None:
- """Select next (enabled) menu item."""
+ @kb.add("down", filter=in_sub_menu)
+ def _down_in_submenu(event: E) -> None:
+ "Select next (enabled) menu item."
menu = self._get_menu(len(self.selected_menu) - 2)
index = self.selected_menu[-1]
- next_indexes = [i for i, item in enumerate(menu.children) if i >
- index and not item.disabled]
+
+ next_indexes = [
+ i
+ for i, item in enumerate(menu.children)
+ if i > index and not item.disabled
+ ]
+
if next_indexes:
self.selected_menu[-1] = next_indexes[0]
- @kb.add('enter')
- def _click(event: E) ->None:
- """Click the selected menu item."""
+ @kb.add("enter")
+ def _click(event: E) -> None:
+ "Click the selected menu item."
item = self._get_menu(len(self.selected_menu) - 1)
if item.handler:
event.app.layout.focus_last()
item.handler()
- self.control = FormattedTextControl(self._get_menu_fragments,
- key_bindings=kb, focusable=True, show_cursor=False)
- self.window = Window(height=1, content=self.control, style=
- 'class:menu-bar')
+
+ # Controls.
+ self.control = FormattedTextControl(
+ self._get_menu_fragments, key_bindings=kb, focusable=True, show_cursor=False
+ )
+
+ self.window = Window(height=1, content=self.control, style="class:menu-bar")
+
submenu = self._submenu(0)
submenu2 = self._submenu(1)
submenu3 = self._submenu(2)
@Condition
- def has_focus() ->bool:
+ def has_focus() -> bool:
return get_app().layout.current_window == self.window
- self.container = FloatContainer(content=HSplit([self.window, body]),
- floats=[Float(xcursor=True, ycursor=True, content=
- ConditionalContainer(content=Shadow(body=submenu), filter=
- has_focus)), Float(attach_to_window=submenu, xcursor=True,
- ycursor=True, allow_cover_cursor=True, content=
- ConditionalContainer(content=Shadow(body=submenu2), filter=
- has_focus & Condition(lambda : len(self.selected_menu) >= 1))),
- Float(attach_to_window=submenu2, xcursor=True, ycursor=True,
- allow_cover_cursor=True, content=ConditionalContainer(content=
- Shadow(body=submenu3), filter=has_focus & Condition(lambda :
- len(self.selected_menu) >= 2)))] + (floats or []), key_bindings
- =key_bindings)
-
- def __pt_container__(self) ->Container:
+
+ self.container = FloatContainer(
+ content=HSplit(
+ [
+ # The titlebar.
+ self.window,
+ # The 'body', like defined above.
+ body,
+ ]
+ ),
+ floats=[
+ Float(
+ xcursor=True,
+ ycursor=True,
+ content=ConditionalContainer(
+ content=Shadow(body=submenu), filter=has_focus
+ ),
+ ),
+ Float(
+ attach_to_window=submenu,
+ xcursor=True,
+ ycursor=True,
+ allow_cover_cursor=True,
+ content=ConditionalContainer(
+ content=Shadow(body=submenu2),
+ filter=has_focus
+ & Condition(lambda: len(self.selected_menu) >= 1),
+ ),
+ ),
+ Float(
+ attach_to_window=submenu2,
+ xcursor=True,
+ ycursor=True,
+ allow_cover_cursor=True,
+ content=ConditionalContainer(
+ content=Shadow(body=submenu3),
+ filter=has_focus
+ & Condition(lambda: len(self.selected_menu) >= 2),
+ ),
+ ),
+ # --
+ ]
+ + (floats or []),
+ key_bindings=key_bindings,
+ )
+
+ def _get_menu(self, level: int) -> MenuItem:
+ menu = self.menu_items[self.selected_menu[0]]
+
+ for i, index in enumerate(self.selected_menu[1:]):
+ if i < level:
+ try:
+ menu = menu.children[index]
+ except IndexError:
+ return MenuItem("debug")
+
+ return menu
+
+ def _get_menu_fragments(self) -> StyleAndTextTuples:
+ focused = get_app().layout.has_focus(self.window)
+
+ # This is called during the rendering. When we discover that this
+ # widget doesn't have the focus anymore. Reset menu state.
+ if not focused:
+ self.selected_menu = [0]
+
+ # Generate text fragments for the main menu.
+ def one_item(i: int, item: MenuItem) -> Iterable[OneStyleAndTextTuple]:
+ def mouse_handler(mouse_event: MouseEvent) -> None:
+ hover = mouse_event.event_type == MouseEventType.MOUSE_MOVE
+ if (
+ mouse_event.event_type == MouseEventType.MOUSE_DOWN
+ or hover
+ and focused
+ ):
+ # Toggle focus.
+ app = get_app()
+ if not hover:
+ if app.layout.has_focus(self.window):
+ if self.selected_menu == [i]:
+ app.layout.focus_last()
+ else:
+ app.layout.focus(self.window)
+ self.selected_menu = [i]
+
+ yield ("class:menu-bar", " ", mouse_handler)
+ if i == self.selected_menu[0] and focused:
+ yield ("[SetMenuPosition]", "", mouse_handler)
+ style = "class:menu-bar.selected-item"
+ else:
+ style = "class:menu-bar"
+ yield style, item.text, mouse_handler
+
+ result: StyleAndTextTuples = []
+ for i, item in enumerate(self.menu_items):
+ result.extend(one_item(i, item))
+
+ return result
+
+ def _submenu(self, level: int = 0) -> Window:
+ def get_text_fragments() -> StyleAndTextTuples:
+ result: StyleAndTextTuples = []
+ if level < len(self.selected_menu):
+ menu = self._get_menu(level)
+ if menu.children:
+ result.append(("class:menu", Border.TOP_LEFT))
+ result.append(("class:menu", Border.HORIZONTAL * (menu.width + 4)))
+ result.append(("class:menu", Border.TOP_RIGHT))
+ result.append(("", "\n"))
+ try:
+ selected_item = self.selected_menu[level + 1]
+ except IndexError:
+ selected_item = -1
+
+ def one_item(
+ i: int, item: MenuItem
+ ) -> Iterable[OneStyleAndTextTuple]:
+ def mouse_handler(mouse_event: MouseEvent) -> None:
+ if item.disabled:
+ # The arrow keys can't interact with menu items that are disabled.
+ # The mouse shouldn't be able to either.
+ return
+ hover = mouse_event.event_type == MouseEventType.MOUSE_MOVE
+ if (
+ mouse_event.event_type == MouseEventType.MOUSE_UP
+ or hover
+ ):
+ app = get_app()
+ if not hover and item.handler:
+ app.layout.focus_last()
+ item.handler()
+ else:
+ self.selected_menu = self.selected_menu[
+ : level + 1
+ ] + [i]
+
+ if i == selected_item:
+ yield ("[SetCursorPosition]", "")
+ style = "class:menu-bar.selected-item"
+ else:
+ style = ""
+
+ yield ("class:menu", Border.VERTICAL)
+ if item.text == "-":
+ yield (
+ style + "class:menu-border",
+ f"{Border.HORIZONTAL * (menu.width + 3)}",
+ mouse_handler,
+ )
+ else:
+ yield (
+ style,
+ f" {item.text}".ljust(menu.width + 3),
+ mouse_handler,
+ )
+
+ if item.children:
+ yield (style, ">", mouse_handler)
+ else:
+ yield (style, " ", mouse_handler)
+
+ if i == selected_item:
+ yield ("[SetMenuPosition]", "")
+ yield ("class:menu", Border.VERTICAL)
+
+ yield ("", "\n")
+
+ for i, item in enumerate(menu.children):
+ result.extend(one_item(i, item))
+
+ result.append(("class:menu", Border.BOTTOM_LEFT))
+ result.append(("class:menu", Border.HORIZONTAL * (menu.width + 4)))
+ result.append(("class:menu", Border.BOTTOM_RIGHT))
+ return result
+
+ return Window(FormattedTextControl(get_text_fragments), style="class:menu")
+
+ @property
+ def floats(self) -> list[Float] | None:
+ return self.container.floats
+
+ def __pt_container__(self) -> Container:
return self.container
class MenuItem:
-
- def __init__(self, text: str='', handler: (Callable[[], None] | None)=
- None, children: (list[MenuItem] | None)=None, shortcut: (Sequence[
- Keys | str] | None)=None, disabled: bool=False) ->None:
+ def __init__(
+ self,
+ text: str = "",
+ handler: Callable[[], None] | None = None,
+ children: list[MenuItem] | None = None,
+ shortcut: Sequence[Keys | str] | None = None,
+ disabled: bool = False,
+ ) -> None:
self.text = text
self.handler = handler
self.children = children or []
self.shortcut = shortcut
self.disabled = disabled
self.selected_item = 0
+
+ @property
+ def width(self) -> int:
+ if self.children:
+ return max(get_cwidth(c.text) for c in self.children)
+ else:
+ return 0
diff --git a/src/prompt_toolkit/widgets/toolbars.py b/src/prompt_toolkit/widgets/toolbars.py
index 69e19222..deddf154 100644
--- a/src/prompt_toolkit/widgets/toolbars.py
+++ b/src/prompt_toolkit/widgets/toolbars.py
@@ -1,31 +1,72 @@
from __future__ import annotations
+
from typing import Any
+
from prompt_toolkit.application.current import get_app
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.enums import SYSTEM_BUFFER
-from prompt_toolkit.filters import Condition, FilterOrBool, emacs_mode, has_arg, has_completions, has_focus, has_validation_error, to_filter, vi_mode, vi_navigation_mode
-from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples, fragment_list_len, to_formatted_text
-from prompt_toolkit.key_binding.key_bindings import ConditionalKeyBindings, KeyBindings, KeyBindingsBase, merge_key_bindings
+from prompt_toolkit.filters import (
+ Condition,
+ FilterOrBool,
+ emacs_mode,
+ has_arg,
+ has_completions,
+ has_focus,
+ has_validation_error,
+ to_filter,
+ vi_mode,
+ vi_navigation_mode,
+)
+from prompt_toolkit.formatted_text import (
+ AnyFormattedText,
+ StyleAndTextTuples,
+ fragment_list_len,
+ to_formatted_text,
+)
+from prompt_toolkit.key_binding.key_bindings import (
+ ConditionalKeyBindings,
+ KeyBindings,
+ KeyBindingsBase,
+ merge_key_bindings,
+)
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.key_binding.vi_state import InputMode
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout.containers import ConditionalContainer, Container, Window
-from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl, SearchBufferControl, UIContent, UIControl
+from prompt_toolkit.layout.controls import (
+ BufferControl,
+ FormattedTextControl,
+ SearchBufferControl,
+ UIContent,
+ UIControl,
+)
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.processors import BeforeInput
from prompt_toolkit.lexers import SimpleLexer
from prompt_toolkit.search import SearchDirection
-__all__ = ['ArgToolbar', 'CompletionsToolbar', 'FormattedTextToolbar',
- 'SearchToolbar', 'SystemToolbar', 'ValidationToolbar']
+
+__all__ = [
+ "ArgToolbar",
+ "CompletionsToolbar",
+ "FormattedTextToolbar",
+ "SearchToolbar",
+ "SystemToolbar",
+ "ValidationToolbar",
+]
+
E = KeyPressEvent
class FormattedTextToolbar(Window):
-
- def __init__(self, text: AnyFormattedText, style: str='', **kw: Any
- ) ->None:
- super().__init__(FormattedTextControl(text, **kw), style=style,
- dont_extend_height=True, height=Dimension(min=1))
+ def __init__(self, text: AnyFormattedText, style: str = "", **kw: Any) -> None:
+ # Note: The style needs to be applied to the toolbar as a whole, not
+ # just the `FormattedTextControl`.
+ super().__init__(
+ FormattedTextControl(text, **kw),
+ style=style,
+ dont_extend_height=True,
+ height=Dimension(min=1),
+ )
class SystemToolbar:
@@ -35,41 +76,135 @@ class SystemToolbar:
:param prompt: Prompt to be displayed to the user.
"""
- def __init__(self, prompt: AnyFormattedText='Shell command: ',
- enable_global_bindings: FilterOrBool=True) ->None:
+ def __init__(
+ self,
+ prompt: AnyFormattedText = "Shell command: ",
+ enable_global_bindings: FilterOrBool = True,
+ ) -> None:
self.prompt = prompt
self.enable_global_bindings = to_filter(enable_global_bindings)
+
self.system_buffer = Buffer(name=SYSTEM_BUFFER)
+
self._bindings = self._build_key_bindings()
- self.buffer_control = BufferControl(buffer=self.system_buffer,
- lexer=SimpleLexer(style='class:system-toolbar.text'),
- input_processors=[BeforeInput(lambda : self.prompt, style=
- 'class:system-toolbar')], key_bindings=self._bindings)
- self.window = Window(self.buffer_control, height=1, style=
- 'class:system-toolbar')
- self.container = ConditionalContainer(content=self.window, filter=
- has_focus(self.system_buffer))
-
- def __pt_container__(self) ->Container:
+
+ self.buffer_control = BufferControl(
+ buffer=self.system_buffer,
+ lexer=SimpleLexer(style="class:system-toolbar.text"),
+ input_processors=[
+ BeforeInput(lambda: self.prompt, style="class:system-toolbar")
+ ],
+ key_bindings=self._bindings,
+ )
+
+ self.window = Window(
+ self.buffer_control, height=1, style="class:system-toolbar"
+ )
+
+ self.container = ConditionalContainer(
+ content=self.window, filter=has_focus(self.system_buffer)
+ )
+
+ def _get_display_before_text(self) -> StyleAndTextTuples:
+ return [
+ ("class:system-toolbar", "Shell command: "),
+ ("class:system-toolbar.text", self.system_buffer.text),
+ ("", "\n"),
+ ]
+
+ def _build_key_bindings(self) -> KeyBindingsBase:
+ focused = has_focus(self.system_buffer)
+
+ # Emacs
+ emacs_bindings = KeyBindings()
+ handle = emacs_bindings.add
+
+ @handle("escape", filter=focused)
+ @handle("c-g", filter=focused)
+ @handle("c-c", filter=focused)
+ def _cancel(event: E) -> None:
+ "Hide system prompt."
+ self.system_buffer.reset()
+ event.app.layout.focus_last()
+
+ @handle("enter", filter=focused)
+ async def _accept(event: E) -> None:
+ "Run system command."
+ await event.app.run_system_command(
+ self.system_buffer.text,
+ display_before_text=self._get_display_before_text(),
+ )
+ self.system_buffer.reset(append_to_history=True)
+ event.app.layout.focus_last()
+
+ # Vi.
+ vi_bindings = KeyBindings()
+ handle = vi_bindings.add
+
+ @handle("escape", filter=focused)
+ @handle("c-c", filter=focused)
+ def _cancel_vi(event: E) -> None:
+ "Hide system prompt."
+ event.app.vi_state.input_mode = InputMode.NAVIGATION
+ self.system_buffer.reset()
+ event.app.layout.focus_last()
+
+ @handle("enter", filter=focused)
+ async def _accept_vi(event: E) -> None:
+ "Run system command."
+ event.app.vi_state.input_mode = InputMode.NAVIGATION
+ await event.app.run_system_command(
+ self.system_buffer.text,
+ display_before_text=self._get_display_before_text(),
+ )
+ self.system_buffer.reset(append_to_history=True)
+ event.app.layout.focus_last()
+
+ # Global bindings. (Listen to these bindings, even when this widget is
+ # not focussed.)
+ global_bindings = KeyBindings()
+ handle = global_bindings.add
+
+ @handle(Keys.Escape, "!", filter=~focused & emacs_mode, is_global=True)
+ def _focus_me(event: E) -> None:
+ "M-'!' will focus this user control."
+ event.app.layout.focus(self.window)
+
+ @handle("!", filter=~focused & vi_mode & vi_navigation_mode, is_global=True)
+ def _focus_me_vi(event: E) -> None:
+ "Focus."
+ event.app.vi_state.input_mode = InputMode.INSERT
+ event.app.layout.focus(self.window)
+
+ return merge_key_bindings(
+ [
+ ConditionalKeyBindings(emacs_bindings, emacs_mode),
+ ConditionalKeyBindings(vi_bindings, vi_mode),
+ ConditionalKeyBindings(global_bindings, self.enable_global_bindings),
+ ]
+ )
+
+ def __pt_container__(self) -> Container:
return self.container
class ArgToolbar:
+ def __init__(self) -> None:
+ def get_formatted_text() -> StyleAndTextTuples:
+ arg = get_app().key_processor.arg or ""
+ if arg == "-":
+ arg = "-1"
- def __init__(self) ->None:
+ return [
+ ("class:arg-toolbar", "Repeat: "),
+ ("class:arg-toolbar.text", arg),
+ ]
- def get_formatted_text() ->StyleAndTextTuples:
- arg = get_app().key_processor.arg or ''
- if arg == '-':
- arg = '-1'
- return [('class:arg-toolbar', 'Repeat: '), (
- 'class:arg-toolbar.text', arg)]
- self.window = Window(FormattedTextControl(get_formatted_text), height=1
- )
- self.container = ConditionalContainer(content=self.window, filter=
- has_arg)
+ self.window = Window(FormattedTextControl(get_formatted_text), height=1)
+
+ self.container = ConditionalContainer(content=self.window, filter=has_arg)
- def __pt_container__(self) ->Container:
+ def __pt_container__(self) -> Container:
return self.container
@@ -79,72 +214,161 @@ class SearchToolbar:
:param ignore_case: Search case insensitive.
"""
- def __init__(self, search_buffer: (Buffer | None)=None, vi_mode: bool=
- False, text_if_not_searching: AnyFormattedText='',
- forward_search_prompt: AnyFormattedText='I-search: ',
- backward_search_prompt: AnyFormattedText='I-search backward: ',
- ignore_case: FilterOrBool=False) ->None:
+ def __init__(
+ self,
+ search_buffer: Buffer | None = None,
+ vi_mode: bool = False,
+ text_if_not_searching: AnyFormattedText = "",
+ forward_search_prompt: AnyFormattedText = "I-search: ",
+ backward_search_prompt: AnyFormattedText = "I-search backward: ",
+ ignore_case: FilterOrBool = False,
+ ) -> None:
if search_buffer is None:
search_buffer = Buffer()
@Condition
- def is_searching() ->bool:
+ def is_searching() -> bool:
return self.control in get_app().layout.search_links
- def get_before_input() ->AnyFormattedText:
+ def get_before_input() -> AnyFormattedText:
if not is_searching():
return text_if_not_searching
- elif self.control.searcher_search_state.direction == SearchDirection.BACKWARD:
- return '?' if vi_mode else backward_search_prompt
+ elif (
+ self.control.searcher_search_state.direction == SearchDirection.BACKWARD
+ ):
+ return "?" if vi_mode else backward_search_prompt
else:
- return '/' if vi_mode else forward_search_prompt
+ return "/" if vi_mode else forward_search_prompt
+
self.search_buffer = search_buffer
- self.control = SearchBufferControl(buffer=search_buffer,
- input_processors=[BeforeInput(get_before_input, style=
- 'class:search-toolbar.prompt')], lexer=SimpleLexer(style=
- 'class:search-toolbar.text'), ignore_case=ignore_case)
- self.container = ConditionalContainer(content=Window(self.control,
- height=1, style='class:search-toolbar'), filter=is_searching)
-
- def __pt_container__(self) ->Container:
+
+ self.control = SearchBufferControl(
+ buffer=search_buffer,
+ input_processors=[
+ BeforeInput(get_before_input, style="class:search-toolbar.prompt")
+ ],
+ lexer=SimpleLexer(style="class:search-toolbar.text"),
+ ignore_case=ignore_case,
+ )
+
+ self.container = ConditionalContainer(
+ content=Window(self.control, height=1, style="class:search-toolbar"),
+ filter=is_searching,
+ )
+
+ def __pt_container__(self) -> Container:
return self.container
class _CompletionsToolbarControl(UIControl):
- pass
+ def create_content(self, width: int, height: int) -> UIContent:
+ all_fragments: StyleAndTextTuples = []
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state:
+ completions = complete_state.completions
+ index = complete_state.complete_index # Can be None!
-class CompletionsToolbar:
+ # Width of the completions without the left/right arrows in the margins.
+ content_width = width - 6
- def __init__(self) ->None:
- self.container = ConditionalContainer(content=Window(
- _CompletionsToolbarControl(), height=1, style=
- 'class:completion-toolbar'), filter=has_completions)
+ # Booleans indicating whether we stripped from the left/right
+ cut_left = False
+ cut_right = False
- def __pt_container__(self) ->Container:
- return self.container
+ # Create Menu content.
+ fragments: StyleAndTextTuples = []
+ for i, c in enumerate(completions):
+ # When there is no more place for the next completion
+ if fragment_list_len(fragments) + len(c.display_text) >= content_width:
+ # If the current one was not yet displayed, page to the next sequence.
+ if i <= (index or 0):
+ fragments = []
+ cut_left = True
+ # If the current one is visible, stop here.
+ else:
+ cut_right = True
+ break
-class ValidationToolbar:
+ fragments.extend(
+ to_formatted_text(
+ c.display_text,
+ style=(
+ "class:completion-toolbar.completion.current"
+ if i == index
+ else "class:completion-toolbar.completion"
+ ),
+ )
+ )
+ fragments.append(("", " "))
+
+ # Extend/strip until the content width.
+ fragments.append(("", " " * (content_width - fragment_list_len(fragments))))
+ fragments = fragments[:content_width]
+
+ # Return fragments
+ all_fragments.append(("", " "))
+ all_fragments.append(
+ ("class:completion-toolbar.arrow", "<" if cut_left else " ")
+ )
+ all_fragments.append(("", " "))
+
+ all_fragments.extend(fragments)
+
+ all_fragments.append(("", " "))
+ all_fragments.append(
+ ("class:completion-toolbar.arrow", ">" if cut_right else " ")
+ )
+ all_fragments.append(("", " "))
- def __init__(self, show_position: bool=False) ->None:
+ def get_line(i: int) -> StyleAndTextTuples:
+ return all_fragments
- def get_formatted_text() ->StyleAndTextTuples:
+ return UIContent(get_line=get_line, line_count=1)
+
+
+class CompletionsToolbar:
+ def __init__(self) -> None:
+ self.container = ConditionalContainer(
+ content=Window(
+ _CompletionsToolbarControl(), height=1, style="class:completion-toolbar"
+ ),
+ filter=has_completions,
+ )
+
+ def __pt_container__(self) -> Container:
+ return self.container
+
+
+class ValidationToolbar:
+ def __init__(self, show_position: bool = False) -> None:
+ def get_formatted_text() -> StyleAndTextTuples:
buff = get_app().current_buffer
+
if buff.validation_error:
- row, column = buff.document.translate_index_to_position(buff
- .validation_error.cursor_position)
+ row, column = buff.document.translate_index_to_position(
+ buff.validation_error.cursor_position
+ )
+
if show_position:
- text = '{} (line={} column={})'.format(buff.
- validation_error.message, row + 1, column + 1)
+ text = "{} (line={} column={})".format(
+ buff.validation_error.message,
+ row + 1,
+ column + 1,
+ )
else:
text = buff.validation_error.message
- return [('class:validation-toolbar', text)]
+
+ return [("class:validation-toolbar", text)]
else:
return []
+
self.control = FormattedTextControl(get_formatted_text)
- self.container = ConditionalContainer(content=Window(self.control,
- height=1), filter=has_validation_error)
- def __pt_container__(self) ->Container:
+ self.container = ConditionalContainer(
+ content=Window(self.control, height=1), filter=has_validation_error
+ )
+
+ def __pt_container__(self) -> Container:
return self.container
diff --git a/src/prompt_toolkit/win32_types.py b/src/prompt_toolkit/win32_types.py
index a86fc3bd..79283b8b 100644
--- a/src/prompt_toolkit/win32_types.py
+++ b/src/prompt_toolkit/win32_types.py
@@ -1,7 +1,12 @@
from __future__ import annotations
+
from ctypes import Structure, Union, c_char, c_long, c_short, c_ulong
from ctypes.wintypes import BOOL, DWORD, LPVOID, WCHAR, WORD
from typing import TYPE_CHECKING
+
+# Input/Output standard device numbers. Note that these are not handle objects.
+# It's the `windll.kernel32.GetStdHandle` system call that turns them into a
+# real handle object.
STD_INPUT_HANDLE = c_ulong(-10)
STD_OUTPUT_HANDLE = c_ulong(-11)
STD_ERROR_HANDLE = c_ulong(-12)
@@ -12,27 +17,42 @@ class COORD(Structure):
Struct in wincon.h
http://msdn.microsoft.com/en-us/library/windows/desktop/ms682119(v=vs.85).aspx
"""
+
if TYPE_CHECKING:
X: int
Y: int
- _fields_ = [('X', c_short), ('Y', c_short)]
- def __repr__(self) ->str:
- return '{}(X={!r}, Y={!r}, type_x={!r}, type_y={!r})'.format(self.
- __class__.__name__, self.X, self.Y, type(self.X), type(self.Y))
+ _fields_ = [
+ ("X", c_short), # Short
+ ("Y", c_short), # Short
+ ]
+
+ def __repr__(self) -> str:
+ return "{}(X={!r}, Y={!r}, type_x={!r}, type_y={!r})".format(
+ self.__class__.__name__,
+ self.X,
+ self.Y,
+ type(self.X),
+ type(self.Y),
+ )
class UNICODE_OR_ASCII(Union):
if TYPE_CHECKING:
AsciiChar: bytes
UnicodeChar: str
- _fields_ = [('AsciiChar', c_char), ('UnicodeChar', WCHAR)]
+
+ _fields_ = [
+ ("AsciiChar", c_char),
+ ("UnicodeChar", WCHAR),
+ ]
class KEY_EVENT_RECORD(Structure):
"""
http://msdn.microsoft.com/en-us/library/windows/desktop/ms684166(v=vs.85).aspx
"""
+
if TYPE_CHECKING:
KeyDown: int
RepeatCount: int
@@ -40,49 +60,67 @@ class KEY_EVENT_RECORD(Structure):
VirtualScanCode: int
uChar: UNICODE_OR_ASCII
ControlKeyState: int
- _fields_ = [('KeyDown', c_long), ('RepeatCount', c_short), (
- 'VirtualKeyCode', c_short), ('VirtualScanCode', c_short), ('uChar',
- UNICODE_OR_ASCII), ('ControlKeyState', c_long)]
+
+ _fields_ = [
+ ("KeyDown", c_long), # bool
+ ("RepeatCount", c_short), # word
+ ("VirtualKeyCode", c_short), # word
+ ("VirtualScanCode", c_short), # word
+ ("uChar", UNICODE_OR_ASCII), # Unicode or ASCII.
+ ("ControlKeyState", c_long), # double word
+ ]
class MOUSE_EVENT_RECORD(Structure):
"""
http://msdn.microsoft.com/en-us/library/windows/desktop/ms684239(v=vs.85).aspx
"""
+
if TYPE_CHECKING:
MousePosition: COORD
ButtonState: int
ControlKeyState: int
EventFlags: int
- _fields_ = [('MousePosition', COORD), ('ButtonState', c_long), (
- 'ControlKeyState', c_long), ('EventFlags', c_long)]
+
+ _fields_ = [
+ ("MousePosition", COORD),
+ ("ButtonState", c_long), # dword
+ ("ControlKeyState", c_long), # dword
+ ("EventFlags", c_long), # dword
+ ]
class WINDOW_BUFFER_SIZE_RECORD(Structure):
"""
http://msdn.microsoft.com/en-us/library/windows/desktop/ms687093(v=vs.85).aspx
"""
+
if TYPE_CHECKING:
Size: COORD
- _fields_ = [('Size', COORD)]
+
+ _fields_ = [("Size", COORD)]
class MENU_EVENT_RECORD(Structure):
"""
http://msdn.microsoft.com/en-us/library/windows/desktop/ms684213(v=vs.85).aspx
"""
+
if TYPE_CHECKING:
CommandId: int
- _fields_ = [('CommandId', c_long)]
+
+ _fields_ = [("CommandId", c_long)] # uint
class FOCUS_EVENT_RECORD(Structure):
"""
http://msdn.microsoft.com/en-us/library/windows/desktop/ms683149(v=vs.85).aspx
"""
+
if TYPE_CHECKING:
SetFocus: int
- _fields_ = [('SetFocus', c_long)]
+
+ _fields_ = [("SetFocus", c_long)] # bool
class EVENT_RECORD(Union):
@@ -92,65 +130,100 @@ class EVENT_RECORD(Union):
WindowBufferSizeEvent: WINDOW_BUFFER_SIZE_RECORD
MenuEvent: MENU_EVENT_RECORD
FocusEvent: FOCUS_EVENT_RECORD
- _fields_ = [('KeyEvent', KEY_EVENT_RECORD), ('MouseEvent',
- MOUSE_EVENT_RECORD), ('WindowBufferSizeEvent',
- WINDOW_BUFFER_SIZE_RECORD), ('MenuEvent', MENU_EVENT_RECORD), (
- 'FocusEvent', FOCUS_EVENT_RECORD)]
+
+ _fields_ = [
+ ("KeyEvent", KEY_EVENT_RECORD),
+ ("MouseEvent", MOUSE_EVENT_RECORD),
+ ("WindowBufferSizeEvent", WINDOW_BUFFER_SIZE_RECORD),
+ ("MenuEvent", MENU_EVENT_RECORD),
+ ("FocusEvent", FOCUS_EVENT_RECORD),
+ ]
class INPUT_RECORD(Structure):
"""
http://msdn.microsoft.com/en-us/library/windows/desktop/ms683499(v=vs.85).aspx
"""
+
if TYPE_CHECKING:
EventType: int
Event: EVENT_RECORD
- _fields_ = [('EventType', c_short), ('Event', EVENT_RECORD)]
+
+ _fields_ = [("EventType", c_short), ("Event", EVENT_RECORD)] # word # Union.
-EventTypes = {(1): 'KeyEvent', (2): 'MouseEvent', (4):
- 'WindowBufferSizeEvent', (8): 'MenuEvent', (16): 'FocusEvent'}
+EventTypes = {
+ 1: "KeyEvent",
+ 2: "MouseEvent",
+ 4: "WindowBufferSizeEvent",
+ 8: "MenuEvent",
+ 16: "FocusEvent",
+}
class SMALL_RECT(Structure):
"""struct in wincon.h."""
+
if TYPE_CHECKING:
Left: int
Top: int
Right: int
Bottom: int
- _fields_ = [('Left', c_short), ('Top', c_short), ('Right', c_short), (
- 'Bottom', c_short)]
+
+ _fields_ = [
+ ("Left", c_short),
+ ("Top", c_short),
+ ("Right", c_short),
+ ("Bottom", c_short),
+ ]
class CONSOLE_SCREEN_BUFFER_INFO(Structure):
"""struct in wincon.h."""
+
if TYPE_CHECKING:
dwSize: COORD
dwCursorPosition: COORD
wAttributes: int
srWindow: SMALL_RECT
dwMaximumWindowSize: COORD
- _fields_ = [('dwSize', COORD), ('dwCursorPosition', COORD), (
- 'wAttributes', WORD), ('srWindow', SMALL_RECT), (
- 'dwMaximumWindowSize', COORD)]
- def __repr__(self) ->str:
- return (
- 'CONSOLE_SCREEN_BUFFER_INFO({!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r})'
- .format(self.dwSize.Y, self.dwSize.X, self.dwCursorPosition.Y,
- self.dwCursorPosition.X, self.wAttributes, self.srWindow.Top,
- self.srWindow.Left, self.srWindow.Bottom, self.srWindow.Right,
- self.dwMaximumWindowSize.Y, self.dwMaximumWindowSize.X))
+ _fields_ = [
+ ("dwSize", COORD),
+ ("dwCursorPosition", COORD),
+ ("wAttributes", WORD),
+ ("srWindow", SMALL_RECT),
+ ("dwMaximumWindowSize", COORD),
+ ]
+
+ def __repr__(self) -> str:
+ return "CONSOLE_SCREEN_BUFFER_INFO({!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r})".format(
+ self.dwSize.Y,
+ self.dwSize.X,
+ self.dwCursorPosition.Y,
+ self.dwCursorPosition.X,
+ self.wAttributes,
+ self.srWindow.Top,
+ self.srWindow.Left,
+ self.srWindow.Bottom,
+ self.srWindow.Right,
+ self.dwMaximumWindowSize.Y,
+ self.dwMaximumWindowSize.X,
+ )
class SECURITY_ATTRIBUTES(Structure):
"""
http://msdn.microsoft.com/en-us/library/windows/desktop/aa379560(v=vs.85).aspx
"""
+
if TYPE_CHECKING:
nLength: int
lpSecurityDescriptor: int
- bInheritHandle: int
- _fields_ = [('nLength', DWORD), ('lpSecurityDescriptor', LPVOID), (
- 'bInheritHandle', BOOL)]
+ bInheritHandle: int # BOOL comes back as 'int'.
+
+ _fields_ = [
+ ("nLength", DWORD),
+ ("lpSecurityDescriptor", LPVOID),
+ ("bInheritHandle", BOOL),
+ ]