summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/prompt_toolkit/__init__.py51
-rw-r--r--src/prompt_toolkit/application/__init__.py32
-rw-r--r--src/prompt_toolkit/application/application.py1625
-rw-r--r--src/prompt_toolkit/application/current.py189
-rw-r--r--src/prompt_toolkit/application/dummy.py55
-rw-r--r--src/prompt_toolkit/application/run_in_terminal.py113
-rw-r--r--src/prompt_toolkit/auto_suggest.py176
-rw-r--r--src/prompt_toolkit/buffer.py2026
-rw-r--r--src/prompt_toolkit/cache.py127
-rw-r--r--src/prompt_toolkit/clipboard/__init__.py17
-rw-r--r--src/prompt_toolkit/clipboard/base.py108
-rw-r--r--src/prompt_toolkit/clipboard/in_memory.py44
-rw-r--r--src/prompt_toolkit/clipboard/pyperclip.py42
-rw-r--r--src/prompt_toolkit/completion/__init__.py43
-rw-r--r--src/prompt_toolkit/completion/base.py451
-rw-r--r--src/prompt_toolkit/completion/deduplicate.py45
-rw-r--r--src/prompt_toolkit/completion/filesystem.py118
-rw-r--r--src/prompt_toolkit/completion/fuzzy_completer.py213
-rw-r--r--src/prompt_toolkit/completion/nested.py108
-rw-r--r--src/prompt_toolkit/completion/word_completer.py94
-rw-r--r--src/prompt_toolkit/contrib/__init__.py0
-rw-r--r--src/prompt_toolkit/contrib/completers/__init__.py5
-rw-r--r--src/prompt_toolkit/contrib/completers/system.py64
-rw-r--r--src/prompt_toolkit/contrib/regular_languages/__init__.py79
-rw-r--r--src/prompt_toolkit/contrib/regular_languages/compiler.py571
-rw-r--r--src/prompt_toolkit/contrib/regular_languages/completion.py94
-rw-r--r--src/prompt_toolkit/contrib/regular_languages/lexer.py93
-rw-r--r--src/prompt_toolkit/contrib/regular_languages/regex_parser.py282
-rw-r--r--src/prompt_toolkit/contrib/regular_languages/validation.py59
-rw-r--r--src/prompt_toolkit/contrib/ssh/__init__.py8
-rw-r--r--src/prompt_toolkit/contrib/ssh/server.py177
-rw-r--r--src/prompt_toolkit/contrib/telnet/__init__.py7
-rw-r--r--src/prompt_toolkit/contrib/telnet/log.py12
-rw-r--r--src/prompt_toolkit/contrib/telnet/protocol.py208
-rw-r--r--src/prompt_toolkit/contrib/telnet/server.py427
-rw-r--r--src/prompt_toolkit/cursor_shapes.py104
-rw-r--r--src/prompt_toolkit/data_structures.py18
-rw-r--r--src/prompt_toolkit/document.py1181
-rw-r--r--src/prompt_toolkit/enums.py19
-rw-r--r--src/prompt_toolkit/eventloop/__init__.py31
-rw-r--r--src/prompt_toolkit/eventloop/async_generator.py124
-rw-r--r--src/prompt_toolkit/eventloop/inputhook.py190
-rw-r--r--src/prompt_toolkit/eventloop/utils.py101
-rw-r--r--src/prompt_toolkit/eventloop/win32.py72
-rw-r--r--src/prompt_toolkit/filters/__init__.py70
-rw-r--r--src/prompt_toolkit/filters/app.py418
-rw-r--r--src/prompt_toolkit/filters/base.py255
-rw-r--r--src/prompt_toolkit/filters/cli.py64
-rw-r--r--src/prompt_toolkit/filters/utils.py41
-rw-r--r--src/prompt_toolkit/formatted_text/__init__.py58
-rw-r--r--src/prompt_toolkit/formatted_text/ansi.py299
-rw-r--r--src/prompt_toolkit/formatted_text/base.py180
-rw-r--r--src/prompt_toolkit/formatted_text/html.py145
-rw-r--r--src/prompt_toolkit/formatted_text/pygments.py32
-rw-r--r--src/prompt_toolkit/formatted_text/utils.py102
-rw-r--r--src/prompt_toolkit/history.py302
-rw-r--r--src/prompt_toolkit/input/__init__.py14
-rw-r--r--src/prompt_toolkit/input/ansi_escape_sequences.py343
-rw-r--r--src/prompt_toolkit/input/base.py152
-rw-r--r--src/prompt_toolkit/input/defaults.py79
-rw-r--r--src/prompt_toolkit/input/posix_pipe.py118
-rw-r--r--src/prompt_toolkit/input/posix_utils.py97
-rw-r--r--src/prompt_toolkit/input/typeahead.py77
-rw-r--r--src/prompt_toolkit/input/vt100.py309
-rw-r--r--src/prompt_toolkit/input/vt100_parser.py249
-rw-r--r--src/prompt_toolkit/input/win32.py749
-rw-r--r--src/prompt_toolkit/input/win32_pipe.py156
-rw-r--r--src/prompt_toolkit/key_binding/__init__.py22
-rw-r--r--src/prompt_toolkit/key_binding/bindings/__init__.py0
-rw-r--r--src/prompt_toolkit/key_binding/bindings/auto_suggest.py65
-rw-r--r--src/prompt_toolkit/key_binding/bindings/basic.py255
-rw-r--r--src/prompt_toolkit/key_binding/bindings/completion.py205
-rw-r--r--src/prompt_toolkit/key_binding/bindings/cpr.py30
-rw-r--r--src/prompt_toolkit/key_binding/bindings/emacs.py557
-rw-r--r--src/prompt_toolkit/key_binding/bindings/focus.py26
-rw-r--r--src/prompt_toolkit/key_binding/bindings/mouse.py348
-rw-r--r--src/prompt_toolkit/key_binding/bindings/named_commands.py690
-rw-r--r--src/prompt_toolkit/key_binding/bindings/open_in_editor.py51
-rw-r--r--src/prompt_toolkit/key_binding/bindings/page_navigation.py84
-rw-r--r--src/prompt_toolkit/key_binding/bindings/scroll.py189
-rw-r--r--src/prompt_toolkit/key_binding/bindings/search.py95
-rw-r--r--src/prompt_toolkit/key_binding/bindings/vi.py2224
-rw-r--r--src/prompt_toolkit/key_binding/defaults.py62
-rw-r--r--src/prompt_toolkit/key_binding/digraphs.py1377
-rw-r--r--src/prompt_toolkit/key_binding/emacs_state.py36
-rw-r--r--src/prompt_toolkit/key_binding/key_bindings.py671
-rw-r--r--src/prompt_toolkit/key_binding/key_processor.py529
-rw-r--r--src/prompt_toolkit/key_binding/vi_state.py107
-rw-r--r--src/prompt_toolkit/keys.py222
-rw-r--r--src/prompt_toolkit/layout/__init__.py146
-rw-r--r--src/prompt_toolkit/layout/containers.py2743
-rw-r--r--src/prompt_toolkit/layout/controls.py944
-rw-r--r--src/prompt_toolkit/layout/dimension.py219
-rw-r--r--src/prompt_toolkit/layout/dummy.py39
-rw-r--r--src/prompt_toolkit/layout/layout.py411
-rw-r--r--src/prompt_toolkit/layout/margins.py303
-rw-r--r--src/prompt_toolkit/layout/menus.py751
-rw-r--r--src/prompt_toolkit/layout/mouse_handlers.py56
-rw-r--r--src/prompt_toolkit/layout/processors.py1013
-rw-r--r--src/prompt_toolkit/layout/screen.py329
-rw-r--r--src/prompt_toolkit/layout/scrollable_pane.py494
-rw-r--r--src/prompt_toolkit/layout/utils.py82
-rw-r--r--src/prompt_toolkit/lexers/__init__.py20
-rw-r--r--src/prompt_toolkit/lexers/base.py84
-rw-r--r--src/prompt_toolkit/lexers/pygments.py327
-rw-r--r--src/prompt_toolkit/log.py12
-rw-r--r--src/prompt_toolkit/mouse_events.py89
-rw-r--r--src/prompt_toolkit/output/__init__.py15
-rw-r--r--src/prompt_toolkit/output/base.py331
-rw-r--r--src/prompt_toolkit/output/color_depth.py64
-rw-r--r--src/prompt_toolkit/output/conemu.py65
-rw-r--r--src/prompt_toolkit/output/defaults.py102
-rw-r--r--src/prompt_toolkit/output/flush_stdout.py87
-rw-r--r--src/prompt_toolkit/output/plain_text.py143
-rw-r--r--src/prompt_toolkit/output/vt100.py747
-rw-r--r--src/prompt_toolkit/output/win32.py683
-rw-r--r--src/prompt_toolkit/output/windows10.py128
-rw-r--r--src/prompt_toolkit/patch_stdout.py296
-rw-r--r--src/prompt_toolkit/py.typed0
-rw-r--r--src/prompt_toolkit/renderer.py813
-rw-r--r--src/prompt_toolkit/search.py230
-rw-r--r--src/prompt_toolkit/selection.py61
-rw-r--r--src/prompt_toolkit/shortcuts/__init__.py46
-rw-r--r--src/prompt_toolkit/shortcuts/dialogs.py330
-rw-r--r--src/prompt_toolkit/shortcuts/progress_bar/__init__.py33
-rw-r--r--src/prompt_toolkit/shortcuts/progress_bar/base.py448
-rw-r--r--src/prompt_toolkit/shortcuts/progress_bar/formatters.py429
-rw-r--r--src/prompt_toolkit/shortcuts/prompt.py1504
-rw-r--r--src/prompt_toolkit/shortcuts/utils.py239
-rw-r--r--src/prompt_toolkit/styles/__init__.py66
-rw-r--r--src/prompt_toolkit/styles/base.py183
-rw-r--r--src/prompt_toolkit/styles/defaults.py235
-rw-r--r--src/prompt_toolkit/styles/named_colors.py161
-rw-r--r--src/prompt_toolkit/styles/pygments.py69
-rw-r--r--src/prompt_toolkit/styles/style.py400
-rw-r--r--src/prompt_toolkit/styles/style_transformation.py373
-rw-r--r--src/prompt_toolkit/token.py10
-rw-r--r--src/prompt_toolkit/utils.py327
-rw-r--r--src/prompt_toolkit/validation.py195
-rw-r--r--src/prompt_toolkit/widgets/__init__.py62
-rw-r--r--src/prompt_toolkit/widgets/base.py981
-rw-r--r--src/prompt_toolkit/widgets/dialogs.py107
-rw-r--r--src/prompt_toolkit/widgets/menus.py374
-rw-r--r--src/prompt_toolkit/widgets/toolbars.py374
-rw-r--r--src/prompt_toolkit/win32_types.py229
145 files changed, 40759 insertions, 0 deletions
diff --git a/src/prompt_toolkit/__init__.py b/src/prompt_toolkit/__init__.py
new file mode 100644
index 0000000..82324cb
--- /dev/null
+++ b/src/prompt_toolkit/__init__.py
@@ -0,0 +1,51 @@
+"""
+prompt_toolkit
+==============
+
+Author: Jonathan Slenders
+
+Description: prompt_toolkit is a Library for building powerful interactive
+ command lines in Python. It can be a replacement for GNU
+ Readline, but it can be much more than that.
+
+See the examples directory to learn about the usage.
+
+Probably, to get started, you might also want to have a look at
+`prompt_toolkit.shortcuts.prompt`.
+"""
+from __future__ import annotations
+
+import re
+
+# note: this is a bit more lax than the actual pep 440 to allow for a/b/rc/dev without a number
+pep440 = re.compile(
+ r"^([1-9]\d*!)?(0|[1-9]\d*)(\.(0|[1-9]\d*))*((a|b|rc)(0|[1-9]\d*)?)?(\.post(0|[1-9]\d*))?(\.dev(0|[1-9]\d*)?)?$",
+ re.UNICODE,
+)
+from .application import Application
+from .formatted_text import ANSI, HTML
+from .shortcuts import PromptSession, print_formatted_text, prompt
+
+# Don't forget to update in `docs/conf.py`!
+__version__ = "3.0.43"
+
+assert pep440.match(__version__)
+
+# Version tuple.
+VERSION = tuple(int(v.rstrip("abrc")) for v in __version__.split(".")[:3])
+
+
+__all__ = [
+ # Application.
+ "Application",
+ # Shortcuts.
+ "prompt",
+ "PromptSession",
+ "print_formatted_text",
+ # Formatted text.
+ "HTML",
+ "ANSI",
+ # Version info.
+ "__version__",
+ "VERSION",
+]
diff --git a/src/prompt_toolkit/application/__init__.py b/src/prompt_toolkit/application/__init__.py
new file mode 100644
index 0000000..569d8c0
--- /dev/null
+++ b/src/prompt_toolkit/application/__init__.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from .application import Application
+from .current import (
+ AppSession,
+ create_app_session,
+ create_app_session_from_tty,
+ get_app,
+ get_app_or_none,
+ get_app_session,
+ set_app,
+)
+from .dummy import DummyApplication
+from .run_in_terminal import in_terminal, run_in_terminal
+
+__all__ = [
+ # Application.
+ "Application",
+ # Current.
+ "AppSession",
+ "get_app_session",
+ "create_app_session",
+ "create_app_session_from_tty",
+ "get_app",
+ "get_app_or_none",
+ "set_app",
+ # Dummy.
+ "DummyApplication",
+ # Run_in_terminal
+ "in_terminal",
+ "run_in_terminal",
+]
diff --git a/src/prompt_toolkit/application/application.py b/src/prompt_toolkit/application/application.py
new file mode 100644
index 0000000..d463781
--- /dev/null
+++ b/src/prompt_toolkit/application/application.py
@@ -0,0 +1,1625 @@
+from __future__ import annotations
+
+import asyncio
+import contextvars
+import os
+import re
+import signal
+import sys
+import threading
+import time
+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 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.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.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_processor import KeyPressEvent, KeyProcessor
+from prompt_toolkit.key_binding.vi_state import ViState
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.layout.containers import Container, Window
+from prompt_toolkit.layout.controls import BufferControl, UIControl
+from prompt_toolkit.layout.dummy import create_dummy_layout
+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.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",
+]
+
+
+E = KeyPressEvent
+_AppResult = TypeVar("_AppResult")
+ApplicationEventHandler = Callable[["Application[_AppResult]"], None]
+
+_SIGWINCH = getattr(signal, "SIGWINCH", None)
+_SIGTSTP = getattr(signal, "SIGTSTP", None)
+
+
+class Application(Generic[_AppResult]):
+ """
+ The main Application class!
+ This glues everything together.
+
+ :param layout: A :class:`~prompt_toolkit.layout.Layout` instance.
+ :param key_bindings:
+ :class:`~prompt_toolkit.key_binding.KeyBindingsBase` instance for
+ the key bindings.
+ :param clipboard: :class:`~prompt_toolkit.clipboard.Clipboard` to use.
+ :param full_screen: When True, run the application on the alternate screen buffer.
+ :param color_depth: Any :class:`~.ColorDepth` value, a callable that
+ returns a :class:`~.ColorDepth` or `None` for default.
+ :param erase_when_done: (bool) Clear the application output when it finishes.
+ :param reverse_vi_search_direction: Normally, in Vi mode, a '/' searches
+ forward and a '?' searches backward. In Readline mode, this is usually
+ reversed.
+ :param min_redraw_interval: Number of seconds to wait between redraws. Use
+ this for applications where `invalidate` is called a lot. This could cause
+ a lot of terminal output, which some terminals are not able to process.
+
+ `None` means that every `invalidate` will be scheduled right away
+ (which is usually fine).
+
+ When one `invalidate` is called, but a scheduled redraw of a previous
+ `invalidate` call has not been executed yet, nothing will happen in any
+ case.
+
+ :param max_render_postpone_time: When there is high CPU (a lot of other
+ scheduled calls), postpone the rendering max x seconds. '0' means:
+ don't postpone. '.5' means: try to draw at least twice a second.
+
+ :param refresh_interval: Automatically invalidate the UI every so many
+ seconds. When `None` (the default), only invalidate when `invalidate`
+ has been called.
+
+ :param terminal_size_polling_interval: Poll the terminal size every so many
+ seconds. Useful if the applications runs in a thread other then then
+ main thread where SIGWINCH can't be handled, or on Windows.
+
+ Filters:
+
+ :param mouse_support: (:class:`~prompt_toolkit.filters.Filter` or
+ boolean). When True, enable mouse support.
+ :param paste_mode: :class:`~prompt_toolkit.filters.Filter` or boolean.
+ :param editing_mode: :class:`~prompt_toolkit.enums.EditingMode`.
+
+ :param enable_page_navigation_bindings: When `True`, enable the page
+ navigation key bindings. These include both Emacs and Vi bindings like
+ page-up, page-down and so on to scroll through pages. Mostly useful for
+ creating an editor or other full screen applications. Probably, you
+ don't want this for the implementation of a REPL. By default, this is
+ enabled if `full_screen` is set.
+
+ Callbacks (all of these should accept an
+ :class:`~prompt_toolkit.application.Application` object as input.)
+
+ :param on_reset: Called during reset.
+ :param on_invalidate: Called when the UI has been invalidated.
+ :param before_render: Called right before rendering.
+ :param after_render: Called right after rendering.
+
+ I/O:
+ (Note that the preferred way to change the input/output is by creating an
+ `AppSession` with the required input/output objects. If you need multiple
+ applications running at the same time, you have to create a separate
+ `AppSession` using a `with create_app_session():` block.
+
+ :param input: :class:`~prompt_toolkit.input.Input` instance.
+ :param output: :class:`~prompt_toolkit.output.Output` instance. (Probably
+ Vt100_Output or Win32Output.)
+
+ Usage:
+
+ app = Application(...)
+ app.run()
+
+ # Or
+ 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, # 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)
+
+ 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)
+
+ 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
+ self.reverse_vi_search_direction = reverse_vi_search_direction
+ self.enable_page_navigation_bindings = enable_page_navigation_bindings
+ self.min_redraw_interval = min_redraw_interval
+ 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()
+
+ #: 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
+
+ #: 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]
+ ] = [] # 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:
+ """
+ Create a `Style` object that merges the default UI style, the default
+ pygments style, and the custom user style.
+ """
+ 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:
+ """
+ The active :class:`.ColorDepth`.
+
+ The current value is determined as follows:
+
+ - If a color depth was given explicitly to this application, use that
+ value.
+ - Otherwise, fall back to the color depth that is reported by the
+ :class:`.Output` implementation. If the :class:`.Output` class was
+ created using `output.defaults.create_output`, then this value is
+ coming from the $PROMPT_TOOLKIT_COLOR_DEPTH environment variable.
+ """
+ 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:
+ """
+ The currently focused :class:`~.Buffer`.
+
+ (This returns a dummy :class:`.Buffer` when none of the actual buffers
+ has the focus. In this case, it's really not practical to check for
+ `None` values or catch exceptions every time.)
+ """
+ return self.layout.current_buffer or Buffer(
+ name="dummy-buffer"
+ ) # Dummy buffer.
+
+ @property
+ def current_search_state(self) -> SearchState:
+ """
+ Return the current :class:`.SearchState`. (The one for the focused
+ :class:`.BufferControl`.)
+ """
+ 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:
+ """
+ Reset everything, for reading the next input.
+ """
+ # 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()
+
+ # 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.
+ """
+ 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."
+ return self._invalidated
+
+ 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.
+ """
+
+ 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.
+ """
+ if self.refresh_interval is not None and self.refresh_interval != 0:
+
+ 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.
+ """
+ # 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()
+
+ 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.
+
+ (This handles the difference in signature between event handler and
+ `self.invalidate`. It also needs to be a method -not a nested
+ function-, so that we can remove it again .)
+ """
+ self.invalidate()
+
+ 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.
+ """
+ # 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:
+ """
+ Called during `run`.
+
+ `self.future` should be set to the new future at the point where this
+ is called in order to avoid data races. `pre_run` can be used to set a
+ `threading.Event` to synchronize with UI termination code, running in
+ another thread that would call `Application.exit`. (See the progress
+ bar code for an example.)
+ """
+ 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
+ called. Return the value that was passed to
+ :meth:`~prompt_toolkit.application.Application.exit`.
+
+ This is the main entry point for a prompt_toolkit
+ :class:`~prompt_toolkit.application.Application` and usually the only
+ place where the event loop is actually running.
+
+ :param pre_run: Optional callable, which is called right after the
+ "reset" of the application.
+ :param set_exception_handler: When set, in case of an exception, go out
+ of the alternate screen and hide the application, display the
+ exception, and wait for the user to press ENTER.
+ :param handle_sigint: Handle SIGINT signal if possible. This will call
+ the `<sigint>` key binding when a SIGINT is received. (This only
+ works in the main thread.)
+ :param slow_callback_duration: Display warnings if code scheduled in
+ the asyncio event loop takes more time than this. The asyncio
+ default of `0.1` is sometimes not sufficient on a slow system,
+ because exceptionally, the drawing of the app, which happens in the
+ event loop, can take a bit longer from time to time.
+ """
+ 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.
+
+ This will run the application in a fresh asyncio event loop.
+
+ :param pre_run: Optional callable, which is called right after the
+ "reset" of the application.
+ :param set_exception_handler: When set, in case of an exception, go out
+ of the alternate screen and hide the application, display the
+ exception, and wait for the user to press ENTER.
+ :param in_thread: When true, run the application in a background
+ thread, and block the current thread until the application
+ terminates. This is useful if we need to be sure the application
+ won't use the current event loop (asyncio does not support nested
+ event loops). A new event loop will be created in this background
+ thread, and that loop will also be closed when the background
+ thread terminates. When this is used, it's especially important to
+ make sure that all asyncio background tasks are managed through
+ `get_appp().create_background_task()`, so that unfinished tasks are
+ properly cancelled before the event loop is closed. This is used
+ for instance in ptpython.
+ :param handle_sigint: Handle SIGINT signal. Call the key binding for
+ `Keys.SIGINT`. (This only works in the main thread.)
+ """
+ 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.
+ """
+ # 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]:
+ """
+ Install our custom breakpointhook for the duration of this context
+ manager. (We will only install the hook if no other custom hook was
+ set.)
+ """
+ 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:
+ """
+ Breakpointhook which uses PDB, but ensures that the application is
+ hidden and input echoing is restored during each debugger dispatch.
+
+ This can be called from any thread. In any case, the application's
+ event loop will be blocked while the PDB input is displayed. The event
+ will continue after leaving the debugger.
+ """
+ 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
+ cancelled.
+
+ Given that we still support Python versions before 3.11, we can't use
+ task groups (and exception groups), because of that, these background
+ tasks are not allowed to raise exceptions. If they do, we'll call the
+ default exception handler from the event loop.
+
+ If at some point, we have Python 3.11 as the minimum supported Python
+ version, then we can use a `TaskGroup` (with the lifetime of
+ `Application.run_async()`, and run run the background tasks in there.
+
+ This is not threadsafe.
+ """
+ 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:
+ """
+ Called when a background task completes. Remove it from
+ `_background_tasks`, and handle exceptions if any.
+ """
+ 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
+ propagate the exception.
+
+ (If we had nurseries like Trio, this would be the `__aexit__` of a
+ nursery.)
+ """
+ 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.
+
+ Useful for situations where `attach_winch_signal_handler` is not sufficient:
+ - If we are not running in the main thread.
+ - On Windows.
+ """
+ size: Size | None = None
+ interval = self.terminal_size_polling_interval
+
+ 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.
+ """
+ if not self.output.responds_to_cpr:
+ return # We know about this already.
+
+ 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) -> None:
+ "Exit without arguments."
+
+ @overload
+ def exit(self, *, result: _AppResult, style: str = "") -> None:
+ "Exit with `_AppResult`."
+
+ @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.
+
+ .. note::
+
+ If `Application.exit` is called before `Application.run()` is
+ called, then the `Application` won't exit (because the
+ `Application.future` doesn't correspond to the current run). Use a
+ `pre_run` hook and an event to synchronize the closing if there's a
+ chance this can happen.
+
+ :param result: Set this result for the application.
+ :param exception: Set this exception as the result for an application. For
+ a prompt, this is often `EOFError` or `KeyboardInterrupt`.
+ :param style: Apply this style on the whole content when quitting,
+ often this is 'class:exiting' for a prompt. (Used when
+ `erase_when_done` is not set.)
+ """
+ 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:
+ """
+ Send CPR request.
+ """
+ # 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.)
+
+ :param command: Shell command to be executed.
+ :param wait_for_enter: FWait for the user to press enter, when the
+ command is finished.
+ :param display_before_text: If given, text to be displayed before the
+ command executes.
+ :return: A `Future` object.
+ """
+ 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.
+
+ :param suspend_group: When true, suspend the whole process group.
+ (This is the default, and probably what you want.)
+ """
+ # 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
+ `run_in_terminal`, otherwise it will destroy the UI.)
+
+ :param text: List of ``(style_str, text)`` tuples.
+ :param style: Style class to use. Defaults to the active style in the CLI.
+ """
+ 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."
+ return self._is_running
+
+ @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`.
+ """
+ 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):
+ """
+ The `KeyBindings` of key bindings for a `Application`.
+ This merges the global key bindings with the one of the current user
+ control.
+ """
+
+ def __init__(self, app: Application[_AppResult]) -> None:
+ self.app = app
+ self._cache: SimpleCache[
+ tuple[Window, frozenset[UIControl]], KeyBindingsBase
+ ] = SimpleCache()
+
+ @property
+ def _version(self) -> Hashable:
+ """Not needed - this object is not going to be wrapped in another
+ KeyBindings object."""
+ raise NotImplementedError
+
+ @property
+ def bindings(self) -> list[Binding]:
+ """Not needed - this object is not going to be wrapped in another
+ KeyBindings object."""
+ raise NotImplementedError
+
+ 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.
+ """
+ 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)
+
+ 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.
+ """
+ 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]:
+ """
+ Attach the given callback as a WINCH signal handler within the context
+ manager. Restore the original signal handler when done.
+
+ The `Application.run` method will register SIGWINCH, so that it will
+ properly repaint when the terminal window resizes. However, using
+ `run_in_terminal`, we can temporarily send an application to the
+ background, and run an other app in between, which will then overwrite the
+ SIGWINCH. This is why it's important to restore the handler when the app
+ terminates.
+ """
+ # 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
new file mode 100644
index 0000000..908141a
--- /dev/null
+++ b/src/prompt_toolkit/application/current.py
@@ -0,0 +1,189 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from contextvars import ContextVar
+from typing import TYPE_CHECKING, Any, Generator
+
+if TYPE_CHECKING:
+ from prompt_toolkit.input.base import Input
+ from prompt_toolkit.output.base import Output
+
+ from .application import Application
+
+__all__ = [
+ "AppSession",
+ "get_app_session",
+ "get_app",
+ "get_app_or_none",
+ "set_app",
+ "create_app_session",
+ "create_app_session_from_tty",
+]
+
+
+class AppSession:
+ """
+ An AppSession is an interactive session, usually connected to one terminal.
+ Within one such session, interaction with many applications can happen, one
+ after the other.
+
+ The input/output device is not supposed to change during one session.
+
+ Warning: Always use the `create_app_session` function to create an
+ instance, so that it gets activated correctly.
+
+ :param input: Use this as a default input for all applications
+ running in this session, unless an input is passed to the `Application`
+ explicitly.
+ :param output: Use this as a default output.
+ """
+
+ def __init__(
+ self, input: Input | None = None, output: Output | None = None
+ ) -> None:
+ self._input = input
+ self._output = output
+
+ # The application will be set dynamically by the `set_app` context
+ # manager. This is called in the application itself.
+ self.app: Application[Any] | None = None
+
+ def __repr__(self) -> str:
+ return f"AppSession(app={self.app!r})"
+
+ @property
+ def input(self) -> Input:
+ if self._input is None:
+ from prompt_toolkit.input.defaults import create_input
+
+ self._input = create_input()
+ return self._input
+
+ @property
+ def output(self) -> Output:
+ if self._output is None:
+ from prompt_toolkit.output.defaults import create_output
+
+ self._output = create_output()
+ return self._output
+
+
+_current_app_session: ContextVar[AppSession] = ContextVar(
+ "_current_app_session", default=AppSession()
+)
+
+
+def get_app_session() -> AppSession:
+ return _current_app_session.get()
+
+
+def get_app() -> Application[Any]:
+ """
+ Get the current active (running) Application.
+ An :class:`.Application` is active during the
+ :meth:`.Application.run_async` call.
+
+ We assume that there can only be one :class:`.Application` active at the
+ same time. There is only one terminal window, with only one stdin and
+ stdout. This makes the code significantly easier than passing around the
+ :class:`.Application` everywhere.
+
+ If no :class:`.Application` is running, then return by default a
+ :class:`.DummyApplication`. For practical reasons, we prefer to not raise
+ an exception. This way, we don't have to check all over the place whether
+ an actual `Application` was returned.
+
+ (For applications like pymux where we can have more than one `Application`,
+ we'll use a work-around to handle that.)
+ """
+ session = _current_app_session.get()
+ if session.app is not None:
+ return session.app
+
+ from .dummy import DummyApplication
+
+ return DummyApplication()
+
+
+def get_app_or_none() -> Application[Any] | None:
+ """
+ Get the current active (running) Application, or return `None` if no
+ application is running.
+ """
+ session = _current_app_session.get()
+ return session.app
+
+
+@contextmanager
+def set_app(app: Application[Any]) -> Generator[None, None, None]:
+ """
+ Context manager that sets the given :class:`.Application` active in an
+ `AppSession`.
+
+ This should only be called by the `Application` itself.
+ The application will automatically be active while its running. If you want
+ the application to be active in other threads/coroutines, where that's not
+ the case, use `contextvars.copy_context()`, or use `Application.context` to
+ run it in the appropriate context.
+ """
+ session = _current_app_session.get()
+
+ previous_app = session.app
+ session.app = app
+ try:
+ yield
+ finally:
+ session.app = previous_app
+
+
+@contextmanager
+def create_app_session(
+ input: Input | None = None, output: Output | None = None
+) -> Generator[AppSession, None, None]:
+ """
+ Create a separate AppSession.
+
+ This is useful if there can be multiple individual `AppSession`s going on.
+ Like in the case of an Telnet/SSH server.
+ """
+ # If no input/output is specified, fall back to the current input/output,
+ # whatever that is.
+ if input is None:
+ input = get_app_session().input
+ if output is None:
+ output = get_app_session().output
+
+ # Create new `AppSession` and activate.
+ session = AppSession(input=input, output=output)
+
+ token = _current_app_session.set(session)
+ try:
+ yield session
+ finally:
+ _current_app_session.reset(token)
+
+
+@contextmanager
+def create_app_session_from_tty() -> Generator[AppSession, None, None]:
+ """
+ Create `AppSession` that always prefers the TTY input/output.
+
+ Even if `sys.stdin` and `sys.stdout` are connected to input/output pipes,
+ this will still use the terminal for interaction (because `sys.stderr` is
+ still connected to the terminal).
+
+ Usage::
+
+ from prompt_toolkit.shortcuts import prompt
+
+ with create_app_session_from_tty():
+ prompt('>')
+ """
+ from prompt_toolkit.input.defaults import create_input
+ from prompt_toolkit.output.defaults import create_output
+
+ input = create_input(always_prefer_tty=True)
+ output = create_output(always_prefer_tty=True)
+
+ with create_app_session(input=input, output=output) as app_session:
+ yield app_session
diff --git a/src/prompt_toolkit/application/dummy.py b/src/prompt_toolkit/application/dummy.py
new file mode 100644
index 0000000..43819e1
--- /dev/null
+++ b/src/prompt_toolkit/application/dummy.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from typing import Callable
+
+from prompt_toolkit.eventloop import InputHook
+from prompt_toolkit.formatted_text import AnyFormattedText
+from prompt_toolkit.input import DummyInput
+from prompt_toolkit.output import DummyOutput
+
+from .application import Application
+
+__all__ = [
+ "DummyApplication",
+]
+
+
+class DummyApplication(Application[None]):
+ """
+ When no :class:`.Application` is running,
+ :func:`.get_app` will run an instance of this :class:`.DummyApplication` instead.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(output=DummyOutput(), input=DummyInput())
+
+ def run(
+ self,
+ pre_run: Callable[[], None] | None = None,
+ set_exception_handler: bool = True,
+ handle_sigint: bool = True,
+ in_thread: bool = False,
+ inputhook: InputHook | None = None,
+ ) -> None:
+ raise NotImplementedError("A DummyApplication is not supposed to run.")
+
+ async def run_async(
+ self,
+ pre_run: Callable[[], None] | None = None,
+ set_exception_handler: bool = True,
+ handle_sigint: bool = True,
+ slow_callback_duration: float = 0.5,
+ ) -> None:
+ raise NotImplementedError("A DummyApplication is not supposed to run.")
+
+ async def run_system_command(
+ self,
+ command: str,
+ wait_for_enter: bool = True,
+ display_before_text: AnyFormattedText = "",
+ wait_text: str = "",
+ ) -> None:
+ raise NotImplementedError
+
+ def suspend_to_background(self, suspend_group: bool = True) -> None:
+ raise NotImplementedError
diff --git a/src/prompt_toolkit/application/run_in_terminal.py b/src/prompt_toolkit/application/run_in_terminal.py
new file mode 100644
index 0000000..1e4da2d
--- /dev/null
+++ b/src/prompt_toolkit/application/run_in_terminal.py
@@ -0,0 +1,113 @@
+"""
+Tools for running functions on the terminal above the current application or prompt.
+"""
+from __future__ import annotations
+
+from asyncio import Future, ensure_future
+from contextlib import asynccontextmanager
+from typing import AsyncGenerator, Awaitable, Callable, TypeVar
+
+from prompt_toolkit.eventloop import run_in_executor_with_context
+
+from .current import get_app_or_none
+
+__all__ = [
+ "run_in_terminal",
+ "in_terminal",
+]
+
+_T = TypeVar("_T")
+
+
+def run_in_terminal(
+ func: Callable[[], _T], render_cli_done: bool = False, in_executor: bool = False
+) -> Awaitable[_T]:
+ """
+ Run function on the terminal above the current application or prompt.
+
+ What this does is first hiding the prompt, then running this callable
+ (which can safely output to the terminal), and then again rendering the
+ prompt which causes the output of this function to scroll above the
+ prompt.
+
+ ``func`` is supposed to be a synchronous function. If you need an
+ asynchronous version of this function, use the ``in_terminal`` context
+ manager directly.
+
+ :param func: The callable to execute.
+ :param render_cli_done: When True, render the interface in the
+ 'Done' state first, then execute the function. If False,
+ erase the interface first.
+ :param in_executor: When True, run in executor. (Use this for long
+ blocking functions, when you don't want to block the event loop.)
+
+ :returns: A `Future`.
+ """
+
+ async def run() -> _T:
+ async with in_terminal(render_cli_done=render_cli_done):
+ if in_executor:
+ return await run_in_executor_with_context(func)
+ else:
+ return func()
+
+ return ensure_future(run())
+
+
+@asynccontextmanager
+async def in_terminal(render_cli_done: bool = False) -> AsyncGenerator[None, None]:
+ """
+ Asynchronous context manager that suspends the current application and runs
+ the body in the terminal.
+
+ .. code::
+
+ async def f():
+ async with in_terminal():
+ call_some_function()
+ await call_some_async_function()
+ """
+ app = get_app_or_none()
+ if app is None or not app._is_running:
+ yield
+ return
+
+ # When a previous `run_in_terminal` call was in progress. Wait for that
+ # to finish, before starting this one. Chain to previous call.
+ previous_run_in_terminal_f = app._running_in_terminal_f
+ new_run_in_terminal_f: Future[None] = Future()
+ app._running_in_terminal_f = new_run_in_terminal_f
+
+ # Wait for the previous `run_in_terminal` to finish.
+ if previous_run_in_terminal_f is not None:
+ await previous_run_in_terminal_f
+
+ # Wait for all CPRs to arrive. We don't want to detach the input until
+ # all cursor position responses have been arrived. Otherwise, the tty
+ # will echo its input and can show stuff like ^[[39;1R.
+ if app.output.responds_to_cpr:
+ await app.renderer.wait_for_cpr_responses()
+
+ # Draw interface in 'done' state, or erase.
+ if render_cli_done:
+ app._redraw(render_as_done=True)
+ else:
+ app.renderer.erase()
+
+ # Disable rendering.
+ app._running_in_terminal = True
+
+ # Detach input.
+ try:
+ with app.input.detach():
+ with app.input.cooked_mode():
+ yield
+ finally:
+ # Redraw interface again.
+ try:
+ app._running_in_terminal = False
+ app.renderer.reset()
+ app._request_absolute_cursor_position()
+ app._redraw()
+ finally:
+ new_run_in_terminal_f.set_result(None)
diff --git a/src/prompt_toolkit/auto_suggest.py b/src/prompt_toolkit/auto_suggest.py
new file mode 100644
index 0000000..98cb4dd
--- /dev/null
+++ b/src/prompt_toolkit/auto_suggest.py
@@ -0,0 +1,176 @@
+"""
+`Fish-style <http://fishshell.com/>`_ like auto-suggestion.
+
+While a user types input in a certain buffer, suggestions are generated
+(asynchronously.) Usually, they are displayed after the input. When the cursor
+presses the right arrow and the cursor is at the end of the input, the
+suggestion will be inserted.
+
+If you want the auto suggestions to be asynchronous (in a background thread),
+because they take too much time, and could potentially block the event loop,
+then wrap the :class:`.AutoSuggest` instance into a
+:class:`.ThreadedAutoSuggest`.
+"""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import TYPE_CHECKING, Callable
+
+from prompt_toolkit.eventloop import run_in_executor_with_context
+
+from .document import Document
+from .filters import Filter, to_filter
+
+if TYPE_CHECKING:
+ from .buffer import Buffer
+
+__all__ = [
+ "Suggestion",
+ "AutoSuggest",
+ "ThreadedAutoSuggest",
+ "DummyAutoSuggest",
+ "AutoSuggestFromHistory",
+ "ConditionalAutoSuggest",
+ "DynamicAutoSuggest",
+]
+
+
+class Suggestion:
+ """
+ Suggestion returned by an auto-suggest algorithm.
+
+ :param text: The suggestion text.
+ """
+
+ def __init__(self, text: str) -> None:
+ self.text = text
+
+ def __repr__(self) -> str:
+ return "Suggestion(%s)" % self.text
+
+
+class AutoSuggest(metaclass=ABCMeta):
+ """
+ Base class for auto suggestion implementations.
+ """
+
+ @abstractmethod
+ def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
+ """
+ Return `None` or a :class:`.Suggestion` instance.
+
+ We receive both :class:`~prompt_toolkit.buffer.Buffer` and
+ :class:`~prompt_toolkit.document.Document`. The reason is that auto
+ suggestions are retrieved asynchronously. (Like completions.) The
+ buffer text could be changed in the meantime, but ``document`` contains
+ the buffer document like it was at the start of the auto suggestion
+ call. So, from here, don't access ``buffer.text``, but use
+ ``document.text`` instead.
+
+ :param buffer: The :class:`~prompt_toolkit.buffer.Buffer` instance.
+ :param document: The :class:`~prompt_toolkit.document.Document` instance.
+ """
+
+ async def get_suggestion_async(
+ self, buff: Buffer, document: Document
+ ) -> Suggestion | None:
+ """
+ Return a :class:`.Future` which is set when the suggestions are ready.
+ This function can be overloaded in order to provide an asynchronous
+ implementation.
+ """
+ return self.get_suggestion(buff, document)
+
+
+class ThreadedAutoSuggest(AutoSuggest):
+ """
+ Wrapper that runs auto suggestions in a thread.
+ (Use this to prevent the user interface from becoming unresponsive if the
+ generation of suggestions takes too much time.)
+ """
+
+ def __init__(self, auto_suggest: AutoSuggest) -> None:
+ self.auto_suggest = auto_suggest
+
+ def get_suggestion(self, buff: Buffer, document: Document) -> Suggestion | None:
+ return self.auto_suggest.get_suggestion(buff, document)
+
+ async def get_suggestion_async(
+ self, buff: Buffer, document: Document
+ ) -> Suggestion | None:
+ """
+ Run the `get_suggestion` function in a thread.
+ """
+
+ def run_get_suggestion_thread() -> Suggestion | None:
+ return self.get_suggestion(buff, document)
+
+ return await run_in_executor_with_context(run_get_suggestion_thread)
+
+
+class DummyAutoSuggest(AutoSuggest):
+ """
+ AutoSuggest class that doesn't return any suggestion.
+ """
+
+ def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
+ return None # No suggestion
+
+
+class AutoSuggestFromHistory(AutoSuggest):
+ """
+ Give suggestions based on the lines in the history.
+ """
+
+ def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
+ history = buffer.history
+
+ # Consider only the last line for the suggestion.
+ text = document.text.rsplit("\n", 1)[-1]
+
+ # Only create a suggestion when this is not an empty line.
+ if text.strip():
+ # Find first matching line in history.
+ for string in reversed(list(history.get_strings())):
+ for line in reversed(string.splitlines()):
+ if line.startswith(text):
+ return Suggestion(line[len(text) :])
+
+ return None
+
+
+class ConditionalAutoSuggest(AutoSuggest):
+ """
+ Auto suggest that can be turned on and of according to a certain condition.
+ """
+
+ def __init__(self, auto_suggest: AutoSuggest, filter: bool | Filter) -> None:
+ self.auto_suggest = auto_suggest
+ self.filter = to_filter(filter)
+
+ def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
+ if self.filter():
+ return self.auto_suggest.get_suggestion(buffer, document)
+
+ return None
+
+
+class DynamicAutoSuggest(AutoSuggest):
+ """
+ Validator class that can dynamically returns any Validator.
+
+ :param get_validator: Callable that returns a :class:`.Validator` instance.
+ """
+
+ def __init__(self, get_auto_suggest: Callable[[], AutoSuggest | None]) -> None:
+ self.get_auto_suggest = get_auto_suggest
+
+ def get_suggestion(self, buff: Buffer, document: Document) -> Suggestion | None:
+ auto_suggest = self.get_auto_suggest() or DummyAutoSuggest()
+ return auto_suggest.get_suggestion(buff, document)
+
+ async def get_suggestion_async(
+ self, buff: Buffer, document: Document
+ ) -> Suggestion | None:
+ auto_suggest = self.get_auto_suggest() or DummyAutoSuggest()
+ return await auto_suggest.get_suggestion_async(buff, document)
diff --git a/src/prompt_toolkit/buffer.py b/src/prompt_toolkit/buffer.py
new file mode 100644
index 0000000..100ca78
--- /dev/null
+++ b/src/prompt_toolkit/buffer.py
@@ -0,0 +1,2026 @@
+"""
+Data structures for the Buffer.
+It holds the text, cursor position, history, etc...
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import re
+import shlex
+import shutil
+import subprocess
+import tempfile
+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 .document import Document
+from .eventloop import aclosing
+from .filters import FilterOrBool, to_filter
+from .history import History, InMemoryHistory
+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",
+]
+
+logger = logging.getLogger(__name__)
+
+
+class EditReadOnlyBuffer(Exception):
+ "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"
+
+
+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:
+ #: 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 []
+
+ #: 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 __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.
+ """
+ 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]:
+ """
+ Return (new_text, new_cursor_position) for this completion.
+ """
+ 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:
+ """
+ Return the current completion, or return `None` when no completion is
+ selected.
+ """
+ if self.complete_index is not None:
+ return self.completions[self.complete_index]
+ return None
+
+
+_QUOTED_WORDS_RE = re.compile(r"""(\s+|".*?"|'.*?')""")
+
+
+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:
+ 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,
+ )
+
+
+BufferEventHandler = Callable[["Buffer"], None]
+BufferAcceptHandler = Callable[["Buffer"], bool]
+
+
+class Buffer:
+ """
+ The core data structure that holds the text and cursor position of the
+ current input line and implements all text manipulations on top of it. It
+ also implements the history, undo stack and the completion state.
+
+ :param completer: :class:`~prompt_toolkit.completion.Completer` instance.
+ :param history: :class:`~prompt_toolkit.history.History` instance.
+ :param tempfile_suffix: The tempfile suffix (extension) to be used for the
+ "open in editor" function. For a Python REPL, this would be ".py", so
+ that the editor knows the syntax highlighting to use. This can also be
+ a callable that returns a string.
+ :param tempfile: For more advanced tempfile situations where you need
+ control over the subdirectories and filename. For a Git Commit Message,
+ this would be ".git/COMMIT_EDITMSG", so that the editor knows the syntax
+ highlighting to use. This can also be a callable that returns a string.
+ :param name: Name for this buffer. E.g. DEFAULT_BUFFER. This is mostly
+ useful for key bindings where we sometimes prefer to refer to a buffer
+ by their name instead of by reference.
+ :param accept_handler: Called when the buffer input is accepted. (Usually
+ when the user presses `enter`.) The accept handler receives this
+ `Buffer` as input and should return True when the buffer text should be
+ kept instead of calling reset.
+
+ In case of a `PromptSession` for instance, we want to keep the text,
+ because we will exit the application, and only reset it during the next
+ run.
+
+ Events:
+
+ :param on_text_changed: When the buffer text changes. (Callable or None.)
+ :param on_text_insert: When new text is inserted. (Callable or None.)
+ :param on_cursor_position_changed: When the cursor moves. (Callable or None.)
+ :param on_completions_changed: When the completions were changed. (Callable or None.)
+ :param on_suggestion_set: When an auto-suggestion text has been set. (Callable or None.)
+
+ Filters:
+
+ :param complete_while_typing: :class:`~prompt_toolkit.filters.Filter`
+ or `bool`. Decide whether or not to do asynchronous autocompleting while
+ typing.
+ :param validate_while_typing: :class:`~prompt_toolkit.filters.Filter`
+ or `bool`. Decide whether or not to do asynchronous validation while
+ typing.
+ :param enable_history_search: :class:`~prompt_toolkit.filters.Filter` or
+ `bool` to indicate when up-arrow partial string matching is enabled. It
+ is advised to not enable this at the same time as
+ `complete_while_typing`, because when there is an autocompletion found,
+ the up arrows usually browse through the completions, rather than
+ through the history.
+ :param read_only: :class:`~prompt_toolkit.filters.Filter`. When True,
+ changes will not be allowed.
+ :param multiline: :class:`~prompt_toolkit.filters.Filter` or `bool`. When
+ not set, pressing `Enter` will call the `accept_handler`. Otherwise,
+ 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,
+ ):
+ # 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
+ self.tempfile_suffix = tempfile_suffix
+ 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_suggestion_set: Event[Buffer] = Event(self, on_suggestion_set)
+
+ # 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:
+ 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}>"
+
+ def reset(
+ self, document: Document | None = None, append_to_history: bool = False
+ ) -> None:
+ """
+ :param append_to_history: Append current input to history first.
+ """
+ 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
+
+ # 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).
+
+ Note::
+
+ This needs to be called from within the event loop of the
+ application, because history loading is async, and we need to be
+ sure the right event loop is active. Therefor, we call this method
+ in the `BufferControl.create_content`.
+
+ There are situations where prompt_toolkit applications are created
+ in one thread, but will later run in a different thread (Ptpython
+ is one example. The REPL runs in a separate thread, in order to
+ prevent interfering with a potential different event loop in the
+ main thread. The REPL UI however is still created in the main
+ thread.) We could decide to not support creating prompt_toolkit
+ objects in one thread and running the application in a different
+ thread, but history loading is the only place where it matters, and
+ this solves it.
+ """
+ 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."""
+ 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."""
+ 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:
+ """
+ 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.)
+ """
+ # 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:
+ """
+ Setting cursor position.
+ """
+ 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:
+ """
+ Return :class:`~prompt_toolkit.document.Document` instance from the
+ current text, cursor position and selection state.
+ """
+ return self._document_cache[
+ self.text, self.cursor_position, self.selection_state
+ ]
+
+ @document.setter
+ 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.)
+ """
+ self.set_document(value)
+
+ 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.
+
+ :param bypass_readonly: When True, don't raise an
+ :class:`.EditReadOnlyBuffer` exception, even
+ when the buffer is read-only.
+
+ .. warning::
+
+ When this buffer is read-only and `bypass_readonly` was not passed,
+ the `EditReadOnlyBuffer` exception will be caught by the
+ `KeyProcessor` and is silently suppressed. This is important to
+ keep in mind when writing key bindings, because it won't do what
+ you expect, and there won't be a stack trace. Use try/finally
+ around this function if you need some cleanup code.
+ """
+ # 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:
+ """
+ True when there is something handling accept.
+ """
+ return bool(self.accept_handler)
+
+ # 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.
+ """
+ # 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:
+ """
+ Transforms the text on a range of lines.
+ When the iterator yield an index not in the range of lines that the
+ document contains, it skips them silently.
+
+ To uppercase some lines::
+
+ new_text = transform_lines(range(5,10), lambda text: text.upper())
+
+ :param line_index_iterator: Iterator of line numbers (int)
+ :param transform_callback: callable that takes the original text of a
+ line, and return the new text for this line.
+
+ :returns: The new text.
+ """
+ # Split lines
+ lines = self.text.split("\n")
+
+ # 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.
+ """
+ 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:
+ """
+ Transform a part of the input string.
+
+ :param from_: (int) start position.
+ :param to: (int) end position.
+ :param transform_callback: Callable which accepts a string and returns
+ the transformed string.
+ """
+ assert from_ < to
+
+ self.text = "".join(
+ [
+ self.text[:from_]
+ + transform_callback(self.text[from_:to])
+ + self.text[to:]
+ ]
+ )
+
+ 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."""
+ 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:
+ """(for multiline edit). Move cursor to the next line."""
+ 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
+ )
+
+ # 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.)
+ """
+ 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:
+ """
+ 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.)
+ """
+ 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:
+ """
+ Delete specified number of characters before cursor and return the
+ deleted text.
+ """
+ assert count >= 0
+ deleted = ""
+
+ 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.
+ """
+ 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:
+ """
+ Join the next line to the current one by deleting the line ending after
+ the current line.
+ """
+ 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:
+ """
+ Join the selected lines.
+ """
+ assert self.selection_state
+
+ # 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.
+ """
+ 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:
+ """
+ Go to this item in the history.
+ """
+ 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:
+ """
+ Browse to the next completions.
+ (Does nothing if there are no completion.)
+ """
+ index: int | 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.)
+ """
+ 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:
+ """
+ Cancel completion, go back to the original text.
+ """
+ if self.complete_state:
+ self.go_to_completion(None)
+ self.complete_state = None
+
+ def _set_completions(self, completions: list[Completion]) -> CompletionState:
+ """
+ Start completions. (Generate list of completions and initialize.)
+
+ By default, no completion will be selected.
+ """
+ self.complete_state = CompletionState(
+ original_document=self.document, completions=completions
+ )
+
+ # 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.
+ """
+ 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,
+ )
+ )
+
+ 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.
+ """
+ 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)
+
+ # (changing text/cursor position will unset complete_state.)
+ self.complete_state = state
+
+ def apply_completion(self, completion: Completion) -> None:
+ """
+ Insert a given completion.
+ """
+ # 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:
+ """
+ Set `history_search_text`.
+ (The text before the cursor will be used for filtering the history.)
+ """
+ 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:
+ """
+ True when the current entry matches the history search.
+ (when we don't have history search, it's also True.)
+ """
+ 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:
+ """
+ Move forwards through the history.
+
+ :param count: Amount of items to move forward.
+ """
+ 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
+
+ # 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.
+ """
+ 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
+
+ # 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
+ history if called repeatedly. If no `n` has been given, take the first
+ argument. (The second word.)
+
+ :param n: (None or int), The index of the word from the previous line
+ to take.
+ """
+ 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
+
+ # 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.
+ """
+ self.yank_nth_arg(n=n, _yank_last_arg=True)
+
+ def start_selection(
+ self, selection_type: SelectionType = SelectionType.CHARACTERS
+ ) -> None:
+ """
+ Take the current cursor position as the start of this selection.
+ """
+ self.selection_state = SelectionState(self.cursor_position, selection_type)
+
+ def copy_selection(self, _cut: bool = False) -> ClipboardData:
+ """
+ Copy selected text and return :class:`.ClipboardData` instance.
+
+ Notice that this doesn't store the copied data on the clipboard yet.
+ You can store it like this:
+
+ .. code:: python
+
+ data = buffer.copy_selection()
+ get_app().clipboard.set_data(data)
+ """
+ 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:
+ """
+ Delete selected text and return :class:`.ClipboardData` instance.
+ """
+ return self.copy_selection(_cut=True)
+
+ def paste_clipboard_data(
+ self,
+ data: ClipboardData,
+ paste_mode: PasteMode = PasteMode.EMACS,
+ count: int = 1,
+ ) -> None:
+ """
+ Insert the data from the clipboard.
+ """
+ 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
+ )
+
+ # 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.
+ """
+ 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:
+ """
+ Insert a new line above the current one.
+ """
+ if copy_margin:
+ insert = self.document.leading_whitespace_in_current_line + "\n"
+ else:
+ insert = "\n"
+
+ 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.
+ """
+ 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:
+ """
+ Insert characters at cursor position.
+
+ :param fire_event: Fire `on_text_insert` event. This is mainly used to
+ trigger autocompletion while typing.
+ """
+ # Original text & cursor position.
+ otext = self.text
+ ocpos = self.cursor_position
+
+ # 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.
+ """
+ # 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.
+
+ We have both variants, because a synchronous version is required.
+ Handling the ENTER key needs to be completely synchronous, otherwise
+ stuff like type-ahead is going to give very weird results. (People
+ could type input while the ENTER key is still processed.)
+
+ An asynchronous version is required if we have `validate_while_typing`
+ enabled.
+ """
+ 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
+
+ # 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.
+ """
+ # 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:
+ """
+ Execute search. Return (working_index, cursor_position) tuple when this
+ search is applied. Returns `None` when this text cannot be found.
+ """
+ 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
+ will be used in the
+ :class:`~prompt_toolkit.layout.BufferControl` to display feedback while
+ searching.
+ """
+ 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
+
+ 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.)
+ """
+ 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:
+ """
+ Apply search. If something is found, set `working_index` and
+ `cursor_position`.
+ """
+ search_result = self._search(
+ search_state, include_current_position=include_current_position, count=count
+ )
+
+ 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).
+ """
+ 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 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.
+ """
+ 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")
+
+ # 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.
+ """
+ # 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.)
+ """
+ # Only one of these options can be selected.
+ assert select_first + select_last + insert_common_part <= 1
+
+ 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.)
+ """
+
+ 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.)
+ """
+
+ @_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.)
+ """
+
+ @_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.
+ """
+ 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()
+
+ if not keep_text:
+ self.reset()
+
+
+_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
+ suggestor and validator running at a time.)
+
+ When the coroutine raises `_Retry`, it is restarted.
+ """
+ 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`."
+
+
+def indent(buffer: Buffer, from_row: int, to_row: int, count: int = 1) -> None:
+ """
+ Indent text of a :class:`.Buffer` object.
+ """
+ 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:
+ """
+ Unindent text of a :class:`.Buffer` object.
+ """
+ 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:
+ """
+ Reformat text, taking the width into account.
+ `to_row` is included.
+ (Vi 'gq' operator.)
+ """
+ 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
new file mode 100644
index 0000000..01dd1f7
--- /dev/null
+++ b/src/prompt_toolkit/cache.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from collections import deque
+from functools import wraps
+from typing import Any, Callable, Dict, Generic, Hashable, Tuple, TypeVar, cast
+
+__all__ = [
+ "SimpleCache",
+ "FastDictCache",
+ "memoized",
+]
+
+_T = TypeVar("_T", bound=Hashable)
+_U = TypeVar("_U")
+
+
+class SimpleCache(Generic[_T, _U]):
+ """
+ Very simple cache that discards the oldest item when the cache size is
+ exceeded.
+
+ :param maxsize: Maximum size of the cache. (Don't make it too big.)
+ """
+
+ def __init__(self, maxsize: int = 8) -> None:
+ assert maxsize > 0
+
+ self._data: dict[_T, _U] = {}
+ self._keys: deque[_T] = deque()
+ self.maxsize: int = maxsize
+
+ def get(self, key: _T, getter_func: Callable[[], _U]) -> _U:
+ """
+ Get object from the cache.
+ If not found, call `getter_func` to resolve it, and put that on the top
+ of the cache instead.
+ """
+ # Look in cache first.
+ try:
+ return self._data[key]
+ except KeyError:
+ # Not found? Get it.
+ value = getter_func()
+ self._data[key] = value
+ self._keys.append(key)
+
+ # Remove the oldest key when the size is exceeded.
+ if len(self._data) > self.maxsize:
+ key_to_remove = self._keys.popleft()
+ if key_to_remove in self._data:
+ del self._data[key_to_remove]
+
+ return value
+
+ def clear(self) -> None:
+ "Clear cache."
+ self._data = {}
+ self._keys = deque()
+
+
+_K = TypeVar("_K", bound=Tuple[Hashable, ...])
+_V = TypeVar("_V")
+
+
+class FastDictCache(Dict[_K, _V]):
+ """
+ Fast, lightweight cache which keeps at most `size` items.
+ It will discard the oldest items in the cache first.
+
+ The cache is a dictionary, which doesn't keep track of access counts.
+ It is perfect to cache little immutable objects which are not expensive to
+ create, but where a dictionary lookup is still much faster than an object
+ instantiation.
+
+ :param get_value: Callable that's called in case of a missing key.
+ """
+
+ # NOTE: This cache is used to cache `prompt_toolkit.layout.screen.Char` and
+ # `prompt_toolkit.Document`. Make sure to keep this really lightweight.
+ # Accessing the cache should stay faster than instantiating new
+ # objects.
+ # (Dictionary lookups are really fast.)
+ # SimpleCache is still required for cases where the cache key is not
+ # the same as the arguments given to the function that creates the
+ # value.)
+ def __init__(self, get_value: Callable[..., _V], size: int = 1000000) -> None:
+ assert size > 0
+
+ self._keys: deque[_K] = deque()
+ self.get_value = get_value
+ self.size = size
+
+ def __missing__(self, key: _K) -> _V:
+ # Remove the oldest key when the size is exceeded.
+ if len(self) > self.size:
+ key_to_remove = self._keys.popleft()
+ if key_to_remove in self:
+ del self[key_to_remove]
+
+ result = self.get_value(*key)
+ self[key] = result
+ self._keys.append(key)
+ return result
+
+
+_F = TypeVar("_F", bound=Callable[..., object])
+
+
+def memoized(maxsize: int = 1024) -> Callable[[_F], _F]:
+ """
+ Memoization decorator for immutable classes and pure functions.
+ """
+
+ def decorator(obj: _F) -> _F:
+ cache: SimpleCache[Hashable, Any] = SimpleCache(maxsize=maxsize)
+
+ @wraps(obj)
+ def new_callable(*a: Any, **kw: Any) -> Any:
+ def create_new() -> Any:
+ return obj(*a, **kw)
+
+ key = (a, tuple(sorted(kw.items())))
+ return cache.get(key, create_new)
+
+ return cast(_F, new_callable)
+
+ return decorator
diff --git a/src/prompt_toolkit/clipboard/__init__.py b/src/prompt_toolkit/clipboard/__init__.py
new file mode 100644
index 0000000..e72f30e
--- /dev/null
+++ b/src/prompt_toolkit/clipboard/__init__.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from .base import Clipboard, ClipboardData, DummyClipboard, DynamicClipboard
+from .in_memory import InMemoryClipboard
+
+# We are not importing `PyperclipClipboard` here, because it would require the
+# `pyperclip` module to be present.
+
+# from .pyperclip import PyperclipClipboard
+
+__all__ = [
+ "Clipboard",
+ "ClipboardData",
+ "DummyClipboard",
+ "DynamicClipboard",
+ "InMemoryClipboard",
+]
diff --git a/src/prompt_toolkit/clipboard/base.py b/src/prompt_toolkit/clipboard/base.py
new file mode 100644
index 0000000..b05275b
--- /dev/null
+++ b/src/prompt_toolkit/clipboard/base.py
@@ -0,0 +1,108 @@
+"""
+Clipboard for command line interface.
+"""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import Callable
+
+from prompt_toolkit.selection import SelectionType
+
+__all__ = [
+ "Clipboard",
+ "ClipboardData",
+ "DummyClipboard",
+ "DynamicClipboard",
+]
+
+
+class ClipboardData:
+ """
+ Text on the clipboard.
+
+ :param text: string
+ :param type: :class:`~prompt_toolkit.selection.SelectionType`
+ """
+
+ def __init__(
+ self, text: str = "", type: SelectionType = SelectionType.CHARACTERS
+ ) -> None:
+ self.text = text
+ self.type = type
+
+
+class Clipboard(metaclass=ABCMeta):
+ """
+ Abstract baseclass for clipboards.
+ (An implementation can be in memory, it can share the X11 or Windows
+ keyboard, or can be persistent.)
+ """
+
+ @abstractmethod
+ def set_data(self, data: ClipboardData) -> None:
+ """
+ Set data to the clipboard.
+
+ :param data: :class:`~.ClipboardData` instance.
+ """
+
+ def set_text(self, text: str) -> None: # Not abstract.
+ """
+ Shortcut for setting plain text on clipboard.
+ """
+ self.set_data(ClipboardData(text))
+
+ def rotate(self) -> None:
+ """
+ For Emacs mode, rotate the kill ring.
+ """
+
+ @abstractmethod
+ def get_data(self) -> ClipboardData:
+ """
+ Return clipboard data.
+ """
+
+
+class DummyClipboard(Clipboard):
+ """
+ Clipboard implementation that doesn't remember anything.
+ """
+
+ def set_data(self, data: ClipboardData) -> None:
+ pass
+
+ def set_text(self, text: str) -> None:
+ pass
+
+ def rotate(self) -> None:
+ pass
+
+ def get_data(self) -> ClipboardData:
+ return ClipboardData()
+
+
+class DynamicClipboard(Clipboard):
+ """
+ Clipboard class that can dynamically returns any Clipboard.
+
+ :param get_clipboard: Callable that returns a :class:`.Clipboard` instance.
+ """
+
+ def __init__(self, get_clipboard: Callable[[], Clipboard | None]) -> None:
+ self.get_clipboard = get_clipboard
+
+ def _clipboard(self) -> Clipboard:
+ return self.get_clipboard() or DummyClipboard()
+
+ def set_data(self, data: ClipboardData) -> None:
+ self._clipboard().set_data(data)
+
+ def set_text(self, text: str) -> None:
+ self._clipboard().set_text(text)
+
+ def rotate(self) -> None:
+ self._clipboard().rotate()
+
+ def get_data(self) -> ClipboardData:
+ return self._clipboard().get_data()
diff --git a/src/prompt_toolkit/clipboard/in_memory.py b/src/prompt_toolkit/clipboard/in_memory.py
new file mode 100644
index 0000000..d9ae081
--- /dev/null
+++ b/src/prompt_toolkit/clipboard/in_memory.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from collections import deque
+
+from .base import Clipboard, ClipboardData
+
+__all__ = [
+ "InMemoryClipboard",
+]
+
+
+class InMemoryClipboard(Clipboard):
+ """
+ Default clipboard implementation.
+ Just keep the data in memory.
+
+ This implements a kill-ring, for Emacs mode.
+ """
+
+ def __init__(self, data: ClipboardData | None = None, max_size: int = 60) -> None:
+ assert max_size >= 1
+
+ self.max_size = max_size
+ self._ring: deque[ClipboardData] = deque()
+
+ if data is not None:
+ self.set_data(data)
+
+ def set_data(self, data: ClipboardData) -> None:
+ self._ring.appendleft(data)
+
+ while len(self._ring) > self.max_size:
+ self._ring.pop()
+
+ def get_data(self) -> ClipboardData:
+ if self._ring:
+ return self._ring[0]
+ else:
+ return ClipboardData()
+
+ def rotate(self) -> None:
+ if self._ring:
+ # Add the very first item at the end.
+ self._ring.append(self._ring.popleft())
diff --git a/src/prompt_toolkit/clipboard/pyperclip.py b/src/prompt_toolkit/clipboard/pyperclip.py
new file mode 100644
index 0000000..66eb711
--- /dev/null
+++ b/src/prompt_toolkit/clipboard/pyperclip.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import pyperclip
+
+from prompt_toolkit.selection import SelectionType
+
+from .base import Clipboard, ClipboardData
+
+__all__ = [
+ "PyperclipClipboard",
+]
+
+
+class PyperclipClipboard(Clipboard):
+ """
+ Clipboard that synchronizes with the Windows/Mac/Linux system clipboard,
+ using the pyperclip module.
+ """
+
+ def __init__(self) -> None:
+ self._data: ClipboardData | None = None
+
+ def set_data(self, data: ClipboardData) -> None:
+ self._data = data
+ pyperclip.copy(data.text)
+
+ def get_data(self) -> ClipboardData:
+ text = pyperclip.paste()
+
+ # When the clipboard data is equal to what we copied last time, reuse
+ # the `ClipboardData` instance. That way we're sure to keep the same
+ # `SelectionType`.
+ if self._data and self._data.text == text:
+ return self._data
+
+ # Pyperclip returned something else. Create a new `ClipboardData`
+ # instance.
+ else:
+ return ClipboardData(
+ text=text,
+ type=SelectionType.LINES if "\n" in text else SelectionType.CHARACTERS,
+ )
diff --git a/src/prompt_toolkit/completion/__init__.py b/src/prompt_toolkit/completion/__init__.py
new file mode 100644
index 0000000..f65a94e
--- /dev/null
+++ b/src/prompt_toolkit/completion/__init__.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from .base import (
+ CompleteEvent,
+ Completer,
+ Completion,
+ ConditionalCompleter,
+ DummyCompleter,
+ DynamicCompleter,
+ ThreadedCompleter,
+ get_common_complete_suffix,
+ merge_completers,
+)
+from .deduplicate import DeduplicateCompleter
+from .filesystem import ExecutableCompleter, PathCompleter
+from .fuzzy_completer import FuzzyCompleter, FuzzyWordCompleter
+from .nested import NestedCompleter
+from .word_completer import WordCompleter
+
+__all__ = [
+ # Base.
+ "Completion",
+ "Completer",
+ "ThreadedCompleter",
+ "DummyCompleter",
+ "DynamicCompleter",
+ "CompleteEvent",
+ "ConditionalCompleter",
+ "merge_completers",
+ "get_common_complete_suffix",
+ # Filesystem.
+ "PathCompleter",
+ "ExecutableCompleter",
+ # Fuzzy
+ "FuzzyCompleter",
+ "FuzzyWordCompleter",
+ # Nested.
+ "NestedCompleter",
+ # Word completer.
+ "WordCompleter",
+ # Deduplicate
+ "DeduplicateCompleter",
+]
diff --git a/src/prompt_toolkit/completion/base.py b/src/prompt_toolkit/completion/base.py
new file mode 100644
index 0000000..04a712d
--- /dev/null
+++ b/src/prompt_toolkit/completion/base.py
@@ -0,0 +1,451 @@
+"""
+"""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import AsyncGenerator, Callable, Iterable, Sequence
+
+from prompt_toolkit.document import Document
+from prompt_toolkit.eventloop import aclosing, generator_to_async_generator
+from prompt_toolkit.filters import FilterOrBool, to_filter
+from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples
+
+__all__ = [
+ "Completion",
+ "Completer",
+ "ThreadedCompleter",
+ "DummyCompleter",
+ "DynamicCompleter",
+ "CompleteEvent",
+ "ConditionalCompleter",
+ "merge_completers",
+ "get_common_complete_suffix",
+]
+
+
+class Completion:
+ """
+ :param text: The new string that will be inserted into the document.
+ :param start_position: Position relative to the cursor_position where the
+ new text will start. The text will be inserted between the
+ start_position and the original cursor position.
+ :param display: (optional string or formatted text) If the completion has
+ to be displayed differently in the completion menu.
+ :param display_meta: (Optional string or formatted text) Meta information
+ about the completion, e.g. the path or source where it's coming from.
+ This can also be a callable that returns a string.
+ :param style: Style string.
+ :param selected_style: Style string, used for a selected completion.
+ This can override the `style` parameter.
+ """
+
+ def __init__(
+ self,
+ text: str,
+ start_position: int = 0,
+ display: AnyFormattedText | None = None,
+ display_meta: AnyFormattedText | None = None,
+ style: str = "",
+ selected_style: str = "",
+ ) -> None:
+ from prompt_toolkit.formatted_text import to_formatted_text
+
+ self.text = text
+ self.start_position = start_position
+ self._display_meta = display_meta
+
+ if display is None:
+ display = text
+
+ self.display = to_formatted_text(display)
+
+ self.style = style
+ self.selected_style = selected_style
+
+ assert self.start_position <= 0
+
+ def __repr__(self) -> str:
+ if isinstance(self.display, str) and self.display == self.text:
+ return "{}(text={!r}, start_position={!r})".format(
+ self.__class__.__name__,
+ self.text,
+ self.start_position,
+ )
+ else:
+ return "{}(text={!r}, start_position={!r}, display={!r})".format(
+ self.__class__.__name__,
+ self.text,
+ self.start_position,
+ self.display,
+ )
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, Completion):
+ return False
+ return (
+ self.text == other.text
+ and self.start_position == other.start_position
+ and self.display == other.display
+ and self._display_meta == other._display_meta
+ )
+
+ def __hash__(self) -> int:
+ return hash((self.text, self.start_position, self.display, self._display_meta))
+
+ @property
+ def display_text(self) -> str:
+ "The 'display' field as plain text."
+ from prompt_toolkit.formatted_text import fragment_list_to_text
+
+ return fragment_list_to_text(self.display)
+
+ @property
+ def display_meta(self) -> StyleAndTextTuples:
+ "Return meta-text. (This is lazy when using a callable)."
+ from prompt_toolkit.formatted_text import to_formatted_text
+
+ return to_formatted_text(self._display_meta or "")
+
+ @property
+ def display_meta_text(self) -> str:
+ "The 'meta' field as plain text."
+ from prompt_toolkit.formatted_text import fragment_list_to_text
+
+ return fragment_list_to_text(self.display_meta)
+
+ def new_completion_from_position(self, position: int) -> Completion:
+ """
+ (Only for internal use!)
+ Get a new completion by splitting this one. Used by `Application` when
+ it needs to have a list of new completions after inserting the common
+ prefix.
+ """
+ assert position - self.start_position >= 0
+
+ return Completion(
+ text=self.text[position - self.start_position :],
+ display=self.display,
+ display_meta=self._display_meta,
+ )
+
+
+class CompleteEvent:
+ """
+ Event that called the completer.
+
+ :param text_inserted: When True, it means that completions are requested
+ because of a text insert. (`Buffer.complete_while_typing`.)
+ :param completion_requested: When True, it means that the user explicitly
+ pressed the `Tab` key in order to view the completions.
+
+ These two flags can be used for instance to implement a completer that
+ shows some completions when ``Tab`` has been pressed, but not
+ automatically when the user presses a space. (Because of
+ `complete_while_typing`.)
+ """
+
+ def __init__(
+ self, text_inserted: bool = False, completion_requested: bool = False
+ ) -> None:
+ assert not (text_inserted and completion_requested)
+
+ #: Automatic completion while typing.
+ self.text_inserted = text_inserted
+
+ #: Used explicitly requested completion by pressing 'tab'.
+ self.completion_requested = completion_requested
+
+ def __repr__(self) -> str:
+ return "{}(text_inserted={!r}, completion_requested={!r})".format(
+ self.__class__.__name__,
+ self.text_inserted,
+ self.completion_requested,
+ )
+
+
+class Completer(metaclass=ABCMeta):
+ """
+ Base class for completer implementations.
+ """
+
+ @abstractmethod
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ """
+ This should be a generator that yields :class:`.Completion` instances.
+
+ If the generation of completions is something expensive (that takes a
+ lot of time), consider wrapping this `Completer` class in a
+ `ThreadedCompleter`. In that case, the completer algorithm runs in a
+ background thread and completions will be displayed as soon as they
+ arrive.
+
+ :param document: :class:`~prompt_toolkit.document.Document` instance.
+ :param complete_event: :class:`.CompleteEvent` instance.
+ """
+ while False:
+ yield
+
+ async def get_completions_async(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> AsyncGenerator[Completion, None]:
+ """
+ Asynchronous generator for completions. (Probably, you won't have to
+ override this.)
+
+ Asynchronous generator of :class:`.Completion` objects.
+ """
+ for item in self.get_completions(document, complete_event):
+ yield item
+
+
+class ThreadedCompleter(Completer):
+ """
+ Wrapper that runs the `get_completions` generator in a thread.
+
+ (Use this to prevent the user interface from becoming unresponsive if the
+ generation of completions takes too much time.)
+
+ The completions will be displayed as soon as they are produced. The user
+ can already select a completion, even if not all completions are displayed.
+ """
+
+ def __init__(self, completer: Completer) -> None:
+ self.completer = completer
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ return self.completer.get_completions(document, complete_event)
+
+ async def get_completions_async(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> AsyncGenerator[Completion, None]:
+ """
+ Asynchronous generator of completions.
+ """
+ # NOTE: Right now, we are consuming the `get_completions` generator in
+ # a synchronous background thread, then passing the results one
+ # at a time over a queue, and consuming this queue in the main
+ # thread (that's what `generator_to_async_generator` does). That
+ # means that if the completer is *very* slow, we'll be showing
+ # completions in the UI once they are computed.
+
+ # It's very tempting to replace this implementation with the
+ # commented code below for several reasons:
+
+ # - `generator_to_async_generator` is not perfect and hard to get
+ # right. It's a lot of complexity for little gain. The
+ # implementation needs a huge buffer for it to be efficient
+ # when there are many completions (like 50k+).
+ # - Normally, a completer is supposed to be fast, users can have
+ # "complete while typing" enabled, and want to see the
+ # completions within a second. Handling one completion at a
+ # time, and rendering once we get it here doesn't make any
+ # sense if this is quick anyway.
+ # - Completers like `FuzzyCompleter` prepare all completions
+ # anyway so that they can be sorted by accuracy before they are
+ # yielded. At the point that we start yielding completions
+ # here, we already have all completions.
+ # - The `Buffer` class has complex logic to invalidate the UI
+ # while it is consuming the completions. We don't want to
+ # invalidate the UI for every completion (if there are many),
+ # but we want to do it often enough so that completions are
+ # being displayed while they are produced.
+
+ # We keep the current behavior mainly for backward-compatibility.
+ # Similarly, it would be better for this function to not return
+ # an async generator, but simply be a coroutine that returns a
+ # list of `Completion` objects, containing all completions at
+ # once.
+
+ # Note that this argument doesn't mean we shouldn't use
+ # `ThreadedCompleter`. It still makes sense to produce
+ # completions in a background thread, because we don't want to
+ # freeze the UI while the user is typing. But sending the
+ # completions one at a time to the UI maybe isn't worth it.
+
+ # def get_all_in_thread() -> List[Completion]:
+ # return list(self.get_completions(document, complete_event))
+
+ # completions = await get_running_loop().run_in_executor(None, get_all_in_thread)
+ # for completion in completions:
+ # yield completion
+
+ async with aclosing(
+ generator_to_async_generator(
+ lambda: self.completer.get_completions(document, complete_event)
+ )
+ ) as async_generator:
+ async for completion in async_generator:
+ yield completion
+
+ def __repr__(self) -> str:
+ return f"ThreadedCompleter({self.completer!r})"
+
+
+class DummyCompleter(Completer):
+ """
+ A completer that doesn't return any completion.
+ """
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ return []
+
+ def __repr__(self) -> str:
+ return "DummyCompleter()"
+
+
+class DynamicCompleter(Completer):
+ """
+ Completer class that can dynamically returns any Completer.
+
+ :param get_completer: Callable that returns a :class:`.Completer` instance.
+ """
+
+ def __init__(self, get_completer: Callable[[], Completer | None]) -> None:
+ self.get_completer = get_completer
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ completer = self.get_completer() or DummyCompleter()
+ return completer.get_completions(document, complete_event)
+
+ async def get_completions_async(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> AsyncGenerator[Completion, None]:
+ completer = self.get_completer() or DummyCompleter()
+
+ async for completion in completer.get_completions_async(
+ document, complete_event
+ ):
+ yield completion
+
+ def __repr__(self) -> str:
+ return f"DynamicCompleter({self.get_completer!r} -> {self.get_completer()!r})"
+
+
+class ConditionalCompleter(Completer):
+ """
+ Wrapper around any other completer that will enable/disable the completions
+ depending on whether the received condition is satisfied.
+
+ :param completer: :class:`.Completer` instance.
+ :param filter: :class:`.Filter` instance.
+ """
+
+ def __init__(self, completer: Completer, filter: FilterOrBool) -> None:
+ self.completer = completer
+ self.filter = to_filter(filter)
+
+ def __repr__(self) -> str:
+ return f"ConditionalCompleter({self.completer!r}, filter={self.filter!r})"
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ # Get all completions in a blocking way.
+ if self.filter():
+ yield from self.completer.get_completions(document, complete_event)
+
+ async def get_completions_async(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> AsyncGenerator[Completion, None]:
+ # Get all completions in a non-blocking way.
+ if self.filter():
+ async with aclosing(
+ self.completer.get_completions_async(document, complete_event)
+ ) as async_generator:
+ async for item in async_generator:
+ yield item
+
+
+class _MergedCompleter(Completer):
+ """
+ Combine several completers into one.
+ """
+
+ def __init__(self, completers: Sequence[Completer]) -> None:
+ self.completers = completers
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ # Get all completions from the other completers in a blocking way.
+ for completer in self.completers:
+ yield from completer.get_completions(document, complete_event)
+
+ async def get_completions_async(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> AsyncGenerator[Completion, None]:
+ # Get all completions from the other completers in a non-blocking way.
+ for completer in self.completers:
+ async with aclosing(
+ completer.get_completions_async(document, complete_event)
+ ) as async_generator:
+ async for item in async_generator:
+ yield item
+
+
+def merge_completers(
+ completers: Sequence[Completer], deduplicate: bool = False
+) -> Completer:
+ """
+ Combine several completers into one.
+
+ :param deduplicate: If `True`, wrap the result in a `DeduplicateCompleter`
+ so that completions that would result in the same text will be
+ deduplicated.
+ """
+ if deduplicate:
+ from .deduplicate import DeduplicateCompleter
+
+ return DeduplicateCompleter(_MergedCompleter(completers))
+
+ return _MergedCompleter(completers)
+
+
+def get_common_complete_suffix(
+ document: Document, completions: Sequence[Completion]
+) -> str:
+ """
+ Return the common prefix for all completions.
+ """
+
+ # Take only completions that don't change the text before the cursor.
+ def doesnt_change_before_cursor(completion: Completion) -> bool:
+ end = completion.text[: -completion.start_position]
+ return document.text_before_cursor.endswith(end)
+
+ completions2 = [c for c in completions if doesnt_change_before_cursor(c)]
+
+ # When there is at least one completion that changes the text before the
+ # cursor, don't return any common part.
+ if len(completions2) != len(completions):
+ return ""
+
+ # Return the common prefix.
+ def get_suffix(completion: Completion) -> str:
+ return completion.text[-completion.start_position :]
+
+ return _commonprefix([get_suffix(c) for c in completions2])
+
+
+def _commonprefix(strings: Iterable[str]) -> str:
+ # Similar to os.path.commonprefix
+ if not strings:
+ return ""
+
+ else:
+ s1 = min(strings)
+ s2 = max(strings)
+
+ for i, c in enumerate(s1):
+ if c != s2[i]:
+ return s1[:i]
+
+ return s1
diff --git a/src/prompt_toolkit/completion/deduplicate.py b/src/prompt_toolkit/completion/deduplicate.py
new file mode 100644
index 0000000..c3d5256
--- /dev/null
+++ b/src/prompt_toolkit/completion/deduplicate.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from typing import Iterable
+
+from prompt_toolkit.document import Document
+
+from .base import CompleteEvent, Completer, Completion
+
+__all__ = ["DeduplicateCompleter"]
+
+
+class DeduplicateCompleter(Completer):
+ """
+ Wrapper around a completer that removes duplicates. Only the first unique
+ completions are kept.
+
+ Completions are considered to be a duplicate if they result in the same
+ document text when they would be applied.
+ """
+
+ def __init__(self, completer: Completer) -> None:
+ self.completer = completer
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ # Keep track of the document strings we'd get after applying any completion.
+ found_so_far: set[str] = set()
+
+ for completion in self.completer.get_completions(document, complete_event):
+ text_if_applied = (
+ document.text[: document.cursor_position + completion.start_position]
+ + completion.text
+ + document.text[document.cursor_position :]
+ )
+
+ if text_if_applied == document.text:
+ # Don't include completions that don't have any effect at all.
+ continue
+
+ if text_if_applied in found_so_far:
+ continue
+
+ found_so_far.add(text_if_applied)
+ yield completion
diff --git a/src/prompt_toolkit/completion/filesystem.py b/src/prompt_toolkit/completion/filesystem.py
new file mode 100644
index 0000000..8e7f87e
--- /dev/null
+++ b/src/prompt_toolkit/completion/filesystem.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+import os
+from typing import Callable, Iterable
+
+from prompt_toolkit.completion import CompleteEvent, Completer, Completion
+from prompt_toolkit.document import Document
+
+__all__ = [
+ "PathCompleter",
+ "ExecutableCompleter",
+]
+
+
+class PathCompleter(Completer):
+ """
+ Complete for Path variables.
+
+ :param get_paths: Callable which returns a list of directories to look into
+ when the user enters a relative path.
+ :param file_filter: Callable which takes a filename and returns whether
+ this file should show up in the completion. ``None``
+ when no filtering has to be done.
+ :param min_input_len: Don't do autocompletion when the input string is shorter.
+ """
+
+ def __init__(
+ self,
+ only_directories: bool = False,
+ get_paths: Callable[[], list[str]] | None = None,
+ file_filter: Callable[[str], bool] | None = None,
+ min_input_len: int = 0,
+ expanduser: bool = False,
+ ) -> None:
+ self.only_directories = only_directories
+ self.get_paths = get_paths or (lambda: ["."])
+ self.file_filter = file_filter or (lambda _: True)
+ self.min_input_len = min_input_len
+ self.expanduser = expanduser
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ text = document.text_before_cursor
+
+ # Complete only when we have at least the minimal input length,
+ # otherwise, we can too many results and autocompletion will become too
+ # heavy.
+ if len(text) < self.min_input_len:
+ return
+
+ try:
+ # Do tilde expansion.
+ if self.expanduser:
+ text = os.path.expanduser(text)
+
+ # Directories where to look.
+ dirname = os.path.dirname(text)
+ if dirname:
+ directories = [
+ os.path.dirname(os.path.join(p, text)) for p in self.get_paths()
+ ]
+ else:
+ directories = self.get_paths()
+
+ # Start of current file.
+ prefix = os.path.basename(text)
+
+ # Get all filenames.
+ filenames = []
+ for directory in directories:
+ # Look for matches in this directory.
+ if os.path.isdir(directory):
+ for filename in os.listdir(directory):
+ if filename.startswith(prefix):
+ filenames.append((directory, filename))
+
+ # Sort
+ filenames = sorted(filenames, key=lambda k: k[1])
+
+ # Yield them.
+ for directory, filename in filenames:
+ completion = filename[len(prefix) :]
+ full_name = os.path.join(directory, filename)
+
+ if os.path.isdir(full_name):
+ # For directories, add a slash to the filename.
+ # (We don't add them to the `completion`. Users can type it
+ # to trigger the autocompletion themselves.)
+ filename += "/"
+ elif self.only_directories:
+ continue
+
+ if not self.file_filter(full_name):
+ continue
+
+ yield Completion(
+ text=completion,
+ start_position=0,
+ display=filename,
+ )
+ except OSError:
+ pass
+
+
+class ExecutableCompleter(PathCompleter):
+ """
+ Complete only executable files in the current path.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(
+ only_directories=False,
+ min_input_len=1,
+ get_paths=lambda: os.environ.get("PATH", "").split(os.pathsep),
+ file_filter=lambda name: os.access(name, os.X_OK),
+ expanduser=True,
+ )
diff --git a/src/prompt_toolkit/completion/fuzzy_completer.py b/src/prompt_toolkit/completion/fuzzy_completer.py
new file mode 100644
index 0000000..25ea892
--- /dev/null
+++ b/src/prompt_toolkit/completion/fuzzy_completer.py
@@ -0,0 +1,213 @@
+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",
+]
+
+
+class FuzzyCompleter(Completer):
+ """
+ Fuzzy completion.
+ This wraps any other completer and turns it into a fuzzy completer.
+
+ If the list of words is: ["leopard" , "gorilla", "dinosaur", "cat", "bee"]
+ Then trying to complete "oar" would yield "leopard" and "dinosaur", but not
+ the others, because they match the regular expression 'o.*a.*r'.
+ Similar, in another application "djm" could expand to "django_migrations".
+
+ The results are sorted by relevance, which is defined as the start position
+ and the length of the match.
+
+ Notice that this is not really a tool to work around spelling mistakes,
+ like what would be possible with difflib. The purpose is rather to have a
+ quicker or more intuitive way to filter the given completions, especially
+ when many completions have a common prefix.
+
+ Fuzzy algorithm is based on this post:
+ https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python
+
+ :param completer: A :class:`~.Completer` instance.
+ :param WORD: When True, use WORD characters.
+ :param pattern: Regex pattern which selects the characters before the
+ cursor that are considered for the fuzzy matching.
+ :param enable_fuzzy: (bool or `Filter`) Enabled the fuzzy behavior. For
+ easily turning fuzzyness on or off according to a certain condition.
+ """
+
+ def __init__(
+ self,
+ completer: Completer,
+ WORD: bool = False,
+ pattern: str | None = None,
+ enable_fuzzy: FilterOrBool = True,
+ ) -> None:
+ assert pattern is None or pattern.startswith("^")
+
+ self.completer = completer
+ self.pattern = pattern
+ self.WORD = WORD
+ self.pattern = pattern
+ self.enable_fuzzy = to_filter(enable_fuzzy)
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ if self.enable_fuzzy():
+ return self._get_fuzzy_completions(document, complete_event)
+ else:
+ return self.completer.get_completions(document, complete_event)
+
+ def _get_pattern(self) -> str:
+ if self.pattern:
+ return self.pattern
+ if self.WORD:
+ return r"[^\s]+"
+ return "^[a-zA-Z0-9_]*"
+
+ def _get_fuzzy_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ word_before_cursor = document.get_word_before_cursor(
+ pattern=re.compile(self._get_pattern())
+ )
+
+ # Get completions
+ document2 = Document(
+ text=document.text[: document.cursor_position - len(word_before_cursor)],
+ cursor_position=document.cursor_position - len(word_before_cursor),
+ )
+
+ inner_completions = list(
+ self.completer.get_completions(document2, complete_event)
+ )
+
+ fuzzy_matches: list[_FuzzyMatch] = []
+
+ if word_before_cursor == "":
+ # If word before the cursor is an empty string, consider all
+ # completions, without filtering everything with an empty regex
+ # pattern.
+ fuzzy_matches = [_FuzzyMatch(0, 0, compl) for compl in inner_completions]
+ else:
+ pat = ".*?".join(map(re.escape, word_before_cursor))
+ pat = f"(?=({pat}))" # lookahead regex to manage overlapping matches
+ regex = re.compile(pat, re.IGNORECASE)
+ for compl in inner_completions:
+ matches = list(regex.finditer(compl.text))
+ if matches:
+ # Prefer the match, closest to the left, then shortest.
+ best = min(matches, key=lambda m: (m.start(), len(m.group(1))))
+ fuzzy_matches.append(
+ _FuzzyMatch(len(best.group(1)), best.start(), compl)
+ )
+
+ def sort_key(fuzzy_match: _FuzzyMatch) -> tuple[int, int]:
+ "Sort by start position, then by the length of the match."
+ return fuzzy_match.start_pos, fuzzy_match.match_length
+
+ fuzzy_matches = sorted(fuzzy_matches, key=sort_key)
+
+ for match in fuzzy_matches:
+ # Include these completions, but set the correct `display`
+ # attribute and `start_position`.
+ yield Completion(
+ text=match.completion.text,
+ start_position=match.completion.start_position
+ - len(word_before_cursor),
+ # We access to private `_display_meta` attribute, because that one is lazy.
+ display_meta=match.completion._display_meta,
+ display=self._get_display(match, word_before_cursor),
+ style=match.completion.style,
+ )
+
+ def _get_display(
+ self, fuzzy_match: _FuzzyMatch, word_before_cursor: str
+ ) -> AnyFormattedText:
+ """
+ Generate formatted text for the display label.
+ """
+
+ def get_display() -> AnyFormattedText:
+ m = fuzzy_match
+ word = m.completion.text
+
+ if m.match_length == 0:
+ # No highlighting when we have zero length matches (no input text).
+ # In this case, use the original display text (which can include
+ # additional styling or characters).
+ return m.completion.display
+
+ result: StyleAndTextTuples = []
+
+ # Text before match.
+ result.append(("class:fuzzymatch.outside", word[: m.start_pos]))
+
+ # The match itself.
+ characters = list(word_before_cursor)
+
+ for c in word[m.start_pos : m.start_pos + m.match_length]:
+ classname = "class:fuzzymatch.inside"
+ if characters and c.lower() == characters[0].lower():
+ classname += ".character"
+ del characters[0]
+
+ result.append((classname, c))
+
+ # Text after match.
+ result.append(
+ ("class:fuzzymatch.outside", word[m.start_pos + m.match_length :])
+ )
+
+ return result
+
+ return get_display()
+
+
+class FuzzyWordCompleter(Completer):
+ """
+ Fuzzy completion on a list of words.
+
+ (This is basically a `WordCompleter` wrapped in a `FuzzyCompleter`.)
+
+ :param words: List of words or callable that returns a list of words.
+ :param meta_dict: Optional dict mapping words to their meta-information.
+ :param WORD: When True, use WORD characters.
+ """
+
+ def __init__(
+ self,
+ words: 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)
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ return self.fuzzy_completer.get_completions(document, complete_event)
+
+
+class _FuzzyMatch(NamedTuple):
+ match_length: int
+ start_pos: int
+ completion: Completion
diff --git a/src/prompt_toolkit/completion/nested.py b/src/prompt_toolkit/completion/nested.py
new file mode 100644
index 0000000..a1d211a
--- /dev/null
+++ b/src/prompt_toolkit/completion/nested.py
@@ -0,0 +1,108 @@
+"""
+Nestedcompleter for completion of hierarchical data structures.
+"""
+from __future__ import annotations
+
+from typing import Any, Iterable, Mapping, Set, Union
+
+from prompt_toolkit.completion import CompleteEvent, Completer, Completion
+from prompt_toolkit.completion.word_completer import WordCompleter
+from prompt_toolkit.document import Document
+
+__all__ = ["NestedCompleter"]
+
+# NestedDict = Mapping[str, Union['NestedDict', Set[str], None, Completer]]
+NestedDict = Mapping[str, Union[Any, Set[str], None, Completer]]
+
+
+class NestedCompleter(Completer):
+ """
+ Completer which wraps around several other completers, and calls any the
+ one that corresponds with the first word of the input.
+
+ By combining multiple `NestedCompleter` instances, we can achieve multiple
+ hierarchical levels of autocompletion. This is useful when `WordCompleter`
+ is not sufficient.
+
+ If you need multiple levels, check out the `from_nested_dict` classmethod.
+ """
+
+ def __init__(
+ self, options: dict[str, Completer | None], ignore_case: bool = True
+ ) -> None:
+ self.options = options
+ self.ignore_case = ignore_case
+
+ def __repr__(self) -> str:
+ return f"NestedCompleter({self.options!r}, ignore_case={self.ignore_case!r})"
+
+ @classmethod
+ def from_nested_dict(cls, data: NestedDict) -> NestedCompleter:
+ """
+ Create a `NestedCompleter`, starting from a nested dictionary data
+ structure, like this:
+
+ .. code::
+
+ data = {
+ 'show': {
+ 'version': None,
+ 'interfaces': None,
+ 'clock': None,
+ 'ip': {'interface': {'brief'}}
+ },
+ 'exit': None
+ 'enable': None
+ }
+
+ The value should be `None` if there is no further completion at some
+ point. If all values in the dictionary are None, it is also possible to
+ use a set instead.
+
+ Values in this data structure can be a completers as well.
+ """
+ options: dict[str, Completer | None] = {}
+ for key, value in data.items():
+ if isinstance(value, Completer):
+ options[key] = value
+ elif isinstance(value, dict):
+ options[key] = cls.from_nested_dict(value)
+ elif isinstance(value, set):
+ options[key] = cls.from_nested_dict({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
new file mode 100644
index 0000000..6ef4031
--- /dev/null
+++ b/src/prompt_toolkit/completion/word_completer.py
@@ -0,0 +1,94 @@
+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",
+]
+
+
+class WordCompleter(Completer):
+ """
+ Simple autocompletion on a list of words.
+
+ :param words: List of words or callable that returns a list of words.
+ :param ignore_case: If True, case-insensitive completion.
+ :param meta_dict: Optional dict mapping words to their meta-text. (This
+ should map strings to strings or formatted text.)
+ :param WORD: When True, use WORD characters.
+ :param sentence: When True, don't complete by comparing the word before the
+ cursor, but by comparing all the text before the cursor. In this case,
+ the list of words is just a list of strings, where each string can
+ contain spaces. (Can not be used together with the WORD option.)
+ :param match_middle: When True, match not only the start, but also in the
+ middle of the word.
+ :param pattern: Optional compiled regex for finding the word before
+ the cursor to complete. When given, use this regex pattern instead of
+ default one (see document._FIND_WORD_RE)
+ """
+
+ def __init__(
+ self,
+ words: 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 {}
+ self.meta_dict = meta_dict or {}
+ self.WORD = WORD
+ self.sentence = sentence
+ self.match_middle = match_middle
+ self.pattern = pattern
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ # Get list of words.
+ words = self.words
+ if callable(words):
+ words = words()
+
+ # Get word/text before cursor.
+ if self.sentence:
+ word_before_cursor = document.text_before_cursor
+ else:
+ word_before_cursor = document.get_word_before_cursor(
+ WORD=self.WORD, pattern=self.pattern
+ )
+
+ if self.ignore_case:
+ word_before_cursor = word_before_cursor.lower()
+
+ def word_matches(word: str) -> bool:
+ """True when the word before the cursor matches."""
+ if self.ignore_case:
+ word = word.lower()
+
+ if self.match_middle:
+ return word_before_cursor in word
+ else:
+ return word.startswith(word_before_cursor)
+
+ for a in words:
+ if word_matches(a):
+ display = self.display_dict.get(a, a)
+ display_meta = self.meta_dict.get(a, "")
+ yield Completion(
+ text=a,
+ start_position=-len(word_before_cursor),
+ display=display,
+ display_meta=display_meta,
+ )
diff --git a/src/prompt_toolkit/contrib/__init__.py b/src/prompt_toolkit/contrib/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/prompt_toolkit/contrib/__init__.py
diff --git a/src/prompt_toolkit/contrib/completers/__init__.py b/src/prompt_toolkit/contrib/completers/__init__.py
new file mode 100644
index 0000000..172fe6f
--- /dev/null
+++ b/src/prompt_toolkit/contrib/completers/__init__.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from .system import SystemCompleter
+
+__all__ = ["SystemCompleter"]
diff --git a/src/prompt_toolkit/contrib/completers/system.py b/src/prompt_toolkit/contrib/completers/system.py
new file mode 100644
index 0000000..5d990e5
--- /dev/null
+++ b/src/prompt_toolkit/contrib/completers/system.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from prompt_toolkit.completion.filesystem import ExecutableCompleter, PathCompleter
+from prompt_toolkit.contrib.regular_languages.compiler import compile
+from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter
+
+__all__ = [
+ "SystemCompleter",
+]
+
+
+class SystemCompleter(GrammarCompleter):
+ """
+ Completer for system commands.
+ """
+
+ def __init__(self) -> None:
+ # Compile grammar.
+ g = compile(
+ r"""
+ # First we have an executable.
+ (?P<executable>[^\s]+)
+
+ # Ignore literals in between.
+ (
+ \s+
+ ("[^"]*" | '[^']*' | [^'"]+ )
+ )*
+
+ \s+
+
+ # Filename as parameters.
+ (
+ (?P<filename>[^\s]+) |
+ "(?P<double_quoted_filename>[^\s]+)" |
+ '(?P<single_quoted_filename>[^\s]+)'
+ )
+ """,
+ escape_funcs={
+ "double_quoted_filename": (lambda string: string.replace('"', '\\"')),
+ "single_quoted_filename": (lambda string: string.replace("'", "\\'")),
+ },
+ unescape_funcs={
+ "double_quoted_filename": (
+ lambda string: string.replace('\\"', '"')
+ ), # XXX: not entirely correct.
+ "single_quoted_filename": (lambda string: string.replace("\\'", "'")),
+ },
+ )
+
+ # Create GrammarCompleter
+ super().__init__(
+ g,
+ {
+ "executable": ExecutableCompleter(),
+ "filename": PathCompleter(only_directories=False, expanduser=True),
+ "double_quoted_filename": PathCompleter(
+ only_directories=False, expanduser=True
+ ),
+ "single_quoted_filename": PathCompleter(
+ only_directories=False, expanduser=True
+ ),
+ },
+ )
diff --git a/src/prompt_toolkit/contrib/regular_languages/__init__.py b/src/prompt_toolkit/contrib/regular_languages/__init__.py
new file mode 100644
index 0000000..c947fd5
--- /dev/null
+++ b/src/prompt_toolkit/contrib/regular_languages/__init__.py
@@ -0,0 +1,79 @@
+r"""
+Tool for expressing the grammar of an input as a regular language.
+==================================================================
+
+The grammar for the input of many simple command line interfaces can be
+expressed by a regular language. Examples are PDB (the Python debugger); a
+simple (bash-like) shell with "pwd", "cd", "cat" and "ls" commands; arguments
+that you can pass to an executable; etc. It is possible to use regular
+expressions for validation and parsing of such a grammar. (More about regular
+languages: http://en.wikipedia.org/wiki/Regular_language)
+
+Example
+-------
+
+Let's take the pwd/cd/cat/ls example. We want to have a shell that accepts
+these three commands. "cd" is followed by a quoted directory name and "cat" is
+followed by a quoted file name. (We allow quotes inside the filename when
+they're escaped with a backslash.) We could define the grammar using the
+following regular expression::
+
+ grammar = \s* (
+ pwd |
+ ls |
+ (cd \s+ " ([^"]|\.)+ ") |
+ (cat \s+ " ([^"]|\.)+ ")
+ ) \s*
+
+
+What can we do with this grammar?
+---------------------------------
+
+- Syntax highlighting: We could use this for instance to give file names
+ different color.
+- Parse the result: .. We can extract the file names and commands by using a
+ regular expression with named groups.
+- Input validation: .. Don't accept anything that does not match this grammar.
+ When combined with a parser, we can also recursively do
+ filename validation (and accept only existing files.)
+- Autocompletion: .... Each part of the grammar can have its own autocompleter.
+ "cat" has to be completed using file names, while "cd"
+ has to be completed using directory names.
+
+How does it work?
+-----------------
+
+As a user of this library, you have to define the grammar of the input as a
+regular expression. The parts of this grammar where autocompletion, validation
+or any other processing is required need to be marked using a regex named
+group. Like ``(?P<varname>...)`` for instance.
+
+When the input is processed for validation (for instance), the regex will
+execute, the named group is captured, and the validator associated with this
+named group will test the captured string.
+
+There is one tricky bit:
+
+ Often we operate on incomplete input (this is by definition the case for
+ autocompletion) and we have to decide for the cursor position in which
+ possible state the grammar it could be and in which way variables could be
+ matched up to that point.
+
+To solve this problem, the compiler takes the original regular expression and
+translates it into a set of other regular expressions which each match certain
+prefixes of the original regular expression. We generate one prefix regular
+expression for every named variable (with this variable being the end of that
+expression).
+
+
+TODO: some examples of:
+ - How to create a highlighter from this grammar.
+ - How to create a validator from this grammar.
+ - How to create an autocompleter from this grammar.
+ - How to create a parser from this grammar.
+"""
+from __future__ import annotations
+
+from .compiler import compile
+
+__all__ = ["compile"]
diff --git a/src/prompt_toolkit/contrib/regular_languages/compiler.py b/src/prompt_toolkit/contrib/regular_languages/compiler.py
new file mode 100644
index 0000000..474f6cf
--- /dev/null
+++ b/src/prompt_toolkit/contrib/regular_languages/compiler.py
@@ -0,0 +1,571 @@
+r"""
+Compiler for a regular grammar.
+
+Example usage::
+
+ # Create and compile grammar.
+ p = compile('add \s+ (?P<var1>[^\s]+) \s+ (?P<var2>[^\s]+)')
+
+ # Match input string.
+ m = p.match('add 23 432')
+
+ # Get variables.
+ m.variables().get('var1') # Returns "23"
+ m.variables().get('var2') # Returns "432"
+
+
+Partial matches are possible::
+
+ # Create and compile grammar.
+ p = compile('''
+ # Operators with two arguments.
+ ((?P<operator1>[^\s]+) \s+ (?P<var1>[^\s]+) \s+ (?P<var2>[^\s]+)) |
+
+ # Operators with only one arguments.
+ ((?P<operator2>[^\s]+) \s+ (?P<var1>[^\s]+))
+ ''')
+
+ # Match partial input string.
+ m = p.match_prefix('add 23')
+
+ # Get variables. (Notice that both operator1 and operator2 contain the
+ # value "add".) This is because our input is incomplete, and we don't know
+ # yet in which rule of the regex we we'll end up. It could also be that
+ # `operator1` and `operator2` have a different autocompleter and we want to
+ # call all possible autocompleters that would result in valid input.)
+ m.variables().get('var1') # Returns "23"
+ m.variables().get('operator1') # Returns "add"
+ m.variables().get('operator2') # Returns "add"
+
+"""
+from __future__ import annotations
+
+import re
+from typing import Callable, Dict, Iterable, Iterator, Pattern
+from typing import Match as RegexMatch
+
+from .regex_parser import (
+ AnyNode,
+ Lookahead,
+ Node,
+ NodeSequence,
+ Regex,
+ Repeat,
+ Variable,
+ parse_regex,
+ tokenize_regex,
+)
+
+__all__ = [
+ "compile",
+]
+
+
+# Name of the named group in the regex, matching trailing input.
+# (Trailing input is when the input contains characters after the end of the
+# expression has been matched.)
+_INVALID_TRAILING_INPUT = "invalid_trailing"
+
+EscapeFuncDict = Dict[str, Callable[[str], str]]
+
+
+class _CompiledGrammar:
+ """
+ Compiles a grammar. This will take the parse tree of a regular expression
+ and compile the grammar.
+
+ :param root_node: :class~`.regex_parser.Node` instance.
+ :param escape_funcs: `dict` mapping variable names to escape callables.
+ :param unescape_funcs: `dict` mapping variable names to unescape callables.
+ """
+
+ def __init__(
+ self,
+ root_node: Node,
+ escape_funcs: EscapeFuncDict | None = None,
+ unescape_funcs: EscapeFuncDict | None = None,
+ ) -> None:
+ self.root_node = root_node
+ self.escape_funcs = escape_funcs or {}
+ self.unescape_funcs = unescape_funcs or {}
+
+ #: Dictionary that will map the regex names to Node instances.
+ self._group_names_to_nodes: dict[
+ str, str
+ ] = {} # Maps regex group names to varnames.
+ counter = [0]
+
+ def create_group_func(node: Variable) -> str:
+ name = "n%s" % counter[0]
+ self._group_names_to_nodes[name] = node.varname
+ counter[0] += 1
+ return name
+
+ # Compile regex strings.
+ self._re_pattern = "^%s$" % self._transform(root_node, create_group_func)
+ self._re_prefix_patterns = list(
+ self._transform_prefix(root_node, create_group_func)
+ )
+
+ # Compile the regex itself.
+ flags = re.DOTALL # Note that we don't need re.MULTILINE! (^ and $
+ # still represent the start and end of input text.)
+ self._re = re.compile(self._re_pattern, flags)
+ self._re_prefix = [re.compile(t, flags) for t in self._re_prefix_patterns]
+
+ # We compile one more set of regexes, similar to `_re_prefix`, but accept any trailing
+ # input. This will ensure that we can still highlight the input correctly, even when the
+ # input contains some additional characters at the end that don't match the grammar.)
+ self._re_prefix_with_trailing_input = [
+ re.compile(
+ r"(?:{})(?P<{}>.*?)$".format(t.rstrip("$"), _INVALID_TRAILING_INPUT),
+ flags,
+ )
+ for t in self._re_prefix_patterns
+ ]
+
+ def escape(self, varname: str, value: str) -> str:
+ """
+ Escape `value` to fit in the place of this variable into the grammar.
+ """
+ f = self.escape_funcs.get(varname)
+ return f(value) if f else value
+
+ def unescape(self, varname: str, value: str) -> str:
+ """
+ Unescape `value`.
+ """
+ f = self.unescape_funcs.get(varname)
+ return f(value) if f else value
+
+ @classmethod
+ def _transform(
+ cls, root_node: Node, create_group_func: Callable[[Variable], str]
+ ) -> str:
+ """
+ Turn a :class:`Node` object into a regular expression.
+
+ :param root_node: The :class:`Node` instance for which we generate the grammar.
+ :param create_group_func: A callable which takes a `Node` and returns the next
+ free name for this node.
+ """
+
+ def transform(node: Node) -> str:
+ # Turn `AnyNode` into an OR.
+ if isinstance(node, AnyNode):
+ return "(?:%s)" % "|".join(transform(c) for c in node.children)
+
+ # Concatenate a `NodeSequence`
+ elif isinstance(node, NodeSequence):
+ return "".join(transform(c) for c in node.children)
+
+ # For Regex and Lookahead nodes, just insert them literally.
+ elif isinstance(node, Regex):
+ return node.regex
+
+ elif isinstance(node, Lookahead):
+ before = "(?!" if node.negative else "(="
+ return before + transform(node.childnode) + ")"
+
+ # A `Variable` wraps the children into a named group.
+ elif isinstance(node, Variable):
+ return f"(?P<{create_group_func(node)}>{transform(node.childnode)})"
+
+ # `Repeat`.
+ elif isinstance(node, Repeat):
+ if node.max_repeat is None:
+ if node.min_repeat == 0:
+ repeat_sign = "*"
+ elif node.min_repeat == 1:
+ repeat_sign = "+"
+ else:
+ repeat_sign = "{%i,%s}" % (
+ node.min_repeat,
+ ("" if node.max_repeat is None else str(node.max_repeat)),
+ )
+
+ return "(?:{}){}{}".format(
+ transform(node.childnode),
+ repeat_sign,
+ ("" if node.greedy else "?"),
+ )
+ else:
+ raise TypeError(f"Got {node!r}")
+
+ return transform(root_node)
+
+ @classmethod
+ def _transform_prefix(
+ cls, root_node: Node, create_group_func: Callable[[Variable], str]
+ ) -> Iterable[str]:
+ """
+ Yield all the regular expressions matching a prefix of the grammar
+ defined by the `Node` instance.
+
+ For each `Variable`, one regex pattern will be generated, with this
+ named group at the end. This is required because a regex engine will
+ terminate once a match is found. For autocompletion however, we need
+ the matches for all possible paths, so that we can provide completions
+ for each `Variable`.
+
+ - So, in the case of an `Any` (`A|B|C)', we generate a pattern for each
+ clause. This is one for `A`, one for `B` and one for `C`. Unless some
+ groups don't contain a `Variable`, then these can be merged together.
+ - In the case of a `NodeSequence` (`ABC`), we generate a pattern for
+ each prefix that ends with a variable, and one pattern for the whole
+ sequence. So, that's one for `A`, one for `AB` and one for `ABC`.
+
+ :param root_node: The :class:`Node` instance for which we generate the grammar.
+ :param create_group_func: A callable which takes a `Node` and returns the next
+ free name for this node.
+ """
+
+ def contains_variable(node: Node) -> bool:
+ if isinstance(node, Regex):
+ return False
+ elif isinstance(node, Variable):
+ return True
+ elif isinstance(node, (Lookahead, Repeat)):
+ return contains_variable(node.childnode)
+ elif isinstance(node, (NodeSequence, AnyNode)):
+ return any(contains_variable(child) for child in node.children)
+
+ return False
+
+ def transform(node: Node) -> Iterable[str]:
+ # Generate separate pattern for all terms that contain variables
+ # within this OR. Terms that don't contain a variable can be merged
+ # together in one pattern.
+ if isinstance(node, AnyNode):
+ # If we have a definition like:
+ # (?P<name> .*) | (?P<city> .*)
+ # Then we want to be able to generate completions for both the
+ # name as well as the city. We do this by yielding two
+ # different regular expressions, because the engine won't
+ # follow multiple paths, if multiple are possible.
+ children_with_variable = []
+ children_without_variable = []
+ for c in node.children:
+ if contains_variable(c):
+ children_with_variable.append(c)
+ else:
+ children_without_variable.append(c)
+
+ for c in children_with_variable:
+ yield from transform(c)
+
+ # Merge options without variable together.
+ if children_without_variable:
+ yield "|".join(
+ r for c in children_without_variable for r in transform(c)
+ )
+
+ # For a sequence, generate a pattern for each prefix that ends with
+ # a variable + one pattern of the complete sequence.
+ # (This is because, for autocompletion, we match the text before
+ # the cursor, and completions are given for the variable that we
+ # match right before the cursor.)
+ elif isinstance(node, NodeSequence):
+ # For all components in the sequence, compute prefix patterns,
+ # as well as full patterns.
+ complete = [cls._transform(c, create_group_func) for c in node.children]
+ prefixes = [list(transform(c)) for c in node.children]
+ variable_nodes = [contains_variable(c) for c in node.children]
+
+ # If any child is contains a variable, we should yield a
+ # pattern up to that point, so that we are sure this will be
+ # matched.
+ for i in range(len(node.children)):
+ if variable_nodes[i]:
+ for c_str in prefixes[i]:
+ yield "".join(complete[:i]) + c_str
+
+ # If there are non-variable nodes, merge all the prefixes into
+ # one pattern. If the input is: "[part1] [part2] [part3]", then
+ # this gets compiled into:
+ # (complete1 + (complete2 + (complete3 | partial3) | partial2) | partial1 )
+ # For nodes that contain a variable, we skip the "|partial"
+ # part here, because thees are matched with the previous
+ # patterns.
+ if not all(variable_nodes):
+ result = []
+
+ # Start with complete patterns.
+ for i in range(len(node.children)):
+ result.append("(?:")
+ result.append(complete[i])
+
+ # Add prefix patterns.
+ for i in range(len(node.children) - 1, -1, -1):
+ if variable_nodes[i]:
+ # No need to yield a prefix for this one, we did
+ # the variable prefixes earlier.
+ result.append(")")
+ else:
+ result.append("|(?:")
+ # If this yields multiple, we should yield all combinations.
+ assert len(prefixes[i]) == 1
+ result.append(prefixes[i][0])
+ result.append("))")
+
+ yield "".join(result)
+
+ elif isinstance(node, Regex):
+ yield "(?:%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.
+ """
+ m = self._re.match(string)
+
+ if m:
+ return Match(
+ string, [(self._re, m)], self._group_names_to_nodes, self.unescape_funcs
+ )
+ return None
+
+ def match_prefix(self, string: str) -> Match | None:
+ """
+ Do a partial match of the string with the grammar. The returned
+ :class:`Match` instance can contain multiple representations of the
+ match. This will never return `None`. If it doesn't match at all, the "trailing input"
+ part will capture all of the input.
+
+ :param string: The input string.
+ """
+ # First try to match using `_re_prefix`. If nothing is found, use the patterns that
+ # also accept trailing characters.
+ for patterns in [self._re_prefix, self._re_prefix_with_trailing_input]:
+ matches = [(r, r.match(string)) for r in patterns]
+ matches2 = [(r, m) for r, m in matches if m]
+
+ if matches2 != []:
+ return Match(
+ string, matches2, self._group_names_to_nodes, self.unescape_funcs
+ )
+
+ return None
+
+
+class Match:
+ """
+ :param string: The input string.
+ :param re_matches: List of (compiled_re_pattern, re_match) tuples.
+ :param group_names_to_nodes: Dictionary mapping all the re group names to the matching Node instances.
+ """
+
+ def __init__(
+ self,
+ string: str,
+ re_matches: list[tuple[Pattern[str], RegexMatch[str]]],
+ group_names_to_nodes: dict[str, str],
+ unescape_funcs: dict[str, Callable[[str], str]],
+ ):
+ self.string = string
+ self._re_matches = re_matches
+ self._group_names_to_nodes = group_names_to_nodes
+ self._unescape_funcs = unescape_funcs
+
+ def _nodes_to_regs(self) -> list[tuple[str, tuple[int, int]]]:
+ """
+ Return a list of (varname, reg) tuples.
+ """
+
+ def get_tuples() -> Iterable[tuple[str, tuple[int, int]]]:
+ for r, re_match in self._re_matches:
+ for group_name, group_index in r.groupindex.items():
+ if group_name != _INVALID_TRAILING_INPUT:
+ regs = re_match.regs
+ reg = regs[group_index]
+ node = self._group_names_to_nodes[group_name]
+ yield (node, reg)
+
+ return list(get_tuples())
+
+ def _nodes_to_values(self) -> list[tuple[str, str, tuple[int, int]]]:
+ """
+ Returns list of (Node, string_value) tuples.
+ """
+
+ def is_none(sl: tuple[int, int]) -> bool:
+ return sl[0] == -1 and sl[1] == -1
+
+ def get(sl: tuple[int, int]) -> str:
+ return self.string[sl[0] : sl[1]]
+
+ return [
+ (varname, get(slice), slice)
+ for varname, slice in self._nodes_to_regs()
+ if not is_none(slice)
+ ]
+
+ def _unescape(self, varname: str, value: str) -> str:
+ unwrapper = self._unescape_funcs.get(varname)
+ return unwrapper(value) if unwrapper else value
+
+ def variables(self) -> Variables:
+ """
+ Returns :class:`Variables` instance.
+ """
+ return Variables(
+ [(k, self._unescape(k, v), sl) for k, v, sl in self._nodes_to_values()]
+ )
+
+ def trailing_input(self) -> MatchVariable | None:
+ """
+ Get the `MatchVariable` instance, representing trailing input, if there is any.
+ "Trailing input" is input at the end that does not match the grammar anymore, but
+ when this is removed from the end of the input, the input would be a valid string.
+ """
+ slices: list[tuple[int, int]] = []
+
+ # Find all regex group for the name _INVALID_TRAILING_INPUT.
+ for r, re_match in self._re_matches:
+ for group_name, group_index in r.groupindex.items():
+ if group_name == _INVALID_TRAILING_INPUT:
+ slices.append(re_match.regs[group_index])
+
+ # Take the smallest part. (Smaller trailing text means that a larger input has
+ # been matched, so that is better.)
+ if slices:
+ slice = (max(i[0] for i in slices), max(i[1] for i in slices))
+ value = self.string[slice[0] : slice[1]]
+ return MatchVariable("<trailing_input>", value, slice)
+ return None
+
+ def end_nodes(self) -> Iterable[MatchVariable]:
+ """
+ Yields `MatchVariable` instances for all the nodes having their end
+ position at the end of the input string.
+ """
+ for varname, reg in self._nodes_to_regs():
+ # If this part goes until the end of the input string.
+ if reg[1] == len(self.string):
+ value = self._unescape(varname, self.string[reg[0] : reg[1]])
+ yield MatchVariable(varname, value, (reg[0], reg[1]))
+
+
+class Variables:
+ def __init__(self, tuples: list[tuple[str, str, tuple[int, int]]]) -> None:
+ #: List of (varname, value, slice) tuples.
+ self._tuples = tuples
+
+ def __repr__(self) -> str:
+ return "{}({})".format(
+ self.__class__.__name__,
+ ", ".join(f"{k}={v!r}" for k, v, _ in self._tuples),
+ )
+
+ def get(self, key: str, default: 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:
+ return self.get(key)
+
+ def __iter__(self) -> Iterator[MatchVariable]:
+ """
+ Yield `MatchVariable` instances.
+ """
+ for varname, value, slice in self._tuples:
+ yield MatchVariable(varname, value, slice)
+
+
+class MatchVariable:
+ """
+ Represents a match of a variable in the grammar.
+
+ :param varname: (string) Name of the variable.
+ :param value: (string) Value of this variable.
+ :param slice: (start, stop) tuple, indicating the position of this variable
+ in the input string.
+ """
+
+ def __init__(self, varname: str, value: str, slice: tuple[int, int]) -> None:
+ self.varname = varname
+ self.value = value
+ self.slice = slice
+
+ self.start = self.slice[0]
+ self.stop = self.slice[1]
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.varname!r}, {self.value!r})"
+
+
+def compile(
+ expression: str,
+ escape_funcs: EscapeFuncDict | None = None,
+ unescape_funcs: EscapeFuncDict | None = None,
+) -> _CompiledGrammar:
+ """
+ Compile grammar (given as regex string), returning a `CompiledGrammar`
+ instance.
+ """
+ return _compile_from_parse_tree(
+ parse_regex(tokenize_regex(expression)),
+ escape_funcs=escape_funcs,
+ unescape_funcs=unescape_funcs,
+ )
+
+
+def _compile_from_parse_tree(
+ root_node: Node,
+ escape_funcs: EscapeFuncDict | None = None,
+ unescape_funcs: EscapeFuncDict | None = None,
+) -> _CompiledGrammar:
+ """
+ Compile grammar (given as parse tree), returning a `CompiledGrammar`
+ instance.
+ """
+ return _CompiledGrammar(
+ root_node, escape_funcs=escape_funcs, unescape_funcs=unescape_funcs
+ )
diff --git a/src/prompt_toolkit/contrib/regular_languages/completion.py b/src/prompt_toolkit/contrib/regular_languages/completion.py
new file mode 100644
index 0000000..2e353e8
--- /dev/null
+++ b/src/prompt_toolkit/contrib/regular_languages/completion.py
@@ -0,0 +1,94 @@
+"""
+Completer for a regular grammar.
+"""
+from __future__ import annotations
+
+from typing import Iterable
+
+from prompt_toolkit.completion import CompleteEvent, Completer, Completion
+from prompt_toolkit.document import Document
+
+from .compiler import Match, _CompiledGrammar
+
+__all__ = [
+ "GrammarCompleter",
+]
+
+
+class GrammarCompleter(Completer):
+ """
+ Completer which can be used for autocompletion according to variables in
+ the grammar. Each variable can have a different autocompleter.
+
+ :param compiled_grammar: `GrammarCompleter` instance.
+ :param completers: `dict` mapping variable names of the grammar to the
+ `Completer` instances to be used for each variable.
+ """
+
+ def __init__(
+ self, compiled_grammar: _CompiledGrammar, completers: dict[str, Completer]
+ ) -> None:
+ self.compiled_grammar = compiled_grammar
+ self.completers = completers
+
+ def get_completions(
+ self, document: Document, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ m = self.compiled_grammar.match_prefix(document.text_before_cursor)
+
+ if m:
+ completions = self._remove_duplicates(
+ self._get_completions_for_match(m, complete_event)
+ )
+
+ yield from completions
+
+ def _get_completions_for_match(
+ self, match: Match, complete_event: CompleteEvent
+ ) -> Iterable[Completion]:
+ """
+ Yield all the possible completions for this input string.
+ (The completer assumes that the cursor position was at the end of the
+ input string.)
+ """
+ for match_variable in match.end_nodes():
+ varname = match_variable.varname
+ start = match_variable.start
+
+ completer = self.completers.get(varname)
+
+ if completer:
+ text = match_variable.value
+
+ # Unwrap text.
+ unwrapped_text = self.compiled_grammar.unescape(varname, text)
+
+ # Create a document, for the completions API (text/cursor_position)
+ document = Document(unwrapped_text, len(unwrapped_text))
+
+ # Call completer
+ for completion in completer.get_completions(document, complete_event):
+ new_text = (
+ unwrapped_text[: len(text) + completion.start_position]
+ + completion.text
+ )
+
+ # Wrap again.
+ yield Completion(
+ text=self.compiled_grammar.escape(varname, new_text),
+ start_position=start - len(match.string),
+ display=completion.display,
+ display_meta=completion.display_meta,
+ )
+
+ def _remove_duplicates(self, items: Iterable[Completion]) -> list[Completion]:
+ """
+ Remove duplicates, while keeping the order.
+ (Sometimes we have duplicates, because the there several matches of the
+ same grammar, each yielding similar completions.)
+ """
+ result: list[Completion] = []
+ for i in items:
+ if i not in result:
+ result.append(i)
+ return result
diff --git a/src/prompt_toolkit/contrib/regular_languages/lexer.py b/src/prompt_toolkit/contrib/regular_languages/lexer.py
new file mode 100644
index 0000000..b0a4deb
--- /dev/null
+++ b/src/prompt_toolkit/contrib/regular_languages/lexer.py
@@ -0,0 +1,93 @@
+"""
+`GrammarLexer` is compatible with other lexers and can be used to highlight
+the input using a regular grammar with annotations.
+"""
+from __future__ import annotations
+
+from typing import Callable
+
+from prompt_toolkit.document import Document
+from prompt_toolkit.formatted_text.base import StyleAndTextTuples
+from prompt_toolkit.formatted_text.utils import split_lines
+from prompt_toolkit.lexers import Lexer
+
+from .compiler import _CompiledGrammar
+
+__all__ = [
+ "GrammarLexer",
+]
+
+
+class GrammarLexer(Lexer):
+ """
+ Lexer which can be used for highlighting of fragments according to variables in the grammar.
+
+ (It does not actual lexing of the string, but it exposes an API, compatible
+ with the Pygments lexer class.)
+
+ :param compiled_grammar: Grammar as returned by the `compile()` function.
+ :param lexers: Dictionary mapping variable names of the regular grammar to
+ the lexers that should be used for this part. (This can
+ call other lexers recursively.) If you wish a part of the
+ grammar to just get one fragment, use a
+ `prompt_toolkit.lexers.SimpleLexer`.
+ """
+
+ def __init__(
+ self,
+ compiled_grammar: _CompiledGrammar,
+ default_style: str = "",
+ lexers: dict[str, Lexer] | None = None,
+ ) -> None:
+ self.compiled_grammar = compiled_grammar
+ self.default_style = default_style
+ self.lexers = lexers or {}
+
+ def _get_text_fragments(self, text: str) -> StyleAndTextTuples:
+ m = self.compiled_grammar.match_prefix(text)
+
+ if m:
+ characters: StyleAndTextTuples = [(self.default_style, c) for c in text]
+
+ for v in m.variables():
+ # If we have a `Lexer` instance for this part of the input.
+ # Tokenize recursively and apply tokens.
+ lexer = self.lexers.get(v.varname)
+
+ if lexer:
+ document = Document(text[v.start : v.stop])
+ lexer_tokens_for_line = lexer.lex_document(document)
+ text_fragments: StyleAndTextTuples = []
+ for i in range(len(document.lines)):
+ text_fragments.extend(lexer_tokens_for_line(i))
+ text_fragments.append(("", "\n"))
+ if text_fragments:
+ text_fragments.pop()
+
+ i = v.start
+ for t, s, *_ in text_fragments:
+ for c in s:
+ if characters[i][0] == self.default_style:
+ characters[i] = (t, characters[i][1])
+ i += 1
+
+ # Highlight trailing input.
+ trailing_input = m.trailing_input()
+ if trailing_input:
+ for i in range(trailing_input.start, trailing_input.stop):
+ characters[i] = ("class:trailing-input", characters[i][1])
+
+ return characters
+ else:
+ return [("", text)]
+
+ def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
+ lines = list(split_lines(self._get_text_fragments(document.text)))
+
+ def get_line(lineno: int) -> StyleAndTextTuples:
+ try:
+ return lines[lineno]
+ except IndexError:
+ return []
+
+ return get_line
diff --git a/src/prompt_toolkit/contrib/regular_languages/regex_parser.py b/src/prompt_toolkit/contrib/regular_languages/regex_parser.py
new file mode 100644
index 0000000..a365ba8
--- /dev/null
+++ b/src/prompt_toolkit/contrib/regular_languages/regex_parser.py
@@ -0,0 +1,282 @@
+"""
+Parser for parsing a regular expression.
+Take a string representing a regular expression and return the root node of its
+parse tree.
+
+usage::
+
+ root_node = parse_regex('(hello|world)')
+
+Remarks:
+- The regex parser processes multiline, it ignores all whitespace and supports
+ multiple named groups with the same name and #-style comments.
+
+Limitations:
+- Lookahead is not supported.
+"""
+from __future__ import annotations
+
+import re
+
+__all__ = [
+ "Repeat",
+ "Variable",
+ "Regex",
+ "Lookahead",
+ "tokenize_regex",
+ "parse_regex",
+]
+
+
+class Node:
+ """
+ Base class for all the grammar nodes.
+ (You don't initialize this one.)
+ """
+
+ def __add__(self, other_node: Node) -> NodeSequence:
+ return NodeSequence([self, other_node])
+
+ def __or__(self, other_node: Node) -> AnyNode:
+ return AnyNode([self, other_node])
+
+
+class AnyNode(Node):
+ """
+ Union operation (OR operation) between several grammars. You don't
+ initialize this yourself, but it's a result of a "Grammar1 | Grammar2"
+ operation.
+ """
+
+ def __init__(self, children: list[Node]) -> None:
+ self.children = children
+
+ def __or__(self, other_node: Node) -> AnyNode:
+ return AnyNode(self.children + [other_node])
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.children!r})"
+
+
+class NodeSequence(Node):
+ """
+ Concatenation operation of several grammars. You don't initialize this
+ yourself, but it's a result of a "Grammar1 + Grammar2" operation.
+ """
+
+ def __init__(self, children: list[Node]) -> None:
+ self.children = children
+
+ def __add__(self, other_node: Node) -> NodeSequence:
+ return NodeSequence(self.children + [other_node])
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.children!r})"
+
+
+class Regex(Node):
+ """
+ Regular expression.
+ """
+
+ def __init__(self, regex: str) -> None:
+ re.compile(regex) # Validate
+
+ self.regex = regex
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}(/{self.regex}/)"
+
+
+class Lookahead(Node):
+ """
+ Lookahead expression.
+ """
+
+ def __init__(self, childnode: Node, negative: bool = False) -> None:
+ self.childnode = childnode
+ self.negative = negative
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.childnode!r})"
+
+
+class Variable(Node):
+ """
+ Mark a variable in the regular grammar. This will be translated into a
+ named group. Each variable can have his own completer, validator, etc..
+
+ :param childnode: The grammar which is wrapped inside this variable.
+ :param varname: String.
+ """
+
+ def __init__(self, childnode: Node, varname: str = "") -> None:
+ self.childnode = childnode
+ self.varname = varname
+
+ def __repr__(self) -> str:
+ return "{}(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:
+ self.childnode = childnode
+ self.min_repeat = min_repeat
+ self.max_repeat = max_repeat
+ self.greedy = greedy
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}(childnode={self.childnode!r})"
+
+
+def tokenize_regex(input: str) -> list[str]:
+ """
+ Takes a string, representing a regular expression as input, and tokenizes
+ it.
+
+ :param input: string, representing a regular expression.
+ :returns: List of tokens.
+ """
+ # Regular expression for tokenizing other regular expressions.
+ p = re.compile(
+ r"""^(
+ \(\?P\<[a-zA-Z0-9_-]+\> | # Start of named group.
+ \(\?#[^)]*\) | # Comment
+ \(\?= | # Start of lookahead assertion
+ \(\?! | # Start of negative lookahead assertion
+ \(\?<= | # If preceded by.
+ \(\?< | # If not preceded by.
+ \(?: | # Start of group. (non capturing.)
+ \( | # Start of group.
+ \(?[iLmsux] | # Flags.
+ \(?P=[a-zA-Z]+\) | # Back reference to named group
+ \) | # End of group.
+ \{[^{}]*\} | # Repetition
+ \*\? | \+\? | \?\?\ | # Non greedy repetition.
+ \* | \+ | \? | # Repetition
+ \#.*\n | # Comment
+ \\. |
+
+ # Character group.
+ \[
+ ( [^\]\\] | \\.)*
+ \] |
+
+ [^(){}] |
+ .
+ )""",
+ re.VERBOSE,
+ )
+
+ tokens = []
+
+ while input:
+ m = p.match(input)
+ if m:
+ token, input = input[: m.end()], input[m.end() :]
+ if not token.isspace():
+ tokens.append(token)
+ else:
+ raise Exception("Could not tokenize input regex.")
+
+ return tokens
+
+
+def parse_regex(regex_tokens: list[str]) -> Node:
+ """
+ Takes a list of tokens from the tokenizer, and returns a parse tree.
+ """
+ # We add a closing brace because that represents the final pop of the stack.
+ tokens: list[str] = [")"] + regex_tokens[::-1]
+
+ def wrap(lst: list[Node]) -> Node:
+ """Turn list into sequence when it contains several items."""
+ if len(lst) == 1:
+ return lst[0]
+ else:
+ return NodeSequence(lst)
+
+ def _parse() -> Node:
+ or_list: list[list[Node]] = []
+ result: list[Node] = []
+
+ def wrapped_result() -> Node:
+ if or_list == []:
+ return wrap(result)
+ else:
+ or_list.append(result)
+ return AnyNode([wrap(i) for i in or_list])
+
+ while tokens:
+ t = tokens.pop()
+
+ if t.startswith("(?P<"):
+ variable = Variable(_parse(), varname=t[4:-1])
+ result.append(variable)
+
+ elif t in ("*", "*?"):
+ greedy = t == "*"
+ result[-1] = Repeat(result[-1], greedy=greedy)
+
+ elif t in ("+", "+?"):
+ greedy = t == "+"
+ result[-1] = Repeat(result[-1], min_repeat=1, greedy=greedy)
+
+ elif t in ("?", "??"):
+ if result == []:
+ raise Exception("Nothing to repeat." + repr(tokens))
+ else:
+ greedy = t == "?"
+ result[-1] = Repeat(
+ result[-1], min_repeat=0, max_repeat=1, greedy=greedy
+ )
+
+ elif t == "|":
+ or_list.append(result)
+ result = []
+
+ elif t in ("(", "(?:"):
+ result.append(_parse())
+
+ elif t == "(?!":
+ result.append(Lookahead(_parse(), negative=True))
+
+ elif t == "(?=":
+ result.append(Lookahead(_parse(), negative=False))
+
+ elif t == ")":
+ return wrapped_result()
+
+ elif t.startswith("#"):
+ pass
+
+ elif t.startswith("{"):
+ # TODO: implement!
+ raise Exception(f"{t}-style repetition not yet supported")
+
+ elif t.startswith("(?"):
+ raise Exception("%r not supported" % t)
+
+ elif t.isspace():
+ pass
+ else:
+ result.append(Regex(t))
+
+ raise Exception("Expecting ')' token")
+
+ result = _parse()
+
+ if len(tokens) != 0:
+ raise Exception("Unmatched parentheses.")
+ else:
+ return result
diff --git a/src/prompt_toolkit/contrib/regular_languages/validation.py b/src/prompt_toolkit/contrib/regular_languages/validation.py
new file mode 100644
index 0000000..8e56e05
--- /dev/null
+++ b/src/prompt_toolkit/contrib/regular_languages/validation.py
@@ -0,0 +1,59 @@
+"""
+Validator for a regular language.
+"""
+from __future__ import annotations
+
+from prompt_toolkit.document import Document
+from prompt_toolkit.validation import ValidationError, Validator
+
+from .compiler import _CompiledGrammar
+
+__all__ = [
+ "GrammarValidator",
+]
+
+
+class GrammarValidator(Validator):
+ """
+ Validator which can be used for validation according to variables in
+ the grammar. Each variable can have its own validator.
+
+ :param compiled_grammar: `GrammarCompleter` instance.
+ :param validators: `dict` mapping variable names of the grammar to the
+ `Validator` instances to be used for each variable.
+ """
+
+ def __init__(
+ self, compiled_grammar: _CompiledGrammar, validators: dict[str, Validator]
+ ) -> None:
+ self.compiled_grammar = compiled_grammar
+ self.validators = validators
+
+ def validate(self, document: Document) -> None:
+ # Parse input document.
+ # We use `match`, not `match_prefix`, because for validation, we want
+ # the actual, unambiguous interpretation of the input.
+ m = self.compiled_grammar.match(document.text)
+
+ if m:
+ for v in m.variables():
+ validator = self.validators.get(v.varname)
+
+ if validator:
+ # Unescape text.
+ unwrapped_text = self.compiled_grammar.unescape(v.varname, v.value)
+
+ # Create a document, for the completions API (text/cursor_position)
+ inner_document = Document(unwrapped_text, len(unwrapped_text))
+
+ try:
+ validator.validate(inner_document)
+ except ValidationError as e:
+ raise ValidationError(
+ cursor_position=v.start + e.cursor_position,
+ message=e.message,
+ ) from e
+ else:
+ raise ValidationError(
+ cursor_position=len(document.text), message="Invalid command"
+ )
diff --git a/src/prompt_toolkit/contrib/ssh/__init__.py b/src/prompt_toolkit/contrib/ssh/__init__.py
new file mode 100644
index 0000000..bbc1c21
--- /dev/null
+++ b/src/prompt_toolkit/contrib/ssh/__init__.py
@@ -0,0 +1,8 @@
+from __future__ import annotations
+
+from .server import PromptToolkitSSHServer, PromptToolkitSSHSession
+
+__all__ = [
+ "PromptToolkitSSHSession",
+ "PromptToolkitSSHServer",
+]
diff --git a/src/prompt_toolkit/contrib/ssh/server.py b/src/prompt_toolkit/contrib/ssh/server.py
new file mode 100644
index 0000000..9a5d402
--- /dev/null
+++ b/src/prompt_toolkit/contrib/ssh/server.py
@@ -0,0 +1,177 @@
+"""
+Utility for running a prompt_toolkit application in an asyncssh server.
+"""
+from __future__ import annotations
+
+import asyncio
+import traceback
+from asyncio import get_running_loop
+from typing import Any, Callable, Coroutine, TextIO, cast
+
+import asyncssh
+
+from prompt_toolkit.application.current import AppSession, create_app_session
+from prompt_toolkit.data_structures import Size
+from prompt_toolkit.input import PipeInput, create_pipe_input
+from prompt_toolkit.output.vt100 import Vt100_Output
+
+__all__ = ["PromptToolkitSSHSession", "PromptToolkitSSHServer"]
+
+
+class PromptToolkitSSHSession(asyncssh.SSHServerSession): # type: ignore
+ def __init__(
+ self,
+ interact: Callable[[PromptToolkitSSHSession], Coroutine[Any, Any, None]],
+ *,
+ enable_cpr: bool,
+ ) -> None:
+ self.interact = interact
+ self.enable_cpr = enable_cpr
+ self.interact_task: asyncio.Task[None] | None = None
+ self._chan: Any | None = None
+ self.app_session: AppSession | None = None
+
+ # PipInput object, for sending input in the CLI.
+ # (This is something that we can use in the prompt_toolkit event loop,
+ # but still write date in manually.)
+ self._input: PipeInput | None = None
+ self._output: Vt100_Output | None = None
+
+ # Output object. Don't render to the real stdout, but write everything
+ # in the SSH channel.
+ class Stdout:
+ def write(s, data: str) -> None:
+ try:
+ if self._chan is not None:
+ self._chan.write(data.replace("\n", "\r\n"))
+ except BrokenPipeError:
+ pass # Channel not open for sending.
+
+ def isatty(s) -> bool:
+ return True
+
+ def flush(s) -> None:
+ pass
+
+ @property
+ def encoding(s) -> str:
+ assert self._chan is not None
+ return str(self._chan._orig_chan.get_encoding()[0])
+
+ self.stdout = cast(TextIO, Stdout())
+
+ def _get_size(self) -> Size:
+ """
+ Callable that returns the current `Size`, required by Vt100_Output.
+ """
+ if self._chan is None:
+ return Size(rows=20, columns=79)
+ else:
+ width, height, pixwidth, pixheight = self._chan.get_terminal_size()
+ return Size(rows=height, columns=width)
+
+ def connection_made(self, chan: Any) -> None:
+ self._chan = chan
+
+ def shell_requested(self) -> bool:
+ return True
+
+ def session_started(self) -> None:
+ self.interact_task = get_running_loop().create_task(self._interact())
+
+ async def _interact(self) -> None:
+ if self._chan is None:
+ # Should not happen.
+ raise Exception("`_interact` called before `connection_made`.")
+
+ if hasattr(self._chan, "set_line_mode") and self._chan._editor is not None:
+ # Disable the line editing provided by asyncssh. Prompt_toolkit
+ # provides the line editing.
+ self._chan.set_line_mode(False)
+
+ term = self._chan.get_terminal_type()
+
+ self._output = Vt100_Output(
+ self.stdout, self._get_size, term=term, enable_cpr=self.enable_cpr
+ )
+
+ with create_pipe_input() as self._input:
+ with create_app_session(input=self._input, output=self._output) as session:
+ self.app_session = session
+ try:
+ await self.interact(self)
+ except BaseException:
+ traceback.print_exc()
+ finally:
+ # Close the connection.
+ self._chan.close()
+ self._input.close()
+
+ def terminal_size_changed(
+ self, width: int, height: int, pixwidth: object, pixheight: object
+ ) -> None:
+ # Send resize event to the current application.
+ if self.app_session and self.app_session.app:
+ self.app_session.app._on_resize()
+
+ def data_received(self, data: str, datatype: object) -> None:
+ if self._input is None:
+ # Should not happen.
+ return
+
+ self._input.send_text(data)
+
+
+class PromptToolkitSSHServer(asyncssh.SSHServer):
+ """
+ Run a prompt_toolkit application over an asyncssh server.
+
+ This takes one argument, an `interact` function, which is called for each
+ connection. This should be an asynchronous function that runs the
+ prompt_toolkit applications. This function runs in an `AppSession`, which
+ means that we can have multiple UI interactions concurrently.
+
+ Example usage:
+
+ .. code:: python
+
+ async def interact(ssh_session: PromptToolkitSSHSession) -> None:
+ await yes_no_dialog("my title", "my text").run_async()
+
+ prompt_session = PromptSession()
+ text = await prompt_session.prompt_async("Type something: ")
+ print_formatted_text('You said: ', text)
+
+ server = PromptToolkitSSHServer(interact=interact)
+ loop = get_running_loop()
+ loop.run_until_complete(
+ asyncssh.create_server(
+ lambda: MySSHServer(interact),
+ "",
+ port,
+ server_host_keys=["/etc/ssh/..."],
+ )
+ )
+ loop.run_forever()
+
+ :param enable_cpr: When `True`, the default, try to detect whether the SSH
+ client runs in a terminal that responds to "cursor position requests".
+ That way, we can properly determine how much space there is available
+ for the UI (especially for drop down menus) to render.
+ """
+
+ def __init__(
+ self,
+ interact: Callable[[PromptToolkitSSHSession], Coroutine[Any, Any, None]],
+ *,
+ enable_cpr: bool = True,
+ ) -> None:
+ self.interact = interact
+ self.enable_cpr = enable_cpr
+
+ def begin_auth(self, username: str) -> bool:
+ # No authentication.
+ return False
+
+ def session_requested(self) -> PromptToolkitSSHSession:
+ return PromptToolkitSSHSession(self.interact, enable_cpr=self.enable_cpr)
diff --git a/src/prompt_toolkit/contrib/telnet/__init__.py b/src/prompt_toolkit/contrib/telnet/__init__.py
new file mode 100644
index 0000000..de902b4
--- /dev/null
+++ b/src/prompt_toolkit/contrib/telnet/__init__.py
@@ -0,0 +1,7 @@
+from __future__ import annotations
+
+from .server import TelnetServer
+
+__all__ = [
+ "TelnetServer",
+]
diff --git a/src/prompt_toolkit/contrib/telnet/log.py b/src/prompt_toolkit/contrib/telnet/log.py
new file mode 100644
index 0000000..0fe8433
--- /dev/null
+++ b/src/prompt_toolkit/contrib/telnet/log.py
@@ -0,0 +1,12 @@
+"""
+Python logger for the telnet server.
+"""
+from __future__ import annotations
+
+import logging
+
+logger = logging.getLogger(__package__)
+
+__all__ = [
+ "logger",
+]
diff --git a/src/prompt_toolkit/contrib/telnet/protocol.py b/src/prompt_toolkit/contrib/telnet/protocol.py
new file mode 100644
index 0000000..4b90e98
--- /dev/null
+++ b/src/prompt_toolkit/contrib/telnet/protocol.py
@@ -0,0 +1,208 @@
+"""
+Parser for the Telnet protocol. (Not a complete implementation of the telnet
+specification, but sufficient for a command line interface.)
+
+Inspired by `Twisted.conch.telnet`.
+"""
+from __future__ import annotations
+
+import struct
+from typing import Callable, Generator
+
+from .log import logger
+
+__all__ = [
+ "TelnetProtocolParser",
+]
+
+
+def int2byte(number: int) -> bytes:
+ return bytes((number,))
+
+
+# Telnet constants.
+NOP = int2byte(0)
+SGA = int2byte(3)
+
+IAC = int2byte(255)
+DO = int2byte(253)
+DONT = int2byte(254)
+LINEMODE = int2byte(34)
+SB = int2byte(250)
+WILL = int2byte(251)
+WONT = int2byte(252)
+MODE = int2byte(1)
+SE = int2byte(240)
+ECHO = int2byte(1)
+NAWS = int2byte(31)
+LINEMODE = int2byte(34)
+SUPPRESS_GO_AHEAD = int2byte(3)
+
+TTYPE = int2byte(24)
+SEND = int2byte(1)
+IS = int2byte(0)
+
+DM = int2byte(242)
+BRK = int2byte(243)
+IP = int2byte(244)
+AO = int2byte(245)
+AYT = int2byte(246)
+EC = int2byte(247)
+EL = int2byte(248)
+GA = int2byte(249)
+
+
+class TelnetProtocolParser:
+ """
+ Parser for the Telnet protocol.
+ Usage::
+
+ def data_received(data):
+ print(data)
+
+ def size_received(rows, columns):
+ print(rows, columns)
+
+ p = TelnetProtocolParser(data_received, size_received)
+ p.feed(binary_data)
+ """
+
+ def __init__(
+ self,
+ data_received_callback: Callable[[bytes], None],
+ size_received_callback: Callable[[int, int], None],
+ ttype_received_callback: Callable[[str], None],
+ ) -> None:
+ self.data_received_callback = data_received_callback
+ self.size_received_callback = size_received_callback
+ self.ttype_received_callback = ttype_received_callback
+
+ self._parser = self._parse_coroutine()
+ self._parser.send(None) # type: ignore
+
+ def received_data(self, data: bytes) -> None:
+ self.data_received_callback(data)
+
+ def do_received(self, data: bytes) -> None:
+ """Received telnet DO command."""
+ logger.info("DO %r", data)
+
+ def dont_received(self, data: bytes) -> None:
+ """Received telnet DONT command."""
+ logger.info("DONT %r", data)
+
+ def will_received(self, data: bytes) -> None:
+ """Received telnet WILL command."""
+ logger.info("WILL %r", data)
+
+ def wont_received(self, data: bytes) -> None:
+ """Received telnet WONT command."""
+ logger.info("WONT %r", data)
+
+ def command_received(self, command: bytes, data: bytes) -> None:
+ if command == DO:
+ self.do_received(data)
+
+ elif command == DONT:
+ self.dont_received(data)
+
+ elif command == WILL:
+ self.will_received(data)
+
+ elif command == WONT:
+ self.wont_received(data)
+
+ else:
+ logger.info("command received %r %r", command, data)
+
+ def naws(self, data: bytes) -> None:
+ """
+ Received NAWS. (Window dimensions.)
+ """
+ if len(data) == 4:
+ # NOTE: the first parameter of struct.unpack should be
+ # a 'str' object. Both on Py2/py3. This crashes on OSX
+ # otherwise.
+ columns, rows = struct.unpack("!HH", data)
+ self.size_received_callback(rows, columns)
+ else:
+ logger.warning("Wrong number of NAWS bytes")
+
+ def ttype(self, data: bytes) -> None:
+ """
+ Received terminal type.
+ """
+ subcmd, data = data[0:1], data[1:]
+ if subcmd == IS:
+ ttype = data.decode("ascii")
+ self.ttype_received_callback(ttype)
+ else:
+ logger.warning("Received a non-IS terminal type Subnegotiation")
+
+ def negotiate(self, data: bytes) -> None:
+ """
+ Got negotiate data.
+ """
+ command, payload = data[0:1], data[1:]
+
+ if command == NAWS:
+ self.naws(payload)
+ elif command == TTYPE:
+ self.ttype(payload)
+ else:
+ logger.info("Negotiate (%r got bytes)", len(data))
+
+ def _parse_coroutine(self) -> Generator[None, bytes, None]:
+ """
+ Parser state machine.
+ Every 'yield' expression returns the next byte.
+ """
+ while True:
+ d = yield
+
+ if d == int2byte(0):
+ pass # NOP
+
+ # Go to state escaped.
+ elif d == IAC:
+ d2 = yield
+
+ if d2 == IAC:
+ self.received_data(d2)
+
+ # Handle simple commands.
+ elif d2 in (NOP, DM, BRK, IP, AO, AYT, EC, EL, GA):
+ self.command_received(d2, b"")
+
+ # Handle IAC-[DO/DONT/WILL/WONT] commands.
+ elif d2 in (DO, DONT, WILL, WONT):
+ d3 = yield
+ self.command_received(d2, d3)
+
+ # Subnegotiation
+ elif d2 == SB:
+ # Consume everything until next IAC-SE
+ data = []
+
+ while True:
+ d3 = yield
+
+ if d3 == IAC:
+ d4 = yield
+ if d4 == SE:
+ break
+ else:
+ data.append(d4)
+ else:
+ data.append(d3)
+
+ self.negotiate(b"".join(data))
+ else:
+ self.received_data(d)
+
+ def feed(self, data: bytes) -> None:
+ """
+ Feed data to the parser.
+ """
+ for b in data:
+ self._parser.send(int2byte(b))
diff --git a/src/prompt_toolkit/contrib/telnet/server.py b/src/prompt_toolkit/contrib/telnet/server.py
new file mode 100644
index 0000000..9ebe66c
--- /dev/null
+++ b/src/prompt_toolkit/contrib/telnet/server.py
@@ -0,0 +1,427 @@
+"""
+Telnet server.
+"""
+from __future__ import annotations
+
+import asyncio
+import contextvars
+import socket
+from asyncio import get_running_loop
+from typing import Any, Callable, Coroutine, TextIO, cast
+
+from prompt_toolkit.application.current import create_app_session, get_app
+from prompt_toolkit.application.run_in_terminal import run_in_terminal
+from prompt_toolkit.data_structures import Size
+from prompt_toolkit.formatted_text import AnyFormattedText, to_formatted_text
+from prompt_toolkit.input import PipeInput, create_pipe_input
+from prompt_toolkit.output.vt100 import Vt100_Output
+from prompt_toolkit.renderer import print_formatted_text as print_formatted_text
+from prompt_toolkit.styles import BaseStyle, DummyStyle
+
+from .log import logger
+from .protocol import (
+ DO,
+ ECHO,
+ IAC,
+ LINEMODE,
+ MODE,
+ NAWS,
+ SB,
+ SE,
+ SEND,
+ SUPPRESS_GO_AHEAD,
+ TTYPE,
+ WILL,
+ TelnetProtocolParser,
+)
+
+__all__ = [
+ "TelnetServer",
+]
+
+
+def int2byte(number: int) -> bytes:
+ return bytes((number,))
+
+
+def _initialize_telnet(connection: socket.socket) -> None:
+ logger.info("Initializing telnet connection")
+
+ # Iac Do Linemode
+ connection.send(IAC + DO + LINEMODE)
+
+ # Suppress Go Ahead. (This seems important for Putty to do correct echoing.)
+ # This will allow bi-directional operation.
+ connection.send(IAC + WILL + SUPPRESS_GO_AHEAD)
+
+ # Iac sb
+ connection.send(IAC + SB + LINEMODE + MODE + int2byte(0) + IAC + SE)
+
+ # IAC Will Echo
+ connection.send(IAC + WILL + ECHO)
+
+ # Negotiate window size
+ connection.send(IAC + DO + NAWS)
+
+ # Negotiate terminal type
+ # Assume the client will accept the negotiation with `IAC + WILL + TTYPE`
+ connection.send(IAC + DO + TTYPE)
+
+ # We can then select the first terminal type supported by the client,
+ # which is generally the best type the client supports
+ # The client should reply with a `IAC + SB + TTYPE + IS + ttype + IAC + SE`
+ connection.send(IAC + SB + TTYPE + SEND + IAC + SE)
+
+
+class _ConnectionStdout:
+ """
+ Wrapper around socket which provides `write` and `flush` methods for the
+ Vt100_Output output.
+ """
+
+ def __init__(self, connection: socket.socket, encoding: str) -> None:
+ self._encoding = encoding
+ self._connection = connection
+ self._errors = "strict"
+ self._buffer: list[bytes] = []
+ self._closed = False
+
+ def write(self, data: str) -> None:
+ data = data.replace("\n", "\r\n")
+ self._buffer.append(data.encode(self._encoding, errors=self._errors))
+ self.flush()
+
+ def isatty(self) -> bool:
+ return True
+
+ def flush(self) -> None:
+ try:
+ if not self._closed:
+ self._connection.send(b"".join(self._buffer))
+ except OSError as e:
+ logger.warning("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:
+ self.conn = conn
+ self.addr = addr
+ self.interact = interact
+ self.server = server
+ self.encoding = encoding
+ self.style = style
+ self._closed = False
+ self._ready = asyncio.Event()
+ self.vt100_input = vt100_input
+ self.enable_cpr = enable_cpr
+ self.vt100_output: Vt100_Output | None = None
+
+ # Create "Output" object.
+ self.size = Size(rows=40, columns=79)
+
+ # Initialize.
+ _initialize_telnet(conn)
+
+ # Create output.
+ def get_size() -> Size:
+ return self.size
+
+ self.stdout = cast(TextIO, _ConnectionStdout(conn, encoding=encoding))
+
+ def data_received(data: bytes) -> None:
+ """TelnetProtocolParser 'data_received' callback"""
+ self.vt100_input.send_bytes(data)
+
+ def size_received(rows: int, columns: int) -> None:
+ """TelnetProtocolParser 'size_received' callback"""
+ self.size = Size(rows=rows, columns=columns)
+ if self.vt100_output is not None and self.context:
+ self.context.run(lambda: get_app()._on_resize())
+
+ def ttype_received(ttype: str) -> None:
+ """TelnetProtocolParser 'ttype_received' callback"""
+ self.vt100_output = Vt100_Output(
+ self.stdout, get_size, term=ttype, enable_cpr=enable_cpr
+ )
+ self._ready.set()
+
+ self.parser = TelnetProtocolParser(data_received, size_received, ttype_received)
+ self.context: contextvars.Context | None = None
+
+ async def run_application(self) -> None:
+ """
+ Run application.
+ """
+
+ def handle_incoming_data() -> None:
+ data = self.conn.recv(1024)
+ if data:
+ self.feed(data)
+ else:
+ # Connection closed by client.
+ logger.info("Connection closed by client. {!r} {!r}".format(*self.addr))
+ self.close()
+
+ # Add reader.
+ loop = get_running_loop()
+ loop.add_reader(self.conn, handle_incoming_data)
+
+ try:
+ # Wait for v100_output to be properly instantiated
+ await self._ready.wait()
+ with create_app_session(input=self.vt100_input, output=self.vt100_output):
+ self.context = contextvars.copy_context()
+ await self.interact(self)
+ finally:
+ self.close()
+
+ def feed(self, data: bytes) -> None:
+ """
+ Handler for incoming data. (Called by TelnetServer.)
+ """
+ self.parser.feed(data)
+
+ def close(self) -> None:
+ """
+ Closed by client.
+ """
+ if not self._closed:
+ self._closed = True
+
+ self.vt100_input.close()
+ get_running_loop().remove_reader(self.conn)
+ self.conn.close()
+ self.stdout.close()
+
+ def send(self, formatted_text: AnyFormattedText) -> None:
+ """
+ Send text to the client.
+ """
+ if self.vt100_output is None:
+ return
+ formatted_text = to_formatted_text(formatted_text)
+ print_formatted_text(
+ self.vt100_output, formatted_text, self.style or DummyStyle()
+ )
+
+ def send_above_prompt(self, formatted_text: AnyFormattedText) -> None:
+ """
+ Send text to the client.
+ This is asynchronous, returns a `Future`.
+ """
+ formatted_text = to_formatted_text(formatted_text)
+ return self._run_in_terminal(lambda: self.send(formatted_text))
+
+ def _run_in_terminal(self, func: Callable[[], None]) -> None:
+ # Make sure that when an application was active for this connection,
+ # that we print the text above the application.
+ if self.context:
+ self.context.run(run_in_terminal, func) # 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.
+ """
+ if self.vt100_output is None:
+ return
+ self.vt100_output.erase_screen()
+ self.vt100_output.cursor_goto(0, 0)
+ self.vt100_output.flush()
+
+
+async def _dummy_interact(connection: TelnetConnection) -> None:
+ pass
+
+
+class TelnetServer:
+ """
+ Telnet server implementation.
+
+ Example::
+
+ async def interact(connection):
+ connection.send("Welcome")
+ session = PromptSession()
+ result = await session.prompt_async(message="Say something: ")
+ connection.send(f"You said: {result}\n")
+
+ async def main():
+ server = TelnetServer(interact=interact, port=2323)
+ await server.run()
+ """
+
+ def __init__(
+ self,
+ host: str = "127.0.0.1",
+ port: int = 23,
+ interact: Callable[
+ [TelnetConnection], Coroutine[Any, Any, None]
+ ] = _dummy_interact,
+ encoding: str = "utf-8",
+ style: BaseStyle | None = None,
+ enable_cpr: bool = True,
+ ) -> None:
+ self.host = host
+ self.port = port
+ self.interact = interact
+ self.encoding = encoding
+ self.style = style
+ self.enable_cpr = enable_cpr
+
+ self._run_task: asyncio.Task[None] | None = None
+ self._application_tasks: list[asyncio.Task[None]] = []
+
+ self.connections: set[TelnetConnection] = set()
+
+ @classmethod
+ def _create_socket(cls, host: str, port: int) -> socket.socket:
+ # Create and bind socket
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ s.bind((host, port))
+
+ s.listen(4)
+ return s
+
+ async def run(self, ready_cb: Callable[[], None] | None = None) -> None:
+ """
+ Run the telnet server, until this gets cancelled.
+
+ :param ready_cb: Callback that will be called at the point that we're
+ actually listening.
+ """
+ socket = self._create_socket(self.host, self.port)
+ logger.info(
+ "Listening for telnet connections on %s port %r", self.host, self.port
+ )
+
+ get_running_loop().add_reader(socket, lambda: self._accept(socket))
+
+ if ready_cb:
+ ready_cb()
+
+ try:
+ # Run forever, until cancelled.
+ await asyncio.Future()
+ finally:
+ get_running_loop().remove_reader(socket)
+ socket.close()
+
+ # Wait for all applications to finish.
+ for t in self._application_tasks:
+ t.cancel()
+
+ # (This is similar to
+ # `Application.cancel_and_wait_for_background_tasks`. We wait for the
+ # background tasks to complete, but don't propagate exceptions, because
+ # we can't use `ExceptionGroup` yet.)
+ if len(self._application_tasks) > 0:
+ await asyncio.wait(
+ self._application_tasks,
+ timeout=None,
+ return_when=asyncio.ALL_COMPLETED,
+ )
+
+ def start(self) -> None:
+ """
+ Deprecated: Use `.run()` instead.
+
+ Start the telnet server (stop by calling and awaiting `stop()`).
+ """
+ if self._run_task is not None:
+ # Already running.
+ return
+
+ self._run_task = get_running_loop().create_task(self.run())
+
+ async def stop(self) -> None:
+ """
+ Deprecated: Use `.run()` instead.
+
+ Stop a telnet server that was started using `.start()` and wait for the
+ cancellation to complete.
+ """
+ if self._run_task is not None:
+ self._run_task.cancel()
+ try:
+ await self._run_task
+ except asyncio.CancelledError:
+ pass
+
+ def _accept(self, listen_socket: socket.socket) -> None:
+ """
+ Accept new incoming connection.
+ """
+ conn, addr = listen_socket.accept()
+ logger.info("New connection %r %r", *addr)
+
+ # Run application for this connection.
+ async def run() -> None:
+ try:
+ with create_pipe_input() as vt100_input:
+ connection = TelnetConnection(
+ conn,
+ addr,
+ self.interact,
+ self,
+ encoding=self.encoding,
+ style=self.style,
+ vt100_input=vt100_input,
+ enable_cpr=self.enable_cpr,
+ )
+ self.connections.add(connection)
+
+ logger.info("Starting interaction %r %r", *addr)
+ try:
+ await connection.run_application()
+ finally:
+ self.connections.remove(connection)
+ logger.info("Stopping interaction %r %r", *addr)
+ except EOFError:
+ # Happens either when the connection is closed by the client
+ # (e.g., when the user types 'control-]', then 'quit' in the
+ # telnet client) or when the user types control-d in a prompt
+ # and this is not handled by the interact function.
+ logger.info("Unhandled EOFError in telnet application.")
+ except KeyboardInterrupt:
+ # Unhandled control-c propagated by a prompt.
+ logger.info("Unhandled KeyboardInterrupt in telnet application.")
+ except BaseException as e:
+ print("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
new file mode 100644
index 0000000..453b72c
--- /dev/null
+++ b/src/prompt_toolkit/cursor_shapes.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import TYPE_CHECKING, Any, Callable, Union
+
+from prompt_toolkit.enums import EditingMode
+from prompt_toolkit.key_binding.vi_state import InputMode
+
+if TYPE_CHECKING:
+ from .application import Application
+
+__all__ = [
+ "CursorShape",
+ "CursorShapeConfig",
+ "SimpleCursorShapeConfig",
+ "ModalCursorShapeConfig",
+ "DynamicCursorShapeConfig",
+ "to_cursor_shape_config",
+]
+
+
+class CursorShape(Enum):
+ # Default value that should tell the output implementation to never send
+ # cursor shape escape sequences. This is the default right now, because
+ # before this `CursorShape` functionality was introduced into
+ # prompt_toolkit itself, people had workarounds to send cursor shapes
+ # escapes into the terminal, by monkey patching some of prompt_toolkit's
+ # internals. We don't want the default prompt_toolkit implementation to
+ # interfere with that. E.g., IPython patches the `ViState.input_mode`
+ # property. See: https://github.com/ipython/ipython/pull/13501/files
+ _NEVER_CHANGE = "_NEVER_CHANGE"
+
+ BLOCK = "BLOCK"
+ BEAM = "BEAM"
+ UNDERLINE = "UNDERLINE"
+ BLINKING_BLOCK = "BLINKING_BLOCK"
+ BLINKING_BEAM = "BLINKING_BEAM"
+ BLINKING_UNDERLINE = "BLINKING_UNDERLINE"
+
+
+class CursorShapeConfig(ABC):
+ @abstractmethod
+ def get_cursor_shape(self, application: Application[Any]) -> CursorShape:
+ """
+ Return the cursor shape to be used in the current state.
+ """
+
+
+AnyCursorShapeConfig = Union[CursorShape, CursorShapeConfig, None]
+
+
+class SimpleCursorShapeConfig(CursorShapeConfig):
+ """
+ Always show the given cursor shape.
+ """
+
+ def __init__(self, cursor_shape: CursorShape = CursorShape._NEVER_CHANGE) -> None:
+ self.cursor_shape = cursor_shape
+
+ def get_cursor_shape(self, application: Application[Any]) -> CursorShape:
+ return self.cursor_shape
+
+
+class ModalCursorShapeConfig(CursorShapeConfig):
+ """
+ Show cursor shape according to the current input mode.
+ """
+
+ def get_cursor_shape(self, application: Application[Any]) -> CursorShape:
+ if application.editing_mode == EditingMode.VI:
+ if application.vi_state.input_mode == InputMode.INSERT:
+ return CursorShape.BEAM
+ if application.vi_state.input_mode == InputMode.REPLACE:
+ return CursorShape.UNDERLINE
+
+ # Default
+ return CursorShape.BLOCK
+
+
+class DynamicCursorShapeConfig(CursorShapeConfig):
+ def __init__(
+ self, get_cursor_shape_config: Callable[[], AnyCursorShapeConfig]
+ ) -> None:
+ self.get_cursor_shape_config = get_cursor_shape_config
+
+ def get_cursor_shape(self, application: Application[Any]) -> CursorShape:
+ return to_cursor_shape_config(self.get_cursor_shape_config()).get_cursor_shape(
+ application
+ )
+
+
+def to_cursor_shape_config(value: AnyCursorShapeConfig) -> CursorShapeConfig:
+ """
+ Take a `CursorShape` instance or `CursorShapeConfig` and turn it into a
+ `CursorShapeConfig`.
+ """
+ if value is None:
+ return SimpleCursorShapeConfig()
+
+ if isinstance(value, CursorShape):
+ return SimpleCursorShapeConfig(value)
+
+ return value
diff --git a/src/prompt_toolkit/data_structures.py b/src/prompt_toolkit/data_structures.py
new file mode 100644
index 0000000..27dd458
--- /dev/null
+++ b/src/prompt_toolkit/data_structures.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from typing import NamedTuple
+
+__all__ = [
+ "Point",
+ "Size",
+]
+
+
+class Point(NamedTuple):
+ x: int
+ y: int
+
+
+class Size(NamedTuple):
+ rows: int
+ columns: int
diff --git a/src/prompt_toolkit/document.py b/src/prompt_toolkit/document.py
new file mode 100644
index 0000000..74f4c13
--- /dev/null
+++ b/src/prompt_toolkit/document.py
@@ -0,0 +1,1181 @@
+"""
+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",
+]
+
+
+# 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(
+ 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]):
+ """
+ Some protection for our 'lines' list, which is assumed to be immutable in the cache.
+ (Useful for detecting obvious bugs.)
+ """
+
+ 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
+ insert = _error
+ pop = _error
+ remove = _error
+ reverse = _error
+ sort = _error # type: ignore
+
+
+class _DocumentCache:
+ 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
+
+
+class Document:
+ """
+ This is a immutable class around the text and cursor position, and contains
+ methods for querying this data, e.g. to give the text before the cursor.
+
+ This class is usually instantiated by a :class:`~prompt_toolkit.buffer.Buffer`
+ object, and accessed as the `document` property of that class.
+
+ :param text: string
+ :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:
+ # 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
+
+ # 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:
+ if not isinstance(other, Document):
+ return False
+
+ 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."
+ return self._text
+
+ @property
+ def cursor_position(self) -> int:
+ "The document cursor position."
+ return self._cursor_position
+
+ @property
+ def selection(self) -> SelectionState | None:
+ ":class:`.SelectionState` object."
+ return self._selection
+
+ @property
+ def current_char(self) -> str:
+ """Return character under cursor or an empty string."""
+ return self._get_char_relative_to_cursor(0) or ""
+
+ @property
+ def char_before_cursor(self) -> str:
+ """Return character before the cursor or an empty string."""
+ return self._get_char_relative_to_cursor(-1) or ""
+
+ @property
+ 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."""
+ _, _, text = self.text_before_cursor.rpartition("\n")
+ return text
+
+ @property
+ def current_line_after_cursor(self) -> str:
+ """Text from the cursor until the end of the line."""
+ text, _, _ = self.text_after_cursor.partition("\n")
+ return text
+
+ @property
+ def lines(self) -> list[str]:
+ """
+ Array of all the lines.
+ """
+ # 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]:
+ """
+ Array pointing to the start indexes of all the lines.
+ """
+ # 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]:
+ """
+ Array of the lines starting from the current line, until the last line.
+ """
+ return self.lines[self.cursor_position_row :]
+
+ @property
+ 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:
+ """Return the text on the line where the cursor is. (when the input
+ consists of just one line, it equals `text`."""
+ return self.current_line_before_cursor + self.current_line_after_cursor
+
+ @property
+ def leading_whitespace_in_current_line(self) -> str:
+ """The leading whitespace in the left margin of the current line."""
+ 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:
+ """
+ Return character relative to cursor position, or empty string
+ """
+ try:
+ return self.text[self.cursor_position + offset]
+ except IndexError:
+ return ""
+
+ @property
+ def on_first_line(self) -> bool:
+ """
+ True when we are at the first line.
+ """
+ return self.cursor_position_row == 0
+
+ @property
+ def on_last_line(self) -> bool:
+ """
+ True when we are at the last line.
+ """
+ return self.cursor_position_row == self.line_count - 1
+
+ @property
+ def cursor_position_row(self) -> int:
+ """
+ Current row. (0-based.)
+ """
+ row, _ = self._find_line_start_index(self.cursor_position)
+ return row
+
+ @property
+ def cursor_position_col(self) -> int:
+ """
+ Current column. (0-based.)
+ """
+ # (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]:
+ """
+ For the index of a character at a certain line, calculate the index of
+ the first character on that line.
+
+ Return (row, index) tuple.
+ """
+ indexes = self._line_start_indexes
+
+ 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.)
+ """
+ # 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:
+ """
+ Given a (row, col) tuple, return the corresponding index.
+ (Row and col params are 0-based.)
+
+ Negative row/col values are turned into zero.
+ """
+ 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:
+ """True when the cursor is at the end of the text."""
+ return self.cursor_position == len(self.text)
+
+ @property
+ def is_cursor_at_the_end_of_line(self) -> bool:
+ """True when the cursor is at the end of this line."""
+ return self.current_char in ("\n", "")
+
+ def has_match_at_current_position(self, sub: str) -> bool:
+ """
+ `True` when this substring is found at the cursor position.
+ """
+ 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:
+ """
+ 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.
+ """
+ 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)
+
+ 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.
+ """
+ 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:
+ """
+ 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.
+ """
+ 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)
+
+ 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.
+
+ :param pattern: (None or compiled regex). When given, use this regex
+ pattern.
+ """
+ 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
+
+ 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.
+
+ :param pattern: (None or compiled regex). When given, use this regex
+ pattern.
+ """
+ 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]
+
+ 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)
+ """
+ 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:
+ """
+ Return the word, currently below the cursor.
+ This returns an empty string when the cursor is on a whitespace region.
+ """
+ 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:
+ """
+ Return an index relative to the cursor position pointing to the start
+ of the next word. Return `None` if nothing was found.
+ """
+ if count < 0:
+ return self.find_previous_word_beginning(count=-count, WORD=WORD)
+
+ 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.
+ """
+ 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
+
+ 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.
+ """
+ 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:
+ """
+ Return an index relative to the cursor position pointing to the end
+ of the previous word. Return `None` if nothing was found.
+ """
+ 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
+
+ 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.
+ """
+ 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
+
+ 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.
+ """
+ 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
+
+ return result
+
+ def get_cursor_left_position(self, count: int = 1) -> int:
+ """
+ Relative position for cursor left.
+ """
+ if count < 0:
+ return self.get_cursor_right_position(-count)
+
+ return -min(self.cursor_position_col, count)
+
+ def get_cursor_right_position(self, count: int = 1) -> int:
+ """
+ Relative position for cursor_right.
+ """
+ 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:
+ """
+ Return the relative cursor position (character index) where we would be if the
+ user pressed the arrow-up button.
+
+ :param preferred_column: When given, go to this column instead of
+ staying at the current column.
+ """
+ assert count >= 1
+ column = (
+ self.cursor_position_col if preferred_column is None else preferred_column
+ )
+
+ 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.
+
+ :param preferred_column: When given, go to this column instead of
+ staying at the current column.
+ """
+ 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:
+ """
+ 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.
+ """
+ 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
+
+ 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.
+ """
+ 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]
+
+ 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.
+ """
+
+ # 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."""
+ return -self.cursor_position
+
+ def get_end_of_document_position(self) -> int:
+ """Relative position for the end of the document."""
+ return len(self.text) - self.cursor_position
+
+ def get_start_of_line_position(self, after_whitespace: bool = False) -> int:
+ """Relative position for the start of this line."""
+ 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:
+ """Relative position for the end of this line."""
+ return len(self.current_line_after_cursor)
+
+ def last_non_blank_of_current_line_position(self) -> int:
+ """
+ Relative position for the last non blank character of this line.
+ """
+ return len(self.current_line.rstrip()) - self.cursor_position_col - 1
+
+ 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.)
+ """
+ line_length = len(self.current_line)
+ current_column = self.cursor_position_col
+ column = max(0, min(line_length, column))
+
+ 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.
+
+ This doesn't take the selection type into account. Use
+ `selection_ranges` instead.
+ """
+ if self.selection:
+ from_, to = sorted(
+ [self.cursor_position, self.selection.original_cursor_position]
+ )
+ else:
+ from_, to = self.cursor_position, self.cursor_position
+
+ 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.
+
+ This will yield several (from, to) tuples in case of a BLOCK selection.
+ This will return zero ranges, like (8,8) for empty lines in a block
+ selection.
+ """
+ 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
+
+ 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.
+
+ The returned upper boundary is not included in the selection, so
+ `(0, 0)` is an empty selection. `(0, 1)`, is a one character selection.
+
+ Returns None if the selection doesn't cover this line at all.
+ """
+ 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)
+
+ 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.
+ """
+ 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
+
+ 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.
+
+ :param paste_mode: Where to paste. (Before/after/emacs.)
+ :param count: When >1, Paste multiple times.
+ """
+ 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.
+ """
+ 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:
+ """
+ Return the start of the current paragraph. (Relative cursor position.)
+ """
+
+ 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.)
+ """
+
+ 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.
+ """
+ return Document(
+ text=self.text + text,
+ cursor_position=self.cursor_position,
+ selection=self.selection,
+ )
+
+ 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.
+ """
+ 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
new file mode 100644
index 0000000..da03633
--- /dev/null
+++ b/src/prompt_toolkit/enums.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from enum import Enum
+
+
+class EditingMode(Enum):
+ # The set of key bindings that is active.
+ VI = "VI"
+ EMACS = "EMACS"
+
+
+#: Name of the search buffer.
+SEARCH_BUFFER = "SEARCH_BUFFER"
+
+#: Name of the default buffer.
+DEFAULT_BUFFER = "DEFAULT_BUFFER"
+
+#: Name of the system buffer.
+SYSTEM_BUFFER = "SYSTEM_BUFFER"
diff --git a/src/prompt_toolkit/eventloop/__init__.py b/src/prompt_toolkit/eventloop/__init__.py
new file mode 100644
index 0000000..5df623b
--- /dev/null
+++ b/src/prompt_toolkit/eventloop/__init__.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from .async_generator import aclosing, generator_to_async_generator
+from .inputhook import (
+ InputHook,
+ InputHookContext,
+ InputHookSelector,
+ new_eventloop_with_inputhook,
+ set_eventloop_with_inputhook,
+)
+from .utils import (
+ call_soon_threadsafe,
+ get_traceback_from_context,
+ run_in_executor_with_context,
+)
+
+__all__ = [
+ # Async generator
+ "generator_to_async_generator",
+ "aclosing",
+ # Utils.
+ "run_in_executor_with_context",
+ "call_soon_threadsafe",
+ "get_traceback_from_context",
+ # Inputhooks.
+ "InputHook",
+ "new_eventloop_with_inputhook",
+ "set_eventloop_with_inputhook",
+ "InputHookSelector",
+ "InputHookContext",
+]
diff --git a/src/prompt_toolkit/eventloop/async_generator.py b/src/prompt_toolkit/eventloop/async_generator.py
new file mode 100644
index 0000000..5aee50a
--- /dev/null
+++ b/src/prompt_toolkit/eventloop/async_generator.py
@@ -0,0 +1,124 @@
+"""
+Implementation for async generators.
+"""
+from __future__ import annotations
+
+from asyncio import get_running_loop
+from contextlib import asynccontextmanager
+from queue import Empty, Full, Queue
+from typing import Any, AsyncGenerator, Callable, Iterable, TypeVar
+
+from .utils import run_in_executor_with_context
+
+__all__ = [
+ "aclosing",
+ "generator_to_async_generator",
+]
+
+_T_Generator = TypeVar("_T_Generator", bound=AsyncGenerator[Any, None])
+
+
+@asynccontextmanager
+async def aclosing(
+ thing: _T_Generator,
+) -> AsyncGenerator[_T_Generator, None]:
+ "Similar to `contextlib.aclosing`, in Python 3.10."
+ try:
+ yield thing
+ finally:
+ await thing.aclose()
+
+
+# By default, choose a buffer size that's a good balance between having enough
+# throughput, but not consuming too much memory. We use this to consume a sync
+# generator of completions as an async generator. If the queue size is very
+# small (like 1), consuming the completions goes really slow (when there are a
+# lot of items). If the queue size would be unlimited or too big, this can
+# cause overconsumption of memory, and cause CPU time spent producing items
+# that are no longer needed (if the consumption of the async generator stops at
+# some point). We need a fixed size in order to get some back pressure from the
+# async consumer to the sync producer. We choose 1000 by default here. If we
+# have around 50k completions, measurements show that 1000 is still
+# significantly faster than a buffer of 100.
+DEFAULT_BUFFER_SIZE: int = 1000
+
+_T = TypeVar("_T")
+
+
+class _Done:
+ pass
+
+
+async def generator_to_async_generator(
+ get_iterable: Callable[[], Iterable[_T]],
+ buffer_size: int = DEFAULT_BUFFER_SIZE,
+) -> AsyncGenerator[_T, None]:
+ """
+ Turn a generator or iterable into an async generator.
+
+ This works by running the generator in a background thread.
+
+ :param get_iterable: Function that returns a generator or iterable when
+ called.
+ :param buffer_size: Size of the queue between the async consumer and the
+ synchronous generator that produces items.
+ """
+ quitting = False
+ # NOTE: We are limiting the queue size in order to have back-pressure.
+ q: Queue[_T | _Done] = Queue(maxsize=buffer_size)
+ loop = get_running_loop()
+
+ def runner() -> None:
+ """
+ Consume the generator in background thread.
+ When items are received, they'll be pushed to the queue.
+ """
+ try:
+ for item in get_iterable():
+ # When this async generator was cancelled (closed), stop this
+ # thread.
+ if quitting:
+ return
+
+ while True:
+ try:
+ q.put(item, timeout=1)
+ except Full:
+ if quitting:
+ return
+ continue
+ else:
+ break
+
+ finally:
+ while True:
+ try:
+ q.put(_Done(), timeout=1)
+ except Full:
+ if quitting:
+ return
+ continue
+ else:
+ break
+
+ # Start background thread.
+ runner_f = run_in_executor_with_context(runner)
+
+ try:
+ while True:
+ try:
+ item = q.get_nowait()
+ except Empty:
+ item = await loop.run_in_executor(None, q.get)
+ if isinstance(item, _Done):
+ break
+ else:
+ yield item
+ finally:
+ # When this async generator is closed (GeneratorExit exception, stop
+ # the background thread as well. - we don't need that anymore.)
+ quitting = True
+
+ # Wait for the background thread to finish. (should happen right after
+ # the last item is yielded).
+ await runner_f
diff --git a/src/prompt_toolkit/eventloop/inputhook.py b/src/prompt_toolkit/eventloop/inputhook.py
new file mode 100644
index 0000000..a4c0eee
--- /dev/null
+++ b/src/prompt_toolkit/eventloop/inputhook.py
@@ -0,0 +1,190 @@
+"""
+Similar to `PyOS_InputHook` of the Python API, we can plug in an input hook in
+the asyncio event loop.
+
+The way this works is by using a custom 'selector' that runs the other event
+loop until the real selector is ready.
+
+It's the responsibility of this event hook to return when there is input ready.
+There are two ways to detect when input is ready:
+
+The inputhook itself is a callable that receives an `InputHookContext`. This
+callable should run the other event loop, and return when the main loop has
+stuff to do. There are two ways to detect when to return:
+
+- Call the `input_is_ready` method periodically. Quit when this returns `True`.
+
+- Add the `fileno` as a watch to the external eventloop. Quit when file descriptor
+ becomes readable. (But don't read from it.)
+
+ Note that this is not the same as checking for `sys.stdin.fileno()`. The
+ eventloop of prompt-toolkit allows thread-based executors, for example for
+ asynchronous autocompletion. When the completion for instance is ready, we
+ also want prompt-toolkit to gain control again in order to display that.
+"""
+from __future__ import annotations
+
+import asyncio
+import os
+import select
+import selectors
+import sys
+import threading
+from asyncio import AbstractEventLoop, get_running_loop
+from selectors import BaseSelector, SelectorKey
+from typing import TYPE_CHECKING, Any, Callable, Mapping
+
+__all__ = [
+ "new_eventloop_with_inputhook",
+ "set_eventloop_with_inputhook",
+ "InputHookSelector",
+ "InputHookContext",
+ "InputHook",
+]
+
+if TYPE_CHECKING:
+ from _typeshed import FileDescriptorLike
+ from typing_extensions import TypeAlias
+
+ _EventMask = int
+
+
+class InputHookContext:
+ """
+ Given as a parameter to the inputhook.
+ """
+
+ def __init__(self, fileno: int, input_is_ready: Callable[[], bool]) -> None:
+ self._fileno = fileno
+ self.input_is_ready = input_is_ready
+
+ def fileno(self) -> int:
+ return self._fileno
+
+
+InputHook: TypeAlias = Callable[[InputHookContext], None]
+
+
+def new_eventloop_with_inputhook(
+ inputhook: Callable[[InputHookContext], None],
+) -> AbstractEventLoop:
+ """
+ Create a new event loop with the given inputhook.
+ """
+ selector = InputHookSelector(selectors.DefaultSelector(), inputhook)
+ loop = asyncio.SelectorEventLoop(selector)
+ return loop
+
+
+def set_eventloop_with_inputhook(
+ inputhook: Callable[[InputHookContext], None],
+) -> AbstractEventLoop:
+ """
+ Create a new event loop with the given inputhook, and activate it.
+ """
+ # Deprecated!
+
+ loop = new_eventloop_with_inputhook(inputhook)
+ asyncio.set_event_loop(loop)
+ return loop
+
+
+class InputHookSelector(BaseSelector):
+ """
+ Usage:
+
+ selector = selectors.SelectSelector()
+ loop = asyncio.SelectorEventLoop(InputHookSelector(selector, inputhook))
+ asyncio.set_event_loop(loop)
+ """
+
+ def __init__(
+ self, selector: BaseSelector, inputhook: Callable[[InputHookContext], None]
+ ) -> None:
+ self.selector = selector
+ self.inputhook = inputhook
+ self._r, self._w = os.pipe()
+
+ def register(
+ self, fileobj: FileDescriptorLike, events: _EventMask, data: Any = None
+ ) -> SelectorKey:
+ return self.selector.register(fileobj, events, data=data)
+
+ def unregister(self, fileobj: FileDescriptorLike) -> SelectorKey:
+ return self.selector.unregister(fileobj)
+
+ def modify(
+ self, fileobj: FileDescriptorLike, events: _EventMask, data: Any = None
+ ) -> SelectorKey:
+ return self.selector.modify(fileobj, events, data=None)
+
+ def select(
+ self, timeout: float | None = None
+ ) -> list[tuple[SelectorKey, _EventMask]]:
+ # If there are tasks in the current event loop,
+ # don't run the input hook.
+ if len(getattr(get_running_loop(), "_ready", [])) > 0:
+ return self.selector.select(timeout=timeout)
+
+ ready = False
+ result = None
+
+ # Run selector in other thread.
+ def run_selector() -> None:
+ nonlocal ready, result
+ result = self.selector.select(timeout=timeout)
+ os.write(self._w, b"x")
+ ready = True
+
+ th = threading.Thread(target=run_selector)
+ th.start()
+
+ def input_is_ready() -> bool:
+ return ready
+
+ # Call inputhook.
+ # The inputhook function is supposed to return when our selector
+ # becomes ready. The inputhook can do that by registering the fd in its
+ # own loop, or by checking the `input_is_ready` function regularly.
+ self.inputhook(InputHookContext(self._r, input_is_ready))
+
+ # Flush the read end of the pipe.
+ try:
+ # Before calling 'os.read', call select.select. This is required
+ # when the gevent monkey patch has been applied. 'os.read' is never
+ # monkey patched and won't be cooperative, so that would block all
+ # other select() calls otherwise.
+ # See: http://www.gevent.org/gevent.os.html
+
+ # Note: On Windows, this is apparently not an issue.
+ # However, if we would ever want to add a select call, it
+ # should use `windll.kernel32.WaitForMultipleObjects`,
+ # because `select.select` can't wait for a pipe on Windows.
+ if sys.platform != "win32":
+ select.select([self._r], [], [], None)
+
+ os.read(self._r, 1024)
+ except OSError:
+ # This happens when the window resizes and a SIGWINCH was received.
+ # We get 'Error: [Errno 4] Interrupted system call'
+ # Just ignore.
+ pass
+
+ # Wait for the real selector to be done.
+ th.join()
+ assert result is not None
+ return result
+
+ def close(self) -> None:
+ """
+ Clean up resources.
+ """
+ if self._r:
+ os.close(self._r)
+ os.close(self._w)
+
+ self._r = self._w = -1
+ self.selector.close()
+
+ def get_map(self) -> Mapping[FileDescriptorLike, SelectorKey]:
+ return self.selector.get_map()
diff --git a/src/prompt_toolkit/eventloop/utils.py b/src/prompt_toolkit/eventloop/utils.py
new file mode 100644
index 0000000..3138361
--- /dev/null
+++ b/src/prompt_toolkit/eventloop/utils.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+import asyncio
+import contextvars
+import sys
+import time
+from asyncio import get_running_loop
+from types import TracebackType
+from typing import Any, Awaitable, Callable, TypeVar, cast
+
+__all__ = [
+ "run_in_executor_with_context",
+ "call_soon_threadsafe",
+ "get_traceback_from_context",
+]
+
+_T = TypeVar("_T")
+
+
+def run_in_executor_with_context(
+ func: Callable[..., _T],
+ *args: Any,
+ loop: asyncio.AbstractEventLoop | None = None,
+) -> Awaitable[_T]:
+ """
+ Run a function in an executor, but make sure it uses the same contextvars.
+ This is required so that the function will see the right application.
+
+ See also: https://bugs.python.org/issue34014
+ """
+ loop = loop or get_running_loop()
+ ctx: contextvars.Context = contextvars.copy_context()
+
+ return loop.run_in_executor(None, ctx.run, func, *args)
+
+
+def call_soon_threadsafe(
+ func: Callable[[], None],
+ max_postpone_time: float | None = None,
+ loop: asyncio.AbstractEventLoop | None = None,
+) -> None:
+ """
+ Wrapper around asyncio's `call_soon_threadsafe`.
+
+ This takes a `max_postpone_time` which can be used to tune the urgency of
+ the method.
+
+ Asyncio runs tasks in first-in-first-out. However, this is not what we
+ want for the render function of the prompt_toolkit UI. Rendering is
+ expensive, but since the UI is invalidated very often, in some situations
+ we render the UI too often, so much that the rendering CPU usage slows down
+ the rest of the processing of the application. (Pymux is an example where
+ we have to balance the CPU time spend on rendering the UI, and parsing
+ process output.)
+ However, we want to set a deadline value, for when the rendering should
+ happen. (The UI should stay responsive).
+ """
+ loop2 = loop or get_running_loop()
+
+ # If no `max_postpone_time` has been given, schedule right now.
+ if max_postpone_time is None:
+ loop2.call_soon_threadsafe(func)
+ return
+
+ max_postpone_until = time.time() + max_postpone_time
+
+ def schedule() -> None:
+ # When there are no other tasks scheduled in the event loop. Run it
+ # now.
+ # Notice: uvloop doesn't have this _ready attribute. In that case,
+ # always call immediately.
+ if not getattr(loop2, "_ready", []):
+ func()
+ return
+
+ # If the timeout expired, run this now.
+ if time.time() > max_postpone_until:
+ func()
+ return
+
+ # Schedule again for later.
+ loop2.call_soon_threadsafe(schedule)
+
+ loop2.call_soon_threadsafe(schedule)
+
+
+def get_traceback_from_context(context: dict[str, Any]) -> TracebackType | None:
+ """
+ Get the traceback object from the context.
+ """
+ exception = context.get("exception")
+ if exception:
+ if hasattr(exception, "__traceback__"):
+ return cast(TracebackType, exception.__traceback__)
+ else:
+ # call_exception_handler() is usually called indirectly
+ # from an except block. If it's not the case, the traceback
+ # is undefined...
+ return sys.exc_info()[2]
+
+ return None
diff --git a/src/prompt_toolkit/eventloop/win32.py b/src/prompt_toolkit/eventloop/win32.py
new file mode 100644
index 0000000..56a0c7d
--- /dev/null
+++ b/src/prompt_toolkit/eventloop/win32.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+import sys
+
+assert sys.platform == "win32"
+
+from ctypes import pointer
+
+from ..utils import SPHINX_AUTODOC_RUNNING
+
+# Do not import win32-specific stuff when generating documentation.
+# Otherwise RTD would be unable to generate docs for this module.
+if not SPHINX_AUTODOC_RUNNING:
+ from ctypes import windll
+
+from ctypes.wintypes import BOOL, DWORD, HANDLE
+
+from prompt_toolkit.win32_types import SECURITY_ATTRIBUTES
+
+__all__ = ["wait_for_handles", "create_win32_event"]
+
+
+WAIT_TIMEOUT = 0x00000102
+INFINITE = -1
+
+
+def wait_for_handles(handles: list[HANDLE], timeout: int = INFINITE) -> HANDLE | None:
+ """
+ Waits for multiple handles. (Similar to 'select') Returns the handle which is ready.
+ Returns `None` on timeout.
+ http://msdn.microsoft.com/en-us/library/windows/desktop/ms687025(v=vs.85).aspx
+
+ Note that handles should be a list of `HANDLE` objects, not integers. See
+ this comment in the patch by @quark-zju for the reason why:
+
+ ''' Make sure HANDLE on Windows has a correct size
+
+ Previously, the type of various HANDLEs are native Python integer
+ types. The ctypes library will treat them as 4-byte integer when used
+ in function arguments. On 64-bit Windows, HANDLE is 8-byte and usually
+ a small integer. Depending on whether the extra 4 bytes are zero-ed out
+ or not, things can happen to work, or break. '''
+
+ This function returns either `None` or one of the given `HANDLE` objects.
+ (The return value can be tested with the `is` operator.)
+ """
+ arrtype = HANDLE * len(handles)
+ handle_array = arrtype(*handles)
+
+ ret: int = windll.kernel32.WaitForMultipleObjects(
+ len(handle_array), handle_array, BOOL(False), DWORD(timeout)
+ )
+
+ if ret == WAIT_TIMEOUT:
+ return None
+ else:
+ return handles[ret]
+
+
+def create_win32_event() -> HANDLE:
+ """
+ Creates a Win32 unnamed Event .
+ http://msdn.microsoft.com/en-us/library/windows/desktop/ms682396(v=vs.85).aspx
+ """
+ return HANDLE(
+ windll.kernel32.CreateEventA(
+ pointer(SECURITY_ATTRIBUTES()),
+ BOOL(True), # Manual reset event.
+ BOOL(False), # Initial state.
+ None, # Unnamed event object.
+ )
+ )
diff --git a/src/prompt_toolkit/filters/__init__.py b/src/prompt_toolkit/filters/__init__.py
new file mode 100644
index 0000000..277f428
--- /dev/null
+++ b/src/prompt_toolkit/filters/__init__.py
@@ -0,0 +1,70 @@
+"""
+Filters decide whether something is active or not (they decide about a boolean
+state). This is used to enable/disable features, like key bindings, parts of
+the layout and other stuff. For instance, we could have a `HasSearch` filter
+attached to some part of the layout, in order to show that part of the user
+interface only while the user is searching.
+
+Filters are made to avoid having to attach callbacks to all event in order to
+propagate state. However, they are lazy, they don't automatically propagate the
+state of what they are observing. Only when a filter is called (it's actually a
+callable), it will calculate its value. So, its not really reactive
+programming, but it's made to fit for this framework.
+
+Filters can be chained using ``&`` and ``|`` operations, and inverted using the
+``~`` operator, for instance::
+
+ filter = has_focus('default') & ~ has_selection
+"""
+from __future__ import annotations
+
+from .app import *
+from .base import Always, Condition, Filter, FilterOrBool, Never
+from .cli import *
+from .utils import is_true, to_filter
+
+__all__ = [
+ # app
+ "has_arg",
+ "has_completions",
+ "completion_is_selected",
+ "has_focus",
+ "buffer_has_focus",
+ "has_selection",
+ "has_validation_error",
+ "is_done",
+ "is_read_only",
+ "is_multiline",
+ "renderer_height_is_known",
+ "in_editing_mode",
+ "in_paste_mode",
+ "vi_mode",
+ "vi_navigation_mode",
+ "vi_insert_mode",
+ "vi_insert_multiple_mode",
+ "vi_replace_mode",
+ "vi_selection_mode",
+ "vi_waiting_for_text_object_mode",
+ "vi_digraph_mode",
+ "vi_recording_macro",
+ "emacs_mode",
+ "emacs_insert_mode",
+ "emacs_selection_mode",
+ "shift_selection_mode",
+ "is_searching",
+ "control_is_searchable",
+ "vi_search_direction_reversed",
+ # base.
+ "Filter",
+ "Never",
+ "Always",
+ "Condition",
+ "FilterOrBool",
+ # utils.
+ "is_true",
+ "to_filter",
+]
+
+from .cli import __all__ as cli_all
+
+__all__.extend(cli_all)
diff --git a/src/prompt_toolkit/filters/app.py b/src/prompt_toolkit/filters/app.py
new file mode 100644
index 0000000..aacb228
--- /dev/null
+++ b/src/prompt_toolkit/filters/app.py
@@ -0,0 +1,418 @@
+"""
+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",
+]
+
+
+# NOTE: `has_focus` below should *not* be `memoized`. It can reference any user
+# control. For instance, if we would continuously create new
+# `PromptSession` instances, then previous instances won't be released,
+# because this memoize (which caches results in the global scope) will
+# still refer to each instance.
+def has_focus(value: FocusableElement) -> Condition:
+ """
+ Enable when this buffer has the focus.
+ """
+ from prompt_toolkit.buffer import Buffer
+ from prompt_toolkit.layout import walk
+ from prompt_toolkit.layout.containers import Container, Window, to_container
+ from prompt_toolkit.layout.controls import UIControl
+
+ if isinstance(value, str):
+
+ def test() -> bool:
+ return get_app().current_buffer.name == value
+
+ elif isinstance(value, Buffer):
+
+ def test() -> bool:
+ return get_app().current_buffer == value
+
+ elif isinstance(value, UIControl):
+
+ def test() -> bool:
+ return get_app().layout.current_control == value
+
+ else:
+ value = to_container(value)
+
+ if isinstance(value, Window):
+
+ def test() -> bool:
+ return get_app().layout.current_window == value
+
+ else:
+
+ def test() -> bool:
+ # Consider focused when any window inside this container is
+ # focused.
+ current_window = get_app().layout.current_window
+
+ for c in walk(cast(Container, value)):
+ if isinstance(c, Window) and c == current_window:
+ return True
+ return False
+
+ @Condition
+ def has_focus_filter() -> bool:
+ return test()
+
+ return has_focus_filter
+
+
+@Condition
+def buffer_has_focus() -> bool:
+ """
+ Enabled when the currently focused control is a `BufferControl`.
+ """
+ return get_app().layout.buffer_has_focus
+
+
+@Condition
+def has_selection() -> bool:
+ """
+ Enable when the current buffer has a selection.
+ """
+ return bool(get_app().current_buffer.selection_state)
+
+
+@Condition
+def has_suggestion() -> bool:
+ """
+ Enable when the current buffer has a suggestion.
+ """
+ buffer = get_app().current_buffer
+ return buffer.suggestion is not None and buffer.suggestion.text != ""
+
+
+@Condition
+def has_completions() -> bool:
+ """
+ Enable when the current buffer has completions.
+ """
+ state = get_app().current_buffer.complete_state
+ return state is not None and len(state.completions) > 0
+
+
+@Condition
+def completion_is_selected() -> bool:
+ """
+ True when the user selected a completion.
+ """
+ complete_state = get_app().current_buffer.complete_state
+ return complete_state is not None and complete_state.current_completion is not None
+
+
+@Condition
+def is_read_only() -> bool:
+ """
+ True when the current buffer is read only.
+ """
+ return get_app().current_buffer.read_only()
+
+
+@Condition
+def is_multiline() -> bool:
+ """
+ True when the current buffer has been marked as multiline.
+ """
+ return get_app().current_buffer.multiline()
+
+
+@Condition
+def has_validation_error() -> bool:
+ "Current buffer has validation error."
+ return get_app().current_buffer.validation_error is not None
+
+
+@Condition
+def has_arg() -> bool:
+ "Enable when the input processor has an 'arg'."
+ return get_app().key_processor.arg is not None
+
+
+@Condition
+def is_done() -> bool:
+ """
+ True when the CLI is returning, aborting or exiting.
+ """
+ return get_app().is_done
+
+
+@Condition
+def renderer_height_is_known() -> bool:
+ """
+ Only True when the renderer knows it's real height.
+
+ (On VT100 terminals, we have to wait for a CPR response, before we can be
+ sure of the available height between the cursor position and the bottom of
+ the terminal. And usually it's nicer to wait with drawing bottom toolbars
+ until we receive the height, in order to avoid flickering -- first drawing
+ somewhere in the middle, and then again at the bottom.)
+ """
+ return get_app().renderer.height_is_known
+
+
+@memoized()
+def in_editing_mode(editing_mode: EditingMode) -> Condition:
+ """
+ Check whether a given editing mode is active. (Vi or Emacs.)
+ """
+
+ @Condition
+ def in_editing_mode_filter() -> bool:
+ return get_app().editing_mode == editing_mode
+
+ return in_editing_mode_filter
+
+
+@Condition
+def in_paste_mode() -> bool:
+ return get_app().paste_mode()
+
+
+@Condition
+def vi_mode() -> bool:
+ return get_app().editing_mode == EditingMode.VI
+
+
+@Condition
+def vi_navigation_mode() -> bool:
+ """
+ Active when the set for Vi navigation key bindings are active.
+ """
+ from prompt_toolkit.key_binding.vi_state import InputMode
+
+ app = get_app()
+
+ if (
+ app.editing_mode != EditingMode.VI
+ or app.vi_state.operator_func
+ or app.vi_state.waiting_for_digraph
+ or app.current_buffer.selection_state
+ ):
+ return False
+
+ return (
+ app.vi_state.input_mode == InputMode.NAVIGATION
+ or app.vi_state.temporary_navigation_mode
+ or app.current_buffer.read_only()
+ )
+
+
+@Condition
+def vi_insert_mode() -> bool:
+ from prompt_toolkit.key_binding.vi_state import InputMode
+
+ app = get_app()
+
+ if (
+ app.editing_mode != EditingMode.VI
+ or app.vi_state.operator_func
+ or app.vi_state.waiting_for_digraph
+ or app.current_buffer.selection_state
+ or app.vi_state.temporary_navigation_mode
+ or app.current_buffer.read_only()
+ ):
+ return False
+
+ return app.vi_state.input_mode == InputMode.INSERT
+
+
+@Condition
+def vi_insert_multiple_mode() -> bool:
+ from prompt_toolkit.key_binding.vi_state import InputMode
+
+ app = get_app()
+
+ if (
+ app.editing_mode != EditingMode.VI
+ or app.vi_state.operator_func
+ or app.vi_state.waiting_for_digraph
+ or app.current_buffer.selection_state
+ or app.vi_state.temporary_navigation_mode
+ or app.current_buffer.read_only()
+ ):
+ return False
+
+ return app.vi_state.input_mode == InputMode.INSERT_MULTIPLE
+
+
+@Condition
+def vi_replace_mode() -> bool:
+ from prompt_toolkit.key_binding.vi_state import InputMode
+
+ app = get_app()
+
+ if (
+ app.editing_mode != EditingMode.VI
+ or app.vi_state.operator_func
+ or app.vi_state.waiting_for_digraph
+ or app.current_buffer.selection_state
+ or app.vi_state.temporary_navigation_mode
+ or app.current_buffer.read_only()
+ ):
+ return False
+
+ return app.vi_state.input_mode == InputMode.REPLACE
+
+
+@Condition
+def vi_replace_single_mode() -> bool:
+ from prompt_toolkit.key_binding.vi_state import InputMode
+
+ app = get_app()
+
+ if (
+ app.editing_mode != EditingMode.VI
+ or app.vi_state.operator_func
+ or app.vi_state.waiting_for_digraph
+ or app.current_buffer.selection_state
+ or app.vi_state.temporary_navigation_mode
+ or app.current_buffer.read_only()
+ ):
+ return False
+
+ return app.vi_state.input_mode == InputMode.REPLACE_SINGLE
+
+
+@Condition
+def vi_selection_mode() -> bool:
+ app = get_app()
+ if app.editing_mode != EditingMode.VI:
+ return False
+
+ return bool(app.current_buffer.selection_state)
+
+
+@Condition
+def vi_waiting_for_text_object_mode() -> bool:
+ app = get_app()
+ if app.editing_mode != EditingMode.VI:
+ return False
+
+ return app.vi_state.operator_func is not None
+
+
+@Condition
+def vi_digraph_mode() -> bool:
+ app = get_app()
+ if app.editing_mode != EditingMode.VI:
+ return False
+
+ return app.vi_state.waiting_for_digraph
+
+
+@Condition
+def vi_recording_macro() -> bool:
+ "When recording a Vi macro."
+ app = get_app()
+ if app.editing_mode != EditingMode.VI:
+ return False
+
+ return app.vi_state.recording_register is not None
+
+
+@Condition
+def emacs_mode() -> bool:
+ "When the Emacs bindings are active."
+ return get_app().editing_mode == EditingMode.EMACS
+
+
+@Condition
+def emacs_insert_mode() -> bool:
+ app = get_app()
+ if (
+ app.editing_mode != EditingMode.EMACS
+ or app.current_buffer.selection_state
+ or app.current_buffer.read_only()
+ ):
+ return False
+ return True
+
+
+@Condition
+def emacs_selection_mode() -> bool:
+ app = get_app()
+ return bool(
+ app.editing_mode == EditingMode.EMACS and app.current_buffer.selection_state
+ )
+
+
+@Condition
+def shift_selection_mode() -> bool:
+ app = get_app()
+ return bool(
+ app.current_buffer.selection_state
+ and app.current_buffer.selection_state.shift_mode
+ )
+
+
+@Condition
+def is_searching() -> bool:
+ "When we are searching."
+ app = get_app()
+ return app.layout.is_searching
+
+
+@Condition
+def control_is_searchable() -> bool:
+ "When the current UIControl is searchable."
+ from prompt_toolkit.layout.controls import BufferControl
+
+ control = get_app().layout.current_control
+
+ return (
+ isinstance(control, BufferControl) and control.search_buffer_control is not None
+ )
+
+
+@Condition
+def vi_search_direction_reversed() -> bool:
+ "When the '/' and '?' key bindings for Vi-style searching have been reversed."
+ return get_app().reverse_vi_search_direction()
diff --git a/src/prompt_toolkit/filters/base.py b/src/prompt_toolkit/filters/base.py
new file mode 100644
index 0000000..afce6dc
--- /dev/null
+++ b/src/prompt_toolkit/filters/base.py
@@ -0,0 +1,255 @@
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import Callable, Iterable, Union
+
+__all__ = ["Filter", "Never", "Always", "Condition", "FilterOrBool"]
+
+
+class Filter(metaclass=ABCMeta):
+ """
+ Base class for any filter to activate/deactivate a feature, depending on a
+ condition.
+
+ The return value of ``__call__`` will tell if the feature should be active.
+ """
+
+ def __init__(self) -> None:
+ self._and_cache: dict[Filter, Filter] = {}
+ self._or_cache: dict[Filter, Filter] = {}
+ self._invert_result: Filter | None = None
+
+ @abstractmethod
+ def __call__(self) -> bool:
+ """
+ The actual call to evaluate the filter.
+ """
+ return True
+
+ def __and__(self, other: Filter) -> Filter:
+ """
+ Chaining of filters using the & operator.
+ """
+ assert isinstance(other, Filter), "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:
+ """
+ Chaining of filters using the | operator.
+ """
+ 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:
+ """
+ Inverting of filters using the ~ operator.
+ """
+ if self._invert_result is None:
+ self._invert_result = _Invert(self)
+
+ return self._invert_result
+
+ def __bool__(self) -> None:
+ """
+ By purpose, we don't allow bool(...) operations directly on a filter,
+ because the meaning is ambiguous.
+
+ Executing a filter has to be done always by calling it. Providing
+ defaults for `None` values should be done through an `is None` check
+ instead of for instance ``filter1 or Always()``.
+ """
+ raise ValueError(
+ "The truth value of a Filter is ambiguous. "
+ "Instead, call it as a function."
+ )
+
+
+def _remove_duplicates(filters: list[Filter]) -> list[Filter]:
+ result = []
+ for f in filters:
+ if f not in result:
+ result.append(f)
+ return result
+
+
+class _AndList(Filter):
+ """
+ Result of &-operation between several filters.
+ """
+
+ def __init__(self, filters: list[Filter]) -> None:
+ super().__init__()
+ self.filters = filters
+
+ @classmethod
+ def create(cls, filters: Iterable[Filter]) -> Filter:
+ """
+ Create a new filter by applying an `&` operator between them.
+
+ If there's only one unique filter in the given iterable, it will return
+ that one filter instead of an `_AndList`.
+ """
+ filters_2: list[Filter] = []
+
+ for f in filters:
+ if isinstance(f, _AndList): # Turn nested _AndLists into one.
+ filters_2.extend(f.filters)
+ else:
+ filters_2.append(f)
+
+ # Remove duplicates. This could speed up execution, and doesn't make a
+ # difference for the evaluation.
+ filters = _remove_duplicates(filters_2)
+
+ # If only one filter is left, return that without wrapping into an
+ # `_AndList`.
+ if len(filters) == 1:
+ return filters[0]
+
+ return cls(filters)
+
+ def __call__(self) -> bool:
+ return all(f() for f in self.filters)
+
+ def __repr__(self) -> str:
+ return "&".join(repr(f) for f in self.filters)
+
+
+class _OrList(Filter):
+ """
+ Result of |-operation between several filters.
+ """
+
+ def __init__(self, filters: list[Filter]) -> None:
+ super().__init__()
+ self.filters = filters
+
+ @classmethod
+ def create(cls, filters: Iterable[Filter]) -> Filter:
+ """
+ Create a new filter by applying an `|` operator between them.
+
+ If there's only one unique filter in the given iterable, it will return
+ that one filter instead of an `_OrList`.
+ """
+ filters_2: list[Filter] = []
+
+ for f in filters:
+ if isinstance(f, _OrList): # Turn nested _AndLists into one.
+ filters_2.extend(f.filters)
+ else:
+ filters_2.append(f)
+
+ # Remove duplicates. This could speed up execution, and doesn't make a
+ # difference for the evaluation.
+ filters = _remove_duplicates(filters_2)
+
+ # If only one filter is left, return that without wrapping into an
+ # `_AndList`.
+ if len(filters) == 1:
+ return filters[0]
+
+ return cls(filters)
+
+ def __call__(self) -> bool:
+ return any(f() for f in self.filters)
+
+ def __repr__(self) -> str:
+ return "|".join(repr(f) for f in self.filters)
+
+
+class _Invert(Filter):
+ """
+ Negation of another filter.
+ """
+
+ def __init__(self, filter: Filter) -> None:
+ super().__init__()
+ self.filter = filter
+
+ def __call__(self) -> bool:
+ return not self.filter()
+
+ def __repr__(self) -> str:
+ return "~%r" % self.filter
+
+
+class Always(Filter):
+ """
+ Always enable feature.
+ """
+
+ def __call__(self) -> bool:
+ return True
+
+ def __or__(self, other: Filter) -> Filter:
+ return self
+
+ def __invert__(self) -> Never:
+ return Never()
+
+
+class Never(Filter):
+ """
+ Never enable feature.
+ """
+
+ def __call__(self) -> bool:
+ return False
+
+ def __and__(self, other: Filter) -> Filter:
+ return self
+
+ def __invert__(self) -> Always:
+ return Always()
+
+
+class Condition(Filter):
+ """
+ Turn any callable into a Filter. The callable is supposed to not take any
+ arguments.
+
+ This can be used as a decorator::
+
+ @Condition
+ def feature_is_active(): # `feature_is_active` becomes a Filter.
+ return True
+
+ :param func: Callable which takes no inputs and returns a boolean.
+ """
+
+ def __init__(self, func: Callable[[], bool]) -> None:
+ super().__init__()
+ self.func = func
+
+ def __call__(self) -> bool:
+ return self.func()
+
+ def __repr__(self) -> str:
+ return "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
new file mode 100644
index 0000000..c95080a
--- /dev/null
+++ b/src/prompt_toolkit/filters/cli.py
@@ -0,0 +1,64 @@
+"""
+For backwards-compatibility. keep this file.
+(Many people are going to have key bindings that rely on this file.)
+"""
+from __future__ import annotations
+
+from .app import *
+
+__all__ = [
+ # Old names.
+ "HasArg",
+ "HasCompletions",
+ "HasFocus",
+ "HasSelection",
+ "HasValidationError",
+ "IsDone",
+ "IsReadOnly",
+ "IsMultiline",
+ "RendererHeightIsKnown",
+ "InEditingMode",
+ "InPasteMode",
+ "ViMode",
+ "ViNavigationMode",
+ "ViInsertMode",
+ "ViInsertMultipleMode",
+ "ViReplaceMode",
+ "ViSelectionMode",
+ "ViWaitingForTextObjectMode",
+ "ViDigraphMode",
+ "EmacsMode",
+ "EmacsInsertMode",
+ "EmacsSelectionMode",
+ "IsSearching",
+ "HasSearch",
+ "ControlIsSearchable",
+]
+
+# Keep the original classnames for backwards compatibility.
+HasValidationError = lambda: has_validation_error
+HasArg = lambda: has_arg
+IsDone = lambda: is_done
+RendererHeightIsKnown = lambda: renderer_height_is_known
+ViNavigationMode = lambda: vi_navigation_mode
+InPasteMode = lambda: in_paste_mode
+EmacsMode = lambda: emacs_mode
+EmacsInsertMode = lambda: emacs_insert_mode
+ViMode = lambda: vi_mode
+IsSearching = lambda: is_searching
+HasSearch = lambda: is_searching
+ControlIsSearchable = lambda: control_is_searchable
+EmacsSelectionMode = lambda: emacs_selection_mode
+ViDigraphMode = lambda: vi_digraph_mode
+ViWaitingForTextObjectMode = lambda: vi_waiting_for_text_object_mode
+ViSelectionMode = lambda: vi_selection_mode
+ViReplaceMode = lambda: vi_replace_mode
+ViInsertMultipleMode = lambda: vi_insert_multiple_mode
+ViInsertMode = lambda: vi_insert_mode
+HasSelection = lambda: has_selection
+HasCompletions = lambda: has_completions
+IsReadOnly = lambda: is_read_only
+IsMultiline = lambda: is_multiline
+
+HasFocus = has_focus # No lambda here! (Has_focus is callable that returns a callable.)
+InEditingMode = in_editing_mode
diff --git a/src/prompt_toolkit/filters/utils.py b/src/prompt_toolkit/filters/utils.py
new file mode 100644
index 0000000..bac85ba
--- /dev/null
+++ b/src/prompt_toolkit/filters/utils.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from .base import Always, Filter, FilterOrBool, Never
+
+__all__ = [
+ "to_filter",
+ "is_true",
+]
+
+
+_always = Always()
+_never = Never()
+
+
+_bool_to_filter: dict[bool, Filter] = {
+ True: _always,
+ False: _never,
+}
+
+
+def to_filter(bool_or_filter: FilterOrBool) -> Filter:
+ """
+ Accept both booleans and Filters as input and
+ turn it into a Filter.
+ """
+ if isinstance(bool_or_filter, bool):
+ return _bool_to_filter[bool_or_filter]
+
+ if isinstance(bool_or_filter, Filter):
+ return bool_or_filter
+
+ raise TypeError("Expecting a bool or a Filter instance. Got %r" % bool_or_filter)
+
+
+def is_true(value: FilterOrBool) -> bool:
+ """
+ Test whether `value` is True. In case of a Filter, call it.
+
+ :param value: Boolean or `Filter` instance.
+ """
+ return to_filter(value)()
diff --git a/src/prompt_toolkit/formatted_text/__init__.py b/src/prompt_toolkit/formatted_text/__init__.py
new file mode 100644
index 0000000..db44ab9
--- /dev/null
+++ b/src/prompt_toolkit/formatted_text/__init__.py
@@ -0,0 +1,58 @@
+"""
+Many places in prompt_toolkit can take either plain text, or formatted text.
+For instance the :func:`~prompt_toolkit.shortcuts.prompt` function takes either
+plain text or formatted text for the prompt. The
+:class:`~prompt_toolkit.layout.FormattedTextControl` can also take either plain
+text or formatted text.
+
+In any case, there is an input that can either be just plain text (a string),
+an :class:`.HTML` object, an :class:`.ANSI` object or a sequence of
+`(style_string, text)` tuples. The :func:`.to_formatted_text` conversion
+function takes any of these and turns all of them into such a tuple sequence.
+"""
+from __future__ import annotations
+
+from .ansi import ANSI
+from .base import (
+ AnyFormattedText,
+ FormattedText,
+ OneStyleAndTextTuple,
+ StyleAndTextTuples,
+ Template,
+ is_formatted_text,
+ merge_formatted_text,
+ to_formatted_text,
+)
+from .html import HTML
+from .pygments import PygmentsTokens
+from .utils import (
+ fragment_list_len,
+ fragment_list_to_text,
+ fragment_list_width,
+ split_lines,
+ to_plain_text,
+)
+
+__all__ = [
+ # Base.
+ "AnyFormattedText",
+ "OneStyleAndTextTuple",
+ "to_formatted_text",
+ "is_formatted_text",
+ "Template",
+ "merge_formatted_text",
+ "FormattedText",
+ "StyleAndTextTuples",
+ # HTML.
+ "HTML",
+ # ANSI.
+ "ANSI",
+ # Pygments.
+ "PygmentsTokens",
+ # Utils.
+ "fragment_list_len",
+ "fragment_list_width",
+ "fragment_list_to_text",
+ "split_lines",
+ "to_plain_text",
+]
diff --git a/src/prompt_toolkit/formatted_text/ansi.py b/src/prompt_toolkit/formatted_text/ansi.py
new file mode 100644
index 0000000..08ec0b3
--- /dev/null
+++ b/src/prompt_toolkit/formatted_text/ansi.py
@@ -0,0 +1,299 @@
+from __future__ import annotations
+
+from string import Formatter
+from typing import Generator
+
+from prompt_toolkit.output.vt100 import BG_ANSI_COLORS, FG_ANSI_COLORS
+from prompt_toolkit.output.vt100 import _256_colors as _256_colors_table
+
+from .base import StyleAndTextTuples
+
+__all__ = [
+ "ANSI",
+ "ansi_escape",
+]
+
+
+class ANSI:
+ """
+ ANSI formatted text.
+ Take something ANSI escaped text, for use as a formatted string. E.g.
+
+ ::
+
+ ANSI('\\x1b[31mhello \\x1b[32mworld')
+
+ Characters between ``\\001`` and ``\\002`` are supposed to have a zero width
+ when printed, but these are literally sent to the terminal output. This can
+ be used for instance, for inserting Final Term prompt commands. They will
+ be translated into a prompt_toolkit '[ZeroWidthEscape]' fragment.
+ """
+
+ def __init__(self, value: str) -> None:
+ self.value = value
+ self._formatted_text: StyleAndTextTuples = []
+
+ # Default style attributes.
+ self._color: str | None = None
+ self._bgcolor: str | None = None
+ self._bold = False
+ self._underline = False
+ self._strike = False
+ self._italic = False
+ self._blink = False
+ self._reverse = False
+ self._hidden = False
+
+ # Process received text.
+ parser = self._parse_corot()
+ parser.send(None) # type: ignore
+ for c in value:
+ parser.send(c)
+
+ def _parse_corot(self) -> Generator[None, str, None]:
+ """
+ Coroutine that parses the ANSI escape sequences.
+ """
+ style = ""
+ formatted_text = self._formatted_text
+
+ while True:
+ # NOTE: CSI is a special token within a stream of characters that
+ # introduces an ANSI control sequence used to set the
+ # style attributes of the following characters.
+ csi = False
+
+ c = yield
+
+ # Everything between \001 and \002 should become a ZeroWidthEscape.
+ if c == "\001":
+ escaped_text = ""
+ while c != "\002":
+ c = yield
+ if c == "\002":
+ formatted_text.append(("[ZeroWidthEscape]", escaped_text))
+ c = yield
+ break
+ else:
+ escaped_text += c
+
+ # Check for CSI
+ if c == "\x1b":
+ # Start of color escape sequence.
+ square_bracket = yield
+ if square_bracket == "[":
+ csi = True
+ else:
+ continue
+ elif c == "\x9b":
+ csi = True
+
+ if csi:
+ # Got a CSI sequence. Color codes are following.
+ current = ""
+ params = []
+
+ while True:
+ char = yield
+
+ # Construct number
+ if char.isdigit():
+ current += char
+
+ # Eval number
+ else:
+ # Limit and save number value
+ params.append(min(int(current or 0), 9999))
+
+ # Get delimiter token if present
+ if char == ";":
+ current = ""
+
+ # Check and evaluate color codes
+ elif char == "m":
+ # Set attributes and token.
+ self._select_graphic_rendition(params)
+ style = self._create_style_string()
+ break
+
+ # Check and evaluate cursor forward
+ elif char == "C":
+ for i in range(params[0]):
+ # add <SPACE> using current style
+ formatted_text.append((style, " "))
+ break
+
+ else:
+ # Ignore unsupported sequence.
+ break
+ else:
+ # Add current character.
+ # NOTE: At this point, we could merge the current character
+ # into the previous tuple if the style did not change,
+ # however, it's not worth the effort given that it will
+ # be "Exploded" once again when it's rendered to the
+ # output.
+ formatted_text.append((style, c))
+
+ def _select_graphic_rendition(self, attrs: list[int]) -> None:
+ """
+ Taken a list of graphics attributes and apply changes.
+ """
+ if not attrs:
+ attrs = [0]
+ else:
+ attrs = list(attrs[::-1])
+
+ while attrs:
+ attr = attrs.pop()
+
+ if attr in _fg_colors:
+ self._color = _fg_colors[attr]
+ elif attr in _bg_colors:
+ self._bgcolor = _bg_colors[attr]
+ elif attr == 1:
+ self._bold = True
+ # elif attr == 2:
+ # self._faint = True
+ elif attr == 3:
+ self._italic = True
+ elif attr == 4:
+ self._underline = True
+ elif attr == 5:
+ self._blink = True # Slow blink
+ elif attr == 6:
+ self._blink = True # Fast blink
+ elif attr == 7:
+ self._reverse = True
+ elif attr == 8:
+ self._hidden = True
+ elif attr == 9:
+ self._strike = True
+ elif attr == 22:
+ self._bold = False # Normal intensity
+ elif attr == 23:
+ self._italic = False
+ elif attr == 24:
+ self._underline = False
+ elif attr == 25:
+ self._blink = False
+ elif attr == 27:
+ self._reverse = False
+ elif attr == 28:
+ self._hidden = False
+ elif attr == 29:
+ self._strike = False
+ elif not attr:
+ # Reset all style attributes
+ self._color = None
+ self._bgcolor = None
+ self._bold = False
+ self._underline = False
+ self._strike = False
+ self._italic = False
+ self._blink = False
+ self._reverse = False
+ self._hidden = False
+
+ elif attr in (38, 48) and len(attrs) > 1:
+ n = attrs.pop()
+
+ # 256 colors.
+ if n == 5 and len(attrs) >= 1:
+ if attr == 38:
+ m = attrs.pop()
+ self._color = _256_colors.get(m)
+ elif attr == 48:
+ m = attrs.pop()
+ self._bgcolor = _256_colors.get(m)
+
+ # True colors.
+ if n == 2 and len(attrs) >= 3:
+ try:
+ color_str = "#{:02x}{:02x}{:02x}".format(
+ attrs.pop(),
+ attrs.pop(),
+ attrs.pop(),
+ )
+ except IndexError:
+ pass
+ else:
+ if attr == 38:
+ self._color = color_str
+ elif attr == 48:
+ self._bgcolor = color_str
+
+ def _create_style_string(self) -> str:
+ """
+ Turn current style flags into a string for usage in a formatted text.
+ """
+ result = []
+ if self._color:
+ result.append(self._color)
+ if self._bgcolor:
+ result.append("bg:" + self._bgcolor)
+ if self._bold:
+ result.append("bold")
+ if self._underline:
+ result.append("underline")
+ if self._strike:
+ result.append("strike")
+ if self._italic:
+ result.append("italic")
+ if self._blink:
+ result.append("blink")
+ if self._reverse:
+ result.append("reverse")
+ if self._hidden:
+ result.append("hidden")
+
+ return " ".join(result)
+
+ def __repr__(self) -> str:
+ return f"ANSI({self.value!r})"
+
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
+ return self._formatted_text
+
+ def format(self, *args: str, **kwargs: str) -> ANSI:
+ """
+ Like `str.format`, but make sure that the arguments are properly
+ escaped. (No ANSI escapes can be injected.)
+ """
+ return ANSI(FORMATTER.vformat(self.value, args, kwargs))
+
+ def __mod__(self, value: object) -> ANSI:
+ """
+ ANSI('<b>%s</b>') % value
+ """
+ if not isinstance(value, tuple):
+ value = (value,)
+
+ value = tuple(ansi_escape(i) for i in value)
+ return ANSI(self.value % value)
+
+
+# Mapping of the ANSI color codes to their names.
+_fg_colors = {v: k for k, v in FG_ANSI_COLORS.items()}
+_bg_colors = {v: k for k, v in BG_ANSI_COLORS.items()}
+
+# Mapping of the escape codes for 256colors to their 'ffffff' value.
+_256_colors = {}
+
+for i, (r, g, b) in enumerate(_256_colors_table.colors):
+ _256_colors[i] = f"#{r:02x}{g:02x}{b:02x}"
+
+
+def ansi_escape(text: object) -> str:
+ """
+ Replace characters with a special meaning.
+ """
+ return str(text).replace("\x1b", "?").replace("\b", "?")
+
+
+class ANSIFormatter(Formatter):
+ def format_field(self, value: object, format_spec: str) -> str:
+ return ansi_escape(format(value, format_spec))
+
+
+FORMATTER = ANSIFormatter()
diff --git a/src/prompt_toolkit/formatted_text/base.py b/src/prompt_toolkit/formatted_text/base.py
new file mode 100644
index 0000000..92de353
--- /dev/null
+++ b/src/prompt_toolkit/formatted_text/base.py
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Tuple, Union, cast
+
+from prompt_toolkit.mouse_events import MouseEvent
+
+if TYPE_CHECKING:
+ from typing_extensions import Protocol
+
+ from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
+
+__all__ = [
+ "OneStyleAndTextTuple",
+ "StyleAndTextTuples",
+ "MagicFormattedText",
+ "AnyFormattedText",
+ "to_formatted_text",
+ "is_formatted_text",
+ "Template",
+ "merge_formatted_text",
+ "FormattedText",
+]
+
+OneStyleAndTextTuple = Union[
+ Tuple[str, str], Tuple[str, str, Callable[[MouseEvent], "NotImplementedOrNone"]]
+]
+
+# List of (style, text) tuples.
+StyleAndTextTuples = List[OneStyleAndTextTuple]
+
+
+if TYPE_CHECKING:
+ from typing_extensions import TypeGuard
+
+ class MagicFormattedText(Protocol):
+ """
+ Any object that implements ``__pt_formatted_text__`` represents formatted
+ text.
+ """
+
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
+ ...
+
+
+AnyFormattedText = Union[
+ str,
+ "MagicFormattedText",
+ StyleAndTextTuples,
+ # Callable[[], 'AnyFormattedText'] # Recursive definition not supported by mypy.
+ Callable[[], Any],
+ None,
+]
+
+
+def to_formatted_text(
+ value: AnyFormattedText, style: str = "", auto_convert: bool = False
+) -> FormattedText:
+ """
+ Convert the given value (which can be formatted text) into a list of text
+ fragments. (Which is the canonical form of formatted text.) The outcome is
+ always a `FormattedText` instance, which is a list of (style, text) tuples.
+
+ It can take a plain text string, an `HTML` or `ANSI` object, anything that
+ implements `__pt_formatted_text__` or a callable that takes no arguments and
+ returns one of those.
+
+ :param style: An additional style string which is applied to all text
+ fragments.
+ :param auto_convert: If `True`, also accept other types, and convert them
+ to a string first.
+ """
+ result: FormattedText | StyleAndTextTuples
+
+ if value is None:
+ result = []
+ elif isinstance(value, str):
+ result = [("", value)]
+ elif isinstance(value, list):
+ result = value # StyleAndTextTuples
+ elif hasattr(value, "__pt_formatted_text__"):
+ result = cast("MagicFormattedText", value).__pt_formatted_text__()
+ elif callable(value):
+ return to_formatted_text(value(), style=style)
+ elif auto_convert:
+ result = [("", f"{value}")]
+ else:
+ raise ValueError(
+ "No formatted text. Expecting a unicode object, "
+ f"HTML, ANSI or a FormattedText instance. Got {value!r}"
+ )
+
+ # Apply extra style.
+ if style:
+ result = cast(
+ StyleAndTextTuples,
+ [(style + " " + item_style, *rest) for item_style, *rest in result],
+ )
+
+ # Make sure the result is wrapped in a `FormattedText`. Among other
+ # reasons, this is important for `print_formatted_text` to work correctly
+ # and distinguish between lists and formatted text.
+ if isinstance(result, FormattedText):
+ return result
+ else:
+ return FormattedText(result)
+
+
+def is_formatted_text(value: object) -> TypeGuard[AnyFormattedText]:
+ """
+ Check whether the input is valid formatted text (for use in assert
+ statements).
+ In case of a callable, it doesn't check the return type.
+ """
+ if callable(value):
+ return True
+ if isinstance(value, (str, list)):
+ return True
+ if hasattr(value, "__pt_formatted_text__"):
+ return True
+ return False
+
+
+class FormattedText(StyleAndTextTuples):
+ """
+ A list of ``(style, text)`` tuples.
+
+ (In some situations, this can also be ``(style, text, mouse_handler)``
+ tuples.)
+ """
+
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
+ return self
+
+ def __repr__(self) -> str:
+ return "FormattedText(%s)" % super().__repr__()
+
+
+class Template:
+ """
+ Template for string interpolation with formatted text.
+
+ Example::
+
+ Template(' ... {} ... ').format(HTML(...))
+
+ :param text: Plain text.
+ """
+
+ def __init__(self, text: str) -> None:
+ assert "{0}" not in text
+ self.text = text
+
+ def format(self, *values: AnyFormattedText) -> AnyFormattedText:
+ def get_result() -> AnyFormattedText:
+ # Split the template in parts.
+ parts = self.text.split("{}")
+ assert len(parts) - 1 == len(values)
+
+ result = FormattedText()
+ for part, val in zip(parts, values):
+ result.append(("", part))
+ result.extend(to_formatted_text(val))
+ result.append(("", parts[-1]))
+ return result
+
+ return get_result
+
+
+def merge_formatted_text(items: Iterable[AnyFormattedText]) -> AnyFormattedText:
+ """
+ Merge (Concatenate) several pieces of formatted text together.
+ """
+
+ def _merge_formatted_text() -> AnyFormattedText:
+ result = FormattedText()
+ for i in items:
+ result.extend(to_formatted_text(i))
+ return result
+
+ return _merge_formatted_text
diff --git a/src/prompt_toolkit/formatted_text/html.py b/src/prompt_toolkit/formatted_text/html.py
new file mode 100644
index 0000000..a940ac8
--- /dev/null
+++ b/src/prompt_toolkit/formatted_text/html.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+import xml.dom.minidom as minidom
+from string import Formatter
+from typing import Any
+
+from .base import FormattedText, StyleAndTextTuples
+
+__all__ = ["HTML"]
+
+
+class HTML:
+ """
+ HTML formatted text.
+ Take something HTML-like, for use as a formatted string.
+
+ ::
+
+ # Turn something into red.
+ HTML('<style fg="ansired" bg="#00ff44">...</style>')
+
+ # Italic, bold, underline and strike.
+ HTML('<i>...</i>')
+ HTML('<b>...</b>')
+ HTML('<u>...</u>')
+ HTML('<s>...</s>')
+
+ All HTML elements become available as a "class" in the style sheet.
+ E.g. ``<username>...</username>`` can be styled, by setting a style for
+ ``username``.
+ """
+
+ def __init__(self, value: str) -> None:
+ self.value = value
+ document = minidom.parseString(f"<html-root>{value}</html-root>")
+
+ result: StyleAndTextTuples = []
+ name_stack: list[str] = []
+ fg_stack: list[str] = []
+ bg_stack: list[str] = []
+
+ def get_current_style() -> str:
+ "Build style string for current node."
+ parts = []
+ if name_stack:
+ parts.append("class:" + ",".join(name_stack))
+
+ if fg_stack:
+ parts.append("fg:" + fg_stack[-1])
+ if bg_stack:
+ parts.append("bg:" + bg_stack[-1])
+ return " ".join(parts)
+
+ def process_node(node: Any) -> None:
+ "Process node recursively."
+ for child in node.childNodes:
+ if child.nodeType == child.TEXT_NODE:
+ result.append((get_current_style(), child.data))
+ else:
+ add_to_name_stack = child.nodeName not in (
+ "#document",
+ "html-root",
+ "style",
+ )
+ fg = bg = ""
+
+ for k, v in child.attributes.items():
+ if k == "fg":
+ fg = v
+ if k == "bg":
+ bg = v
+ if k == "color":
+ fg = v # Alias for 'fg'.
+
+ # Check for spaces in attributes. This would result in
+ # invalid style strings otherwise.
+ if " " in fg:
+ raise ValueError('"fg" attribute contains a space.')
+ if " " in bg:
+ raise ValueError('"bg" attribute contains a space.')
+
+ if add_to_name_stack:
+ name_stack.append(child.nodeName)
+ if fg:
+ fg_stack.append(fg)
+ if bg:
+ bg_stack.append(bg)
+
+ process_node(child)
+
+ if add_to_name_stack:
+ name_stack.pop()
+ if fg:
+ fg_stack.pop()
+ if bg:
+ bg_stack.pop()
+
+ process_node(document)
+
+ self.formatted_text = FormattedText(result)
+
+ def __repr__(self) -> str:
+ return f"HTML({self.value!r})"
+
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
+ return self.formatted_text
+
+ def format(self, *args: object, **kwargs: object) -> HTML:
+ """
+ Like `str.format`, but make sure that the arguments are properly
+ escaped.
+ """
+ return HTML(FORMATTER.vformat(self.value, args, kwargs))
+
+ def __mod__(self, value: object) -> HTML:
+ """
+ HTML('<b>%s</b>') % value
+ """
+ if not isinstance(value, tuple):
+ value = (value,)
+
+ value = tuple(html_escape(i) for i in value)
+ return HTML(self.value % value)
+
+
+class HTMLFormatter(Formatter):
+ def format_field(self, value: object, format_spec: str) -> str:
+ return html_escape(format(value, format_spec))
+
+
+def html_escape(text: object) -> str:
+ # The string interpolation functions also take integers and other types.
+ # Convert to string first.
+ if not isinstance(text, str):
+ text = f"{text}"
+
+ return (
+ text.replace("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ .replace('"', "&quot;")
+ )
+
+
+FORMATTER = HTMLFormatter()
diff --git a/src/prompt_toolkit/formatted_text/pygments.py b/src/prompt_toolkit/formatted_text/pygments.py
new file mode 100644
index 0000000..d4ef3ad
--- /dev/null
+++ b/src/prompt_toolkit/formatted_text/pygments.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from prompt_toolkit.styles.pygments import pygments_token_to_classname
+
+from .base import StyleAndTextTuples
+
+if TYPE_CHECKING:
+ from pygments.token import Token
+
+__all__ = [
+ "PygmentsTokens",
+]
+
+
+class PygmentsTokens:
+ """
+ Turn a pygments token list into a list of prompt_toolkit text fragments
+ (``(style_str, text)`` tuples).
+ """
+
+ def __init__(self, token_list: list[tuple[Token, str]]) -> None:
+ self.token_list = token_list
+
+ def __pt_formatted_text__(self) -> StyleAndTextTuples:
+ result: StyleAndTextTuples = []
+
+ for token, text in self.token_list:
+ result.append(("class:" + pygments_token_to_classname(token), text))
+
+ return result
diff --git a/src/prompt_toolkit/formatted_text/utils.py b/src/prompt_toolkit/formatted_text/utils.py
new file mode 100644
index 0000000..c8c37e0
--- /dev/null
+++ b/src/prompt_toolkit/formatted_text/utils.py
@@ -0,0 +1,102 @@
+"""
+Utilities for manipulating formatted text.
+
+When ``to_formatted_text`` has been called, we get a list of ``(style, text)``
+tuples. This file contains functions for manipulating such a list.
+"""
+from __future__ import annotations
+
+from typing import Iterable, cast
+
+from prompt_toolkit.utils import get_cwidth
+
+from .base import (
+ AnyFormattedText,
+ OneStyleAndTextTuple,
+ StyleAndTextTuples,
+ to_formatted_text,
+)
+
+__all__ = [
+ "to_plain_text",
+ "fragment_list_len",
+ "fragment_list_width",
+ "fragment_list_to_text",
+ "split_lines",
+]
+
+
+def to_plain_text(value: AnyFormattedText) -> str:
+ """
+ Turn any kind of formatted text back into plain text.
+ """
+ return fragment_list_to_text(to_formatted_text(value))
+
+
+def fragment_list_len(fragments: StyleAndTextTuples) -> int:
+ """
+ Return the amount of characters in this text fragment list.
+
+ :param fragments: List of ``(style_str, text)`` or
+ ``(style_str, text, mouse_handler)`` tuples.
+ """
+ ZeroWidthEscape = "[ZeroWidthEscape]"
+ return sum(len(item[1]) for item in fragments if ZeroWidthEscape not in item[0])
+
+
+def fragment_list_width(fragments: StyleAndTextTuples) -> int:
+ """
+ Return the character width of this text fragment list.
+ (Take double width characters into account.)
+
+ :param fragments: List of ``(style_str, text)`` or
+ ``(style_str, text, mouse_handler)`` tuples.
+ """
+ ZeroWidthEscape = "[ZeroWidthEscape]"
+ return sum(
+ get_cwidth(c)
+ for item in fragments
+ for c in item[1]
+ if ZeroWidthEscape not in item[0]
+ )
+
+
+def fragment_list_to_text(fragments: StyleAndTextTuples) -> str:
+ """
+ Concatenate all the text parts again.
+
+ :param fragments: List of ``(style_str, text)`` or
+ ``(style_str, text, mouse_handler)`` tuples.
+ """
+ ZeroWidthEscape = "[ZeroWidthEscape]"
+ return "".join(item[1] for item in fragments if ZeroWidthEscape not in item[0])
+
+
+def split_lines(
+ fragments: Iterable[OneStyleAndTextTuple],
+) -> Iterable[StyleAndTextTuples]:
+ """
+ Take a single list of (style_str, text) tuples and yield one such list for each
+ line. Just like str.split, this will yield at least one item.
+
+ :param fragments: Iterable of ``(style_str, text)`` or
+ ``(style_str, text, mouse_handler)`` tuples.
+ """
+ line: StyleAndTextTuples = []
+
+ for style, string, *mouse_handler in fragments:
+ parts = string.split("\n")
+
+ for part in parts[:-1]:
+ 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
new file mode 100644
index 0000000..553918e
--- /dev/null
+++ b/src/prompt_toolkit/history.py
@@ -0,0 +1,302 @@
+"""
+Implementations for the history of a `Buffer`.
+
+NOTE: There is no `DynamicHistory`:
+ This doesn't work well, because the `Buffer` needs to be able to attach
+ an event handler to the event when a history entry is loaded. This
+ loading can be done asynchronously and making the history swappable would
+ probably break this.
+"""
+from __future__ import annotations
+
+import datetime
+import os
+import threading
+from abc import ABCMeta, abstractmethod
+from asyncio import get_running_loop
+from typing import AsyncGenerator, Iterable, Sequence
+
+__all__ = [
+ "History",
+ "ThreadedHistory",
+ "DummyHistory",
+ "FileHistory",
+ "InMemoryHistory",
+]
+
+
+class History(metaclass=ABCMeta):
+ """
+ Base ``History`` class.
+
+ This also includes abstract methods for loading/storing history.
+ """
+
+ def __init__(self) -> None:
+ # In memory storage for strings.
+ self._loaded = False
+
+ # History that's loaded already, in reverse order. Latest, most recent
+ # item first.
+ self._loaded_strings: list[str] = []
+
+ #
+ # Methods expected by `Buffer`.
+ #
+
+ async def load(self) -> AsyncGenerator[str, None]:
+ """
+ Load the history and yield all the entries in reverse order (latest,
+ most recent history entry first).
+
+ This method can be called multiple times from the `Buffer` to
+ repopulate the history when prompting for a new input. So we are
+ responsible here for both caching, and making sure that strings that
+ were were appended to the history will be incorporated next time this
+ method is called.
+ """
+ if not self._loaded:
+ self._loaded_strings = list(self.load_history_strings())
+ self._loaded = True
+
+ for item in self._loaded_strings:
+ yield item
+
+ def get_strings(self) -> list[str]:
+ """
+ Get the strings from the history that are loaded so far.
+ (In order. Oldest item first.)
+ """
+ return self._loaded_strings[::-1]
+
+ def append_string(self, string: str) -> None:
+ "Add string to the history."
+ self._loaded_strings.insert(0, string)
+ self.store_string(string)
+
+ #
+ # Implementation for specific backends.
+ #
+
+ @abstractmethod
+ def load_history_strings(self) -> Iterable[str]:
+ """
+ This should be a generator that yields `str` instances.
+
+ It should yield the most recent items first, because they are the most
+ important. (The history can already be used, even when it's only
+ partially loaded.)
+ """
+ while False:
+ yield
+
+ @abstractmethod
+ def store_string(self, string: str) -> None:
+ """
+ Store the string in persistent storage.
+ """
+
+
+class ThreadedHistory(History):
+ """
+ Wrapper around `History` implementations that run the `load()` generator in
+ a thread.
+
+ Use this to increase the start-up time of prompt_toolkit applications.
+ History entries are available as soon as they are loaded. We don't have to
+ wait for everything to be loaded.
+ """
+
+ def __init__(self, history: History) -> None:
+ super().__init__()
+
+ self.history = history
+
+ self._load_thread: threading.Thread | None = None
+
+ # Lock for accessing/manipulating `_loaded_strings` and `_loaded`
+ # together in a consistent state.
+ self._lock = threading.Lock()
+
+ # Events created by each `load()` call. Used to wait for new history
+ # entries from the loader thread.
+ self._string_load_events: list[threading.Event] = []
+
+ async def load(self) -> AsyncGenerator[str, None]:
+ """
+ Like `History.load(), but call `self.load_history_strings()` in a
+ background thread.
+ """
+ # Start the load thread, if this is called for the first time.
+ if not self._load_thread:
+ self._load_thread = threading.Thread(
+ target=self._in_load_thread,
+ daemon=True,
+ )
+ self._load_thread.start()
+
+ # Consume the `_loaded_strings` list, using asyncio.
+ loop = get_running_loop()
+
+ # Create threading Event so that we can wait for new items.
+ event = threading.Event()
+ event.set()
+ self._string_load_events.append(event)
+
+ items_yielded = 0
+
+ try:
+ while True:
+ # Wait for new items to be available.
+ # (Use a timeout, because the executor thread is not a daemon
+ # thread. The "slow-history.py" example would otherwise hang if
+ # Control-C is pressed before the history is fully loaded,
+ # because there's still this non-daemon executor thread waiting
+ # for this event.)
+ got_timeout = await loop.run_in_executor(
+ None, lambda: event.wait(timeout=0.5)
+ )
+ if not got_timeout:
+ continue
+
+ # Read new items (in lock).
+ def in_executor() -> tuple[list[str], bool]:
+ with self._lock:
+ new_items = self._loaded_strings[items_yielded:]
+ done = self._loaded
+ event.clear()
+ return new_items, done
+
+ new_items, done = await loop.run_in_executor(None, in_executor)
+
+ items_yielded += len(new_items)
+
+ for item in new_items:
+ yield item
+
+ if done:
+ break
+ finally:
+ self._string_load_events.remove(event)
+
+ def _in_load_thread(self) -> None:
+ try:
+ # Start with an empty list. In case `append_string()` was called
+ # before `load()` happened. Then `.store_string()` will have
+ # written these entries back to disk and we will reload it.
+ self._loaded_strings = []
+
+ for item in self.history.load_history_strings():
+ with self._lock:
+ self._loaded_strings.append(item)
+
+ for event in self._string_load_events:
+ event.set()
+ finally:
+ with self._lock:
+ self._loaded = True
+ for event in self._string_load_events:
+ event.set()
+
+ def append_string(self, string: str) -> None:
+ with self._lock:
+ self._loaded_strings.insert(0, string)
+ self.store_string(string)
+
+ # All of the following are proxied to `self.history`.
+
+ def load_history_strings(self) -> Iterable[str]:
+ return self.history.load_history_strings()
+
+ def store_string(self, string: str) -> None:
+ self.history.store_string(string)
+
+ def __repr__(self) -> str:
+ return f"ThreadedHistory({self.history!r})"
+
+
+class InMemoryHistory(History):
+ """
+ :class:`.History` class that keeps a list of all strings in memory.
+
+ In order to prepopulate the history, it's possible to call either
+ `append_string` for all items or pass a list of strings to `__init__` here.
+ """
+
+ def __init__(self, history_strings: Sequence[str] | None = None) -> None:
+ super().__init__()
+ # Emulating disk storage.
+ if history_strings is None:
+ self._storage = []
+ else:
+ self._storage = list(history_strings)
+
+ def load_history_strings(self) -> Iterable[str]:
+ yield from self._storage[::-1]
+
+ def store_string(self, string: str) -> None:
+ self._storage.append(string)
+
+
+class DummyHistory(History):
+ """
+ :class:`.History` object that doesn't remember anything.
+ """
+
+ def load_history_strings(self) -> Iterable[str]:
+ return []
+
+ def store_string(self, string: str) -> None:
+ pass
+
+ def append_string(self, string: str) -> None:
+ # Don't remember this.
+ pass
+
+
+class FileHistory(History):
+ """
+ :class:`.History` class that stores all strings in a file.
+ """
+
+ def __init__(self, filename: str) -> None:
+ self.filename = filename
+ super().__init__()
+
+ def load_history_strings(self) -> Iterable[str]:
+ strings: list[str] = []
+ lines: list[str] = []
+
+ def add() -> None:
+ if lines:
+ # Join and drop trailing newline.
+ string = "".join(lines)[:-1]
+
+ strings.append(string)
+
+ if os.path.exists(self.filename):
+ with open(self.filename, "rb") as f:
+ for line_bytes in f:
+ line = line_bytes.decode("utf-8", errors="replace")
+
+ if line.startswith("+"):
+ lines.append(line[1:])
+ else:
+ add()
+ lines = []
+
+ add()
+
+ # Reverse the order, because newest items have to go first.
+ return reversed(strings)
+
+ def store_string(self, string: str) -> None:
+ # Save to file.
+ with open(self.filename, "ab") as f:
+
+ def write(t: str) -> None:
+ f.write(t.encode("utf-8"))
+
+ write("\n# %s\n" % datetime.datetime.now())
+ for line in string.split("\n"):
+ write("+%s\n" % line)
diff --git a/src/prompt_toolkit/input/__init__.py b/src/prompt_toolkit/input/__init__.py
new file mode 100644
index 0000000..ed8631b
--- /dev/null
+++ b/src/prompt_toolkit/input/__init__.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from .base import DummyInput, Input, PipeInput
+from .defaults import create_input, create_pipe_input
+
+__all__ = [
+ # Base.
+ "Input",
+ "PipeInput",
+ "DummyInput",
+ # Defaults.
+ "create_input",
+ "create_pipe_input",
+]
diff --git a/src/prompt_toolkit/input/ansi_escape_sequences.py b/src/prompt_toolkit/input/ansi_escape_sequences.py
new file mode 100644
index 0000000..5648c66
--- /dev/null
+++ b/src/prompt_toolkit/input/ansi_escape_sequences.py
@@ -0,0 +1,343 @@
+"""
+Mappings from VT100 (ANSI) escape sequences to the corresponding prompt_toolkit
+keys.
+
+We are not using the terminfo/termcap databases to detect the ANSI escape
+sequences for the input. Instead, we recognize 99% of the most common
+sequences. This works well, because in practice, every modern terminal is
+mostly Xterm compatible.
+
+Some useful docs:
+- Mintty: https://github.com/mintty/mintty/blob/master/wiki/Keycodes.md
+"""
+from __future__ import annotations
+
+from ..keys import Keys
+
+__all__ = [
+ "ANSI_SEQUENCES",
+ "REVERSE_ANSI_SEQUENCES",
+]
+
+# Mapping of vt100 escape codes to Keys.
+ANSI_SEQUENCES: dict[str, Keys | tuple[Keys, ...]] = {
+ # Control keys.
+ "\x00": Keys.ControlAt, # Control-At (Also for Ctrl-Space)
+ "\x01": Keys.ControlA, # Control-A (home)
+ "\x02": Keys.ControlB, # Control-B (emacs cursor left)
+ "\x03": Keys.ControlC, # Control-C (interrupt)
+ "\x04": Keys.ControlD, # Control-D (exit)
+ "\x05": Keys.ControlE, # Control-E (end)
+ "\x06": Keys.ControlF, # Control-F (cursor forward)
+ "\x07": Keys.ControlG, # Control-G
+ "\x08": Keys.ControlH, # Control-H (8) (Identical to '\b')
+ "\x09": Keys.ControlI, # Control-I (9) (Identical to '\t')
+ "\x0a": Keys.ControlJ, # Control-J (10) (Identical to '\n')
+ "\x0b": Keys.ControlK, # Control-K (delete until end of line; vertical tab)
+ "\x0c": Keys.ControlL, # Control-L (clear; form feed)
+ "\x0d": Keys.ControlM, # Control-M (13) (Identical to '\r')
+ "\x0e": Keys.ControlN, # Control-N (14) (history forward)
+ "\x0f": Keys.ControlO, # Control-O (15)
+ "\x10": Keys.ControlP, # Control-P (16) (history back)
+ "\x11": Keys.ControlQ, # Control-Q
+ "\x12": Keys.ControlR, # Control-R (18) (reverse search)
+ "\x13": Keys.ControlS, # Control-S (19) (forward search)
+ "\x14": Keys.ControlT, # Control-T
+ "\x15": Keys.ControlU, # Control-U
+ "\x16": Keys.ControlV, # Control-V
+ "\x17": Keys.ControlW, # Control-W
+ "\x18": Keys.ControlX, # Control-X
+ "\x19": Keys.ControlY, # Control-Y (25)
+ "\x1a": Keys.ControlZ, # Control-Z
+ "\x1b": Keys.Escape, # Also Control-[
+ "\x9b": Keys.ShiftEscape,
+ "\x1c": Keys.ControlBackslash, # Both Control-\ (also Ctrl-| )
+ "\x1d": Keys.ControlSquareClose, # Control-]
+ "\x1e": Keys.ControlCircumflex, # Control-^
+ "\x1f": Keys.ControlUnderscore, # Control-underscore (Also for Ctrl-hyphen.)
+ # ASCII Delete (0x7f)
+ # Vt220 (and Linux terminal) send this when pressing backspace. We map this
+ # to ControlH, because that will make it easier to create key bindings that
+ # work everywhere, with the trade-off that it's no longer possible to
+ # handle backspace and control-h individually for the few terminals that
+ # support it. (Most terminals send ControlH when backspace is pressed.)
+ # See: http://www.ibb.net/~anne/keyboard.html
+ "\x7f": Keys.ControlH,
+ # --
+ # Various
+ "\x1b[1~": Keys.Home, # tmux
+ "\x1b[2~": Keys.Insert,
+ "\x1b[3~": Keys.Delete,
+ "\x1b[4~": Keys.End, # tmux
+ "\x1b[5~": Keys.PageUp,
+ "\x1b[6~": Keys.PageDown,
+ "\x1b[7~": Keys.Home, # xrvt
+ "\x1b[8~": Keys.End, # xrvt
+ "\x1b[Z": Keys.BackTab, # shift + tab
+ "\x1b\x09": Keys.BackTab, # Linux console
+ "\x1b[~": Keys.BackTab, # Windows console
+ # --
+ # Function keys.
+ "\x1bOP": Keys.F1,
+ "\x1bOQ": Keys.F2,
+ "\x1bOR": Keys.F3,
+ "\x1bOS": Keys.F4,
+ "\x1b[[A": Keys.F1, # Linux console.
+ "\x1b[[B": Keys.F2, # Linux console.
+ "\x1b[[C": Keys.F3, # Linux console.
+ "\x1b[[D": Keys.F4, # Linux console.
+ "\x1b[[E": Keys.F5, # Linux console.
+ "\x1b[11~": Keys.F1, # rxvt-unicode
+ "\x1b[12~": Keys.F2, # rxvt-unicode
+ "\x1b[13~": Keys.F3, # rxvt-unicode
+ "\x1b[14~": Keys.F4, # rxvt-unicode
+ "\x1b[15~": Keys.F5,
+ "\x1b[17~": Keys.F6,
+ "\x1b[18~": Keys.F7,
+ "\x1b[19~": Keys.F8,
+ "\x1b[20~": Keys.F9,
+ "\x1b[21~": Keys.F10,
+ "\x1b[23~": Keys.F11,
+ "\x1b[24~": Keys.F12,
+ "\x1b[25~": Keys.F13,
+ "\x1b[26~": Keys.F14,
+ "\x1b[28~": Keys.F15,
+ "\x1b[29~": Keys.F16,
+ "\x1b[31~": Keys.F17,
+ "\x1b[32~": Keys.F18,
+ "\x1b[33~": Keys.F19,
+ "\x1b[34~": Keys.F20,
+ # Xterm
+ "\x1b[1;2P": Keys.F13,
+ "\x1b[1;2Q": Keys.F14,
+ # '\x1b[1;2R': Keys.F15, # Conflicts with CPR response.
+ "\x1b[1;2S": Keys.F16,
+ "\x1b[15;2~": Keys.F17,
+ "\x1b[17;2~": Keys.F18,
+ "\x1b[18;2~": Keys.F19,
+ "\x1b[19;2~": Keys.F20,
+ "\x1b[20;2~": Keys.F21,
+ "\x1b[21;2~": Keys.F22,
+ "\x1b[23;2~": Keys.F23,
+ "\x1b[24;2~": Keys.F24,
+ # --
+ # CSI 27 disambiguated modified "other" keys (xterm)
+ # Ref: https://invisible-island.net/xterm/modified-keys.html
+ # These are currently unsupported, so just re-map some common ones to the
+ # unmodified versions
+ "\x1b[27;2;13~": Keys.ControlM, # Shift + Enter
+ "\x1b[27;5;13~": Keys.ControlM, # Ctrl + Enter
+ "\x1b[27;6;13~": Keys.ControlM, # Ctrl + Shift + Enter
+ # --
+ # Control + function keys.
+ "\x1b[1;5P": Keys.ControlF1,
+ "\x1b[1;5Q": Keys.ControlF2,
+ # "\x1b[1;5R": Keys.ControlF3, # Conflicts with CPR response.
+ "\x1b[1;5S": Keys.ControlF4,
+ "\x1b[15;5~": Keys.ControlF5,
+ "\x1b[17;5~": Keys.ControlF6,
+ "\x1b[18;5~": Keys.ControlF7,
+ "\x1b[19;5~": Keys.ControlF8,
+ "\x1b[20;5~": Keys.ControlF9,
+ "\x1b[21;5~": Keys.ControlF10,
+ "\x1b[23;5~": Keys.ControlF11,
+ "\x1b[24;5~": Keys.ControlF12,
+ "\x1b[1;6P": Keys.ControlF13,
+ "\x1b[1;6Q": Keys.ControlF14,
+ # "\x1b[1;6R": Keys.ControlF15, # Conflicts with CPR response.
+ "\x1b[1;6S": Keys.ControlF16,
+ "\x1b[15;6~": Keys.ControlF17,
+ "\x1b[17;6~": Keys.ControlF18,
+ "\x1b[18;6~": Keys.ControlF19,
+ "\x1b[19;6~": Keys.ControlF20,
+ "\x1b[20;6~": Keys.ControlF21,
+ "\x1b[21;6~": Keys.ControlF22,
+ "\x1b[23;6~": Keys.ControlF23,
+ "\x1b[24;6~": Keys.ControlF24,
+ # --
+ # Tmux (Win32 subsystem) sends the following scroll events.
+ "\x1b[62~": Keys.ScrollUp,
+ "\x1b[63~": Keys.ScrollDown,
+ "\x1b[200~": Keys.BracketedPaste, # Start of bracketed paste.
+ # --
+ # Sequences generated by numpad 5. Not sure what it means. (It doesn't
+ # appear in 'infocmp'. Just ignore.
+ "\x1b[E": Keys.Ignore, # Xterm.
+ "\x1b[G": Keys.Ignore, # Linux console.
+ # --
+ # Meta/control/escape + pageup/pagedown/insert/delete.
+ "\x1b[3;2~": Keys.ShiftDelete, # xterm, gnome-terminal.
+ "\x1b[5;2~": Keys.ShiftPageUp,
+ "\x1b[6;2~": Keys.ShiftPageDown,
+ "\x1b[2;3~": (Keys.Escape, Keys.Insert),
+ "\x1b[3;3~": (Keys.Escape, Keys.Delete),
+ "\x1b[5;3~": (Keys.Escape, Keys.PageUp),
+ "\x1b[6;3~": (Keys.Escape, Keys.PageDown),
+ "\x1b[2;4~": (Keys.Escape, Keys.ShiftInsert),
+ "\x1b[3;4~": (Keys.Escape, Keys.ShiftDelete),
+ "\x1b[5;4~": (Keys.Escape, Keys.ShiftPageUp),
+ "\x1b[6;4~": (Keys.Escape, Keys.ShiftPageDown),
+ "\x1b[3;5~": Keys.ControlDelete, # xterm, gnome-terminal.
+ "\x1b[5;5~": Keys.ControlPageUp,
+ "\x1b[6;5~": Keys.ControlPageDown,
+ "\x1b[3;6~": Keys.ControlShiftDelete,
+ "\x1b[5;6~": Keys.ControlShiftPageUp,
+ "\x1b[6;6~": Keys.ControlShiftPageDown,
+ "\x1b[2;7~": (Keys.Escape, Keys.ControlInsert),
+ "\x1b[5;7~": (Keys.Escape, Keys.ControlPageDown),
+ "\x1b[6;7~": (Keys.Escape, Keys.ControlPageDown),
+ "\x1b[2;8~": (Keys.Escape, Keys.ControlShiftInsert),
+ "\x1b[5;8~": (Keys.Escape, Keys.ControlShiftPageDown),
+ "\x1b[6;8~": (Keys.Escape, Keys.ControlShiftPageDown),
+ # --
+ # Arrows.
+ # (Normal cursor mode).
+ "\x1b[A": Keys.Up,
+ "\x1b[B": Keys.Down,
+ "\x1b[C": Keys.Right,
+ "\x1b[D": Keys.Left,
+ "\x1b[H": Keys.Home,
+ "\x1b[F": Keys.End,
+ # Tmux sends following keystrokes when control+arrow is pressed, but for
+ # Emacs ansi-term sends the same sequences for normal arrow keys. Consider
+ # it a normal arrow press, because that's more important.
+ # (Application cursor mode).
+ "\x1bOA": Keys.Up,
+ "\x1bOB": Keys.Down,
+ "\x1bOC": Keys.Right,
+ "\x1bOD": Keys.Left,
+ "\x1bOF": Keys.End,
+ "\x1bOH": Keys.Home,
+ # Shift + arrows.
+ "\x1b[1;2A": Keys.ShiftUp,
+ "\x1b[1;2B": Keys.ShiftDown,
+ "\x1b[1;2C": Keys.ShiftRight,
+ "\x1b[1;2D": Keys.ShiftLeft,
+ "\x1b[1;2F": Keys.ShiftEnd,
+ "\x1b[1;2H": Keys.ShiftHome,
+ # Meta + arrow keys. Several terminals handle this differently.
+ # The following sequences are for xterm and gnome-terminal.
+ # (Iterm sends ESC followed by the normal arrow_up/down/left/right
+ # sequences, and the OSX Terminal sends ESCb and ESCf for "alt
+ # arrow_left" and "alt arrow_right." We don't handle these
+ # explicitly, in here, because would could not distinguish between
+ # pressing ESC (to go to Vi navigation mode), followed by just the
+ # 'b' or 'f' key. These combinations are handled in
+ # the input processor.)
+ "\x1b[1;3A": (Keys.Escape, Keys.Up),
+ "\x1b[1;3B": (Keys.Escape, Keys.Down),
+ "\x1b[1;3C": (Keys.Escape, Keys.Right),
+ "\x1b[1;3D": (Keys.Escape, Keys.Left),
+ "\x1b[1;3F": (Keys.Escape, Keys.End),
+ "\x1b[1;3H": (Keys.Escape, Keys.Home),
+ # Alt+shift+number.
+ "\x1b[1;4A": (Keys.Escape, Keys.ShiftDown),
+ "\x1b[1;4B": (Keys.Escape, Keys.ShiftUp),
+ "\x1b[1;4C": (Keys.Escape, Keys.ShiftRight),
+ "\x1b[1;4D": (Keys.Escape, Keys.ShiftLeft),
+ "\x1b[1;4F": (Keys.Escape, Keys.ShiftEnd),
+ "\x1b[1;4H": (Keys.Escape, Keys.ShiftHome),
+ # Control + arrows.
+ "\x1b[1;5A": Keys.ControlUp, # Cursor Mode
+ "\x1b[1;5B": Keys.ControlDown, # Cursor Mode
+ "\x1b[1;5C": Keys.ControlRight, # Cursor Mode
+ "\x1b[1;5D": Keys.ControlLeft, # Cursor Mode
+ "\x1b[1;5F": Keys.ControlEnd,
+ "\x1b[1;5H": Keys.ControlHome,
+ # Tmux sends following keystrokes when control+arrow is pressed, but for
+ # Emacs ansi-term sends the same sequences for normal arrow keys. Consider
+ # it a normal arrow press, because that's more important.
+ "\x1b[5A": Keys.ControlUp,
+ "\x1b[5B": Keys.ControlDown,
+ "\x1b[5C": Keys.ControlRight,
+ "\x1b[5D": Keys.ControlLeft,
+ "\x1bOc": Keys.ControlRight, # rxvt
+ "\x1bOd": Keys.ControlLeft, # rxvt
+ # Control + shift + arrows.
+ "\x1b[1;6A": Keys.ControlShiftDown,
+ "\x1b[1;6B": Keys.ControlShiftUp,
+ "\x1b[1;6C": Keys.ControlShiftRight,
+ "\x1b[1;6D": Keys.ControlShiftLeft,
+ "\x1b[1;6F": Keys.ControlShiftEnd,
+ "\x1b[1;6H": Keys.ControlShiftHome,
+ # Control + Meta + arrows.
+ "\x1b[1;7A": (Keys.Escape, Keys.ControlDown),
+ "\x1b[1;7B": (Keys.Escape, Keys.ControlUp),
+ "\x1b[1;7C": (Keys.Escape, Keys.ControlRight),
+ "\x1b[1;7D": (Keys.Escape, Keys.ControlLeft),
+ "\x1b[1;7F": (Keys.Escape, Keys.ControlEnd),
+ "\x1b[1;7H": (Keys.Escape, Keys.ControlHome),
+ # Meta + Shift + arrows.
+ "\x1b[1;8A": (Keys.Escape, Keys.ControlShiftDown),
+ "\x1b[1;8B": (Keys.Escape, Keys.ControlShiftUp),
+ "\x1b[1;8C": (Keys.Escape, Keys.ControlShiftRight),
+ "\x1b[1;8D": (Keys.Escape, Keys.ControlShiftLeft),
+ "\x1b[1;8F": (Keys.Escape, Keys.ControlShiftEnd),
+ "\x1b[1;8H": (Keys.Escape, Keys.ControlShiftHome),
+ # Meta + arrow on (some?) Macs when using iTerm defaults (see issue #483).
+ "\x1b[1;9A": (Keys.Escape, Keys.Up),
+ "\x1b[1;9B": (Keys.Escape, Keys.Down),
+ "\x1b[1;9C": (Keys.Escape, Keys.Right),
+ "\x1b[1;9D": (Keys.Escape, Keys.Left),
+ # --
+ # Control/shift/meta + number in mintty.
+ # (c-2 will actually send c-@ and c-6 will send c-^.)
+ "\x1b[1;5p": Keys.Control0,
+ "\x1b[1;5q": Keys.Control1,
+ "\x1b[1;5r": Keys.Control2,
+ "\x1b[1;5s": Keys.Control3,
+ "\x1b[1;5t": Keys.Control4,
+ "\x1b[1;5u": Keys.Control5,
+ "\x1b[1;5v": Keys.Control6,
+ "\x1b[1;5w": Keys.Control7,
+ "\x1b[1;5x": Keys.Control8,
+ "\x1b[1;5y": Keys.Control9,
+ "\x1b[1;6p": Keys.ControlShift0,
+ "\x1b[1;6q": Keys.ControlShift1,
+ "\x1b[1;6r": Keys.ControlShift2,
+ "\x1b[1;6s": Keys.ControlShift3,
+ "\x1b[1;6t": Keys.ControlShift4,
+ "\x1b[1;6u": Keys.ControlShift5,
+ "\x1b[1;6v": Keys.ControlShift6,
+ "\x1b[1;6w": Keys.ControlShift7,
+ "\x1b[1;6x": Keys.ControlShift8,
+ "\x1b[1;6y": Keys.ControlShift9,
+ "\x1b[1;7p": (Keys.Escape, Keys.Control0),
+ "\x1b[1;7q": (Keys.Escape, Keys.Control1),
+ "\x1b[1;7r": (Keys.Escape, Keys.Control2),
+ "\x1b[1;7s": (Keys.Escape, Keys.Control3),
+ "\x1b[1;7t": (Keys.Escape, Keys.Control4),
+ "\x1b[1;7u": (Keys.Escape, Keys.Control5),
+ "\x1b[1;7v": (Keys.Escape, Keys.Control6),
+ "\x1b[1;7w": (Keys.Escape, Keys.Control7),
+ "\x1b[1;7x": (Keys.Escape, Keys.Control8),
+ "\x1b[1;7y": (Keys.Escape, Keys.Control9),
+ "\x1b[1;8p": (Keys.Escape, Keys.ControlShift0),
+ "\x1b[1;8q": (Keys.Escape, Keys.ControlShift1),
+ "\x1b[1;8r": (Keys.Escape, Keys.ControlShift2),
+ "\x1b[1;8s": (Keys.Escape, Keys.ControlShift3),
+ "\x1b[1;8t": (Keys.Escape, Keys.ControlShift4),
+ "\x1b[1;8u": (Keys.Escape, Keys.ControlShift5),
+ "\x1b[1;8v": (Keys.Escape, Keys.ControlShift6),
+ "\x1b[1;8w": (Keys.Escape, Keys.ControlShift7),
+ "\x1b[1;8x": (Keys.Escape, Keys.ControlShift8),
+ "\x1b[1;8y": (Keys.Escape, Keys.ControlShift9),
+}
+
+
+def _get_reverse_ansi_sequences() -> dict[Keys, str]:
+ """
+ Create a dictionary that maps prompt_toolkit keys back to the VT100 escape
+ sequences.
+ """
+ result: dict[Keys, str] = {}
+
+ for sequence, key in ANSI_SEQUENCES.items():
+ if not isinstance(key, tuple):
+ if key not in result:
+ result[key] = sequence
+
+ return result
+
+
+REVERSE_ANSI_SEQUENCES = _get_reverse_ansi_sequences()
diff --git a/src/prompt_toolkit/input/base.py b/src/prompt_toolkit/input/base.py
new file mode 100644
index 0000000..fd1429d
--- /dev/null
+++ b/src/prompt_toolkit/input/base.py
@@ -0,0 +1,152 @@
+"""
+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",
+]
+
+
+class Input(metaclass=ABCMeta):
+ """
+ Abstraction for any input.
+
+ An instance of this class can be given to the constructor of a
+ :class:`~prompt_toolkit.application.Application` and will also be
+ passed to the :class:`~prompt_toolkit.eventloop.base.EventLoop`.
+ """
+
+ @abstractmethod
+ def fileno(self) -> int:
+ """
+ Fileno for putting this in an event loop.
+ """
+
+ @abstractmethod
+ def typeahead_hash(self) -> str:
+ """
+ Identifier for storing type ahead key presses.
+ """
+
+ @abstractmethod
+ def read_keys(self) -> list[KeyPress]:
+ """
+ Return a list of Key objects which are read/parsed from the input.
+ """
+
+ def flush_keys(self) -> list[KeyPress]:
+ """
+ Flush the underlying parser. and return the pending keys.
+ (Used for vt100 input.)
+ """
+ return []
+
+ def flush(self) -> None:
+ "The event loop can call this when the input has to be flushed."
+ pass
+
+ @abstractproperty
+ def closed(self) -> bool:
+ "Should be true when the input stream is closed."
+ return False
+
+ @abstractmethod
+ def raw_mode(self) -> ContextManager[None]:
+ """
+ Context manager that turns the input into raw mode.
+ """
+
+ @abstractmethod
+ def cooked_mode(self) -> ContextManager[None]:
+ """
+ Context manager that turns the input into cooked mode.
+ """
+
+ @abstractmethod
+ def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
+ """
+ Return a context manager that makes this input active in the current
+ event loop.
+ """
+
+ @abstractmethod
+ def detach(self) -> ContextManager[None]:
+ """
+ Return a context manager that makes sure that this input is not active
+ in the current event loop.
+ """
+
+ def close(self) -> None:
+ "Close input."
+ pass
+
+
+class PipeInput(Input):
+ """
+ Abstraction for pipe input.
+ """
+
+ @abstractmethod
+ def send_bytes(self, data: bytes) -> None:
+ """Feed byte string into the pipe"""
+
+ @abstractmethod
+ def send_text(self, data: str) -> None:
+ """Feed a text string into the pipe"""
+
+
+class DummyInput(Input):
+ """
+ Input for use in a `DummyApplication`
+
+ If used in an actual application, it will make the application render
+ itself once and exit immediately, due to an `EOFError`.
+ """
+
+ def fileno(self) -> int:
+ raise NotImplementedError
+
+ def typeahead_hash(self) -> str:
+ return "dummy-%s" % id(self)
+
+ def read_keys(self) -> list[KeyPress]:
+ return []
+
+ @property
+ def closed(self) -> bool:
+ # This needs to be true, so that the dummy input will trigger an
+ # `EOFError` immediately in the application.
+ return True
+
+ def raw_mode(self) -> ContextManager[None]:
+ return _dummy_context_manager()
+
+ def cooked_mode(self) -> ContextManager[None]:
+ return _dummy_context_manager()
+
+ def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
+ # Call the callback immediately once after attaching.
+ # This tells the callback to call `read_keys` and check the
+ # `input.closed` flag, after which it won't receive any keys, but knows
+ # that `EOFError` should be raised. This unblocks `read_from_input` in
+ # `application.py`.
+ input_ready_callback()
+
+ return _dummy_context_manager()
+
+ def detach(self) -> ContextManager[None]:
+ return _dummy_context_manager()
+
+
+@contextmanager
+def _dummy_context_manager() -> Generator[None, None, None]:
+ yield
diff --git a/src/prompt_toolkit/input/defaults.py b/src/prompt_toolkit/input/defaults.py
new file mode 100644
index 0000000..483eeb2
--- /dev/null
+++ b/src/prompt_toolkit/input/defaults.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import io
+import sys
+from typing import ContextManager, TextIO
+
+from .base import DummyInput, Input, PipeInput
+
+__all__ = [
+ "create_input",
+ "create_pipe_input",
+]
+
+
+def create_input(stdin: TextIO | None = None, always_prefer_tty: bool = False) -> Input:
+ """
+ Create the appropriate `Input` object for the current os/environment.
+
+ :param always_prefer_tty: When set, if `sys.stdin` is connected to a Unix
+ `pipe`, check whether `sys.stdout` or `sys.stderr` are connected to a
+ pseudo terminal. If so, open the tty for reading instead of reading for
+ `sys.stdin`. (We can open `stdout` or `stderr` for reading, this is how
+ a `$PAGER` works.)
+ """
+ if sys.platform == "win32":
+ from .win32 import Win32Input
+
+ # If `stdin` was assigned `None` (which happens with pythonw.exe), use
+ # a `DummyInput`. This triggers `EOFError` in the application code.
+ if stdin is None and sys.stdin is None:
+ return DummyInput()
+
+ return Win32Input(stdin or sys.stdin)
+ else:
+ from .vt100 import Vt100Input
+
+ # If no input TextIO is given, use stdin/stdout.
+ if stdin is None:
+ stdin = sys.stdin
+
+ if always_prefer_tty:
+ for obj in [sys.stdin, sys.stdout, sys.stderr]:
+ if obj.isatty():
+ stdin = obj
+ break
+
+ # If we can't access the file descriptor for the selected stdin, return
+ # a `DummyInput` instead. This can happen for instance in unit tests,
+ # when `sys.stdin` is patched by something that's not an actual file.
+ # (Instantiating `Vt100Input` would fail in this case.)
+ try:
+ stdin.fileno()
+ except io.UnsupportedOperation:
+ return DummyInput()
+
+ return Vt100Input(stdin)
+
+
+def create_pipe_input() -> ContextManager[PipeInput]:
+ """
+ Create an input pipe.
+ This is mostly useful for unit testing.
+
+ Usage::
+
+ with create_pipe_input() as input:
+ input.send_text('inputdata')
+
+ Breaking change: In prompt_toolkit 3.0.28 and earlier, this was returning
+ the `PipeInput` directly, rather than through a context manager.
+ """
+ if sys.platform == "win32":
+ from .win32_pipe import Win32PipeInput
+
+ return Win32PipeInput.create()
+ else:
+ from .posix_pipe import PosixPipeInput
+
+ return PosixPipeInput.create()
diff --git a/src/prompt_toolkit/input/posix_pipe.py b/src/prompt_toolkit/input/posix_pipe.py
new file mode 100644
index 0000000..c131fb8
--- /dev/null
+++ b/src/prompt_toolkit/input/posix_pipe.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+import sys
+
+assert sys.platform != "win32"
+
+import os
+from contextlib import contextmanager
+from typing import ContextManager, Iterator, TextIO, cast
+
+from ..utils import DummyContext
+from .base import PipeInput
+from .vt100 import Vt100Input
+
+__all__ = [
+ "PosixPipeInput",
+]
+
+
+class _Pipe:
+ "Wrapper around os.pipe, that ensures we don't double close any end."
+
+ def __init__(self) -> None:
+ self.read_fd, self.write_fd = os.pipe()
+ self._read_closed = False
+ self._write_closed = False
+
+ def close_read(self) -> None:
+ "Close read-end if not yet closed."
+ if self._read_closed:
+ return
+
+ os.close(self.read_fd)
+ self._read_closed = True
+
+ def close_write(self) -> None:
+ "Close write-end if not yet closed."
+ if self._write_closed:
+ return
+
+ os.close(self.write_fd)
+ self._write_closed = True
+
+ def close(self) -> None:
+ "Close both read and write ends."
+ self.close_read()
+ self.close_write()
+
+
+class PosixPipeInput(Vt100Input, PipeInput):
+ """
+ Input that is send through a pipe.
+ This is useful if we want to send the input programmatically into the
+ application. Mostly useful for unit testing.
+
+ Usage::
+
+ with PosixPipeInput.create() as input:
+ input.send_text('inputdata')
+ """
+
+ _id = 0
+
+ def __init__(self, _pipe: _Pipe, _text: str = "") -> None:
+ # Private constructor. Users should use the public `.create()` method.
+ self.pipe = _pipe
+
+ class Stdin:
+ encoding = "utf-8"
+
+ def isatty(stdin) -> bool:
+ return True
+
+ def fileno(stdin) -> int:
+ return self.pipe.read_fd
+
+ super().__init__(cast(TextIO, Stdin()))
+ self.send_text(_text)
+
+ # Identifier for every PipeInput for the hash.
+ self.__class__._id += 1
+ self._id = self.__class__._id
+
+ @classmethod
+ @contextmanager
+ def create(cls, text: str = "") -> Iterator[PosixPipeInput]:
+ pipe = _Pipe()
+ try:
+ yield PosixPipeInput(_pipe=pipe, _text=text)
+ finally:
+ pipe.close()
+
+ def send_bytes(self, data: bytes) -> None:
+ os.write(self.pipe.write_fd, data)
+
+ def send_text(self, data: str) -> None:
+ "Send text to the input."
+ os.write(self.pipe.write_fd, data.encode("utf-8"))
+
+ def raw_mode(self) -> ContextManager[None]:
+ return DummyContext()
+
+ def cooked_mode(self) -> ContextManager[None]:
+ return DummyContext()
+
+ def close(self) -> None:
+ "Close pipe fds."
+ # Only close the write-end of the pipe. This will unblock the reader
+ # callback (in vt100.py > _attached_input), which eventually will raise
+ # `EOFError`. If we'd also close the read-end, then the event loop
+ # won't wake up the corresponding callback because of this.
+ self.pipe.close_write()
+
+ def typeahead_hash(self) -> str:
+ """
+ This needs to be unique for every `PipeInput`.
+ """
+ return f"pipe-input-{self._id}"
diff --git a/src/prompt_toolkit/input/posix_utils.py b/src/prompt_toolkit/input/posix_utils.py
new file mode 100644
index 0000000..4a78dc4
--- /dev/null
+++ b/src/prompt_toolkit/input/posix_utils.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import os
+import select
+from codecs import getincrementaldecoder
+
+__all__ = [
+ "PosixStdinReader",
+]
+
+
+class PosixStdinReader:
+ """
+ Wrapper around stdin which reads (nonblocking) the next available 1024
+ bytes and decodes it.
+
+ Note that you can't be sure that the input file is closed if the ``read``
+ function returns an empty string. When ``errors=ignore`` is passed,
+ ``read`` can return an empty string if all malformed input was replaced by
+ an empty string. (We can't block here and wait for more input.) So, because
+ of that, check the ``closed`` attribute, to be sure that the file has been
+ closed.
+
+ :param stdin_fd: File descriptor from which we read.
+ :param errors: Can be 'ignore', 'strict' or 'replace'.
+ On Python3, this can be 'surrogateescape', which is the default.
+
+ 'surrogateescape' is preferred, because this allows us to transfer
+ unrecognized bytes to the key bindings. Some terminals, like lxterminal
+ and Guake, use the 'Mxx' notation to send mouse events, where each 'x'
+ can be any possible byte.
+ """
+
+ # By default, we want to 'ignore' errors here. The input stream can be full
+ # of junk. One occurrence of this that I had was when using iTerm2 on OS X,
+ # with "Option as Meta" checked (You should choose "Option as +Esc".)
+
+ def __init__(
+ self, stdin_fd: int, errors: str = "surrogateescape", encoding: str = "utf-8"
+ ) -> None:
+ self.stdin_fd = stdin_fd
+ self.errors = errors
+
+ # Create incremental decoder for decoding stdin.
+ # We can not just do `os.read(stdin.fileno(), 1024).decode('utf-8')`, because
+ # it could be that we are in the middle of a utf-8 byte sequence.
+ self._stdin_decoder_cls = getincrementaldecoder(encoding)
+ self._stdin_decoder = self._stdin_decoder_cls(errors=errors)
+
+ #: True when there is nothing anymore to read.
+ self.closed = False
+
+ def read(self, count: int = 1024) -> str:
+ # By default we choose a rather small chunk size, because reading
+ # big amounts of input at once, causes the event loop to process
+ # all these key bindings also at once without going back to the
+ # loop. This will make the application feel unresponsive.
+ """
+ Read the input and return it as a string.
+
+ Return the text. Note that this can return an empty string, even when
+ the input stream was not yet closed. This means that something went
+ wrong during the decoding.
+ """
+ if self.closed:
+ return ""
+
+ # Check whether there is some input to read. `os.read` would block
+ # otherwise.
+ # (Actually, the event loop is responsible to make sure that this
+ # function is only called when there is something to read, but for some
+ # reason this happens in certain situations.)
+ try:
+ if not select.select([self.stdin_fd], [], [], 0)[0]:
+ return ""
+ except OSError:
+ # Happens for instance when the file descriptor was closed.
+ # (We had this in ptterm, where the FD became ready, a callback was
+ # scheduled, but in the meantime another callback closed it already.)
+ self.closed = True
+
+ # Note: the following works better than wrapping `self.stdin` like
+ # `codecs.getreader('utf-8')(stdin)` and doing `read(1)`.
+ # Somehow that causes some latency when the escape
+ # character is pressed. (Especially on combination with the `select`.)
+ try:
+ data = os.read(self.stdin_fd, count)
+
+ # Nothing more to read, stream is closed.
+ if data == b"":
+ self.closed = True
+ return ""
+ except OSError:
+ # In case of SIGWINCH
+ data = b""
+
+ return self._stdin_decoder.decode(data)
diff --git a/src/prompt_toolkit/input/typeahead.py b/src/prompt_toolkit/input/typeahead.py
new file mode 100644
index 0000000..a45e7cf
--- /dev/null
+++ b/src/prompt_toolkit/input/typeahead.py
@@ -0,0 +1,77 @@
+r"""
+Store input key strokes if we did read more than was required.
+
+The input classes `Vt100Input` and `Win32Input` read the input text in chunks
+of a few kilobytes. This means that if we read input from stdin, it could be
+that we read a couple of lines (with newlines in between) at once.
+
+This creates a problem: potentially, we read too much from stdin. Sometimes
+people paste several lines at once because they paste input in a REPL and
+expect each input() call to process one line. Or they rely on type ahead
+because the application can't keep up with the processing.
+
+However, we need to read input in bigger chunks. We need this mostly to support
+pasting of larger chunks of text. We don't want everything to become
+unresponsive because we:
+ - read one character;
+ - parse one character;
+ - call the key binding, which does a string operation with one character;
+ - and render the user interface.
+Doing text operations on single characters is very inefficient in Python, so we
+prefer to work on bigger chunks of text. This is why we have to read the input
+in bigger chunks.
+
+Further, line buffering is also not an option, because it doesn't work well in
+the architecture. We use lower level Posix APIs, that work better with the
+event loop and so on. In fact, there is also nothing that defines that only \n
+can accept the input, you could create a key binding for any key to accept the
+input.
+
+To support type ahead, this module will store all the key strokes that were
+read too early, so that they can be feed into to the next `prompt()` call or to
+the next prompt_toolkit `Application`.
+"""
+from __future__ import annotations
+
+from collections import defaultdict
+
+from ..key_binding import KeyPress
+from .base import Input
+
+__all__ = [
+ "store_typeahead",
+ "get_typeahead",
+ "clear_typeahead",
+]
+
+_buffer: dict[str, list[KeyPress]] = defaultdict(list)
+
+
+def store_typeahead(input_obj: Input, key_presses: list[KeyPress]) -> None:
+ """
+ Insert typeahead key presses for the given input.
+ """
+ global _buffer
+ key = input_obj.typeahead_hash()
+ _buffer[key].extend(key_presses)
+
+
+def get_typeahead(input_obj: Input) -> list[KeyPress]:
+ """
+ Retrieve typeahead and reset the buffer for this input.
+ """
+ global _buffer
+
+ key = input_obj.typeahead_hash()
+ result = _buffer[key]
+ _buffer[key] = []
+ return result
+
+
+def clear_typeahead(input_obj: Input) -> None:
+ """
+ Clear typeahead buffer.
+ """
+ global _buffer
+ key = input_obj.typeahead_hash()
+ _buffer[key] = []
diff --git a/src/prompt_toolkit/input/vt100.py b/src/prompt_toolkit/input/vt100.py
new file mode 100644
index 0000000..c1660de
--- /dev/null
+++ b/src/prompt_toolkit/input/vt100.py
@@ -0,0 +1,309 @@
+from __future__ import annotations
+
+import sys
+
+assert sys.platform != "win32"
+
+import contextlib
+import io
+import termios
+import tty
+from asyncio import AbstractEventLoop, get_running_loop
+from typing import Callable, ContextManager, Generator, TextIO
+
+from ..key_binding import KeyPress
+from .base import Input
+from .posix_utils import PosixStdinReader
+from .vt100_parser import Vt100Parser
+
+__all__ = [
+ "Vt100Input",
+ "raw_mode",
+ "cooked_mode",
+]
+
+
+class Vt100Input(Input):
+ """
+ Vt100 input for Posix systems.
+ (This uses a posix file descriptor that can be registered in the event loop.)
+ """
+
+ # For the error messages. Only display "Input is not a terminal" once per
+ # file descriptor.
+ _fds_not_a_terminal: set[int] = set()
+
+ def __init__(self, stdin: TextIO) -> None:
+ # Test whether the given input object has a file descriptor.
+ # (Idle reports stdin to be a TTY, but fileno() is not implemented.)
+ try:
+ # This should not raise, but can return 0.
+ stdin.fileno()
+ except io.UnsupportedOperation as e:
+ if "idlelib.run" in sys.modules:
+ raise io.UnsupportedOperation(
+ "Stdin is not a terminal. Running from Idle is not supported."
+ ) from e
+ else:
+ raise io.UnsupportedOperation("Stdin is not a terminal.") from e
+
+ # Even when we have a file descriptor, it doesn't mean it's a TTY.
+ # Normally, this requires a real TTY device, but people instantiate
+ # this class often during unit tests as well. They use for instance
+ # pexpect to pipe data into an application. For convenience, we print
+ # an error message and go on.
+ isatty = stdin.isatty()
+ fd = stdin.fileno()
+
+ if not isatty and fd not in Vt100Input._fds_not_a_terminal:
+ msg = "Warning: Input is not a terminal (fd=%r).\n"
+ sys.stderr.write(msg % fd)
+ sys.stderr.flush()
+ Vt100Input._fds_not_a_terminal.add(fd)
+
+ #
+ self.stdin = stdin
+
+ # Create a backup of the fileno(). We want this to work even if the
+ # underlying file is closed, so that `typeahead_hash()` keeps working.
+ self._fileno = stdin.fileno()
+
+ self._buffer: list[KeyPress] = [] # Buffer to collect the Key objects.
+ self.stdin_reader = PosixStdinReader(self._fileno, encoding=stdin.encoding)
+ self.vt100_parser = Vt100Parser(
+ lambda key_press: self._buffer.append(key_press)
+ )
+
+ def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
+ """
+ Return a context manager that makes this input active in the current
+ event loop.
+ """
+ return _attached_input(self, input_ready_callback)
+
+ def detach(self) -> ContextManager[None]:
+ """
+ Return a context manager that makes sure that this input is not active
+ in the current event loop.
+ """
+ return _detached_input(self)
+
+ def read_keys(self) -> list[KeyPress]:
+ "Read list of KeyPress."
+ # Read text from stdin.
+ data = self.stdin_reader.read()
+
+ # Pass it through our vt100 parser.
+ self.vt100_parser.feed(data)
+
+ # Return result.
+ result = self._buffer
+ self._buffer = []
+ return result
+
+ def flush_keys(self) -> list[KeyPress]:
+ """
+ Flush pending keys and return them.
+ (Used for flushing the 'escape' key.)
+ """
+ # Flush all pending keys. (This is most important to flush the vt100
+ # 'Escape' key early when nothing else follows.)
+ self.vt100_parser.flush()
+
+ # Return result.
+ result = self._buffer
+ self._buffer = []
+ return result
+
+ @property
+ def closed(self) -> bool:
+ return self.stdin_reader.closed
+
+ def raw_mode(self) -> ContextManager[None]:
+ return raw_mode(self.stdin.fileno())
+
+ def cooked_mode(self) -> ContextManager[None]:
+ return cooked_mode(self.stdin.fileno())
+
+ def fileno(self) -> int:
+ return self.stdin.fileno()
+
+ def typeahead_hash(self) -> str:
+ return f"fd-{self._fileno}"
+
+
+_current_callbacks: dict[
+ tuple[AbstractEventLoop, int], Callable[[], None] | None
+] = {} # (loop, fd) -> current callback
+
+
+@contextlib.contextmanager
+def _attached_input(
+ input: Vt100Input, callback: Callable[[], None]
+) -> Generator[None, None, None]:
+ """
+ Context manager that makes this input active in the current event loop.
+
+ :param input: :class:`~prompt_toolkit.input.Input` object.
+ :param callback: Called when the input is ready to read.
+ """
+ loop = get_running_loop()
+ fd = input.fileno()
+ previous = _current_callbacks.get((loop, fd))
+
+ def callback_wrapper() -> None:
+ """Wrapper around the callback that already removes the reader when
+ the input is closed. Otherwise, we keep continuously calling this
+ callback, until we leave the context manager (which can happen a bit
+ later). This fixes issues when piping /dev/null into a prompt_toolkit
+ application."""
+ if input.closed:
+ loop.remove_reader(fd)
+ callback()
+
+ try:
+ loop.add_reader(fd, callback_wrapper)
+ except PermissionError:
+ # For `EPollSelector`, adding /dev/null to the event loop will raise
+ # `PermissionError` (that doesn't happen for `SelectSelector`
+ # apparently). Whenever we get a `PermissionError`, we can raise
+ # `EOFError`, because there's not more to be read anyway. `EOFError` is
+ # an exception that people expect in
+ # `prompt_toolkit.application.Application.run()`.
+ # To reproduce, do: `ptpython 0< /dev/null 1< /dev/null`
+ raise EOFError
+
+ _current_callbacks[loop, fd] = callback
+
+ try:
+ yield
+ finally:
+ loop.remove_reader(fd)
+
+ if previous:
+ loop.add_reader(fd, previous)
+ _current_callbacks[loop, fd] = previous
+ else:
+ del _current_callbacks[loop, fd]
+
+
+@contextlib.contextmanager
+def _detached_input(input: Vt100Input) -> Generator[None, None, None]:
+ loop = get_running_loop()
+ fd = input.fileno()
+ previous = _current_callbacks.get((loop, fd))
+
+ if previous:
+ loop.remove_reader(fd)
+ _current_callbacks[loop, fd] = None
+
+ try:
+ yield
+ finally:
+ if previous:
+ loop.add_reader(fd, previous)
+ _current_callbacks[loop, fd] = previous
+
+
+class raw_mode:
+ """
+ ::
+
+ with raw_mode(stdin):
+ ''' the pseudo-terminal stdin is now used in raw mode '''
+
+ We ignore errors when executing `tcgetattr` fails.
+ """
+
+ # There are several reasons for ignoring errors:
+ # 1. To avoid the "Inappropriate ioctl for device" crash if somebody would
+ # execute this code (In a Python REPL, for instance):
+ #
+ # import os; f = open(os.devnull); os.dup2(f.fileno(), 0)
+ #
+ # The result is that the eventloop will stop correctly, because it has
+ # to logic to quit when stdin is closed. However, we should not fail at
+ # this point. See:
+ # https://github.com/jonathanslenders/python-prompt-toolkit/pull/393
+ # https://github.com/jonathanslenders/python-prompt-toolkit/issues/392
+
+ # 2. Related, when stdin is an SSH pipe, and no full terminal was allocated.
+ # See: https://github.com/jonathanslenders/python-prompt-toolkit/pull/165
+ def __init__(self, fileno: int) -> None:
+ self.fileno = fileno
+ self.attrs_before: list[int | list[bytes | int]] | None
+ try:
+ self.attrs_before = termios.tcgetattr(fileno)
+ except termios.error:
+ # Ignore attribute errors.
+ self.attrs_before = None
+
+ def __enter__(self) -> None:
+ # NOTE: On os X systems, using pty.setraw() fails. Therefor we are using this:
+ try:
+ newattr = termios.tcgetattr(self.fileno)
+ except termios.error:
+ pass
+ else:
+ newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG])
+ newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG])
+
+ # VMIN defines the number of characters read at a time in
+ # non-canonical mode. It seems to default to 1 on Linux, but on
+ # Solaris and derived operating systems it defaults to 4. (This is
+ # because the VMIN slot is the same as the VEOF slot, which
+ # defaults to ASCII EOT = Ctrl-D = 4.)
+ newattr[tty.CC][termios.VMIN] = 1
+
+ termios.tcsetattr(self.fileno, termios.TCSANOW, newattr)
+
+ @classmethod
+ def _patch_lflag(cls, attrs: int) -> int:
+ return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG)
+
+ @classmethod
+ def _patch_iflag(cls, attrs: int) -> int:
+ return attrs & ~(
+ # Disable XON/XOFF flow control on output and input.
+ # (Don't capture Ctrl-S and Ctrl-Q.)
+ # Like executing: "stty -ixon."
+ termios.IXON
+ | termios.IXOFF
+ |
+ # Don't translate carriage return into newline on input.
+ termios.ICRNL
+ | termios.INLCR
+ | termios.IGNCR
+ )
+
+ def __exit__(self, *a: object) -> None:
+ if self.attrs_before is not None:
+ try:
+ termios.tcsetattr(self.fileno, termios.TCSANOW, self.attrs_before)
+ except termios.error:
+ pass
+
+ # # Put the terminal in application mode.
+ # self._stdout.write('\x1b[?1h')
+
+
+class cooked_mode(raw_mode):
+ """
+ The opposite of ``raw_mode``, used when we need cooked mode inside a
+ `raw_mode` block. Used in `Application.run_in_terminal`.::
+
+ with cooked_mode(stdin):
+ ''' the pseudo-terminal stdin is now used in cooked mode. '''
+ """
+
+ @classmethod
+ def _patch_lflag(cls, attrs: int) -> int:
+ return attrs | (termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG)
+
+ @classmethod
+ def _patch_iflag(cls, attrs: int) -> int:
+ # Turn the ICRNL flag back on. (Without this, calling `input()` in
+ # run_in_terminal doesn't work and displays ^M instead. Ptpython
+ # evaluates commands using `run_in_terminal`, so it's important that
+ # they translate ^M back into ^J.)
+ return attrs | termios.ICRNL
diff --git a/src/prompt_toolkit/input/vt100_parser.py b/src/prompt_toolkit/input/vt100_parser.py
new file mode 100644
index 0000000..99e2d99
--- /dev/null
+++ b/src/prompt_toolkit/input/vt100_parser.py
@@ -0,0 +1,249 @@
+"""
+Parser for VT100 input stream.
+"""
+from __future__ import annotations
+
+import re
+from typing import Callable, Dict, Generator
+
+from ..key_binding.key_processor import KeyPress
+from ..keys import Keys
+from .ansi_escape_sequences import ANSI_SEQUENCES
+
+__all__ = [
+ "Vt100Parser",
+]
+
+
+# Regex matching any CPR response
+# (Note that we use '\Z' instead of '$', because '$' could include a trailing
+# newline.)
+_cpr_response_re = re.compile("^" + re.escape("\x1b[") + r"\d+;\d+R\Z")
+
+# Mouse events:
+# Typical: "Esc[MaB*" Urxvt: "Esc[96;14;13M" and for Xterm SGR: "Esc[<64;85;12M"
+_mouse_event_re = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")
+
+# Regex matching any valid prefix of a CPR response.
+# (Note that it doesn't contain the last character, the 'R'. The prefix has to
+# be shorter.)
+_cpr_response_prefix_re = re.compile("^" + re.escape("\x1b[") + r"[\d;]*\Z")
+
+_mouse_event_prefix_re = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]*|M.{0,2})\Z")
+
+
+class _Flush:
+ """Helper object to indicate flush operation to the parser."""
+
+ pass
+
+
+class _IsPrefixOfLongerMatchCache(Dict[str, bool]):
+ """
+ Dictionary that maps input sequences to a boolean indicating whether there is
+ any key that start with this characters.
+ """
+
+ def __missing__(self, prefix: str) -> bool:
+ # (hard coded) If this could be a prefix of a CPR response, return
+ # True.
+ if _cpr_response_prefix_re.match(prefix) or _mouse_event_prefix_re.match(
+ prefix
+ ):
+ result = True
+ else:
+ # If this could be a prefix of anything else, also return True.
+ result = any(
+ v
+ for k, v in ANSI_SEQUENCES.items()
+ if k.startswith(prefix) and k != prefix
+ )
+
+ self[prefix] = result
+ return result
+
+
+_IS_PREFIX_OF_LONGER_MATCH_CACHE = _IsPrefixOfLongerMatchCache()
+
+
+class Vt100Parser:
+ """
+ Parser for VT100 input stream.
+ Data can be fed through the `feed` method and the given callback will be
+ called with KeyPress objects.
+
+ ::
+
+ def callback(key):
+ pass
+ i = Vt100Parser(callback)
+ i.feed('data\x01...')
+
+ :attr feed_key_callback: Function that will be called when a key is parsed.
+ """
+
+ # Lookup table of ANSI escape sequences for a VT100 terminal
+ # Hint: in order to know what sequences your terminal writes to stdin, run
+ # "od -c" and start typing.
+ def __init__(self, feed_key_callback: Callable[[KeyPress], None]) -> None:
+ self.feed_key_callback = feed_key_callback
+ self.reset()
+
+ def reset(self, request: bool = False) -> None:
+ self._in_bracketed_paste = False
+ self._start_parser()
+
+ def _start_parser(self) -> None:
+ """
+ Start the parser coroutine.
+ """
+ self._input_parser = self._input_parser_generator()
+ self._input_parser.send(None) # type: ignore
+
+ def _get_match(self, prefix: str) -> None | Keys | tuple[Keys, ...]:
+ """
+ Return the key (or keys) that maps to this prefix.
+ """
+ # (hard coded) If we match a CPR response, return Keys.CPRResponse.
+ # (This one doesn't fit in the ANSI_SEQUENCES, because it contains
+ # integer variables.)
+ if _cpr_response_re.match(prefix):
+ return Keys.CPRResponse
+
+ elif _mouse_event_re.match(prefix):
+ return Keys.Vt100MouseEvent
+
+ # Otherwise, use the mappings.
+ try:
+ return ANSI_SEQUENCES[prefix]
+ except KeyError:
+ return None
+
+ def _input_parser_generator(self) -> Generator[None, str | _Flush, None]:
+ """
+ Coroutine (state machine) for the input parser.
+ """
+ prefix = ""
+ retry = False
+ flush = False
+
+ while True:
+ flush = False
+
+ if retry:
+ retry = False
+ else:
+ # Get next character.
+ c = yield
+
+ if isinstance(c, _Flush):
+ flush = True
+ else:
+ prefix += c
+
+ # If we have some data, check for matches.
+ if prefix:
+ is_prefix_of_longer_match = _IS_PREFIX_OF_LONGER_MATCH_CACHE[prefix]
+ match = self._get_match(prefix)
+
+ # Exact matches found, call handlers..
+ if (flush or not is_prefix_of_longer_match) and match:
+ self._call_handler(match, prefix)
+ prefix = ""
+
+ # No exact match found.
+ elif (flush or not is_prefix_of_longer_match) and not match:
+ found = False
+ retry = True
+
+ # Loop over the input, try the longest match first and
+ # shift.
+ for i in range(len(prefix), 0, -1):
+ match = self._get_match(prefix[:i])
+ if match:
+ self._call_handler(match, prefix[:i])
+ prefix = prefix[i:]
+ found = True
+
+ if not found:
+ self._call_handler(prefix[0], prefix[0])
+ prefix = prefix[1:]
+
+ def _call_handler(
+ self, key: str | Keys | tuple[Keys, ...], insert_text: str
+ ) -> None:
+ """
+ Callback to handler.
+ """
+ if isinstance(key, tuple):
+ # Received ANSI sequence that corresponds with multiple keys
+ # (probably alt+something). Handle keys individually, but only pass
+ # data payload to first KeyPress (so that we won't insert it
+ # multiple times).
+ for i, k in enumerate(key):
+ self._call_handler(k, insert_text if i == 0 else "")
+ else:
+ if key == Keys.BracketedPaste:
+ self._in_bracketed_paste = True
+ self._paste_buffer = ""
+ else:
+ self.feed_key_callback(KeyPress(key, insert_text))
+
+ def feed(self, data: str) -> None:
+ """
+ Feed the input stream.
+
+ :param data: Input string (unicode).
+ """
+ # Handle bracketed paste. (We bypass the parser that matches all other
+ # key presses and keep reading input until we see the end mark.)
+ # This is much faster then parsing character by character.
+ if self._in_bracketed_paste:
+ self._paste_buffer += data
+ end_mark = "\x1b[201~"
+
+ if end_mark in self._paste_buffer:
+ end_index = self._paste_buffer.index(end_mark)
+
+ # Feed content to key bindings.
+ paste_content = self._paste_buffer[:end_index]
+ self.feed_key_callback(KeyPress(Keys.BracketedPaste, paste_content))
+
+ # Quit bracketed paste mode and handle remaining input.
+ self._in_bracketed_paste = False
+ remaining = self._paste_buffer[end_index + len(end_mark) :]
+ self._paste_buffer = ""
+
+ self.feed(remaining)
+
+ # Handle normal input character by character.
+ else:
+ for i, c in enumerate(data):
+ if self._in_bracketed_paste:
+ # Quit loop and process from this position when the parser
+ # entered bracketed paste.
+ self.feed(data[i:])
+ break
+ else:
+ self._input_parser.send(c)
+
+ def flush(self) -> None:
+ """
+ Flush the buffer of the input stream.
+
+ This will allow us to handle the escape key (or maybe meta) sooner.
+ The input received by the escape key is actually the same as the first
+ characters of e.g. Arrow-Up, so without knowing what follows the escape
+ sequence, we don't know whether escape has been pressed, or whether
+ it's something else. This flush function should be called after a
+ timeout, and processes everything that's still in the buffer as-is, so
+ without assuming any characters will follow.
+ """
+ self._input_parser.send(_Flush())
+
+ def feed_and_flush(self, data: str) -> None:
+ """
+ Wrapper around ``feed`` and ``flush``.
+ """
+ self.feed(data)
+ self.flush()
diff --git a/src/prompt_toolkit/input/win32.py b/src/prompt_toolkit/input/win32.py
new file mode 100644
index 0000000..35e8948
--- /dev/null
+++ b/src/prompt_toolkit/input/win32.py
@@ -0,0 +1,749 @@
+from __future__ import annotations
+
+import os
+import sys
+from abc import abstractmethod
+from asyncio import get_running_loop
+from contextlib import contextmanager
+
+from ..utils import SPHINX_AUTODOC_RUNNING
+
+assert sys.platform == "win32"
+
+# Do not import win32-specific stuff when generating documentation.
+# Otherwise RTD would be unable to generate docs for this module.
+if not SPHINX_AUTODOC_RUNNING:
+ import msvcrt
+ from ctypes import windll
+
+from ctypes import Array, pointer
+from ctypes.wintypes import DWORD, HANDLE
+from typing import Callable, ContextManager, Iterable, Iterator, TextIO
+
+from prompt_toolkit.eventloop import run_in_executor_with_context
+from prompt_toolkit.eventloop.win32 import create_win32_event, wait_for_handles
+from prompt_toolkit.key_binding.key_processor import KeyPress
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.mouse_events import MouseButton, MouseEventType
+from prompt_toolkit.win32_types import (
+ INPUT_RECORD,
+ KEY_EVENT_RECORD,
+ MOUSE_EVENT_RECORD,
+ STD_INPUT_HANDLE,
+ EventTypes,
+)
+
+from .ansi_escape_sequences import REVERSE_ANSI_SEQUENCES
+from .base import Input
+
+__all__ = [
+ "Win32Input",
+ "ConsoleInputReader",
+ "raw_mode",
+ "cooked_mode",
+ "attach_win32_input",
+ "detach_win32_input",
+]
+
+# Win32 Constants for MOUSE_EVENT_RECORD.
+# See: https://docs.microsoft.com/en-us/windows/console/mouse-event-record-str
+FROM_LEFT_1ST_BUTTON_PRESSED = 0x1
+RIGHTMOST_BUTTON_PRESSED = 0x2
+MOUSE_MOVED = 0x0001
+MOUSE_WHEELED = 0x0004
+
+
+class _Win32InputBase(Input):
+ """
+ Base class for `Win32Input` and `Win32PipeInput`.
+ """
+
+ def __init__(self) -> None:
+ self.win32_handles = _Win32Handles()
+
+ @property
+ @abstractmethod
+ def handle(self) -> HANDLE:
+ pass
+
+
+class Win32Input(_Win32InputBase):
+ """
+ `Input` class that reads from the Windows console.
+ """
+
+ def __init__(self, stdin: TextIO | None = None) -> None:
+ super().__init__()
+ self.console_input_reader = ConsoleInputReader()
+
+ def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
+ """
+ Return a context manager that makes this input active in the current
+ event loop.
+ """
+ return attach_win32_input(self, input_ready_callback)
+
+ def detach(self) -> ContextManager[None]:
+ """
+ Return a context manager that makes sure that this input is not active
+ in the current event loop.
+ """
+ return detach_win32_input(self)
+
+ def read_keys(self) -> list[KeyPress]:
+ return list(self.console_input_reader.read())
+
+ def flush(self) -> None:
+ pass
+
+ @property
+ def closed(self) -> bool:
+ return False
+
+ def raw_mode(self) -> ContextManager[None]:
+ return raw_mode()
+
+ def cooked_mode(self) -> ContextManager[None]:
+ return cooked_mode()
+
+ def fileno(self) -> int:
+ # The windows console doesn't depend on the file handle, so
+ # this is not used for the event loop (which uses the
+ # handle instead). But it's used in `Application.run_system_command`
+ # which opens a subprocess with a given stdin/stdout.
+ return sys.stdin.fileno()
+
+ def typeahead_hash(self) -> str:
+ return "win32-input"
+
+ def close(self) -> None:
+ self.console_input_reader.close()
+
+ @property
+ def handle(self) -> HANDLE:
+ return self.console_input_reader.handle
+
+
+class ConsoleInputReader:
+ """
+ :param recognize_paste: When True, try to discover paste actions and turn
+ the event into a BracketedPaste.
+ """
+
+ # Keys with character data.
+ mappings = {
+ b"\x1b": Keys.Escape,
+ b"\x00": Keys.ControlSpace, # Control-Space (Also for Ctrl-@)
+ b"\x01": Keys.ControlA, # Control-A (home)
+ b"\x02": Keys.ControlB, # Control-B (emacs cursor left)
+ b"\x03": Keys.ControlC, # Control-C (interrupt)
+ b"\x04": Keys.ControlD, # Control-D (exit)
+ b"\x05": Keys.ControlE, # Control-E (end)
+ b"\x06": Keys.ControlF, # Control-F (cursor forward)
+ b"\x07": Keys.ControlG, # Control-G
+ b"\x08": Keys.ControlH, # Control-H (8) (Identical to '\b')
+ b"\x09": Keys.ControlI, # Control-I (9) (Identical to '\t')
+ b"\x0a": Keys.ControlJ, # Control-J (10) (Identical to '\n')
+ b"\x0b": Keys.ControlK, # Control-K (delete until end of line; vertical tab)
+ b"\x0c": Keys.ControlL, # Control-L (clear; form feed)
+ b"\x0d": Keys.ControlM, # Control-M (enter)
+ b"\x0e": Keys.ControlN, # Control-N (14) (history forward)
+ b"\x0f": Keys.ControlO, # Control-O (15)
+ b"\x10": Keys.ControlP, # Control-P (16) (history back)
+ b"\x11": Keys.ControlQ, # Control-Q
+ b"\x12": Keys.ControlR, # Control-R (18) (reverse search)
+ b"\x13": Keys.ControlS, # Control-S (19) (forward search)
+ b"\x14": Keys.ControlT, # Control-T
+ b"\x15": Keys.ControlU, # Control-U
+ b"\x16": Keys.ControlV, # Control-V
+ b"\x17": Keys.ControlW, # Control-W
+ b"\x18": Keys.ControlX, # Control-X
+ b"\x19": Keys.ControlY, # Control-Y (25)
+ b"\x1a": Keys.ControlZ, # Control-Z
+ b"\x1c": Keys.ControlBackslash, # Both Control-\ and Ctrl-|
+ b"\x1d": Keys.ControlSquareClose, # Control-]
+ b"\x1e": Keys.ControlCircumflex, # Control-^
+ b"\x1f": Keys.ControlUnderscore, # Control-underscore (Also for Ctrl-hyphen.)
+ b"\x7f": Keys.Backspace, # (127) Backspace (ASCII Delete.)
+ }
+
+ # Keys that don't carry character data.
+ keycodes = {
+ # Home/End
+ 33: Keys.PageUp,
+ 34: Keys.PageDown,
+ 35: Keys.End,
+ 36: Keys.Home,
+ # Arrows
+ 37: Keys.Left,
+ 38: Keys.Up,
+ 39: Keys.Right,
+ 40: Keys.Down,
+ 45: Keys.Insert,
+ 46: Keys.Delete,
+ # F-keys.
+ 112: Keys.F1,
+ 113: Keys.F2,
+ 114: Keys.F3,
+ 115: Keys.F4,
+ 116: Keys.F5,
+ 117: Keys.F6,
+ 118: Keys.F7,
+ 119: Keys.F8,
+ 120: Keys.F9,
+ 121: Keys.F10,
+ 122: Keys.F11,
+ 123: Keys.F12,
+ }
+
+ LEFT_ALT_PRESSED = 0x0002
+ RIGHT_ALT_PRESSED = 0x0001
+ SHIFT_PRESSED = 0x0010
+ LEFT_CTRL_PRESSED = 0x0008
+ RIGHT_CTRL_PRESSED = 0x0004
+
+ def __init__(self, recognize_paste: bool = True) -> None:
+ self._fdcon = None
+ self.recognize_paste = recognize_paste
+
+ # When stdin is a tty, use that handle, otherwise, create a handle from
+ # CONIN$.
+ self.handle: HANDLE
+ if sys.stdin.isatty():
+ self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
+ else:
+ self._fdcon = os.open("CONIN$", os.O_RDWR | os.O_BINARY)
+ self.handle = HANDLE(msvcrt.get_osfhandle(self._fdcon))
+
+ def close(self) -> None:
+ "Close fdcon."
+ if self._fdcon is not None:
+ os.close(self._fdcon)
+
+ def read(self) -> Iterable[KeyPress]:
+ """
+ Return a list of `KeyPress` instances. It won't return anything when
+ there was nothing to read. (This function doesn't block.)
+
+ http://msdn.microsoft.com/en-us/library/windows/desktop/ms684961(v=vs.85).aspx
+ """
+ max_count = 2048 # Max events to read at the same time.
+
+ read = DWORD(0)
+ arrtype = INPUT_RECORD * max_count
+ input_records = arrtype()
+
+ # Check whether there is some input to read. `ReadConsoleInputW` would
+ # block otherwise.
+ # (Actually, the event loop is responsible to make sure that this
+ # function is only called when there is something to read, but for some
+ # reason this happened in the asyncio_win32 loop, and it's better to be
+ # safe anyway.)
+ if not wait_for_handles([self.handle], timeout=0):
+ return
+
+ # Get next batch of input event.
+ windll.kernel32.ReadConsoleInputW(
+ self.handle, pointer(input_records), max_count, pointer(read)
+ )
+
+ # First, get all the keys from the input buffer, in order to determine
+ # whether we should consider this a paste event or not.
+ all_keys = list(self._get_keys(read, input_records))
+
+ # Fill in 'data' for key presses.
+ all_keys = [self._insert_key_data(key) for key in all_keys]
+
+ # Correct non-bmp characters that are passed as separate surrogate codes
+ all_keys = list(self._merge_paired_surrogates(all_keys))
+
+ if self.recognize_paste and self._is_paste(all_keys):
+ gen = iter(all_keys)
+ k: 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:
+ """
+ Insert KeyPress data, for vt100 compatibility.
+ """
+ if key_press.data:
+ return key_press
+
+ if isinstance(key_press.key, Keys):
+ data = REVERSE_ANSI_SEQUENCES.get(key_press.key, "")
+ else:
+ data = ""
+
+ return KeyPress(key_press.key, data)
+
+ def _get_keys(
+ self, read: DWORD, input_records: Array[INPUT_RECORD]
+ ) -> Iterator[KeyPress]:
+ """
+ Generator that yields `KeyPress` objects from the input records.
+ """
+ for i in range(read.value):
+ ir = input_records[i]
+
+ # Get the right EventType from the EVENT_RECORD.
+ # (For some reason the Windows console application 'cmder'
+ # [http://gooseberrycreative.com/cmder/] can return '0' for
+ # ir.EventType. -- Just ignore that.)
+ if ir.EventType in EventTypes:
+ ev = getattr(ir.Event, EventTypes[ir.EventType])
+
+ # Process if this is a key event. (We also have mouse, menu and
+ # focus events.)
+ if isinstance(ev, KEY_EVENT_RECORD) and ev.KeyDown:
+ yield from self._event_to_key_presses(ev)
+
+ elif isinstance(ev, MOUSE_EVENT_RECORD):
+ yield from self._handle_mouse(ev)
+
+ @staticmethod
+ def _merge_paired_surrogates(key_presses: list[KeyPress]) -> Iterator[KeyPress]:
+ """
+ Combines consecutive KeyPresses with high and low surrogates into
+ single characters
+ """
+ buffered_high_surrogate = None
+ for key in key_presses:
+ is_text = not isinstance(key.key, Keys)
+ is_high_surrogate = is_text and "\uD800" <= key.key <= "\uDBFF"
+ is_low_surrogate = is_text and "\uDC00" <= key.key <= "\uDFFF"
+
+ if buffered_high_surrogate:
+ if is_low_surrogate:
+ # convert high surrogate + low surrogate to single character
+ fullchar = (
+ (buffered_high_surrogate.key + key.key)
+ .encode("utf-16-le", "surrogatepass")
+ .decode("utf-16-le")
+ )
+ key = KeyPress(fullchar, fullchar)
+ else:
+ yield buffered_high_surrogate
+ buffered_high_surrogate = None
+
+ if is_high_surrogate:
+ buffered_high_surrogate = key
+ else:
+ yield key
+
+ if buffered_high_surrogate:
+ yield buffered_high_surrogate
+
+ @staticmethod
+ def _is_paste(keys: list[KeyPress]) -> bool:
+ """
+ Return `True` when we should consider this list of keys as a paste
+ event. Pasted text on windows will be turned into a
+ `Keys.BracketedPaste` event. (It's not 100% correct, but it is probably
+ the best possible way to detect pasting of text and handle that
+ correctly.)
+ """
+ # Consider paste when it contains at least one newline and at least one
+ # other character.
+ text_count = 0
+ newline_count = 0
+
+ for k in keys:
+ if not isinstance(k.key, Keys):
+ text_count += 1
+ if k.key == Keys.ControlM:
+ newline_count += 1
+
+ return newline_count >= 1 and text_count >= 1
+
+ def _event_to_key_presses(self, ev: KEY_EVENT_RECORD) -> list[KeyPress]:
+ """
+ For this `KEY_EVENT_RECORD`, return a list of `KeyPress` instances.
+ """
+ assert isinstance(ev, KEY_EVENT_RECORD) and ev.KeyDown
+
+ result: KeyPress | None = None
+
+ control_key_state = ev.ControlKeyState
+ u_char = ev.uChar.UnicodeChar
+ # Use surrogatepass because u_char may be an unmatched surrogate
+ ascii_char = u_char.encode("utf-8", "surrogatepass")
+
+ # NOTE: We don't use `ev.uChar.AsciiChar`. That appears to be the
+ # unicode code point truncated to 1 byte. See also:
+ # https://github.com/ipython/ipython/issues/10004
+ # https://github.com/jonathanslenders/python-prompt-toolkit/issues/389
+
+ if u_char == "\x00":
+ if ev.VirtualKeyCode in self.keycodes:
+ result = KeyPress(self.keycodes[ev.VirtualKeyCode], "")
+ else:
+ if ascii_char in self.mappings:
+ if self.mappings[ascii_char] == Keys.ControlJ:
+ u_char = (
+ "\n" # Windows sends \n, turn into \r for unix compatibility.
+ )
+ result = KeyPress(self.mappings[ascii_char], u_char)
+ else:
+ result = KeyPress(u_char, u_char)
+
+ # First we handle Shift-Control-Arrow/Home/End (need to do this first)
+ if (
+ (
+ control_key_state & self.LEFT_CTRL_PRESSED
+ or control_key_state & self.RIGHT_CTRL_PRESSED
+ )
+ and control_key_state & self.SHIFT_PRESSED
+ and result
+ ):
+ mapping: dict[str, str] = {
+ Keys.Left: Keys.ControlShiftLeft,
+ Keys.Right: Keys.ControlShiftRight,
+ Keys.Up: Keys.ControlShiftUp,
+ Keys.Down: Keys.ControlShiftDown,
+ Keys.Home: Keys.ControlShiftHome,
+ Keys.End: Keys.ControlShiftEnd,
+ Keys.Insert: Keys.ControlShiftInsert,
+ Keys.PageUp: Keys.ControlShiftPageUp,
+ Keys.PageDown: Keys.ControlShiftPageDown,
+ }
+ result.key = mapping.get(result.key, result.key)
+
+ # Correctly handle Control-Arrow/Home/End and Control-Insert/Delete keys.
+ if (
+ control_key_state & self.LEFT_CTRL_PRESSED
+ or control_key_state & self.RIGHT_CTRL_PRESSED
+ ) and result:
+ mapping = {
+ Keys.Left: Keys.ControlLeft,
+ Keys.Right: Keys.ControlRight,
+ Keys.Up: Keys.ControlUp,
+ Keys.Down: Keys.ControlDown,
+ Keys.Home: Keys.ControlHome,
+ Keys.End: Keys.ControlEnd,
+ Keys.Insert: Keys.ControlInsert,
+ Keys.Delete: Keys.ControlDelete,
+ Keys.PageUp: Keys.ControlPageUp,
+ Keys.PageDown: Keys.ControlPageDown,
+ }
+ result.key = mapping.get(result.key, result.key)
+
+ # Turn 'Tab' into 'BackTab' when shift was pressed.
+ # Also handle other shift-key combination
+ if control_key_state & self.SHIFT_PRESSED and result:
+ mapping = {
+ Keys.Tab: Keys.BackTab,
+ Keys.Left: Keys.ShiftLeft,
+ Keys.Right: Keys.ShiftRight,
+ Keys.Up: Keys.ShiftUp,
+ Keys.Down: Keys.ShiftDown,
+ Keys.Home: Keys.ShiftHome,
+ Keys.End: Keys.ShiftEnd,
+ Keys.Insert: Keys.ShiftInsert,
+ Keys.Delete: Keys.ShiftDelete,
+ Keys.PageUp: Keys.ShiftPageUp,
+ Keys.PageDown: Keys.ShiftPageDown,
+ }
+ result.key = mapping.get(result.key, result.key)
+
+ # Turn 'Space' into 'ControlSpace' when control was pressed.
+ if (
+ (
+ control_key_state & self.LEFT_CTRL_PRESSED
+ or control_key_state & self.RIGHT_CTRL_PRESSED
+ )
+ and result
+ and result.data == " "
+ ):
+ result = KeyPress(Keys.ControlSpace, " ")
+
+ # Turn Control-Enter into META-Enter. (On a vt100 terminal, we cannot
+ # detect this combination. But it's really practical on Windows.)
+ if (
+ (
+ control_key_state & self.LEFT_CTRL_PRESSED
+ or control_key_state & self.RIGHT_CTRL_PRESSED
+ )
+ and result
+ and result.key == Keys.ControlJ
+ ):
+ return [KeyPress(Keys.Escape, ""), result]
+
+ # Return result. If alt was pressed, prefix the result with an
+ # 'Escape' key, just like unix VT100 terminals do.
+
+ # NOTE: Only replace the left alt with escape. The right alt key often
+ # acts as altgr and is used in many non US keyboard layouts for
+ # typing some special characters, like a backslash. We don't want
+ # all backslashes to be prefixed with escape. (Esc-\ has a
+ # meaning in E-macs, for instance.)
+ if result:
+ meta_pressed = control_key_state & self.LEFT_ALT_PRESSED
+
+ if meta_pressed:
+ return [KeyPress(Keys.Escape, ""), result]
+ else:
+ return [result]
+
+ else:
+ return []
+
+ def _handle_mouse(self, ev: MOUSE_EVENT_RECORD) -> list[KeyPress]:
+ """
+ Handle mouse events. Return a list of KeyPress instances.
+ """
+ event_flags = ev.EventFlags
+ button_state = ev.ButtonState
+
+ event_type: MouseEventType | None = None
+ button: MouseButton = MouseButton.NONE
+
+ # Scroll events.
+ if event_flags & MOUSE_WHEELED:
+ if button_state > 0:
+ event_type = MouseEventType.SCROLL_UP
+ else:
+ event_type = MouseEventType.SCROLL_DOWN
+ else:
+ # Handle button state for non-scroll events.
+ if button_state == FROM_LEFT_1ST_BUTTON_PRESSED:
+ button = MouseButton.LEFT
+
+ elif button_state == RIGHTMOST_BUTTON_PRESSED:
+ button = MouseButton.RIGHT
+
+ # Move events.
+ if event_flags & MOUSE_MOVED:
+ event_type = MouseEventType.MOUSE_MOVE
+
+ # No key pressed anymore: mouse up.
+ if event_type is None:
+ if button_state > 0:
+ # Some button pressed.
+ event_type = MouseEventType.MOUSE_DOWN
+ else:
+ # No button pressed.
+ event_type = MouseEventType.MOUSE_UP
+
+ data = ";".join(
+ [
+ button.value,
+ event_type.value,
+ str(ev.MousePosition.X),
+ str(ev.MousePosition.Y),
+ ]
+ )
+ return [KeyPress(Keys.WindowsMouseEvent, data)]
+
+
+class _Win32Handles:
+ """
+ Utility to keep track of which handles are connectod to which callbacks.
+
+ `add_win32_handle` starts a tiny event loop in another thread which waits
+ for the Win32 handle to become ready. When this happens, the callback will
+ be called in the current asyncio event loop using `call_soon_threadsafe`.
+
+ `remove_win32_handle` will stop this tiny event loop.
+
+ NOTE: We use this technique, so that we don't have to use the
+ `ProactorEventLoop` on Windows and we can wait for things like stdin
+ in a `SelectorEventLoop`. This is important, because our inputhook
+ mechanism (used by IPython), only works with the `SelectorEventLoop`.
+ """
+
+ def __init__(self) -> None:
+ self._handle_callbacks: dict[int, Callable[[], None]] = {}
+
+ # Windows Events that are triggered when we have to stop watching this
+ # handle.
+ self._remove_events: dict[int, HANDLE] = {}
+
+ def add_win32_handle(self, handle: HANDLE, callback: Callable[[], None]) -> None:
+ """
+ Add a Win32 handle to the event loop.
+ """
+ handle_value = handle.value
+
+ if handle_value is None:
+ raise ValueError("Invalid handle.")
+
+ # Make sure to remove a previous registered handler first.
+ self.remove_win32_handle(handle)
+
+ loop = get_running_loop()
+ self._handle_callbacks[handle_value] = callback
+
+ # Create remove event.
+ remove_event = create_win32_event()
+ self._remove_events[handle_value] = remove_event
+
+ # Add reader.
+ def ready() -> None:
+ # Tell the callback that input's ready.
+ try:
+ callback()
+ finally:
+ run_in_executor_with_context(wait, loop=loop)
+
+ # Wait for the input to become ready.
+ # (Use an executor for this, the Windows asyncio event loop doesn't
+ # allow us to wait for handles like stdin.)
+ def wait() -> None:
+ # Wait until either the handle becomes ready, or the remove event
+ # has been set.
+ result = wait_for_handles([remove_event, handle])
+
+ if result is remove_event:
+ windll.kernel32.CloseHandle(remove_event)
+ return
+ else:
+ loop.call_soon_threadsafe(ready)
+
+ run_in_executor_with_context(wait, loop=loop)
+
+ def remove_win32_handle(self, handle: HANDLE) -> Callable[[], None] | None:
+ """
+ Remove a Win32 handle from the event loop.
+ Return either the registered handler or `None`.
+ """
+ if handle.value is None:
+ return None # Ignore.
+
+ # Trigger remove events, so that the reader knows to stop.
+ try:
+ event = self._remove_events.pop(handle.value)
+ except KeyError:
+ pass
+ else:
+ windll.kernel32.SetEvent(event)
+
+ try:
+ return self._handle_callbacks.pop(handle.value)
+ except KeyError:
+ return None
+
+
+@contextmanager
+def attach_win32_input(
+ input: _Win32InputBase, callback: Callable[[], None]
+) -> Iterator[None]:
+ """
+ Context manager that makes this input active in the current event loop.
+
+ :param input: :class:`~prompt_toolkit.input.Input` object.
+ :param input_ready_callback: Called when the input is ready to read.
+ """
+ win32_handles = input.win32_handles
+ handle = input.handle
+
+ if handle.value is None:
+ raise ValueError("Invalid handle.")
+
+ # Add reader.
+ previous_callback = win32_handles.remove_win32_handle(handle)
+ win32_handles.add_win32_handle(handle, callback)
+
+ try:
+ yield
+ finally:
+ win32_handles.remove_win32_handle(handle)
+
+ if previous_callback:
+ win32_handles.add_win32_handle(handle, previous_callback)
+
+
+@contextmanager
+def detach_win32_input(input: _Win32InputBase) -> Iterator[None]:
+ win32_handles = input.win32_handles
+ handle = input.handle
+
+ if handle.value is None:
+ raise ValueError("Invalid handle.")
+
+ previous_callback = win32_handles.remove_win32_handle(handle)
+
+ try:
+ yield
+ finally:
+ if previous_callback:
+ win32_handles.add_win32_handle(handle, previous_callback)
+
+
+class raw_mode:
+ """
+ ::
+
+ with raw_mode(stdin):
+ ''' the windows terminal is now in 'raw' mode. '''
+
+ The ``fileno`` attribute is ignored. This is to be compatible with the
+ `raw_input` method of `.vt100_input`.
+ """
+
+ def __init__(self, fileno: int | None = None) -> None:
+ self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE))
+
+ def __enter__(self) -> None:
+ # Remember original mode.
+ original_mode = DWORD()
+ windll.kernel32.GetConsoleMode(self.handle, pointer(original_mode))
+ self.original_mode = original_mode
+
+ self._patch()
+
+ def _patch(self) -> None:
+ # Set raw
+ ENABLE_ECHO_INPUT = 0x0004
+ ENABLE_LINE_INPUT = 0x0002
+ ENABLE_PROCESSED_INPUT = 0x0001
+
+ windll.kernel32.SetConsoleMode(
+ self.handle,
+ self.original_mode.value
+ & ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT),
+ )
+
+ def __exit__(self, *a: object) -> None:
+ # Restore original mode
+ windll.kernel32.SetConsoleMode(self.handle, self.original_mode)
+
+
+class cooked_mode(raw_mode):
+ """
+ ::
+
+ with cooked_mode(stdin):
+ ''' The pseudo-terminal stdin is now used in cooked mode. '''
+ """
+
+ def _patch(self) -> None:
+ # Set cooked.
+ ENABLE_ECHO_INPUT = 0x0004
+ ENABLE_LINE_INPUT = 0x0002
+ ENABLE_PROCESSED_INPUT = 0x0001
+
+ windll.kernel32.SetConsoleMode(
+ self.handle,
+ self.original_mode.value
+ | (ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT),
+ )
diff --git a/src/prompt_toolkit/input/win32_pipe.py b/src/prompt_toolkit/input/win32_pipe.py
new file mode 100644
index 0000000..0bafa49
--- /dev/null
+++ b/src/prompt_toolkit/input/win32_pipe.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+import sys
+
+assert sys.platform == "win32"
+
+from contextlib import contextmanager
+from ctypes import windll
+from ctypes.wintypes import HANDLE
+from typing import Callable, ContextManager, Iterator
+
+from prompt_toolkit.eventloop.win32 import create_win32_event
+
+from ..key_binding import KeyPress
+from ..utils import DummyContext
+from .base import PipeInput
+from .vt100_parser import Vt100Parser
+from .win32 import _Win32InputBase, attach_win32_input, detach_win32_input
+
+__all__ = ["Win32PipeInput"]
+
+
+class Win32PipeInput(_Win32InputBase, PipeInput):
+ """
+ This is an input pipe that works on Windows.
+ Text or bytes can be feed into the pipe, and key strokes can be read from
+ the pipe. This is useful if we want to send the input programmatically into
+ the application. Mostly useful for unit testing.
+
+ Notice that even though it's Windows, we use vt100 escape sequences over
+ the pipe.
+
+ Usage::
+
+ input = Win32PipeInput()
+ input.send_text('inputdata')
+ """
+
+ _id = 0
+
+ def __init__(self, _event: HANDLE) -> None:
+ super().__init__()
+ # Event (handle) for registering this input in the event loop.
+ # This event is set when there is data available to read from the pipe.
+ # Note: We use this approach instead of using a regular pipe, like
+ # returned from `os.pipe()`, because making such a regular pipe
+ # non-blocking is tricky and this works really well.
+ self._event = create_win32_event()
+
+ self._closed = False
+
+ # Parser for incoming keys.
+ self._buffer: list[KeyPress] = [] # Buffer to collect the Key objects.
+ self.vt100_parser = Vt100Parser(lambda key: self._buffer.append(key))
+
+ # Identifier for every PipeInput for the hash.
+ self.__class__._id += 1
+ self._id = self.__class__._id
+
+ @classmethod
+ @contextmanager
+ def create(cls) -> Iterator[Win32PipeInput]:
+ event = create_win32_event()
+ try:
+ yield Win32PipeInput(_event=event)
+ finally:
+ windll.kernel32.CloseHandle(event)
+
+ @property
+ def closed(self) -> bool:
+ return self._closed
+
+ def fileno(self) -> int:
+ """
+ The windows pipe doesn't depend on the file handle.
+ """
+ raise NotImplementedError
+
+ @property
+ def handle(self) -> HANDLE:
+ "The handle used for registering this pipe in the event loop."
+ return self._event
+
+ def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]:
+ """
+ Return a context manager that makes this input active in the current
+ event loop.
+ """
+ return attach_win32_input(self, input_ready_callback)
+
+ def detach(self) -> ContextManager[None]:
+ """
+ Return a context manager that makes sure that this input is not active
+ in the current event loop.
+ """
+ return detach_win32_input(self)
+
+ def read_keys(self) -> list[KeyPress]:
+ "Read list of KeyPress."
+
+ # Return result.
+ result = self._buffer
+ self._buffer = []
+
+ # Reset event.
+ if not self._closed:
+ # (If closed, the event should not reset.)
+ windll.kernel32.ResetEvent(self._event)
+
+ return result
+
+ def flush_keys(self) -> list[KeyPress]:
+ """
+ Flush pending keys and return them.
+ (Used for flushing the 'escape' key.)
+ """
+ # Flush all pending keys. (This is most important to flush the vt100
+ # 'Escape' key early when nothing else follows.)
+ self.vt100_parser.flush()
+
+ # Return result.
+ result = self._buffer
+ self._buffer = []
+ return result
+
+ def send_bytes(self, data: bytes) -> None:
+ "Send bytes to the input."
+ self.send_text(data.decode("utf-8", "ignore"))
+
+ def send_text(self, text: str) -> None:
+ "Send text to the input."
+ if self._closed:
+ raise ValueError("Attempt to write into a closed pipe.")
+
+ # Pass it through our vt100 parser.
+ self.vt100_parser.feed(text)
+
+ # Set event.
+ windll.kernel32.SetEvent(self._event)
+
+ def raw_mode(self) -> ContextManager[None]:
+ return DummyContext()
+
+ def cooked_mode(self) -> ContextManager[None]:
+ return DummyContext()
+
+ def close(self) -> None:
+ "Close write-end of the pipe."
+ self._closed = True
+ windll.kernel32.SetEvent(self._event)
+
+ def typeahead_hash(self) -> str:
+ """
+ This needs to be unique for every `PipeInput`.
+ """
+ return f"pipe-input-{self._id}"
diff --git a/src/prompt_toolkit/key_binding/__init__.py b/src/prompt_toolkit/key_binding/__init__.py
new file mode 100644
index 0000000..c31746a
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/__init__.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from .key_bindings import (
+ ConditionalKeyBindings,
+ DynamicKeyBindings,
+ KeyBindings,
+ KeyBindingsBase,
+ merge_key_bindings,
+)
+from .key_processor import KeyPress, KeyPressEvent
+
+__all__ = [
+ # key_bindings.
+ "ConditionalKeyBindings",
+ "DynamicKeyBindings",
+ "KeyBindings",
+ "KeyBindingsBase",
+ "merge_key_bindings",
+ # key_processor
+ "KeyPress",
+ "KeyPressEvent",
+]
diff --git a/src/prompt_toolkit/key_binding/bindings/__init__.py b/src/prompt_toolkit/key_binding/bindings/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/__init__.py
diff --git a/src/prompt_toolkit/key_binding/bindings/auto_suggest.py b/src/prompt_toolkit/key_binding/bindings/auto_suggest.py
new file mode 100644
index 0000000..3d8a843
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/auto_suggest.py
@@ -0,0 +1,65 @@
+"""
+Key bindings for auto suggestion (for fish-style auto suggestion).
+"""
+from __future__ import annotations
+
+import re
+
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.filters import Condition, emacs_mode
+from prompt_toolkit.key_binding.key_bindings import KeyBindings
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+
+__all__ = [
+ "load_auto_suggest_bindings",
+]
+
+E = KeyPressEvent
+
+
+def load_auto_suggest_bindings() -> KeyBindings:
+ """
+ Key bindings for accepting auto suggestion text.
+
+ (This has to come after the Vi bindings, because they also have an
+ implementation for the "right arrow", but we really want the suggestion
+ binding when a suggestion is available.)
+ """
+ key_bindings = KeyBindings()
+ handle = key_bindings.add
+
+ @Condition
+ def suggestion_available() -> bool:
+ app = get_app()
+ return (
+ app.current_buffer.suggestion is not None
+ and len(app.current_buffer.suggestion.text) > 0
+ and app.current_buffer.document.is_cursor_at_the_end
+ )
+
+ @handle("c-f", filter=suggestion_available)
+ @handle("c-e", filter=suggestion_available)
+ @handle("right", filter=suggestion_available)
+ def _accept(event: E) -> None:
+ """
+ Accept suggestion.
+ """
+ b = event.current_buffer
+ suggestion = b.suggestion
+
+ if suggestion:
+ b.insert_text(suggestion.text)
+
+ @handle("escape", "f", filter=suggestion_available & emacs_mode)
+ def _fill(event: E) -> None:
+ """
+ Fill partial suggestion.
+ """
+ b = event.current_buffer
+ suggestion = b.suggestion
+
+ if suggestion:
+ t = re.split(r"([^\s/]+(?:\s+|/))", suggestion.text)
+ b.insert_text(next(x for x in t if x))
+
+ return key_bindings
diff --git a/src/prompt_toolkit/key_binding/bindings/basic.py b/src/prompt_toolkit/key_binding/bindings/basic.py
new file mode 100644
index 0000000..084548d
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/basic.py
@@ -0,0 +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.key_binding.key_processor import KeyPress, KeyPressEvent
+from prompt_toolkit.keys import Keys
+
+from ..key_bindings import KeyBindings
+from .named_commands import get_by_name
+
+__all__ = [
+ "load_basic_bindings",
+]
+
+E = KeyPressEvent
+
+
+def if_no_repeat(event: E) -> bool:
+ """Callable that returns True when the previous event was delivered to
+ another handler."""
+ return not event.is_repeat
+
+
+def load_basic_bindings() -> KeyBindings:
+ key_bindings = KeyBindings()
+ insert_mode = vi_insert_mode | emacs_insert_mode
+ handle = key_bindings.add
+
+ @handle("c-a")
+ @handle("c-b")
+ @handle("c-c")
+ @handle("c-d")
+ @handle("c-e")
+ @handle("c-f")
+ @handle("c-g")
+ @handle("c-h")
+ @handle("c-i")
+ @handle("c-j")
+ @handle("c-k")
+ @handle("c-l")
+ @handle("c-m")
+ @handle("c-n")
+ @handle("c-o")
+ @handle("c-p")
+ @handle("c-q")
+ @handle("c-r")
+ @handle("c-s")
+ @handle("c-t")
+ @handle("c-u")
+ @handle("c-v")
+ @handle("c-w")
+ @handle("c-x")
+ @handle("c-y")
+ @handle("c-z")
+ @handle("f1")
+ @handle("f2")
+ @handle("f3")
+ @handle("f4")
+ @handle("f5")
+ @handle("f6")
+ @handle("f7")
+ @handle("f8")
+ @handle("f9")
+ @handle("f10")
+ @handle("f11")
+ @handle("f12")
+ @handle("f13")
+ @handle("f14")
+ @handle("f15")
+ @handle("f16")
+ @handle("f17")
+ @handle("f18")
+ @handle("f19")
+ @handle("f20")
+ @handle("f21")
+ @handle("f22")
+ @handle("f23")
+ @handle("f24")
+ @handle("c-@") # Also c-space.
+ @handle("c-\\")
+ @handle("c-]")
+ @handle("c-^")
+ @handle("c-_")
+ @handle("backspace")
+ @handle("up")
+ @handle("down")
+ @handle("right")
+ @handle("left")
+ @handle("s-up")
+ @handle("s-down")
+ @handle("s-right")
+ @handle("s-left")
+ @handle("home")
+ @handle("end")
+ @handle("s-home")
+ @handle("s-end")
+ @handle("delete")
+ @handle("s-delete")
+ @handle("c-delete")
+ @handle("pageup")
+ @handle("pagedown")
+ @handle("s-tab")
+ @handle("tab")
+ @handle("c-s-left")
+ @handle("c-s-right")
+ @handle("c-s-home")
+ @handle("c-s-end")
+ @handle("c-left")
+ @handle("c-right")
+ @handle("c-up")
+ @handle("c-down")
+ @handle("c-home")
+ @handle("c-end")
+ @handle("insert")
+ @handle("s-insert")
+ @handle("c-insert")
+ @handle("<sigint>")
+ @handle(Keys.Ignore)
+ def _ignore(event: E) -> None:
+ """
+ First, for any of these keys, Don't do anything by default. Also don't
+ catch them in the 'Any' handler which will insert them as data.
+
+ If people want to insert these characters as a literal, they can always
+ do by doing a quoted insert. (ControlQ in emacs mode, ControlV in Vi
+ mode.)
+ """
+ pass
+
+ # Readline-style bindings.
+ handle("home")(get_by_name("beginning-of-line"))
+ handle("end")(get_by_name("end-of-line"))
+ handle("left")(get_by_name("backward-char"))
+ handle("right")(get_by_name("forward-char"))
+ handle("c-up")(get_by_name("previous-history"))
+ handle("c-down")(get_by_name("next-history"))
+ handle("c-l")(get_by_name("clear-screen"))
+
+ handle("c-k", filter=insert_mode)(get_by_name("kill-line"))
+ handle("c-u", filter=insert_mode)(get_by_name("unix-line-discard"))
+ handle("backspace", filter=insert_mode, save_before=if_no_repeat)(
+ get_by_name("backward-delete-char")
+ )
+ handle("delete", filter=insert_mode, save_before=if_no_repeat)(
+ get_by_name("delete-char")
+ )
+ handle("c-delete", filter=insert_mode, save_before=if_no_repeat)(
+ get_by_name("delete-char")
+ )
+ handle(Keys.Any, filter=insert_mode, save_before=if_no_repeat)(
+ get_by_name("self-insert")
+ )
+ handle("c-t", filter=insert_mode)(get_by_name("transpose-chars"))
+ handle("c-i", filter=insert_mode)(get_by_name("menu-complete"))
+ handle("s-tab", filter=insert_mode)(get_by_name("menu-complete-backward"))
+
+ # Control-W should delete, using whitespace as separator, while M-Del
+ # should delete using [^a-zA-Z0-9] as a boundary.
+ handle("c-w", filter=insert_mode)(get_by_name("unix-word-rubout"))
+
+ handle("pageup", filter=~has_selection)(get_by_name("previous-history"))
+ handle("pagedown", filter=~has_selection)(get_by_name("next-history"))
+
+ # CTRL keys.
+
+ @Condition
+ def has_text_before_cursor() -> bool:
+ return bool(get_app().current_buffer.text)
+
+ handle("c-d", filter=has_text_before_cursor & insert_mode)(
+ get_by_name("delete-char")
+ )
+
+ @handle("enter", filter=insert_mode & is_multiline)
+ def _newline(event: E) -> None:
+ """
+ Newline (in case of multiline input.
+ """
+ event.current_buffer.newline(copy_margin=not in_paste_mode())
+
+ @handle("c-j")
+ def _newline2(event: E) -> None:
+ r"""
+ By default, handle \n as if it were a \r (enter).
+ (It appears that some terminals send \n instead of \r when pressing
+ enter. - at least the Linux subsystem for Windows.)
+ """
+ event.key_processor.feed(KeyPress(Keys.ControlM, "\r"), first=True)
+
+ # Delete the word before the cursor.
+
+ @handle("up")
+ def _go_up(event: E) -> None:
+ event.current_buffer.auto_up(count=event.arg)
+
+ @handle("down")
+ def _go_down(event: E) -> None:
+ event.current_buffer.auto_down(count=event.arg)
+
+ @handle("delete", filter=has_selection)
+ def _cut(event: E) -> None:
+ data = event.current_buffer.cut_selection()
+ event.app.clipboard.set_data(data)
+
+ # Global bindings.
+
+ @handle("c-z")
+ def _insert_ctrl_z(event: E) -> None:
+ """
+ By default, control-Z should literally insert Ctrl-Z.
+ (Ansi Ctrl-Z, code 26 in MSDOS means End-Of-File.
+ In a Python REPL for instance, it's possible to type
+ Control-Z followed by enter to quit.)
+
+ When the system bindings are loaded and suspend-to-background is
+ supported, that will override this binding.
+ """
+ event.current_buffer.insert_text(event.data)
+
+ @handle(Keys.BracketedPaste)
+ def _paste(event: E) -> None:
+ """
+ Pasting from clipboard.
+ """
+ data = event.data
+
+ # Be sure to use \n as line ending.
+ # Some terminals (Like iTerm2) seem to paste \r\n line endings in a
+ # bracketed paste. See: https://github.com/ipython/ipython/issues/9737
+ data = data.replace("\r\n", "\n")
+ data = data.replace("\r", "\n")
+
+ event.current_buffer.insert_text(data)
+
+ @Condition
+ def in_quoted_insert() -> bool:
+ return get_app().quoted_insert
+
+ @handle(Keys.Any, filter=in_quoted_insert, eager=True)
+ def _insert_text(event: E) -> None:
+ """
+ Handle quoted insert.
+ """
+ event.current_buffer.insert_text(event.data, overwrite=False)
+ event.app.quoted_insert = False
+
+ return key_bindings
diff --git a/src/prompt_toolkit/key_binding/bindings/completion.py b/src/prompt_toolkit/key_binding/bindings/completion.py
new file mode 100644
index 0000000..016821f
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/completion.py
@@ -0,0 +1,205 @@
+"""
+Key binding handlers for displaying completions.
+"""
+from __future__ import annotations
+
+import asyncio
+import math
+from typing import TYPE_CHECKING
+
+from prompt_toolkit.application.run_in_terminal import in_terminal
+from prompt_toolkit.completion import (
+ CompleteEvent,
+ Completion,
+ get_common_complete_suffix,
+)
+from prompt_toolkit.formatted_text import StyleAndTextTuples
+from prompt_toolkit.key_binding.key_bindings import KeyBindings
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.utils import get_cwidth
+
+if TYPE_CHECKING:
+ from prompt_toolkit.application import Application
+ from prompt_toolkit.shortcuts import PromptSession
+
+__all__ = [
+ "generate_completions",
+ "display_completions_like_readline",
+]
+
+E = KeyPressEvent
+
+
+def generate_completions(event: E) -> None:
+ r"""
+ Tab-completion: where the first tab completes the common suffix and the
+ second tab lists all the completions.
+ """
+ b = event.current_buffer
+
+ # When already navigating through completions, select the next one.
+ if b.complete_state:
+ b.complete_next()
+ else:
+ b.start_completion(insert_common_part=True)
+
+
+def display_completions_like_readline(event: E) -> None:
+ """
+ Key binding handler for readline-style tab completion.
+ This is meant to be as similar as possible to the way how readline displays
+ completions.
+
+ Generate the completions immediately (blocking) and display them above the
+ prompt in columns.
+
+ Usage::
+
+ # Call this handler when 'Tab' has been pressed.
+ key_bindings.add(Keys.ControlI)(display_completions_like_readline)
+ """
+ # Request completions.
+ b = event.current_buffer
+ if b.completer is None:
+ return
+ complete_event = CompleteEvent(completion_requested=True)
+ completions = list(b.completer.get_completions(b.document, complete_event))
+
+ # Calculate the common suffix.
+ common_suffix = get_common_complete_suffix(b.document, completions)
+
+ # One completion: insert it.
+ if len(completions) == 1:
+ b.delete_before_cursor(-completions[0].start_position)
+ b.insert_text(completions[0].text)
+ # Multiple completions with common part.
+ elif common_suffix:
+ b.insert_text(common_suffix)
+ # Otherwise: display all completions.
+ elif completions:
+ _display_completions_like_readline(event.app, completions)
+
+
+def _display_completions_like_readline(
+ app: Application[object], completions: list[Completion]
+) -> asyncio.Task[None]:
+ """
+ Display the list of completions in columns above the prompt.
+ This will ask for a confirmation if there are too many completions to fit
+ on a single page and provide a paginator to walk through them.
+ """
+ from prompt_toolkit.formatted_text import to_formatted_text
+ from prompt_toolkit.shortcuts.prompt import create_confirm_session
+
+ # Get terminal dimensions.
+ term_size = app.output.get_size()
+ term_width = term_size.columns
+ term_height = term_size.rows
+
+ # Calculate amount of required columns/rows for displaying the
+ # completions. (Keep in mind that completions are displayed
+ # alphabetically column-wise.)
+ max_compl_width = min(
+ term_width, max(get_cwidth(c.display_text) for c in completions) + 1
+ )
+ column_count = max(1, term_width // max_compl_width)
+ completions_per_page = column_count * (term_height - 1)
+ page_count = int(math.ceil(len(completions) / float(completions_per_page)))
+ # Note: math.ceil can return float on Python2.
+
+ def display(page: int) -> None:
+ # Display completions.
+ page_completions = completions[
+ page * completions_per_page : (page + 1) * completions_per_page
+ ]
+
+ page_row_count = int(math.ceil(len(page_completions) / float(column_count)))
+ page_columns = [
+ page_completions[i * page_row_count : (i + 1) * page_row_count]
+ for i in range(column_count)
+ ]
+
+ result: StyleAndTextTuples = []
+
+ for r in range(page_row_count):
+ for c in range(column_count):
+ try:
+ completion = page_columns[c][r]
+ style = "class:readline-like-completions.completion " + (
+ completion.style or ""
+ )
+
+ result.extend(to_formatted_text(completion.display, style=style))
+
+ # Add padding.
+ padding = max_compl_width - get_cwidth(completion.display_text)
+ result.append((completion.style, " " * padding))
+ except IndexError:
+ pass
+ result.append(("", "\n"))
+
+ app.print_text(to_formatted_text(result, "class:readline-like-completions"))
+
+ # User interaction through an application generator function.
+ async def run_compl() -> None:
+ "Coroutine."
+ async with in_terminal(render_cli_done=True):
+ if len(completions) > completions_per_page:
+ # Ask confirmation if it doesn't fit on the screen.
+ confirm = await create_confirm_session(
+ f"Display all {len(completions)} possibilities?",
+ ).prompt_async()
+
+ if confirm:
+ # Display pages.
+ for page in range(page_count):
+ display(page)
+
+ if page != page_count - 1:
+ # Display --MORE-- and go to the next page.
+ show_more = await _create_more_session(
+ "--MORE--"
+ ).prompt_async()
+
+ if not show_more:
+ return
+ else:
+ app.output.flush()
+ else:
+ # Display all completions.
+ display(0)
+
+ return app.create_background_task(run_compl())
+
+
+def _create_more_session(message: str = "--MORE--") -> PromptSession[bool]:
+ """
+ Create a `PromptSession` object for displaying the "--MORE--".
+ """
+ from prompt_toolkit.shortcuts import PromptSession
+
+ bindings = KeyBindings()
+
+ @bindings.add(" ")
+ @bindings.add("y")
+ @bindings.add("Y")
+ @bindings.add(Keys.ControlJ)
+ @bindings.add(Keys.ControlM)
+ @bindings.add(Keys.ControlI) # Tab.
+ def _yes(event: E) -> None:
+ event.app.exit(result=True)
+
+ @bindings.add("n")
+ @bindings.add("N")
+ @bindings.add("q")
+ @bindings.add("Q")
+ @bindings.add(Keys.ControlC)
+ def _no(event: E) -> None:
+ event.app.exit(result=False)
+
+ @bindings.add(Keys.Any)
+ def _ignore(event: E) -> None:
+ "Disable inserting of text."
+
+ return PromptSession(message, key_bindings=bindings, erase_when_done=True)
diff --git a/src/prompt_toolkit/key_binding/bindings/cpr.py b/src/prompt_toolkit/key_binding/bindings/cpr.py
new file mode 100644
index 0000000..cd9df0a
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/cpr.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.keys import Keys
+
+from ..key_bindings import KeyBindings
+
+__all__ = [
+ "load_cpr_bindings",
+]
+
+E = KeyPressEvent
+
+
+def load_cpr_bindings() -> KeyBindings:
+ key_bindings = KeyBindings()
+
+ @key_bindings.add(Keys.CPRResponse, save_before=lambda e: False)
+ def _(event: E) -> None:
+ """
+ Handle incoming Cursor-Position-Request response.
+ """
+ # The incoming data looks like u'\x1b[35;1R'
+ # Parse row/col information.
+ row, col = map(int, event.data[2:-1].split(";"))
+
+ # Report absolute cursor position to the renderer.
+ event.app.renderer.report_absolute_cursor_row(row)
+
+ return key_bindings
diff --git a/src/prompt_toolkit/key_binding/bindings/emacs.py b/src/prompt_toolkit/key_binding/bindings/emacs.py
new file mode 100644
index 0000000..80a66fd
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/emacs.py
@@ -0,0 +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.key_binding.key_bindings import Binding
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.selection import SelectionType
+
+from ..key_bindings import ConditionalKeyBindings, KeyBindings, KeyBindingsBase
+from .named_commands import get_by_name
+
+__all__ = [
+ "load_emacs_bindings",
+ "load_emacs_search_bindings",
+ "load_emacs_shift_selection_bindings",
+]
+
+E = KeyPressEvent
+
+
+def load_emacs_bindings() -> KeyBindingsBase:
+ """
+ Some e-macs extensions.
+ """
+ # Overview of Readline emacs commands:
+ # http://www.catonmat.net/download/readline-emacs-editing-mode-cheat-sheet.pdf
+ key_bindings = KeyBindings()
+ handle = key_bindings.add
+
+ insert_mode = emacs_insert_mode
+
+ @handle("escape")
+ def _esc(event: E) -> None:
+ """
+ By default, ignore escape key.
+
+ (If we don't put this here, and Esc is followed by a key which sequence
+ is not handled, we'll insert an Escape character in the input stream.
+ Something we don't want and happens to easily in emacs mode.
+ Further, people can always use ControlQ to do a quoted insert.)
+ """
+ pass
+
+ handle("c-a")(get_by_name("beginning-of-line"))
+ handle("c-b")(get_by_name("backward-char"))
+ handle("c-delete", filter=insert_mode)(get_by_name("kill-word"))
+ handle("c-e")(get_by_name("end-of-line"))
+ handle("c-f")(get_by_name("forward-char"))
+ handle("c-left")(get_by_name("backward-word"))
+ handle("c-right")(get_by_name("forward-word"))
+ handle("c-x", "r", "y", filter=insert_mode)(get_by_name("yank"))
+ handle("c-y", filter=insert_mode)(get_by_name("yank"))
+ handle("escape", "b")(get_by_name("backward-word"))
+ handle("escape", "c", filter=insert_mode)(get_by_name("capitalize-word"))
+ handle("escape", "d", filter=insert_mode)(get_by_name("kill-word"))
+ handle("escape", "f")(get_by_name("forward-word"))
+ handle("escape", "l", filter=insert_mode)(get_by_name("downcase-word"))
+ handle("escape", "u", filter=insert_mode)(get_by_name("uppercase-word"))
+ handle("escape", "y", filter=insert_mode)(get_by_name("yank-pop"))
+ handle("escape", "backspace", filter=insert_mode)(get_by_name("backward-kill-word"))
+ handle("escape", "\\", filter=insert_mode)(get_by_name("delete-horizontal-space"))
+
+ handle("c-home")(get_by_name("beginning-of-buffer"))
+ handle("c-end")(get_by_name("end-of-buffer"))
+
+ handle("c-_", save_before=(lambda e: False), filter=insert_mode)(
+ get_by_name("undo")
+ )
+
+ handle("c-x", "c-u", save_before=(lambda e: False), filter=insert_mode)(
+ get_by_name("undo")
+ )
+
+ handle("escape", "<", filter=~has_selection)(get_by_name("beginning-of-history"))
+ handle("escape", ">", filter=~has_selection)(get_by_name("end-of-history"))
+
+ handle("escape", ".", filter=insert_mode)(get_by_name("yank-last-arg"))
+ handle("escape", "_", filter=insert_mode)(get_by_name("yank-last-arg"))
+ handle("escape", "c-y", filter=insert_mode)(get_by_name("yank-nth-arg"))
+ handle("escape", "#", filter=insert_mode)(get_by_name("insert-comment"))
+ handle("c-o")(get_by_name("operate-and-get-next"))
+
+ # ControlQ does a quoted insert. Not that for vt100 terminals, you have to
+ # disable flow control by running ``stty -ixon``, otherwise Ctrl-Q and
+ # Ctrl-S are captured by the terminal.
+ handle("c-q", filter=~has_selection)(get_by_name("quoted-insert"))
+
+ handle("c-x", "(")(get_by_name("start-kbd-macro"))
+ handle("c-x", ")")(get_by_name("end-kbd-macro"))
+ handle("c-x", "e")(get_by_name("call-last-kbd-macro"))
+
+ @handle("c-n")
+ def _next(event: E) -> None:
+ "Next line."
+ event.current_buffer.auto_down()
+
+ @handle("c-p")
+ def _prev(event: E) -> None:
+ "Previous line."
+ event.current_buffer.auto_up(count=event.arg)
+
+ def handle_digit(c: str) -> None:
+ """
+ Handle input of arguments.
+ The first number needs to be preceded by escape.
+ """
+
+ @handle(c, filter=has_arg)
+ @handle("escape", c)
+ def _(event: E) -> None:
+ event.append_to_arg_count(c)
+
+ for c in "0123456789":
+ handle_digit(c)
+
+ @handle("escape", "-", filter=~has_arg)
+ def _meta_dash(event: E) -> None:
+ """"""
+ if event._arg is None:
+ event.append_to_arg_count("-")
+
+ @handle("-", filter=Condition(lambda: get_app().key_processor.arg == "-"))
+ def _dash(event: E) -> None:
+ """
+ When '-' is typed again, after exactly '-' has been given as an
+ argument, ignore this.
+ """
+ event.app.key_processor.arg = "-"
+
+ @Condition
+ def is_returnable() -> bool:
+ return get_app().current_buffer.is_returnable
+
+ # Meta + Enter: always accept input.
+ handle("escape", "enter", filter=insert_mode & is_returnable)(
+ get_by_name("accept-line")
+ )
+
+ # Enter: accept input in single line mode.
+ handle("enter", filter=insert_mode & is_returnable & ~is_multiline)(
+ get_by_name("accept-line")
+ )
+
+ def character_search(buff: Buffer, char: str, count: int) -> None:
+ if count < 0:
+ match = buff.document.find_backwards(
+ char, in_current_line=True, count=-count
+ )
+ else:
+ match = buff.document.find(char, in_current_line=True, count=count)
+
+ if match is not None:
+ buff.cursor_position += match
+
+ @handle("c-]", Keys.Any)
+ def _goto_char(event: E) -> None:
+ "When Ctl-] + a character is pressed. go to that character."
+ # Also named 'character-search'
+ character_search(event.current_buffer, event.data, event.arg)
+
+ @handle("escape", "c-]", Keys.Any)
+ def _goto_char_backwards(event: E) -> None:
+ "Like Ctl-], but backwards."
+ # Also named 'character-search-backward'
+ character_search(event.current_buffer, event.data, -event.arg)
+
+ @handle("escape", "a")
+ def _prev_sentence(event: E) -> None:
+ "Previous sentence."
+ # TODO:
+
+ @handle("escape", "e")
+ def _end_of_sentence(event: E) -> None:
+ "Move to end of sentence."
+ # TODO:
+
+ @handle("escape", "t", filter=insert_mode)
+ def _swap_characters(event: E) -> None:
+ """
+ Swap the last two words before the cursor.
+ """
+ # TODO
+
+ @handle("escape", "*", filter=insert_mode)
+ def _insert_all_completions(event: E) -> None:
+ """
+ `meta-*`: Insert all possible completions of the preceding text.
+ """
+ buff = event.current_buffer
+
+ # List all completions.
+ complete_event = CompleteEvent(text_inserted=False, completion_requested=True)
+ completions = list(
+ buff.completer.get_completions(buff.document, complete_event)
+ )
+
+ # Insert them.
+ text_to_insert = " ".join(c.text for c in completions)
+ buff.insert_text(text_to_insert)
+
+ @handle("c-x", "c-x")
+ def _toggle_start_end(event: E) -> None:
+ """
+ Move cursor back and forth between the start and end of the current
+ line.
+ """
+ buffer = event.current_buffer
+
+ if buffer.document.is_cursor_at_the_end_of_line:
+ buffer.cursor_position += buffer.document.get_start_of_line_position(
+ after_whitespace=False
+ )
+ else:
+ buffer.cursor_position += buffer.document.get_end_of_line_position()
+
+ @handle("c-@") # Control-space or Control-@
+ def _start_selection(event: E) -> None:
+ """
+ Start of the selection (if the current buffer is not empty).
+ """
+ # Take the current cursor position as the start of this selection.
+ buff = event.current_buffer
+ if buff.text:
+ buff.start_selection(selection_type=SelectionType.CHARACTERS)
+
+ @handle("c-g", filter=~has_selection)
+ def _cancel(event: E) -> None:
+ """
+ Control + G: Cancel completion menu and validation state.
+ """
+ event.current_buffer.complete_state = None
+ event.current_buffer.validation_error = None
+
+ @handle("c-g", filter=has_selection)
+ def _cancel_selection(event: E) -> None:
+ """
+ Cancel selection.
+ """
+ event.current_buffer.exit_selection()
+
+ @handle("c-w", filter=has_selection)
+ @handle("c-x", "r", "k", filter=has_selection)
+ def _cut(event: E) -> None:
+ """
+ Cut selected text.
+ """
+ data = event.current_buffer.cut_selection()
+ event.app.clipboard.set_data(data)
+
+ @handle("escape", "w", filter=has_selection)
+ def _copy(event: E) -> None:
+ """
+ Copy selected text.
+ """
+ data = event.current_buffer.copy_selection()
+ event.app.clipboard.set_data(data)
+
+ @handle("escape", "left")
+ def _start_of_word(event: E) -> None:
+ """
+ Cursor to start of previous word.
+ """
+ buffer = event.current_buffer
+ buffer.cursor_position += (
+ buffer.document.find_previous_word_beginning(count=event.arg) or 0
+ )
+
+ @handle("escape", "right")
+ def _start_next_word(event: E) -> None:
+ """
+ Cursor to start of next word.
+ """
+ buffer = event.current_buffer
+ buffer.cursor_position += (
+ buffer.document.find_next_word_beginning(count=event.arg)
+ or buffer.document.get_end_of_document_position()
+ )
+
+ @handle("escape", "/", filter=insert_mode)
+ def _complete(event: E) -> None:
+ """
+ M-/: Complete.
+ """
+ b = event.current_buffer
+ if b.complete_state:
+ b.complete_next()
+ else:
+ b.start_completion(select_first=True)
+
+ @handle("c-c", ">", filter=has_selection)
+ def _indent(event: E) -> None:
+ """
+ Indent selected text.
+ """
+ buffer = event.current_buffer
+
+ buffer.cursor_position += buffer.document.get_start_of_line_position(
+ after_whitespace=True
+ )
+
+ from_, to = buffer.document.selection_range()
+ from_, _ = buffer.document.translate_index_to_position(from_)
+ to, _ = buffer.document.translate_index_to_position(to)
+
+ indent(buffer, from_, to + 1, count=event.arg)
+
+ @handle("c-c", "<", filter=has_selection)
+ def _unindent(event: E) -> None:
+ """
+ Unindent selected text.
+ """
+ buffer = event.current_buffer
+
+ from_, to = buffer.document.selection_range()
+ from_, _ = buffer.document.translate_index_to_position(from_)
+ to, _ = buffer.document.translate_index_to_position(to)
+
+ unindent(buffer, from_, to + 1, count=event.arg)
+
+ return ConditionalKeyBindings(key_bindings, emacs_mode)
+
+
+def load_emacs_search_bindings() -> KeyBindingsBase:
+ key_bindings = KeyBindings()
+ handle = key_bindings.add
+ from . import search
+
+ # NOTE: We don't bind 'Escape' to 'abort_search'. The reason is that we
+ # want Alt+Enter to accept input directly in incremental search mode.
+ # Instead, we have double escape.
+
+ handle("c-r")(search.start_reverse_incremental_search)
+ handle("c-s")(search.start_forward_incremental_search)
+
+ handle("c-c")(search.abort_search)
+ handle("c-g")(search.abort_search)
+ handle("c-r")(search.reverse_incremental_search)
+ handle("c-s")(search.forward_incremental_search)
+ handle("up")(search.reverse_incremental_search)
+ handle("down")(search.forward_incremental_search)
+ handle("enter")(search.accept_search)
+
+ # Handling of escape.
+ handle("escape", eager=True)(search.accept_search)
+
+ # Like Readline, it's more natural to accept the search when escape has
+ # been pressed, however instead the following two bindings could be used
+ # instead.
+ # #handle('escape', 'escape', eager=True)(search.abort_search)
+ # #handle('escape', 'enter', eager=True)(search.accept_search_and_accept_input)
+
+ # If Read-only: also include the following key bindings:
+
+ # '/' and '?' key bindings for searching, just like Vi mode.
+ handle("?", filter=is_read_only & ~vi_search_direction_reversed)(
+ search.start_reverse_incremental_search
+ )
+ handle("/", filter=is_read_only & ~vi_search_direction_reversed)(
+ search.start_forward_incremental_search
+ )
+ handle("?", filter=is_read_only & vi_search_direction_reversed)(
+ search.start_forward_incremental_search
+ )
+ handle("/", filter=is_read_only & vi_search_direction_reversed)(
+ search.start_reverse_incremental_search
+ )
+
+ @handle("n", filter=is_read_only)
+ def _jump_next(event: E) -> None:
+ "Jump to next match."
+ event.current_buffer.apply_search(
+ event.app.current_search_state,
+ include_current_position=False,
+ count=event.arg,
+ )
+
+ @handle("N", filter=is_read_only)
+ def _jump_prev(event: E) -> None:
+ "Jump to previous match."
+ event.current_buffer.apply_search(
+ ~event.app.current_search_state,
+ include_current_position=False,
+ count=event.arg,
+ )
+
+ return ConditionalKeyBindings(key_bindings, emacs_mode)
+
+
+def load_emacs_shift_selection_bindings() -> KeyBindingsBase:
+ """
+ Bindings to select text with shift + cursor movements
+ """
+
+ key_bindings = KeyBindings()
+ handle = key_bindings.add
+
+ def unshift_move(event: E) -> None:
+ """
+ Used for the shift selection mode. When called with
+ a shift + movement key press event, moves the cursor
+ as if shift is not pressed.
+ """
+ key = event.key_sequence[0].key
+
+ if key == Keys.ShiftUp:
+ event.current_buffer.auto_up(count=event.arg)
+ return
+ if key == Keys.ShiftDown:
+ event.current_buffer.auto_down(count=event.arg)
+ return
+
+ # the other keys are handled through their readline command
+ key_to_command: dict[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
new file mode 100644
index 0000000..24aa3ce
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/focus.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+
+__all__ = [
+ "focus_next",
+ "focus_previous",
+]
+
+E = KeyPressEvent
+
+
+def focus_next(event: E) -> None:
+ """
+ Focus the next visible Window.
+ (Often bound to the `Tab` key.)
+ """
+ event.app.layout.focus_next()
+
+
+def focus_previous(event: E) -> None:
+ """
+ Focus the previous visible Window.
+ (Often bound to the `BackTab` key.)
+ """
+ event.app.layout.focus_previous()
diff --git a/src/prompt_toolkit/key_binding/bindings/mouse.py b/src/prompt_toolkit/key_binding/bindings/mouse.py
new file mode 100644
index 0000000..cb426ce
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/mouse.py
@@ -0,0 +1,348 @@
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING
+
+from prompt_toolkit.data_structures import Point
+from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.mouse_events import (
+ MouseButton,
+ MouseEvent,
+ MouseEventType,
+ MouseModifier,
+)
+
+from ..key_bindings import KeyBindings
+
+if TYPE_CHECKING:
+ from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
+
+__all__ = [
+ "load_mouse_bindings",
+]
+
+E = KeyPressEvent
+
+# fmt: off
+SCROLL_UP = MouseEventType.SCROLL_UP
+SCROLL_DOWN = MouseEventType.SCROLL_DOWN
+MOUSE_DOWN = MouseEventType.MOUSE_DOWN
+MOUSE_MOVE = MouseEventType.MOUSE_MOVE
+MOUSE_UP = MouseEventType.MOUSE_UP
+
+NO_MODIFIER : frozenset[MouseModifier] = frozenset()
+SHIFT : frozenset[MouseModifier] = frozenset({MouseModifier.SHIFT})
+ALT : frozenset[MouseModifier] = frozenset({MouseModifier.ALT})
+SHIFT_ALT : frozenset[MouseModifier] = frozenset({MouseModifier.SHIFT, MouseModifier.ALT})
+CONTROL : frozenset[MouseModifier] = frozenset({MouseModifier.CONTROL})
+SHIFT_CONTROL : frozenset[MouseModifier] = frozenset({MouseModifier.SHIFT, MouseModifier.CONTROL})
+ALT_CONTROL : frozenset[MouseModifier] = frozenset({MouseModifier.ALT, MouseModifier.CONTROL})
+SHIFT_ALT_CONTROL: frozenset[MouseModifier] = frozenset({MouseModifier.SHIFT, MouseModifier.ALT, MouseModifier.CONTROL})
+UNKNOWN_MODIFIER : frozenset[MouseModifier] = frozenset()
+
+LEFT = MouseButton.LEFT
+MIDDLE = MouseButton.MIDDLE
+RIGHT = MouseButton.RIGHT
+NO_BUTTON = MouseButton.NONE
+UNKNOWN_BUTTON = MouseButton.UNKNOWN
+
+xterm_sgr_mouse_events = {
+ ( 0, "m") : (LEFT, MOUSE_UP, NO_MODIFIER), # left_up 0+ + + =0
+ ( 4, "m") : (LEFT, MOUSE_UP, SHIFT), # left_up Shift 0+4+ + =4
+ ( 8, "m") : (LEFT, MOUSE_UP, ALT), # left_up Alt 0+ +8+ =8
+ (12, "m") : (LEFT, MOUSE_UP, SHIFT_ALT), # left_up Shift Alt 0+4+8+ =12
+ (16, "m") : (LEFT, MOUSE_UP, CONTROL), # left_up Control 0+ + +16=16
+ (20, "m") : (LEFT, MOUSE_UP, SHIFT_CONTROL), # left_up Shift Control 0+4+ +16=20
+ (24, "m") : (LEFT, MOUSE_UP, ALT_CONTROL), # left_up Alt Control 0+ +8+16=24
+ (28, "m") : (LEFT, MOUSE_UP, SHIFT_ALT_CONTROL), # left_up Shift Alt Control 0+4+8+16=28
+
+ ( 1, "m") : (MIDDLE, MOUSE_UP, NO_MODIFIER), # middle_up 1+ + + =1
+ ( 5, "m") : (MIDDLE, MOUSE_UP, SHIFT), # middle_up Shift 1+4+ + =5
+ ( 9, "m") : (MIDDLE, MOUSE_UP, ALT), # middle_up Alt 1+ +8+ =9
+ (13, "m") : (MIDDLE, MOUSE_UP, SHIFT_ALT), # middle_up Shift Alt 1+4+8+ =13
+ (17, "m") : (MIDDLE, MOUSE_UP, CONTROL), # middle_up Control 1+ + +16=17
+ (21, "m") : (MIDDLE, MOUSE_UP, SHIFT_CONTROL), # middle_up Shift Control 1+4+ +16=21
+ (25, "m") : (MIDDLE, MOUSE_UP, ALT_CONTROL), # middle_up Alt Control 1+ +8+16=25
+ (29, "m") : (MIDDLE, MOUSE_UP, SHIFT_ALT_CONTROL), # middle_up Shift Alt Control 1+4+8+16=29
+
+ ( 2, "m") : (RIGHT, MOUSE_UP, NO_MODIFIER), # right_up 2+ + + =2
+ ( 6, "m") : (RIGHT, MOUSE_UP, SHIFT), # right_up Shift 2+4+ + =6
+ (10, "m") : (RIGHT, MOUSE_UP, ALT), # right_up Alt 2+ +8+ =10
+ (14, "m") : (RIGHT, MOUSE_UP, SHIFT_ALT), # right_up Shift Alt 2+4+8+ =14
+ (18, "m") : (RIGHT, MOUSE_UP, CONTROL), # right_up Control 2+ + +16=18
+ (22, "m") : (RIGHT, MOUSE_UP, SHIFT_CONTROL), # right_up Shift Control 2+4+ +16=22
+ (26, "m") : (RIGHT, MOUSE_UP, ALT_CONTROL), # right_up Alt Control 2+ +8+16=26
+ (30, "m") : (RIGHT, MOUSE_UP, SHIFT_ALT_CONTROL), # right_up Shift Alt Control 2+4+8+16=30
+
+ ( 0, "M") : (LEFT, MOUSE_DOWN, NO_MODIFIER), # left_down 0+ + + =0
+ ( 4, "M") : (LEFT, MOUSE_DOWN, SHIFT), # left_down Shift 0+4+ + =4
+ ( 8, "M") : (LEFT, MOUSE_DOWN, ALT), # left_down Alt 0+ +8+ =8
+ (12, "M") : (LEFT, MOUSE_DOWN, SHIFT_ALT), # left_down Shift Alt 0+4+8+ =12
+ (16, "M") : (LEFT, MOUSE_DOWN, CONTROL), # left_down Control 0+ + +16=16
+ (20, "M") : (LEFT, MOUSE_DOWN, SHIFT_CONTROL), # left_down Shift Control 0+4+ +16=20
+ (24, "M") : (LEFT, MOUSE_DOWN, ALT_CONTROL), # left_down Alt Control 0+ +8+16=24
+ (28, "M") : (LEFT, MOUSE_DOWN, SHIFT_ALT_CONTROL), # left_down Shift Alt Control 0+4+8+16=28
+
+ ( 1, "M") : (MIDDLE, MOUSE_DOWN, NO_MODIFIER), # middle_down 1+ + + =1
+ ( 5, "M") : (MIDDLE, MOUSE_DOWN, SHIFT), # middle_down Shift 1+4+ + =5
+ ( 9, "M") : (MIDDLE, MOUSE_DOWN, ALT), # middle_down Alt 1+ +8+ =9
+ (13, "M") : (MIDDLE, MOUSE_DOWN, SHIFT_ALT), # middle_down Shift Alt 1+4+8+ =13
+ (17, "M") : (MIDDLE, MOUSE_DOWN, CONTROL), # middle_down Control 1+ + +16=17
+ (21, "M") : (MIDDLE, MOUSE_DOWN, SHIFT_CONTROL), # middle_down Shift Control 1+4+ +16=21
+ (25, "M") : (MIDDLE, MOUSE_DOWN, ALT_CONTROL), # middle_down Alt Control 1+ +8+16=25
+ (29, "M") : (MIDDLE, MOUSE_DOWN, SHIFT_ALT_CONTROL), # middle_down Shift Alt Control 1+4+8+16=29
+
+ ( 2, "M") : (RIGHT, MOUSE_DOWN, NO_MODIFIER), # right_down 2+ + + =2
+ ( 6, "M") : (RIGHT, MOUSE_DOWN, SHIFT), # right_down Shift 2+4+ + =6
+ (10, "M") : (RIGHT, MOUSE_DOWN, ALT), # right_down Alt 2+ +8+ =10
+ (14, "M") : (RIGHT, MOUSE_DOWN, SHIFT_ALT), # right_down Shift Alt 2+4+8+ =14
+ (18, "M") : (RIGHT, MOUSE_DOWN, CONTROL), # right_down Control 2+ + +16=18
+ (22, "M") : (RIGHT, MOUSE_DOWN, SHIFT_CONTROL), # right_down Shift Control 2+4+ +16=22
+ (26, "M") : (RIGHT, MOUSE_DOWN, ALT_CONTROL), # right_down Alt Control 2+ +8+16=26
+ (30, "M") : (RIGHT, MOUSE_DOWN, SHIFT_ALT_CONTROL), # right_down Shift Alt Control 2+4+8+16=30
+
+ (32, "M") : (LEFT, MOUSE_MOVE, NO_MODIFIER), # left_drag 32+ + + =32
+ (36, "M") : (LEFT, MOUSE_MOVE, SHIFT), # left_drag Shift 32+4+ + =36
+ (40, "M") : (LEFT, MOUSE_MOVE, ALT), # left_drag Alt 32+ +8+ =40
+ (44, "M") : (LEFT, MOUSE_MOVE, SHIFT_ALT), # left_drag Shift Alt 32+4+8+ =44
+ (48, "M") : (LEFT, MOUSE_MOVE, CONTROL), # left_drag Control 32+ + +16=48
+ (52, "M") : (LEFT, MOUSE_MOVE, SHIFT_CONTROL), # left_drag Shift Control 32+4+ +16=52
+ (56, "M") : (LEFT, MOUSE_MOVE, ALT_CONTROL), # left_drag Alt Control 32+ +8+16=56
+ (60, "M") : (LEFT, MOUSE_MOVE, SHIFT_ALT_CONTROL), # left_drag Shift Alt Control 32+4+8+16=60
+
+ (33, "M") : (MIDDLE, MOUSE_MOVE, NO_MODIFIER), # middle_drag 33+ + + =33
+ (37, "M") : (MIDDLE, MOUSE_MOVE, SHIFT), # middle_drag Shift 33+4+ + =37
+ (41, "M") : (MIDDLE, MOUSE_MOVE, ALT), # middle_drag Alt 33+ +8+ =41
+ (45, "M") : (MIDDLE, MOUSE_MOVE, SHIFT_ALT), # middle_drag Shift Alt 33+4+8+ =45
+ (49, "M") : (MIDDLE, MOUSE_MOVE, CONTROL), # middle_drag Control 33+ + +16=49
+ (53, "M") : (MIDDLE, MOUSE_MOVE, SHIFT_CONTROL), # middle_drag Shift Control 33+4+ +16=53
+ (57, "M") : (MIDDLE, MOUSE_MOVE, ALT_CONTROL), # middle_drag Alt Control 33+ +8+16=57
+ (61, "M") : (MIDDLE, MOUSE_MOVE, SHIFT_ALT_CONTROL), # middle_drag Shift Alt Control 33+4+8+16=61
+
+ (34, "M") : (RIGHT, MOUSE_MOVE, NO_MODIFIER), # right_drag 34+ + + =34
+ (38, "M") : (RIGHT, MOUSE_MOVE, SHIFT), # right_drag Shift 34+4+ + =38
+ (42, "M") : (RIGHT, MOUSE_MOVE, ALT), # right_drag Alt 34+ +8+ =42
+ (46, "M") : (RIGHT, MOUSE_MOVE, SHIFT_ALT), # right_drag Shift Alt 34+4+8+ =46
+ (50, "M") : (RIGHT, MOUSE_MOVE, CONTROL), # right_drag Control 34+ + +16=50
+ (54, "M") : (RIGHT, MOUSE_MOVE, SHIFT_CONTROL), # right_drag Shift Control 34+4+ +16=54
+ (58, "M") : (RIGHT, MOUSE_MOVE, ALT_CONTROL), # right_drag Alt Control 34+ +8+16=58
+ (62, "M") : (RIGHT, MOUSE_MOVE, SHIFT_ALT_CONTROL), # right_drag Shift Alt Control 34+4+8+16=62
+
+ (35, "M") : (NO_BUTTON, MOUSE_MOVE, NO_MODIFIER), # none_drag 35+ + + =35
+ (39, "M") : (NO_BUTTON, MOUSE_MOVE, SHIFT), # none_drag Shift 35+4+ + =39
+ (43, "M") : (NO_BUTTON, MOUSE_MOVE, ALT), # none_drag Alt 35+ +8+ =43
+ (47, "M") : (NO_BUTTON, MOUSE_MOVE, SHIFT_ALT), # none_drag Shift Alt 35+4+8+ =47
+ (51, "M") : (NO_BUTTON, MOUSE_MOVE, CONTROL), # none_drag Control 35+ + +16=51
+ (55, "M") : (NO_BUTTON, MOUSE_MOVE, SHIFT_CONTROL), # none_drag Shift Control 35+4+ +16=55
+ (59, "M") : (NO_BUTTON, MOUSE_MOVE, ALT_CONTROL), # none_drag Alt Control 35+ +8+16=59
+ (63, "M") : (NO_BUTTON, MOUSE_MOVE, SHIFT_ALT_CONTROL), # none_drag Shift Alt Control 35+4+8+16=63
+
+ (64, "M") : (NO_BUTTON, SCROLL_UP, NO_MODIFIER), # scroll_up 64+ + + =64
+ (68, "M") : (NO_BUTTON, SCROLL_UP, SHIFT), # scroll_up Shift 64+4+ + =68
+ (72, "M") : (NO_BUTTON, SCROLL_UP, ALT), # scroll_up Alt 64+ +8+ =72
+ (76, "M") : (NO_BUTTON, SCROLL_UP, SHIFT_ALT), # scroll_up Shift Alt 64+4+8+ =76
+ (80, "M") : (NO_BUTTON, SCROLL_UP, CONTROL), # scroll_up Control 64+ + +16=80
+ (84, "M") : (NO_BUTTON, SCROLL_UP, SHIFT_CONTROL), # scroll_up Shift Control 64+4+ +16=84
+ (88, "M") : (NO_BUTTON, SCROLL_UP, ALT_CONTROL), # scroll_up Alt Control 64+ +8+16=88
+ (92, "M") : (NO_BUTTON, SCROLL_UP, SHIFT_ALT_CONTROL), # scroll_up Shift Alt Control 64+4+8+16=92
+
+ (65, "M") : (NO_BUTTON, SCROLL_DOWN, NO_MODIFIER), # scroll_down 64+ + + =65
+ (69, "M") : (NO_BUTTON, SCROLL_DOWN, SHIFT), # scroll_down Shift 64+4+ + =69
+ (73, "M") : (NO_BUTTON, SCROLL_DOWN, ALT), # scroll_down Alt 64+ +8+ =73
+ (77, "M") : (NO_BUTTON, SCROLL_DOWN, SHIFT_ALT), # scroll_down Shift Alt 64+4+8+ =77
+ (81, "M") : (NO_BUTTON, SCROLL_DOWN, CONTROL), # scroll_down Control 64+ + +16=81
+ (85, "M") : (NO_BUTTON, SCROLL_DOWN, SHIFT_CONTROL), # scroll_down Shift Control 64+4+ +16=85
+ (89, "M") : (NO_BUTTON, SCROLL_DOWN, ALT_CONTROL), # scroll_down Alt Control 64+ +8+16=89
+ (93, "M") : (NO_BUTTON, SCROLL_DOWN, SHIFT_ALT_CONTROL), # scroll_down Shift Alt Control 64+4+8+16=93
+}
+
+typical_mouse_events = {
+ 32: (LEFT , MOUSE_DOWN , UNKNOWN_MODIFIER),
+ 33: (MIDDLE , MOUSE_DOWN , UNKNOWN_MODIFIER),
+ 34: (RIGHT , MOUSE_DOWN , UNKNOWN_MODIFIER),
+ 35: (UNKNOWN_BUTTON , MOUSE_UP , UNKNOWN_MODIFIER),
+
+ 64: (LEFT , MOUSE_MOVE , UNKNOWN_MODIFIER),
+ 65: (MIDDLE , MOUSE_MOVE , UNKNOWN_MODIFIER),
+ 66: (RIGHT , MOUSE_MOVE , UNKNOWN_MODIFIER),
+ 67: (NO_BUTTON , MOUSE_MOVE , UNKNOWN_MODIFIER),
+
+ 96: (NO_BUTTON , SCROLL_UP , UNKNOWN_MODIFIER),
+ 97: (NO_BUTTON , SCROLL_DOWN, UNKNOWN_MODIFIER),
+}
+
+urxvt_mouse_events={
+ 32: (UNKNOWN_BUTTON, MOUSE_DOWN , UNKNOWN_MODIFIER),
+ 35: (UNKNOWN_BUTTON, MOUSE_UP , UNKNOWN_MODIFIER),
+ 96: (NO_BUTTON , SCROLL_UP , UNKNOWN_MODIFIER),
+ 97: (NO_BUTTON , SCROLL_DOWN, UNKNOWN_MODIFIER),
+}
+# fmt:on
+
+
+def load_mouse_bindings() -> KeyBindings:
+ """
+ Key bindings, required for mouse support.
+ (Mouse events enter through the key binding system.)
+ """
+ key_bindings = KeyBindings()
+
+ @key_bindings.add(Keys.Vt100MouseEvent)
+ def _(event: E) -> NotImplementedOrNone:
+ """
+ Handling of incoming mouse event.
+ """
+ # TypicaL: "eSC[MaB*"
+ # Urxvt: "Esc[96;14;13M"
+ # Xterm SGR: "Esc[<64;85;12M"
+
+ # Parse incoming packet.
+ if event.data[2] == "M":
+ # Typical.
+ mouse_event, x, y = map(ord, event.data[3:])
+
+ # TODO: Is it possible to add modifiers here?
+ mouse_button, mouse_event_type, mouse_modifiers = typical_mouse_events[
+ mouse_event
+ ]
+
+ # Handle situations where `PosixStdinReader` used surrogateescapes.
+ if x >= 0xDC00:
+ x -= 0xDC00
+ if y >= 0xDC00:
+ y -= 0xDC00
+
+ x -= 32
+ y -= 32
+ else:
+ # Urxvt and Xterm SGR.
+ # When the '<' is not present, we are not using the Xterm SGR mode,
+ # but Urxvt instead.
+ data = event.data[2:]
+ if data[:1] == "<":
+ sgr = True
+ data = data[1:]
+ else:
+ sgr = False
+
+ # Extract coordinates.
+ mouse_event, x, y = map(int, data[:-1].split(";"))
+ m = data[-1]
+
+ # Parse event type.
+ if sgr:
+ try:
+ (
+ mouse_button,
+ mouse_event_type,
+ mouse_modifiers,
+ ) = xterm_sgr_mouse_events[mouse_event, m]
+ except KeyError:
+ return NotImplemented
+
+ else:
+ # Some other terminals, like urxvt, Hyper terminal, ...
+ (
+ mouse_button,
+ mouse_event_type,
+ mouse_modifiers,
+ ) = urxvt_mouse_events.get(
+ mouse_event, (UNKNOWN_BUTTON, MOUSE_MOVE, UNKNOWN_MODIFIER)
+ )
+
+ x -= 1
+ y -= 1
+
+ # Only handle mouse events when we know the window height.
+ if event.app.renderer.height_is_known and mouse_event_type is not None:
+ # Take region above the layout into account. The reported
+ # coordinates are absolute to the visible part of the terminal.
+ from prompt_toolkit.renderer import HeightIsUnknownError
+
+ try:
+ y -= event.app.renderer.rows_above_layout
+ except HeightIsUnknownError:
+ return NotImplemented
+
+ # Call the mouse handler from the renderer.
+
+ # Note: This can return `NotImplemented` if no mouse handler was
+ # found for this position, or if no repainting needs to
+ # happen. this way, we avoid excessive repaints during mouse
+ # movements.
+ handler = event.app.renderer.mouse_handlers.mouse_handlers[y][x]
+ return handler(
+ MouseEvent(
+ position=Point(x=x, y=y),
+ event_type=mouse_event_type,
+ button=mouse_button,
+ modifiers=mouse_modifiers,
+ )
+ )
+
+ return NotImplemented
+
+ @key_bindings.add(Keys.ScrollUp)
+ def _scroll_up(event: E) -> None:
+ """
+ Scroll up event without cursor position.
+ """
+ # We don't receive a cursor position, so we don't know which window to
+ # scroll. Just send an 'up' key press instead.
+ event.key_processor.feed(KeyPress(Keys.Up), first=True)
+
+ @key_bindings.add(Keys.ScrollDown)
+ def _scroll_down(event: E) -> None:
+ """
+ Scroll down event without cursor position.
+ """
+ event.key_processor.feed(KeyPress(Keys.Down), first=True)
+
+ @key_bindings.add(Keys.WindowsMouseEvent)
+ def _mouse(event: E) -> NotImplementedOrNone:
+ """
+ Handling of mouse events for Windows.
+ """
+ # This key binding should only exist for Windows.
+ if sys.platform == "win32":
+ # Parse data.
+ pieces = event.data.split(";")
+
+ button = MouseButton(pieces[0])
+ event_type = MouseEventType(pieces[1])
+ x = int(pieces[2])
+ y = int(pieces[3])
+
+ # Make coordinates absolute to the visible part of the terminal.
+ output = event.app.renderer.output
+
+ from prompt_toolkit.output.win32 import Win32Output
+ from prompt_toolkit.output.windows10 import Windows10_Output
+
+ if isinstance(output, (Win32Output, Windows10_Output)):
+ screen_buffer_info = output.get_win32_screen_buffer_info()
+ rows_above_cursor = (
+ screen_buffer_info.dwCursorPosition.Y
+ - event.app.renderer._cursor_pos.y
+ )
+ y -= rows_above_cursor
+
+ # Call the mouse event handler.
+ # (Can return `NotImplemented`.)
+ handler = event.app.renderer.mouse_handlers.mouse_handlers[y][x]
+
+ return handler(
+ MouseEvent(
+ position=Point(x=x, y=y),
+ event_type=event_type,
+ button=button,
+ modifiers=UNKNOWN_MODIFIER,
+ )
+ )
+
+ # No mouse handler found. Return `NotImplemented` so that we don't
+ # invalidate the UI.
+ return NotImplemented
+
+ return key_bindings
diff --git a/src/prompt_toolkit/key_binding/bindings/named_commands.py b/src/prompt_toolkit/key_binding/bindings/named_commands.py
new file mode 100644
index 0000000..d836116
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/named_commands.py
@@ -0,0 +1,690 @@
+"""
+Key bindings which are also known by GNU Readline by the given names.
+
+See: http://www.delorie.com/gnu/docs/readline/rlman_13.html
+"""
+from __future__ import annotations
+
+from typing import Callable, TypeVar, Union, cast
+
+from prompt_toolkit.document import Document
+from prompt_toolkit.enums import EditingMode
+from prompt_toolkit.key_binding.key_bindings import Binding, key_binding
+from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.layout.controls import BufferControl
+from prompt_toolkit.search import SearchDirection
+from prompt_toolkit.selection import PasteMode
+
+from .completion import display_completions_like_readline, generate_completions
+
+__all__ = [
+ "get_by_name",
+]
+
+
+# Typing.
+_Handler = Callable[[KeyPressEvent], None]
+_HandlerOrBinding = Union[_Handler, Binding]
+_T = TypeVar("_T", bound=_HandlerOrBinding)
+E = KeyPressEvent
+
+
+# Registry that maps the Readline command names to their handlers.
+_readline_commands: dict[str, Binding] = {}
+
+
+def register(name: str) -> Callable[[_T], _T]:
+ """
+ Store handler in the `_readline_commands` dictionary.
+ """
+
+ def decorator(handler: _T) -> _T:
+ "`handler` is a callable or Binding."
+ if isinstance(handler, Binding):
+ _readline_commands[name] = handler
+ else:
+ _readline_commands[name] = key_binding()(cast(_Handler, handler))
+
+ return handler
+
+ return decorator
+
+
+def get_by_name(name: str) -> Binding:
+ """
+ Return the handler for the (Readline) command with the given name.
+ """
+ try:
+ return _readline_commands[name]
+ except KeyError as e:
+ raise KeyError("Unknown Readline command: %r" % name) from e
+
+
+#
+# Commands for moving
+# See: http://www.delorie.com/gnu/docs/readline/rlman_14.html
+#
+
+
+@register("beginning-of-buffer")
+def beginning_of_buffer(event: E) -> None:
+ """
+ Move to the start of the buffer.
+ """
+ buff = event.current_buffer
+ buff.cursor_position = 0
+
+
+@register("end-of-buffer")
+def end_of_buffer(event: E) -> None:
+ """
+ Move to the end of the buffer.
+ """
+ buff = event.current_buffer
+ buff.cursor_position = len(buff.text)
+
+
+@register("beginning-of-line")
+def beginning_of_line(event: E) -> None:
+ """
+ Move to the start of the current line.
+ """
+ buff = event.current_buffer
+ buff.cursor_position += buff.document.get_start_of_line_position(
+ after_whitespace=False
+ )
+
+
+@register("end-of-line")
+def end_of_line(event: E) -> None:
+ """
+ Move to the end of the line.
+ """
+ buff = event.current_buffer
+ buff.cursor_position += buff.document.get_end_of_line_position()
+
+
+@register("forward-char")
+def forward_char(event: E) -> None:
+ """
+ Move forward a character.
+ """
+ buff = event.current_buffer
+ buff.cursor_position += buff.document.get_cursor_right_position(count=event.arg)
+
+
+@register("backward-char")
+def backward_char(event: E) -> None:
+ "Move back a character."
+ buff = event.current_buffer
+ buff.cursor_position += buff.document.get_cursor_left_position(count=event.arg)
+
+
+@register("forward-word")
+def forward_word(event: E) -> None:
+ """
+ Move forward to the end of the next word. Words are composed of letters and
+ digits.
+ """
+ buff = event.current_buffer
+ pos = buff.document.find_next_word_ending(count=event.arg)
+
+ if pos:
+ buff.cursor_position += pos
+
+
+@register("backward-word")
+def backward_word(event: E) -> None:
+ """
+ Move back to the start of the current or previous word. Words are composed
+ of letters and digits.
+ """
+ buff = event.current_buffer
+ pos = buff.document.find_previous_word_beginning(count=event.arg)
+
+ if pos:
+ buff.cursor_position += pos
+
+
+@register("clear-screen")
+def clear_screen(event: E) -> None:
+ """
+ Clear the screen and redraw everything at the top of the screen.
+ """
+ event.app.renderer.clear()
+
+
+@register("redraw-current-line")
+def redraw_current_line(event: E) -> None:
+ """
+ Refresh the current line.
+ (Readline defines this command, but prompt-toolkit doesn't have it.)
+ """
+ pass
+
+
+#
+# Commands for manipulating the history.
+# See: http://www.delorie.com/gnu/docs/readline/rlman_15.html
+#
+
+
+@register("accept-line")
+def accept_line(event: E) -> None:
+ """
+ Accept the line regardless of where the cursor is.
+ """
+ event.current_buffer.validate_and_handle()
+
+
+@register("previous-history")
+def previous_history(event: E) -> None:
+ """
+ Move `back` through the history list, fetching the previous command.
+ """
+ event.current_buffer.history_backward(count=event.arg)
+
+
+@register("next-history")
+def next_history(event: E) -> None:
+ """
+ Move `forward` through the history list, fetching the next command.
+ """
+ event.current_buffer.history_forward(count=event.arg)
+
+
+@register("beginning-of-history")
+def beginning_of_history(event: E) -> None:
+ """
+ Move to the first line in the history.
+ """
+ event.current_buffer.go_to_history(0)
+
+
+@register("end-of-history")
+def end_of_history(event: E) -> None:
+ """
+ Move to the end of the input history, i.e., the line currently being entered.
+ """
+ event.current_buffer.history_forward(count=10**100)
+ buff = event.current_buffer
+ buff.go_to_history(len(buff._working_lines) - 1)
+
+
+@register("reverse-search-history")
+def reverse_search_history(event: E) -> None:
+ """
+ Search backward starting at the current line and moving `up` through
+ the history as necessary. This is an incremental search.
+ """
+ control = event.app.layout.current_control
+
+ if isinstance(control, BufferControl) and control.search_buffer_control:
+ event.app.current_search_state.direction = SearchDirection.BACKWARD
+ event.app.layout.current_control = control.search_buffer_control
+
+
+#
+# Commands for changing text
+#
+
+
+@register("end-of-file")
+def end_of_file(event: E) -> None:
+ """
+ Exit.
+ """
+ event.app.exit()
+
+
+@register("delete-char")
+def delete_char(event: E) -> None:
+ """
+ Delete character before the cursor.
+ """
+ deleted = event.current_buffer.delete(count=event.arg)
+ if not deleted:
+ event.app.output.bell()
+
+
+@register("backward-delete-char")
+def backward_delete_char(event: E) -> None:
+ """
+ Delete the character behind the cursor.
+ """
+ if event.arg < 0:
+ # When a negative argument has been given, this should delete in front
+ # of the cursor.
+ deleted = event.current_buffer.delete(count=-event.arg)
+ else:
+ deleted = event.current_buffer.delete_before_cursor(count=event.arg)
+
+ if not deleted:
+ event.app.output.bell()
+
+
+@register("self-insert")
+def self_insert(event: E) -> None:
+ """
+ Insert yourself.
+ """
+ event.current_buffer.insert_text(event.data * event.arg)
+
+
+@register("transpose-chars")
+def transpose_chars(event: E) -> None:
+ """
+ Emulate Emacs transpose-char behavior: at the beginning of the buffer,
+ do nothing. At the end of a line or buffer, swap the characters before
+ the cursor. Otherwise, move the cursor right, and then swap the
+ characters before the cursor.
+ """
+ b = event.current_buffer
+ p = b.cursor_position
+ if p == 0:
+ return
+ elif p == len(b.text) or b.text[p] == "\n":
+ b.swap_characters_before_cursor()
+ else:
+ b.cursor_position += b.document.get_cursor_right_position()
+ b.swap_characters_before_cursor()
+
+
+@register("uppercase-word")
+def uppercase_word(event: E) -> None:
+ """
+ Uppercase the current (or following) word.
+ """
+ buff = event.current_buffer
+
+ for i in range(event.arg):
+ pos = buff.document.find_next_word_ending()
+ words = buff.document.text_after_cursor[:pos]
+ buff.insert_text(words.upper(), overwrite=True)
+
+
+@register("downcase-word")
+def downcase_word(event: E) -> None:
+ """
+ Lowercase the current (or following) word.
+ """
+ buff = event.current_buffer
+
+ for i in range(event.arg): # XXX: not DRY: see meta_c and meta_u!!
+ pos = buff.document.find_next_word_ending()
+ words = buff.document.text_after_cursor[:pos]
+ buff.insert_text(words.lower(), overwrite=True)
+
+
+@register("capitalize-word")
+def capitalize_word(event: E) -> None:
+ """
+ Capitalize the current (or following) word.
+ """
+ buff = event.current_buffer
+
+ for i in range(event.arg):
+ pos = buff.document.find_next_word_ending()
+ words = buff.document.text_after_cursor[:pos]
+ buff.insert_text(words.title(), overwrite=True)
+
+
+@register("quoted-insert")
+def quoted_insert(event: E) -> None:
+ """
+ Add the next character typed to the line verbatim. This is how to insert
+ key sequences like C-q, for example.
+ """
+ event.app.quoted_insert = True
+
+
+#
+# Killing and yanking.
+#
+
+
+@register("kill-line")
+def kill_line(event: E) -> None:
+ """
+ Kill the text from the cursor to the end of the line.
+
+ If we are at the end of the line, this should remove the newline.
+ (That way, it is possible to delete multiple lines by executing this
+ command multiple times.)
+ """
+ buff = event.current_buffer
+ if event.arg < 0:
+ deleted = buff.delete_before_cursor(
+ count=-buff.document.get_start_of_line_position()
+ )
+ else:
+ if buff.document.current_char == "\n":
+ deleted = buff.delete(1)
+ else:
+ deleted = buff.delete(count=buff.document.get_end_of_line_position())
+ event.app.clipboard.set_text(deleted)
+
+
+@register("kill-word")
+def kill_word(event: E) -> None:
+ """
+ Kill from point to the end of the current word, or if between words, to the
+ end of the next word. Word boundaries are the same as forward-word.
+ """
+ buff = event.current_buffer
+ pos = buff.document.find_next_word_ending(count=event.arg)
+
+ if pos:
+ deleted = buff.delete(count=pos)
+
+ if event.is_repeat:
+ deleted = event.app.clipboard.get_data().text + deleted
+
+ event.app.clipboard.set_text(deleted)
+
+
+@register("unix-word-rubout")
+def unix_word_rubout(event: E, WORD: bool = True) -> None:
+ """
+ Kill the word behind point, using whitespace as a word boundary.
+ Usually bound to ControlW.
+ """
+ buff = event.current_buffer
+ pos = buff.document.find_start_of_previous_word(count=event.arg, WORD=WORD)
+
+ if pos is None:
+ # Nothing found? delete until the start of the document. (The
+ # input starts with whitespace and no words were found before the
+ # cursor.)
+ pos = -buff.cursor_position
+
+ if pos:
+ deleted = buff.delete_before_cursor(count=-pos)
+
+ # If the previous key press was also Control-W, concatenate deleted
+ # text.
+ if event.is_repeat:
+ deleted += event.app.clipboard.get_data().text
+
+ event.app.clipboard.set_text(deleted)
+ else:
+ # Nothing to delete. Bell.
+ event.app.output.bell()
+
+
+@register("backward-kill-word")
+def backward_kill_word(event: E) -> None:
+ """
+ Kills the word before point, using "not a letter nor a digit" as a word boundary.
+ Usually bound to M-Del or M-Backspace.
+ """
+ unix_word_rubout(event, WORD=False)
+
+
+@register("delete-horizontal-space")
+def delete_horizontal_space(event: E) -> None:
+ """
+ Delete all spaces and tabs around point.
+ """
+ buff = event.current_buffer
+ text_before_cursor = buff.document.text_before_cursor
+ text_after_cursor = buff.document.text_after_cursor
+
+ delete_before = len(text_before_cursor) - len(text_before_cursor.rstrip("\t "))
+ delete_after = len(text_after_cursor) - len(text_after_cursor.lstrip("\t "))
+
+ buff.delete_before_cursor(count=delete_before)
+ buff.delete(count=delete_after)
+
+
+@register("unix-line-discard")
+def unix_line_discard(event: E) -> None:
+ """
+ Kill backward from the cursor to the beginning of the current line.
+ """
+ buff = event.current_buffer
+
+ if buff.document.cursor_position_col == 0 and buff.document.cursor_position > 0:
+ buff.delete_before_cursor(count=1)
+ else:
+ deleted = buff.delete_before_cursor(
+ count=-buff.document.get_start_of_line_position()
+ )
+ event.app.clipboard.set_text(deleted)
+
+
+@register("yank")
+def yank(event: E) -> None:
+ """
+ Paste before cursor.
+ """
+ event.current_buffer.paste_clipboard_data(
+ event.app.clipboard.get_data(), count=event.arg, paste_mode=PasteMode.EMACS
+ )
+
+
+@register("yank-nth-arg")
+def yank_nth_arg(event: E) -> None:
+ """
+ Insert the first argument of the previous command. With an argument, insert
+ the nth word from the previous command (start counting at 0).
+ """
+ n = event.arg if event.arg_present else None
+ event.current_buffer.yank_nth_arg(n)
+
+
+@register("yank-last-arg")
+def yank_last_arg(event: E) -> None:
+ """
+ Like `yank_nth_arg`, but if no argument has been given, yank the last word
+ of each line.
+ """
+ n = event.arg if event.arg_present else None
+ event.current_buffer.yank_last_arg(n)
+
+
+@register("yank-pop")
+def yank_pop(event: E) -> None:
+ """
+ Rotate the kill ring, and yank the new top. Only works following yank or
+ yank-pop.
+ """
+ buff = event.current_buffer
+ doc_before_paste = buff.document_before_paste
+ clipboard = event.app.clipboard
+
+ if doc_before_paste is not None:
+ buff.document = doc_before_paste
+ clipboard.rotate()
+ buff.paste_clipboard_data(clipboard.get_data(), paste_mode=PasteMode.EMACS)
+
+
+#
+# Completion.
+#
+
+
+@register("complete")
+def complete(event: E) -> None:
+ """
+ Attempt to perform completion.
+ """
+ display_completions_like_readline(event)
+
+
+@register("menu-complete")
+def menu_complete(event: E) -> None:
+ """
+ Generate completions, or go to the next completion. (This is the default
+ way of completing input in prompt_toolkit.)
+ """
+ generate_completions(event)
+
+
+@register("menu-complete-backward")
+def menu_complete_backward(event: E) -> None:
+ """
+ Move backward through the list of possible completions.
+ """
+ event.current_buffer.complete_previous()
+
+
+#
+# Keyboard macros.
+#
+
+
+@register("start-kbd-macro")
+def start_kbd_macro(event: E) -> None:
+ """
+ Begin saving the characters typed into the current keyboard macro.
+ """
+ event.app.emacs_state.start_macro()
+
+
+@register("end-kbd-macro")
+def end_kbd_macro(event: E) -> None:
+ """
+ Stop saving the characters typed into the current keyboard macro and save
+ the definition.
+ """
+ event.app.emacs_state.end_macro()
+
+
+@register("call-last-kbd-macro")
+@key_binding(record_in_macro=False)
+def call_last_kbd_macro(event: E) -> None:
+ """
+ Re-execute the last keyboard macro defined, by making the characters in the
+ macro appear as if typed at the keyboard.
+
+ Notice that we pass `record_in_macro=False`. This ensures that the 'c-x e'
+ key sequence doesn't appear in the recording itself. This function inserts
+ the body of the called macro back into the KeyProcessor, so these keys will
+ be added later on to the macro of their handlers have `record_in_macro=True`.
+ """
+ # Insert the macro.
+ macro = event.app.emacs_state.macro
+
+ if macro:
+ event.app.key_processor.feed_multiple(macro, first=True)
+
+
+@register("print-last-kbd-macro")
+def print_last_kbd_macro(event: E) -> None:
+ """
+ Print the last keyboard macro.
+ """
+
+ # TODO: Make the format suitable for the inputrc file.
+ def print_macro() -> None:
+ macro = event.app.emacs_state.macro
+ if macro:
+ for k in macro:
+ print(k)
+
+ from prompt_toolkit.application.run_in_terminal import run_in_terminal
+
+ run_in_terminal(print_macro)
+
+
+#
+# Miscellaneous Commands.
+#
+
+
+@register("undo")
+def undo(event: E) -> None:
+ """
+ Incremental undo.
+ """
+ event.current_buffer.undo()
+
+
+@register("insert-comment")
+def insert_comment(event: E) -> None:
+ """
+ Without numeric argument, comment all lines.
+ With numeric argument, uncomment all lines.
+ In any case accept the input.
+ """
+ buff = event.current_buffer
+
+ # Transform all lines.
+ if event.arg != 1:
+
+ def change(line: str) -> str:
+ return line[1:] if line.startswith("#") else line
+
+ else:
+
+ def change(line: str) -> str:
+ return "#" + line
+
+ buff.document = Document(
+ text="\n".join(map(change, buff.text.splitlines())), cursor_position=0
+ )
+
+ # Accept input.
+ buff.validate_and_handle()
+
+
+@register("vi-editing-mode")
+def vi_editing_mode(event: E) -> None:
+ """
+ Switch to Vi editing mode.
+ """
+ event.app.editing_mode = EditingMode.VI
+
+
+@register("emacs-editing-mode")
+def emacs_editing_mode(event: E) -> None:
+ """
+ Switch to Emacs editing mode.
+ """
+ event.app.editing_mode = EditingMode.EMACS
+
+
+@register("prefix-meta")
+def prefix_meta(event: E) -> None:
+ """
+ Metafy the next character typed. This is for keyboards without a meta key.
+
+ Sometimes people also want to bind other keys to Meta, e.g. 'jj'::
+
+ key_bindings.add_key_binding('j', 'j', filter=ViInsertMode())(prefix_meta)
+ """
+ # ('first' should be true, because we want to insert it at the current
+ # position in the queue.)
+ event.app.key_processor.feed(KeyPress(Keys.Escape), first=True)
+
+
+@register("operate-and-get-next")
+def operate_and_get_next(event: E) -> None:
+ """
+ Accept the current line for execution and fetch the next line relative to
+ the current line from the history for editing.
+ """
+ buff = event.current_buffer
+ new_index = buff.working_index + 1
+
+ # Accept the current input. (This will also redraw the interface in the
+ # 'done' state.)
+ buff.validate_and_handle()
+
+ # Set the new index at the start of the next run.
+ def set_working_index() -> None:
+ if new_index < len(buff._working_lines):
+ buff.working_index = new_index
+
+ event.app.pre_run_callables.append(set_working_index)
+
+
+@register("edit-and-execute-command")
+def edit_and_execute(event: E) -> None:
+ """
+ Invoke an editor on the current command line, and accept the result.
+ """
+ buff = event.current_buffer
+ buff.open_in_editor(validate_and_handle=True)
diff --git a/src/prompt_toolkit/key_binding/bindings/open_in_editor.py b/src/prompt_toolkit/key_binding/bindings/open_in_editor.py
new file mode 100644
index 0000000..d156424
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/open_in_editor.py
@@ -0,0 +1,51 @@
+"""
+Open in editor key bindings.
+"""
+from __future__ import annotations
+
+from prompt_toolkit.filters import emacs_mode, has_selection, vi_navigation_mode
+
+from ..key_bindings import KeyBindings, KeyBindingsBase, merge_key_bindings
+from .named_commands import get_by_name
+
+__all__ = [
+ "load_open_in_editor_bindings",
+ "load_emacs_open_in_editor_bindings",
+ "load_vi_open_in_editor_bindings",
+]
+
+
+def load_open_in_editor_bindings() -> KeyBindingsBase:
+ """
+ Load both the Vi and emacs key bindings for handling edit-and-execute-command.
+ """
+ return merge_key_bindings(
+ [
+ load_emacs_open_in_editor_bindings(),
+ load_vi_open_in_editor_bindings(),
+ ]
+ )
+
+
+def load_emacs_open_in_editor_bindings() -> KeyBindings:
+ """
+ Pressing C-X C-E will open the buffer in an external editor.
+ """
+ key_bindings = KeyBindings()
+
+ key_bindings.add("c-x", "c-e", filter=emacs_mode & ~has_selection)(
+ get_by_name("edit-and-execute-command")
+ )
+
+ return key_bindings
+
+
+def load_vi_open_in_editor_bindings() -> KeyBindings:
+ """
+ Pressing 'v' in navigation mode will open the buffer in an external editor.
+ """
+ key_bindings = KeyBindings()
+ key_bindings.add("v", filter=vi_navigation_mode)(
+ get_by_name("edit-and-execute-command")
+ )
+ return key_bindings
diff --git a/src/prompt_toolkit/key_binding/bindings/page_navigation.py b/src/prompt_toolkit/key_binding/bindings/page_navigation.py
new file mode 100644
index 0000000..3918e14
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/page_navigation.py
@@ -0,0 +1,84 @@
+"""
+Key bindings for extra page navigation: bindings for up/down scrolling through
+long pages, like in Emacs or Vi.
+"""
+from __future__ import annotations
+
+from prompt_toolkit.filters import buffer_has_focus, emacs_mode, vi_mode
+from prompt_toolkit.key_binding.key_bindings import (
+ ConditionalKeyBindings,
+ KeyBindings,
+ KeyBindingsBase,
+ merge_key_bindings,
+)
+
+from .scroll import (
+ scroll_backward,
+ scroll_forward,
+ scroll_half_page_down,
+ scroll_half_page_up,
+ scroll_one_line_down,
+ scroll_one_line_up,
+ scroll_page_down,
+ scroll_page_up,
+)
+
+__all__ = [
+ "load_page_navigation_bindings",
+ "load_emacs_page_navigation_bindings",
+ "load_vi_page_navigation_bindings",
+]
+
+
+def load_page_navigation_bindings() -> KeyBindingsBase:
+ """
+ Load both the Vi and Emacs bindings for page navigation.
+ """
+ # Only enable when a `Buffer` is focused, otherwise, we would catch keys
+ # when another widget is focused (like for instance `c-d` in a
+ # ptterm.Terminal).
+ return ConditionalKeyBindings(
+ merge_key_bindings(
+ [
+ load_emacs_page_navigation_bindings(),
+ load_vi_page_navigation_bindings(),
+ ]
+ ),
+ buffer_has_focus,
+ )
+
+
+def load_emacs_page_navigation_bindings() -> KeyBindingsBase:
+ """
+ Key bindings, for scrolling up and down through pages.
+ This are separate bindings, because GNU readline doesn't have them.
+ """
+ key_bindings = KeyBindings()
+ handle = key_bindings.add
+
+ handle("c-v")(scroll_page_down)
+ handle("pagedown")(scroll_page_down)
+ handle("escape", "v")(scroll_page_up)
+ handle("pageup")(scroll_page_up)
+
+ return ConditionalKeyBindings(key_bindings, emacs_mode)
+
+
+def load_vi_page_navigation_bindings() -> KeyBindingsBase:
+ """
+ Key bindings, for scrolling up and down through pages.
+ This are separate bindings, because GNU readline doesn't have them.
+ """
+ key_bindings = KeyBindings()
+ handle = key_bindings.add
+
+ handle("c-f")(scroll_forward)
+ handle("c-b")(scroll_backward)
+ handle("c-d")(scroll_half_page_down)
+ handle("c-u")(scroll_half_page_up)
+ handle("c-e")(scroll_one_line_down)
+ handle("c-y")(scroll_one_line_up)
+ handle("pagedown")(scroll_page_down)
+ handle("pageup")(scroll_page_up)
+
+ return ConditionalKeyBindings(key_bindings, vi_mode)
diff --git a/src/prompt_toolkit/key_binding/bindings/scroll.py b/src/prompt_toolkit/key_binding/bindings/scroll.py
new file mode 100644
index 0000000..83a4be1
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/scroll.py
@@ -0,0 +1,189 @@
+"""
+Key bindings, for scrolling up and down through pages.
+
+This are separate bindings, because GNU readline doesn't have them, but
+they are very useful for navigating through long multiline buffers, like in
+Vi, Emacs, etc...
+"""
+from __future__ import annotations
+
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+
+__all__ = [
+ "scroll_forward",
+ "scroll_backward",
+ "scroll_half_page_up",
+ "scroll_half_page_down",
+ "scroll_one_line_up",
+ "scroll_one_line_down",
+]
+
+E = KeyPressEvent
+
+
+def scroll_forward(event: E, half: bool = False) -> None:
+ """
+ Scroll window down.
+ """
+ w = event.app.layout.current_window
+ b = event.app.current_buffer
+
+ if w and w.render_info:
+ info = w.render_info
+ ui_content = info.ui_content
+
+ # Height to scroll.
+ scroll_height = info.window_height
+ if half:
+ scroll_height //= 2
+
+ # Calculate how many lines is equivalent to that vertical space.
+ y = b.document.cursor_position_row + 1
+ height = 0
+ while y < ui_content.line_count:
+ line_height = info.get_height_for_line(y)
+
+ if height + line_height < scroll_height:
+ height += line_height
+ y += 1
+ else:
+ break
+
+ b.cursor_position = b.document.translate_row_col_to_index(y, 0)
+
+
+def scroll_backward(event: E, half: bool = False) -> None:
+ """
+ Scroll window up.
+ """
+ w = event.app.layout.current_window
+ b = event.app.current_buffer
+
+ if w and w.render_info:
+ info = w.render_info
+
+ # Height to scroll.
+ scroll_height = info.window_height
+ if half:
+ scroll_height //= 2
+
+ # Calculate how many lines is equivalent to that vertical space.
+ y = max(0, b.document.cursor_position_row - 1)
+ height = 0
+ while y > 0:
+ line_height = info.get_height_for_line(y)
+
+ if height + line_height < scroll_height:
+ height += line_height
+ y -= 1
+ else:
+ break
+
+ b.cursor_position = b.document.translate_row_col_to_index(y, 0)
+
+
+def scroll_half_page_down(event: E) -> None:
+ """
+ Same as ControlF, but only scroll half a page.
+ """
+ scroll_forward(event, half=True)
+
+
+def scroll_half_page_up(event: E) -> None:
+ """
+ Same as ControlB, but only scroll half a page.
+ """
+ scroll_backward(event, half=True)
+
+
+def scroll_one_line_down(event: E) -> None:
+ """
+ scroll_offset += 1
+ """
+ w = event.app.layout.current_window
+ b = event.app.current_buffer
+
+ if w:
+ # When the cursor is at the top, move to the next line. (Otherwise, only scroll.)
+ if w.render_info:
+ info = w.render_info
+
+ if w.vertical_scroll < info.content_height - info.window_height:
+ if info.cursor_position.y <= info.configured_scroll_offsets.top:
+ b.cursor_position += b.document.get_cursor_down_position()
+
+ w.vertical_scroll += 1
+
+
+def scroll_one_line_up(event: E) -> None:
+ """
+ scroll_offset -= 1
+ """
+ w = event.app.layout.current_window
+ b = event.app.current_buffer
+
+ if w:
+ # When the cursor is at the bottom, move to the previous line. (Otherwise, only scroll.)
+ if w.render_info:
+ info = w.render_info
+
+ if w.vertical_scroll > 0:
+ first_line_height = info.get_height_for_line(info.first_visible_line())
+
+ cursor_up = info.cursor_position.y - (
+ info.window_height
+ - 1
+ - first_line_height
+ - info.configured_scroll_offsets.bottom
+ )
+
+ # Move cursor up, as many steps as the height of the first line.
+ # TODO: not entirely correct yet, in case of line wrapping and many long lines.
+ for _ in range(max(0, cursor_up)):
+ b.cursor_position += b.document.get_cursor_up_position()
+
+ # Scroll window
+ w.vertical_scroll -= 1
+
+
+def scroll_page_down(event: E) -> None:
+ """
+ Scroll page down. (Prefer the cursor at the top of the page, after scrolling.)
+ """
+ w = event.app.layout.current_window
+ b = event.app.current_buffer
+
+ if w and w.render_info:
+ # Scroll down one page.
+ line_index = max(w.render_info.last_visible_line(), w.vertical_scroll + 1)
+ w.vertical_scroll = line_index
+
+ b.cursor_position = b.document.translate_row_col_to_index(line_index, 0)
+ b.cursor_position += b.document.get_start_of_line_position(
+ after_whitespace=True
+ )
+
+
+def scroll_page_up(event: E) -> None:
+ """
+ Scroll page up. (Prefer the cursor at the bottom of the page, after scrolling.)
+ """
+ w = event.app.layout.current_window
+ b = event.app.current_buffer
+
+ if w and w.render_info:
+ # Put cursor at the first visible line. (But make sure that the cursor
+ # moves at least one line up.)
+ line_index = max(
+ 0,
+ min(w.render_info.first_visible_line(), b.document.cursor_position_row - 1),
+ )
+
+ b.cursor_position = b.document.translate_row_col_to_index(line_index, 0)
+ b.cursor_position += b.document.get_start_of_line_position(
+ after_whitespace=True
+ )
+
+ # Set the scroll offset. We can safely set it to zero; the Window will
+ # make sure that it scrolls at least until the cursor becomes visible.
+ w.vertical_scroll = 0
diff --git a/src/prompt_toolkit/key_binding/bindings/search.py b/src/prompt_toolkit/key_binding/bindings/search.py
new file mode 100644
index 0000000..ba5e117
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/search.py
@@ -0,0 +1,95 @@
+"""
+Search related key bindings.
+"""
+from __future__ import annotations
+
+from prompt_toolkit import search
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.filters import Condition, control_is_searchable, is_searching
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+
+from ..key_bindings import key_binding
+
+__all__ = [
+ "abort_search",
+ "accept_search",
+ "start_reverse_incremental_search",
+ "start_forward_incremental_search",
+ "reverse_incremental_search",
+ "forward_incremental_search",
+ "accept_search_and_accept_input",
+]
+
+E = KeyPressEvent
+
+
+@key_binding(filter=is_searching)
+def abort_search(event: E) -> None:
+ """
+ Abort an incremental search and restore the original
+ line.
+ (Usually bound to ControlG/ControlC.)
+ """
+ search.stop_search()
+
+
+@key_binding(filter=is_searching)
+def accept_search(event: E) -> None:
+ """
+ When enter pressed in isearch, quit isearch mode. (Multiline
+ isearch would be too complicated.)
+ (Usually bound to Enter.)
+ """
+ search.accept_search()
+
+
+@key_binding(filter=control_is_searchable)
+def start_reverse_incremental_search(event: E) -> None:
+ """
+ Enter reverse incremental search.
+ (Usually ControlR.)
+ """
+ search.start_search(direction=search.SearchDirection.BACKWARD)
+
+
+@key_binding(filter=control_is_searchable)
+def start_forward_incremental_search(event: E) -> None:
+ """
+ Enter forward incremental search.
+ (Usually ControlS.)
+ """
+ search.start_search(direction=search.SearchDirection.FORWARD)
+
+
+@key_binding(filter=is_searching)
+def reverse_incremental_search(event: E) -> None:
+ """
+ Apply reverse incremental search, but keep search buffer focused.
+ """
+ search.do_incremental_search(search.SearchDirection.BACKWARD, count=event.arg)
+
+
+@key_binding(filter=is_searching)
+def forward_incremental_search(event: E) -> None:
+ """
+ Apply forward incremental search, but keep search buffer focused.
+ """
+ search.do_incremental_search(search.SearchDirection.FORWARD, count=event.arg)
+
+
+@Condition
+def _previous_buffer_is_returnable() -> bool:
+ """
+ True if the previously focused buffer has a return handler.
+ """
+ prev_control = get_app().layout.search_target_buffer_control
+ return bool(prev_control and prev_control.buffer.is_returnable)
+
+
+@key_binding(filter=is_searching & _previous_buffer_is_returnable)
+def accept_search_and_accept_input(event: E) -> None:
+ """
+ Accept the search operation first, then accept the input.
+ """
+ search.accept_search()
+ event.current_buffer.validate_and_handle()
diff --git a/src/prompt_toolkit/key_binding/bindings/vi.py b/src/prompt_toolkit/key_binding/bindings/vi.py
new file mode 100644
index 0000000..5cc74b4
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/bindings/vi.py
@@ -0,0 +1,2224 @@
+# 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.input.vt100_parser import Vt100Parser
+from prompt_toolkit.key_binding.digraphs import DIGRAPHS
+from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
+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",
+]
+
+E = KeyPressEvent
+
+ascii_lowercase = string.ascii_lowercase
+
+vi_register_names = ascii_lowercase + "0123456789"
+
+
+class TextObjectType(Enum):
+ EXCLUSIVE = "EXCLUSIVE"
+ INCLUSIVE = "INCLUSIVE"
+ LINEWISE = "LINEWISE"
+ BLOCK = "BLOCK"
+
+
+class TextObject:
+ """
+ Return struct for functions wrapped in ``text_object``.
+ Both `start` and `end` are relative to the current cursor position.
+ """
+
+ def __init__(
+ self, start: int, end: int = 0, type: TextObjectType = TextObjectType.EXCLUSIVE
+ ):
+ self.start = start
+ self.end = end
+ self.type = type
+
+ @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.
+ """
+ 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]:
+ """
+ Return a (start, end) tuple with start <= end that indicates the range
+ operators should operate on.
+ `buffer` is used to get start and end of line positions.
+
+ This should return something that can be used in a slice, so the `end`
+ position is *not* included.
+ """
+ start, end = self.sorted()
+ doc = document
+
+ 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.
+ """
+ # Get absolute cursor positions from the text object.
+ from_, to = self.operator_range(buffer.document)
+ from_ += buffer.cursor_position
+ to += buffer.cursor_position
+
+ # 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.
+ """
+ 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)
+
+
+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.
+ """
+
+ 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)
+
+
+def create_operator_decorator(
+ key_bindings: KeyBindings,
+) -> Callable[..., Callable[[_OF], _OF]]:
+ """
+ Create a decorator that can be used for registering Vi operators.
+ """
+
+ 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
+
+ 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
+ """
+ # 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
new file mode 100644
index 0000000..166da8d
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/defaults.py
@@ -0,0 +1,62 @@
+"""
+Default key bindings.::
+
+ key_bindings = load_key_bindings()
+ app = Application(key_bindings=key_bindings)
+"""
+from __future__ import annotations
+
+from prompt_toolkit.filters import buffer_has_focus
+from prompt_toolkit.key_binding.bindings.basic import load_basic_bindings
+from prompt_toolkit.key_binding.bindings.cpr import load_cpr_bindings
+from prompt_toolkit.key_binding.bindings.emacs import (
+ load_emacs_bindings,
+ load_emacs_search_bindings,
+ load_emacs_shift_selection_bindings,
+)
+from prompt_toolkit.key_binding.bindings.mouse import load_mouse_bindings
+from prompt_toolkit.key_binding.bindings.vi import (
+ load_vi_bindings,
+ load_vi_search_bindings,
+)
+from prompt_toolkit.key_binding.key_bindings import (
+ ConditionalKeyBindings,
+ KeyBindingsBase,
+ merge_key_bindings,
+)
+
+__all__ = [
+ "load_key_bindings",
+]
+
+
+def load_key_bindings() -> KeyBindingsBase:
+ """
+ Create a KeyBindings object that contains the default key bindings.
+ """
+ all_bindings = merge_key_bindings(
+ [
+ # Load basic bindings.
+ load_basic_bindings(),
+ # Load emacs bindings.
+ load_emacs_bindings(),
+ load_emacs_search_bindings(),
+ load_emacs_shift_selection_bindings(),
+ # Load Vi bindings.
+ load_vi_bindings(),
+ load_vi_search_bindings(),
+ ]
+ )
+
+ return merge_key_bindings(
+ [
+ # Make sure that the above key bindings are only active if the
+ # currently focused control is a `BufferControl`. For other controls, we
+ # don't want these key bindings to intervene. (This would break "ptterm"
+ # for instance, which handles 'Keys.Any' in the user control itself.)
+ ConditionalKeyBindings(all_bindings, buffer_has_focus),
+ # Active, even when no buffer has been focused.
+ load_mouse_bindings(),
+ load_cpr_bindings(),
+ ]
+ )
diff --git a/src/prompt_toolkit/key_binding/digraphs.py b/src/prompt_toolkit/key_binding/digraphs.py
new file mode 100644
index 0000000..1e8a432
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/digraphs.py
@@ -0,0 +1,1377 @@
+"""
+Vi Digraphs.
+This is a list of special characters that can be inserted in Vi insert mode by
+pressing Control-K followed by to normal characters.
+
+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 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
new file mode 100644
index 0000000..6a2ebf4
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/emacs_state.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from .key_processor import KeyPress
+
+__all__ = [
+ "EmacsState",
+]
+
+
+class EmacsState:
+ """
+ Mutable class to hold Emacs specific state.
+ """
+
+ def __init__(self) -> None:
+ # Simple macro recording. (Like Readline does.)
+ # (For Emacs mode.)
+ self.macro: list[KeyPress] | None = []
+ self.current_recording: list[KeyPress] | None = None
+
+ def reset(self) -> None:
+ self.current_recording = None
+
+ @property
+ def is_recording(self) -> bool:
+ "Tell whether we are recording a macro."
+ return self.current_recording is not None
+
+ def start_macro(self) -> None:
+ "Start recording macro."
+ self.current_recording = []
+
+ def end_macro(self) -> None:
+ "End recording macro."
+ self.macro = self.current_recording
+ self.current_recording = None
diff --git a/src/prompt_toolkit/key_binding/key_bindings.py b/src/prompt_toolkit/key_binding/key_bindings.py
new file mode 100644
index 0000000..62530f2
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/key_bindings.py
@@ -0,0 +1,671 @@
+"""
+Key bindings registry.
+
+A `KeyBindings` object is a container that holds a list of key bindings. It has a
+very efficient internal data structure for checking which key bindings apply
+for a pressed key.
+
+Typical usage::
+
+ kb = KeyBindings()
+
+ @kb.add(Keys.ControlX, Keys.ControlC, filter=INSERT)
+ def handler(event):
+ # Handle ControlX-ControlC key sequence.
+ pass
+
+It is also possible to combine multiple KeyBindings objects. We do this in the
+default key bindings. There are some KeyBindings objects that contain the Emacs
+bindings, while others contain the Vi bindings. They are merged together using
+`merge_key_bindings`.
+
+We also have a `ConditionalKeyBindings` object that can enable/disable a group of
+key bindings at once.
+
+
+It is also possible to add a filter to a function, before a key binding has
+been assigned, through the `key_binding` decorator.::
+
+ # First define a key handler with the `filter`.
+ @key_binding(filter=condition)
+ def my_key_binding(event):
+ ...
+
+ # Later, add it to the key bindings.
+ kb.add(Keys.A, my_key_binding)
+"""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod, abstractproperty
+from inspect import isawaitable
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Callable,
+ Coroutine,
+ Hashable,
+ Sequence,
+ Tuple,
+ TypeVar,
+ Union,
+ cast,
+)
+
+from prompt_toolkit.cache import SimpleCache
+from prompt_toolkit.filters import FilterOrBool, Never, to_filter
+from prompt_toolkit.keys import KEY_ALIASES, Keys
+
+if TYPE_CHECKING:
+ # Avoid circular imports.
+ from .key_processor import KeyPressEvent
+
+ # The only two return values for a mouse handler (and key bindings) are
+ # `None` and `NotImplemented`. For the type checker it's best to annotate
+ # this as `object`. (The consumer never expects a more specific instance:
+ # checking for NotImplemented can be done using `is NotImplemented`.)
+ NotImplementedOrNone = object
+ # Other non-working options are:
+ # * Optional[Literal[NotImplemented]]
+ # --> Doesn't work, Literal can't take an Any.
+ # * None
+ # --> Doesn't work. We can't assign the result of a function that
+ # returns `None` to a variable.
+ # * Any
+ # --> Works, but too broad.
+
+
+__all__ = [
+ "NotImplementedOrNone",
+ "Binding",
+ "KeyBindingsBase",
+ "KeyBindings",
+ "ConditionalKeyBindings",
+ "merge_key_bindings",
+ "DynamicKeyBindings",
+ "GlobalOnlyKeyBindings",
+]
+
+# Key bindings can be regular functions or coroutines.
+# In both cases, if they return `NotImplemented`, the UI won't be invalidated.
+# This is mainly used in case of mouse move events, to prevent excessive
+# repainting during mouse move events.
+KeyHandlerCallable = Callable[
+ ["KeyPressEvent"],
+ Union["NotImplementedOrNone", Coroutine[Any, Any, "NotImplementedOrNone"]],
+]
+
+
+class Binding:
+ """
+ Key binding: (key sequence + handler + filter).
+ (Immutable binding class.)
+
+ :param record_in_macro: When True, don't record this key binding when a
+ macro is recorded.
+ """
+
+ def __init__(
+ self,
+ keys: tuple[Keys | str, ...],
+ handler: KeyHandlerCallable,
+ filter: FilterOrBool = True,
+ eager: FilterOrBool = False,
+ is_global: FilterOrBool = False,
+ save_before: Callable[[KeyPressEvent], bool] = (lambda e: True),
+ record_in_macro: FilterOrBool = True,
+ ) -> None:
+ self.keys = keys
+ self.handler = handler
+ self.filter = to_filter(filter)
+ self.eager = to_filter(eager)
+ self.is_global = to_filter(is_global)
+ self.save_before = save_before
+ self.record_in_macro = to_filter(record_in_macro)
+
+ def call(self, event: KeyPressEvent) -> None:
+ result = self.handler(event)
+
+ # If the handler is a coroutine, create an asyncio task.
+ if isawaitable(result):
+ awaitable = cast(Coroutine[Any, Any, "NotImplementedOrNone"], result)
+
+ async def bg_task() -> None:
+ result = await awaitable
+ if result != NotImplemented:
+ event.app.invalidate()
+
+ event.app.create_background_task(bg_task())
+
+ elif result != NotImplemented:
+ event.app.invalidate()
+
+ def __repr__(self) -> str:
+ return "{}(keys={!r}, handler={!r})".format(
+ self.__class__.__name__,
+ self.keys,
+ self.handler,
+ )
+
+
+# Sequence of keys presses.
+KeysTuple = Tuple[Union[Keys, str], ...]
+
+
+class KeyBindingsBase(metaclass=ABCMeta):
+ """
+ Interface for a KeyBindings.
+ """
+
+ @abstractproperty
+ def _version(self) -> Hashable:
+ """
+ For cache invalidation. - This should increase every time that
+ something changes.
+ """
+ return 0
+
+ @abstractmethod
+ def get_bindings_for_keys(self, keys: KeysTuple) -> list[Binding]:
+ """
+ Return a list of key bindings that can handle these keys.
+ (This return also inactive bindings, so the `filter` still has to be
+ called, for checking it.)
+
+ :param keys: tuple of keys.
+ """
+ return []
+
+ @abstractmethod
+ def get_bindings_starting_with_keys(self, keys: KeysTuple) -> list[Binding]:
+ """
+ Return a list of key bindings that handle a key sequence starting with
+ `keys`. (It does only return bindings for which the sequences are
+ longer than `keys`. And like `get_bindings_for_keys`, it also includes
+ inactive bindings.)
+
+ :param keys: tuple of keys.
+ """
+ return []
+
+ @abstractproperty
+ def bindings(self) -> list[Binding]:
+ """
+ List of `Binding` objects.
+ (These need to be exposed, so that `KeyBindings` objects can be merged
+ together.)
+ """
+ return []
+
+ # `add` and `remove` don't have to be part of this interface.
+
+
+T = TypeVar("T", bound=Union[KeyHandlerCallable, Binding])
+
+
+class KeyBindings(KeyBindingsBase):
+ """
+ A container for a set of key bindings.
+
+ Example usage::
+
+ kb = KeyBindings()
+
+ @kb.add('c-t')
+ def _(event):
+ print('Control-T pressed')
+
+ @kb.add('c-a', 'c-b')
+ def _(event):
+ print('Control-A pressed, followed by Control-B')
+
+ @kb.add('c-x', filter=is_searching)
+ def _(event):
+ print('Control-X pressed') # Works only if we are searching.
+
+ """
+
+ def __init__(self) -> None:
+ self._bindings: list[Binding] = []
+ self._get_bindings_for_keys_cache: SimpleCache[
+ KeysTuple, list[Binding]
+ ] = SimpleCache(maxsize=10000)
+ self._get_bindings_starting_with_keys_cache: SimpleCache[
+ KeysTuple, list[Binding]
+ ] = SimpleCache(maxsize=1000)
+ self.__version = 0 # For cache invalidation.
+
+ def _clear_cache(self) -> None:
+ self.__version += 1
+ self._get_bindings_for_keys_cache.clear()
+ self._get_bindings_starting_with_keys_cache.clear()
+
+ @property
+ def bindings(self) -> list[Binding]:
+ return self._bindings
+
+ @property
+ def _version(self) -> Hashable:
+ return self.__version
+
+ def add(
+ self,
+ *keys: Keys | str,
+ filter: FilterOrBool = True,
+ eager: FilterOrBool = False,
+ is_global: FilterOrBool = False,
+ save_before: Callable[[KeyPressEvent], bool] = (lambda e: True),
+ record_in_macro: FilterOrBool = True,
+ ) -> Callable[[T], T]:
+ """
+ Decorator for adding a key bindings.
+
+ :param filter: :class:`~prompt_toolkit.filters.Filter` to determine
+ when this key binding is active.
+ :param eager: :class:`~prompt_toolkit.filters.Filter` or `bool`.
+ When True, ignore potential longer matches when this key binding is
+ hit. E.g. when there is an active eager key binding for Ctrl-X,
+ execute the handler immediately and ignore the key binding for
+ Ctrl-X Ctrl-E of which it is a prefix.
+ :param is_global: When this key bindings is added to a `Container` or
+ `Control`, make it a global (always active) binding.
+ :param save_before: Callable that takes an `Event` and returns True if
+ we should save the current buffer, before handling the event.
+ (That's the default.)
+ :param record_in_macro: Record these key bindings when a macro is
+ being recorded. (True by default.)
+ """
+ assert keys
+
+ keys = tuple(_parse_key(k) for k in keys)
+
+ if isinstance(filter, Never):
+ # When a filter is Never, it will always stay disabled, so in that
+ # case don't bother putting it in the key bindings. It will slow
+ # down every key press otherwise.
+ def decorator(func: T) -> T:
+ return func
+
+ else:
+
+ def decorator(func: T) -> T:
+ if isinstance(func, Binding):
+ # We're adding an existing Binding object.
+ self.bindings.append(
+ Binding(
+ keys,
+ func.handler,
+ filter=func.filter & to_filter(filter),
+ eager=to_filter(eager) | func.eager,
+ is_global=to_filter(is_global) | func.is_global,
+ save_before=func.save_before,
+ record_in_macro=func.record_in_macro,
+ )
+ )
+ else:
+ self.bindings.append(
+ Binding(
+ keys,
+ cast(KeyHandlerCallable, func),
+ filter=filter,
+ eager=eager,
+ is_global=is_global,
+ save_before=save_before,
+ record_in_macro=record_in_macro,
+ )
+ )
+ self._clear_cache()
+
+ return func
+
+ return decorator
+
+ def remove(self, *args: Keys | str | KeyHandlerCallable) -> None:
+ """
+ Remove a key binding.
+
+ This expects either a function that was given to `add` method as
+ parameter or a sequence of key bindings.
+
+ Raises `ValueError` when no bindings was found.
+
+ Usage::
+
+ remove(handler) # Pass handler.
+ remove('c-x', 'c-a') # Or pass the key bindings.
+ """
+ found = False
+
+ if callable(args[0]):
+ assert len(args) == 1
+ function = args[0]
+
+ # Remove the given function.
+ for b in self.bindings:
+ if b.handler == function:
+ self.bindings.remove(b)
+ found = True
+
+ else:
+ assert len(args) > 0
+ args = cast(Tuple[Union[Keys, str]], args)
+
+ # Remove this sequence of key bindings.
+ keys = tuple(_parse_key(k) for k in args)
+
+ for b in self.bindings:
+ if b.keys == keys:
+ self.bindings.remove(b)
+ found = True
+
+ if found:
+ self._clear_cache()
+ else:
+ # No key binding found for this function. Raise ValueError.
+ raise ValueError(f"Binding not found: {function!r}")
+
+ # For backwards-compatibility.
+ add_binding = add
+ remove_binding = remove
+
+ def get_bindings_for_keys(self, keys: KeysTuple) -> list[Binding]:
+ """
+ Return a list of key bindings that can handle this key.
+ (This return also inactive bindings, so the `filter` still has to be
+ called, for checking it.)
+
+ :param keys: tuple of keys.
+ """
+
+ def get() -> list[Binding]:
+ result: list[tuple[int, Binding]] = []
+
+ for b in self.bindings:
+ if len(keys) == len(b.keys):
+ match = True
+ any_count = 0
+
+ for i, j in zip(b.keys, keys):
+ if i != j and i != Keys.Any:
+ match = False
+ break
+
+ if i == Keys.Any:
+ any_count += 1
+
+ if match:
+ result.append((any_count, b))
+
+ # Place bindings that have more 'Any' occurrences in them at the end.
+ result = sorted(result, key=lambda item: -item[0])
+
+ return [item[1] for item in result]
+
+ return self._get_bindings_for_keys_cache.get(keys, get)
+
+ def get_bindings_starting_with_keys(self, keys: KeysTuple) -> list[Binding]:
+ """
+ Return a list of key bindings that handle a key sequence starting with
+ `keys`. (It does only return bindings for which the sequences are
+ longer than `keys`. And like `get_bindings_for_keys`, it also includes
+ inactive bindings.)
+
+ :param keys: tuple of keys.
+ """
+
+ def get() -> list[Binding]:
+ result = []
+ for b in self.bindings:
+ if len(keys) < len(b.keys):
+ match = True
+ for i, j in zip(b.keys, keys):
+ if i != j and i != Keys.Any:
+ match = False
+ break
+ if match:
+ result.append(b)
+ return result
+
+ return self._get_bindings_starting_with_keys_cache.get(keys, get)
+
+
+def _parse_key(key: Keys | str) -> str | Keys:
+ """
+ Replace key by alias and verify whether it's a valid one.
+ """
+ # Already a parse key? -> Return it.
+ if isinstance(key, Keys):
+ return key
+
+ # Lookup aliases.
+ key = KEY_ALIASES.get(key, key)
+
+ # Replace 'space' by ' '
+ if key == "space":
+ key = " "
+
+ # Return as `Key` object when it's a special key.
+ try:
+ return Keys(key)
+ except ValueError:
+ pass
+
+ # Final validation.
+ if len(key) != 1:
+ raise ValueError(f"Invalid key: {key}")
+
+ return key
+
+
+def key_binding(
+ filter: FilterOrBool = True,
+ eager: FilterOrBool = False,
+ is_global: FilterOrBool = False,
+ save_before: Callable[[KeyPressEvent], bool] = (lambda event: True),
+ record_in_macro: FilterOrBool = True,
+) -> Callable[[KeyHandlerCallable], Binding]:
+ """
+ Decorator that turn a function into a `Binding` object. This can be added
+ to a `KeyBindings` object when a key binding is assigned.
+ """
+ assert save_before is None or callable(save_before)
+
+ filter = to_filter(filter)
+ eager = to_filter(eager)
+ is_global = to_filter(is_global)
+ save_before = save_before
+ record_in_macro = to_filter(record_in_macro)
+ keys = ()
+
+ def decorator(function: KeyHandlerCallable) -> Binding:
+ return Binding(
+ keys,
+ function,
+ filter=filter,
+ eager=eager,
+ is_global=is_global,
+ save_before=save_before,
+ record_in_macro=record_in_macro,
+ )
+
+ return decorator
+
+
+class _Proxy(KeyBindingsBase):
+ """
+ Common part for ConditionalKeyBindings and _MergedKeyBindings.
+ """
+
+ def __init__(self) -> None:
+ # `KeyBindings` to be synchronized with all the others.
+ self._bindings2: KeyBindingsBase = KeyBindings()
+ self._last_version: Hashable = ()
+
+ def _update_cache(self) -> None:
+ """
+ If `self._last_version` is outdated, then this should update
+ the version and `self._bindings2`.
+ """
+ raise NotImplementedError
+
+ # Proxy methods to self._bindings2.
+
+ @property
+ def bindings(self) -> list[Binding]:
+ self._update_cache()
+ return self._bindings2.bindings
+
+ @property
+ def _version(self) -> Hashable:
+ self._update_cache()
+ return self._last_version
+
+ def get_bindings_for_keys(self, keys: KeysTuple) -> list[Binding]:
+ self._update_cache()
+ return self._bindings2.get_bindings_for_keys(keys)
+
+ def get_bindings_starting_with_keys(self, keys: KeysTuple) -> list[Binding]:
+ self._update_cache()
+ return self._bindings2.get_bindings_starting_with_keys(keys)
+
+
+class ConditionalKeyBindings(_Proxy):
+ """
+ Wraps around a `KeyBindings`. Disable/enable all the key bindings according to
+ the given (additional) filter.::
+
+ @Condition
+ def setting_is_true():
+ return True # or False
+
+ registry = ConditionalKeyBindings(key_bindings, setting_is_true)
+
+ When new key bindings are added to this object. They are also
+ enable/disabled according to the given `filter`.
+
+ :param registries: List of :class:`.KeyBindings` objects.
+ :param filter: :class:`~prompt_toolkit.filters.Filter` object.
+ """
+
+ def __init__(
+ self, key_bindings: KeyBindingsBase, filter: FilterOrBool = True
+ ) -> None:
+ _Proxy.__init__(self)
+
+ self.key_bindings = key_bindings
+ self.filter = to_filter(filter)
+
+ def _update_cache(self) -> None:
+ "If the original key bindings was changed. Update our copy version."
+ expected_version = self.key_bindings._version
+
+ if self._last_version != expected_version:
+ bindings2 = KeyBindings()
+
+ # Copy all bindings from `self.key_bindings`, adding our condition.
+ for b in self.key_bindings.bindings:
+ bindings2.bindings.append(
+ Binding(
+ keys=b.keys,
+ handler=b.handler,
+ filter=self.filter & b.filter,
+ eager=b.eager,
+ is_global=b.is_global,
+ save_before=b.save_before,
+ record_in_macro=b.record_in_macro,
+ )
+ )
+
+ self._bindings2 = bindings2
+ self._last_version = expected_version
+
+
+class _MergedKeyBindings(_Proxy):
+ """
+ Merge multiple registries of key bindings into one.
+
+ This class acts as a proxy to multiple :class:`.KeyBindings` objects, but
+ behaves as if this is just one bigger :class:`.KeyBindings`.
+
+ :param registries: List of :class:`.KeyBindings` objects.
+ """
+
+ def __init__(self, registries: Sequence[KeyBindingsBase]) -> None:
+ _Proxy.__init__(self)
+ self.registries = registries
+
+ def _update_cache(self) -> None:
+ """
+ If one of the original registries was changed. Update our merged
+ version.
+ """
+ expected_version = tuple(r._version for r in self.registries)
+
+ if self._last_version != expected_version:
+ bindings2 = KeyBindings()
+
+ for reg in self.registries:
+ bindings2.bindings.extend(reg.bindings)
+
+ self._bindings2 = bindings2
+ self._last_version = expected_version
+
+
+def merge_key_bindings(bindings: Sequence[KeyBindingsBase]) -> _MergedKeyBindings:
+ """
+ Merge multiple :class:`.Keybinding` objects together.
+
+ Usage::
+
+ bindings = merge_key_bindings([bindings1, bindings2, ...])
+ """
+ return _MergedKeyBindings(bindings)
+
+
+class DynamicKeyBindings(_Proxy):
+ """
+ KeyBindings class that can dynamically returns any KeyBindings.
+
+ :param get_key_bindings: Callable that returns a :class:`.KeyBindings` instance.
+ """
+
+ def __init__(self, get_key_bindings: Callable[[], KeyBindingsBase | None]) -> None:
+ self.get_key_bindings = get_key_bindings
+ self.__version = 0
+ self._last_child_version = None
+ self._dummy = KeyBindings() # Empty key bindings.
+
+ def _update_cache(self) -> None:
+ key_bindings = self.get_key_bindings() or self._dummy
+ assert isinstance(key_bindings, KeyBindingsBase)
+ version = id(key_bindings), key_bindings._version
+
+ self._bindings2 = key_bindings
+ self._last_version = version
+
+
+class GlobalOnlyKeyBindings(_Proxy):
+ """
+ Wrapper around a :class:`.KeyBindings` object that only exposes the global
+ key bindings.
+ """
+
+ def __init__(self, key_bindings: KeyBindingsBase) -> None:
+ _Proxy.__init__(self)
+ self.key_bindings = key_bindings
+
+ def _update_cache(self) -> None:
+ """
+ If one of the original registries was changed. Update our merged
+ version.
+ """
+ expected_version = self.key_bindings._version
+
+ if self._last_version != expected_version:
+ bindings2 = KeyBindings()
+
+ for b in self.key_bindings.bindings:
+ if b.is_global():
+ bindings2.bindings.append(b)
+
+ self._bindings2 = bindings2
+ self._last_version = expected_version
diff --git a/src/prompt_toolkit/key_binding/key_processor.py b/src/prompt_toolkit/key_binding/key_processor.py
new file mode 100644
index 0000000..4c4f0d1
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/key_processor.py
@@ -0,0 +1,529 @@
+"""
+An :class:`~.KeyProcessor` receives callbacks for the keystrokes parsed from
+the input in the :class:`~prompt_toolkit.inputstream.InputStream` instance.
+
+The `KeyProcessor` will according to the implemented keybindings call the
+correct callbacks when new key presses are feed through `feed`.
+"""
+from __future__ import annotations
+
+import weakref
+from asyncio import Task, sleep
+from collections import deque
+from typing import TYPE_CHECKING, Any, Generator
+
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.enums import EditingMode
+from prompt_toolkit.filters.app import vi_navigation_mode
+from prompt_toolkit.keys import Keys
+from prompt_toolkit.utils import Event
+
+from .key_bindings import Binding, KeyBindingsBase
+
+if TYPE_CHECKING:
+ from prompt_toolkit.application import Application
+ from prompt_toolkit.buffer import Buffer
+
+
+__all__ = [
+ "KeyProcessor",
+ "KeyPress",
+ "KeyPressEvent",
+]
+
+
+class KeyPress:
+ """
+ :param key: A `Keys` instance or text (one character).
+ :param data: The received string on stdin. (Often vt100 escape codes.)
+ """
+
+ def __init__(self, key: Keys | str, data: str | None = None) -> None:
+ assert isinstance(key, Keys) or len(key) == 1
+
+ if data is None:
+ if isinstance(key, Keys):
+ data = key.value
+ else:
+ data = key # 'key' is a one character string.
+
+ self.key = key
+ self.data = data
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}(key={self.key!r}, data={self.data!r})"
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, KeyPress):
+ return False
+ return self.key == other.key and self.data == other.data
+
+
+"""
+Helper object to indicate flush operation in the KeyProcessor.
+NOTE: the implementation is very similar to the VT100 parser.
+"""
+_Flush = KeyPress("?", data="_Flush")
+
+
+class KeyProcessor:
+ """
+ Statemachine that receives :class:`KeyPress` instances and according to the
+ key bindings in the given :class:`KeyBindings`, calls the matching handlers.
+
+ ::
+
+ p = KeyProcessor(key_bindings)
+
+ # Send keys into the processor.
+ p.feed(KeyPress(Keys.ControlX, '\x18'))
+ p.feed(KeyPress(Keys.ControlC, '\x03')
+
+ # Process all the keys in the queue.
+ p.process_keys()
+
+ # Now the ControlX-ControlC callback will be called if this sequence is
+ # registered in the key bindings.
+
+ :param key_bindings: `KeyBindingsBase` instance.
+ """
+
+ def __init__(self, key_bindings: KeyBindingsBase) -> None:
+ self._bindings = key_bindings
+
+ self.before_key_press = Event(self)
+ self.after_key_press = Event(self)
+
+ self._flush_wait_task: Task[None] | None = None
+
+ self.reset()
+
+ def reset(self) -> None:
+ self._previous_key_sequence: list[KeyPress] = []
+ self._previous_handler: Binding | None = None
+
+ # The queue of keys not yet send to our _process generator/state machine.
+ self.input_queue: deque[KeyPress] = deque()
+
+ # The key buffer that is matched in the generator state machine.
+ # (This is at at most the amount of keys that make up for one key binding.)
+ self.key_buffer: list[KeyPress] = []
+
+ #: Readline argument (for repetition of commands.)
+ #: https://www.gnu.org/software/bash/manual/html_node/Readline-Arguments.html
+ self.arg: str | None = None
+
+ # Start the processor coroutine.
+ self._process_coroutine = self._process()
+ self._process_coroutine.send(None) # type: ignore
+
+ def _get_matches(self, key_presses: list[KeyPress]) -> list[Binding]:
+ """
+ For a list of :class:`KeyPress` instances. Give the matching handlers
+ that would handle this.
+ """
+ keys = tuple(k.key for k in key_presses)
+
+ # Try match, with mode flag
+ return [b for b in self._bindings.get_bindings_for_keys(keys) if b.filter()]
+
+ def _is_prefix_of_longer_match(self, key_presses: list[KeyPress]) -> bool:
+ """
+ For a list of :class:`KeyPress` instances. Return True if there is any
+ handler that is bound to a suffix of this keys.
+ """
+ keys = tuple(k.key for k in key_presses)
+
+ # Get the filters for all the key bindings that have a longer match.
+ # Note that we transform it into a `set`, because we don't care about
+ # the actual bindings and executing it more than once doesn't make
+ # sense. (Many key bindings share the same filter.)
+ filters = {
+ b.filter for b in self._bindings.get_bindings_starting_with_keys(keys)
+ }
+
+ # When any key binding is active, return True.
+ return any(f() for f in filters)
+
+ def _process(self) -> Generator[None, KeyPress, None]:
+ """
+ Coroutine implementing the key match algorithm. Key strokes are sent
+ into this generator, and it calls the appropriate handlers.
+ """
+ buffer = self.key_buffer
+ retry = False
+
+ while True:
+ flush = False
+
+ if retry:
+ retry = False
+ else:
+ key = yield
+ if key is _Flush:
+ flush = True
+ else:
+ buffer.append(key)
+
+ # If we have some key presses, check for matches.
+ if buffer:
+ matches = self._get_matches(buffer)
+
+ if flush:
+ is_prefix_of_longer_match = False
+ else:
+ is_prefix_of_longer_match = self._is_prefix_of_longer_match(buffer)
+
+ # When eager matches were found, give priority to them and also
+ # ignore all the longer matches.
+ eager_matches = [m for m in matches if m.eager()]
+
+ if eager_matches:
+ matches = eager_matches
+ is_prefix_of_longer_match = False
+
+ # Exact matches found, call handler.
+ if not is_prefix_of_longer_match and matches:
+ self._call_handler(matches[-1], key_sequence=buffer[:])
+ del buffer[:] # Keep reference.
+
+ # No match found.
+ elif not is_prefix_of_longer_match and not matches:
+ retry = True
+ found = False
+
+ # Loop over the input, try longest match first and shift.
+ for i in range(len(buffer), 0, -1):
+ matches = self._get_matches(buffer[:i])
+ if matches:
+ self._call_handler(matches[-1], key_sequence=buffer[:i])
+ del buffer[:i]
+ found = True
+ break
+
+ if not found:
+ del buffer[:1]
+
+ def feed(self, key_press: KeyPress, first: bool = False) -> None:
+ """
+ Add a new :class:`KeyPress` to the input queue.
+ (Don't forget to call `process_keys` in order to process the queue.)
+
+ :param first: If true, insert before everything else.
+ """
+ if first:
+ self.input_queue.appendleft(key_press)
+ else:
+ self.input_queue.append(key_press)
+
+ def feed_multiple(self, key_presses: list[KeyPress], first: bool = False) -> None:
+ """
+ :param first: If true, insert before everything else.
+ """
+ if first:
+ self.input_queue.extendleft(reversed(key_presses))
+ else:
+ self.input_queue.extend(key_presses)
+
+ def process_keys(self) -> None:
+ """
+ Process all the keys in the `input_queue`.
+ (To be called after `feed`.)
+
+ Note: because of the `feed`/`process_keys` separation, it is
+ possible to call `feed` from inside a key binding.
+ This function keeps looping until the queue is empty.
+ """
+ app = get_app()
+
+ def not_empty() -> bool:
+ # When the application result is set, stop processing keys. (E.g.
+ # if ENTER was received, followed by a few additional key strokes,
+ # leave the other keys in the queue.)
+ if app.is_done:
+ # But if there are still CPRResponse keys in the queue, these
+ # need to be processed.
+ return any(k for k in self.input_queue if k.key == Keys.CPRResponse)
+ else:
+ return bool(self.input_queue)
+
+ def get_next() -> KeyPress:
+ if app.is_done:
+ # Only process CPR responses. Everything else is typeahead.
+ cpr = [k for k in self.input_queue if k.key == Keys.CPRResponse][0]
+ self.input_queue.remove(cpr)
+ return cpr
+ else:
+ return self.input_queue.popleft()
+
+ is_flush = False
+
+ while not_empty():
+ # Process next key.
+ key_press = get_next()
+
+ is_flush = key_press is _Flush
+ is_cpr = key_press.key == Keys.CPRResponse
+
+ if not is_flush and not is_cpr:
+ self.before_key_press.fire()
+
+ try:
+ self._process_coroutine.send(key_press)
+ except Exception:
+ # If for some reason something goes wrong in the parser, (maybe
+ # an exception was raised) restart the processor for next time.
+ self.reset()
+ self.empty_queue()
+ raise
+
+ if not is_flush and not is_cpr:
+ self.after_key_press.fire()
+
+ # Skip timeout if the last key was flush.
+ if not is_flush:
+ self._start_timeout()
+
+ def empty_queue(self) -> list[KeyPress]:
+ """
+ Empty the input queue. Return the unprocessed input.
+ """
+ key_presses = list(self.input_queue)
+ self.input_queue.clear()
+
+ # Filter out CPRs. We don't want to return these.
+ key_presses = [k for k in key_presses if k.key != Keys.CPRResponse]
+ return key_presses
+
+ def _call_handler(self, handler: Binding, key_sequence: list[KeyPress]) -> None:
+ app = get_app()
+ was_recording_emacs = app.emacs_state.is_recording
+ was_recording_vi = bool(app.vi_state.recording_register)
+ was_temporary_navigation_mode = app.vi_state.temporary_navigation_mode
+ arg = self.arg
+ self.arg = None
+
+ event = KeyPressEvent(
+ weakref.ref(self),
+ arg=arg,
+ key_sequence=key_sequence,
+ previous_key_sequence=self._previous_key_sequence,
+ is_repeat=(handler == self._previous_handler),
+ )
+
+ # Save the state of the current buffer.
+ if handler.save_before(event):
+ event.app.current_buffer.save_to_undo_stack()
+
+ # Call handler.
+ from prompt_toolkit.buffer import EditReadOnlyBuffer
+
+ try:
+ handler.call(event)
+ self._fix_vi_cursor_position(event)
+
+ except EditReadOnlyBuffer:
+ # When a key binding does an attempt to change a buffer which is
+ # read-only, we can ignore that. We sound a bell and go on.
+ app.output.bell()
+
+ if was_temporary_navigation_mode:
+ self._leave_vi_temp_navigation_mode(event)
+
+ self._previous_key_sequence = key_sequence
+ self._previous_handler = handler
+
+ # Record the key sequence in our macro. (Only if we're in macro mode
+ # before and after executing the key.)
+ if handler.record_in_macro():
+ if app.emacs_state.is_recording and was_recording_emacs:
+ recording = app.emacs_state.current_recording
+ if recording is not None: # Should always be true, given that
+ # `was_recording_emacs` is set.
+ recording.extend(key_sequence)
+
+ if app.vi_state.recording_register and was_recording_vi:
+ for k in key_sequence:
+ app.vi_state.current_recording += k.data
+
+ def _fix_vi_cursor_position(self, event: KeyPressEvent) -> None:
+ """
+ After every command, make sure that if we are in Vi navigation mode, we
+ never put the cursor after the last character of a line. (Unless it's
+ an empty line.)
+ """
+ app = event.app
+ buff = app.current_buffer
+ preferred_column = buff.preferred_column
+
+ if (
+ vi_navigation_mode()
+ and buff.document.is_cursor_at_the_end_of_line
+ and len(buff.document.current_line) > 0
+ ):
+ buff.cursor_position -= 1
+
+ # Set the preferred_column for arrow up/down again.
+ # (This was cleared after changing the cursor position.)
+ buff.preferred_column = preferred_column
+
+ def _leave_vi_temp_navigation_mode(self, event: KeyPressEvent) -> None:
+ """
+ If we're in Vi temporary navigation (normal) mode, return to
+ insert/replace mode after executing one action.
+ """
+ app = event.app
+
+ if app.editing_mode == EditingMode.VI:
+ # Not waiting for a text object and no argument has been given.
+ if app.vi_state.operator_func is None and self.arg is None:
+ app.vi_state.temporary_navigation_mode = False
+
+ def _start_timeout(self) -> None:
+ """
+ Start auto flush timeout. Similar to Vim's `timeoutlen` option.
+
+ Start a background coroutine with a timer. When this timeout expires
+ and no key was pressed in the meantime, we flush all data in the queue
+ and call the appropriate key binding handlers.
+ """
+ app = get_app()
+ timeout = app.timeoutlen
+
+ if timeout is None:
+ return
+
+ async def wait() -> None:
+ "Wait for timeout."
+ # This sleep can be cancelled. In that case we don't flush.
+ await sleep(timeout)
+
+ if len(self.key_buffer) > 0:
+ # (No keys pressed in the meantime.)
+ flush_keys()
+
+ def flush_keys() -> None:
+ "Flush keys."
+ self.feed(_Flush)
+ self.process_keys()
+
+ # Automatically flush keys.
+ if self._flush_wait_task:
+ self._flush_wait_task.cancel()
+ self._flush_wait_task = app.create_background_task(wait())
+
+ def send_sigint(self) -> None:
+ """
+ Send SIGINT. Immediately call the SIGINT key handler.
+ """
+ self.feed(KeyPress(key=Keys.SIGINT), first=True)
+ self.process_keys()
+
+
+class KeyPressEvent:
+ """
+ Key press event, delivered to key bindings.
+
+ :param key_processor_ref: Weak reference to the `KeyProcessor`.
+ :param arg: Repetition argument.
+ :param key_sequence: List of `KeyPress` instances.
+ :param previouskey_sequence: Previous list of `KeyPress` instances.
+ :param is_repeat: True when the previous event was delivered to the same handler.
+ """
+
+ def __init__(
+ self,
+ key_processor_ref: weakref.ReferenceType[KeyProcessor],
+ arg: str | None,
+ key_sequence: list[KeyPress],
+ previous_key_sequence: list[KeyPress],
+ is_repeat: bool,
+ ) -> None:
+ self._key_processor_ref = key_processor_ref
+ self.key_sequence = key_sequence
+ self.previous_key_sequence = previous_key_sequence
+
+ #: True when the previous key sequence was handled by the same handler.
+ self.is_repeat = is_repeat
+
+ self._arg = arg
+ self._app = get_app()
+
+ def __repr__(self) -> str:
+ return "KeyPressEvent(arg={!r}, key_sequence={!r}, is_repeat={!r})".format(
+ self.arg,
+ self.key_sequence,
+ self.is_repeat,
+ )
+
+ @property
+ def data(self) -> str:
+ return self.key_sequence[-1].data
+
+ @property
+ def key_processor(self) -> KeyProcessor:
+ processor = self._key_processor_ref()
+ if processor is None:
+ raise Exception("KeyProcessor was lost. This should not happen.")
+ return processor
+
+ @property
+ def app(self) -> Application[Any]:
+ """
+ The current `Application` object.
+ """
+ return self._app
+
+ @property
+ def current_buffer(self) -> Buffer:
+ """
+ The current buffer.
+ """
+ return self.app.current_buffer
+
+ @property
+ def arg(self) -> int:
+ """
+ Repetition argument.
+ """
+ if self._arg == "-":
+ return -1
+
+ result = int(self._arg or 1)
+
+ # Don't exceed a million.
+ if int(result) >= 1000000:
+ result = 1
+
+ return result
+
+ @property
+ def arg_present(self) -> bool:
+ """
+ True if repetition argument was explicitly provided.
+ """
+ return self._arg is not None
+
+ def append_to_arg_count(self, data: str) -> None:
+ """
+ Add digit to the input argument.
+
+ :param data: the typed digit as string
+ """
+ assert data in "-0123456789"
+ current = self._arg
+
+ if data == "-":
+ assert current is None or current == "-"
+ result = data
+ elif current is None:
+ result = data
+ else:
+ result = f"{current}{data}"
+
+ self.key_processor.arg = result
+
+ @property
+ def cli(self) -> Application[Any]:
+ "For backward-compatibility."
+ return self.app
diff --git a/src/prompt_toolkit/key_binding/vi_state.py b/src/prompt_toolkit/key_binding/vi_state.py
new file mode 100644
index 0000000..7ec552f
--- /dev/null
+++ b/src/prompt_toolkit/key_binding/vi_state.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+from enum import Enum
+from typing import TYPE_CHECKING, Callable
+
+from prompt_toolkit.clipboard import ClipboardData
+
+if TYPE_CHECKING:
+ from .key_bindings.vi import TextObject
+ from .key_processor import KeyPressEvent
+
+__all__ = [
+ "InputMode",
+ "CharacterFind",
+ "ViState",
+]
+
+
+class InputMode(str, Enum):
+ value: str
+
+ INSERT = "vi-insert"
+ INSERT_MULTIPLE = "vi-insert-multiple"
+ NAVIGATION = "vi-navigation" # Normal mode.
+ REPLACE = "vi-replace"
+ REPLACE_SINGLE = "vi-replace-single"
+
+
+class CharacterFind:
+ def __init__(self, character: str, backwards: bool = False) -> None:
+ self.character = character
+ self.backwards = backwards
+
+
+class ViState:
+ """
+ Mutable class to hold the state of the Vi navigation.
+ """
+
+ def __init__(self) -> None:
+ #: None or CharacterFind instance. (This is used to repeat the last
+ #: search in Vi mode, by pressing the 'n' or 'N' in navigation mode.)
+ self.last_character_find: CharacterFind | None = None
+
+ # When an operator is given and we are waiting for text object,
+ # -- e.g. in the case of 'dw', after the 'd' --, an operator callback
+ # is set here.
+ self.operator_func: None | (Callable[[KeyPressEvent, TextObject], None]) = None
+ self.operator_arg: int | None = None
+
+ #: Named registers. Maps register name (e.g. 'a') to
+ #: :class:`ClipboardData` instances.
+ self.named_registers: dict[str, ClipboardData] = {}
+
+ #: The Vi mode we're currently in to.
+ self.__input_mode = InputMode.INSERT
+
+ #: Waiting for digraph.
+ self.waiting_for_digraph = False
+ self.digraph_symbol1: str | None = None # (None or a symbol.)
+
+ #: When true, make ~ act as an operator.
+ self.tilde_operator = False
+
+ #: Register in which we are recording a macro.
+ #: `None` when not recording anything.
+ # Note that the recording is only stored in the register after the
+ # recording is stopped. So we record in a separate `current_recording`
+ # variable.
+ self.recording_register: str | None = None
+ self.current_recording: str = ""
+
+ # Temporary navigation (normal) mode.
+ # This happens when control-o has been pressed in insert or replace
+ # mode. The user can now do one navigation action and we'll return back
+ # to insert/replace.
+ self.temporary_navigation_mode = False
+
+ @property
+ def input_mode(self) -> InputMode:
+ "Get `InputMode`."
+ return self.__input_mode
+
+ @input_mode.setter
+ def input_mode(self, value: InputMode) -> None:
+ "Set `InputMode`."
+ if value == InputMode.NAVIGATION:
+ self.waiting_for_digraph = False
+ self.operator_func = None
+ self.operator_arg = None
+
+ self.__input_mode = value
+
+ def reset(self) -> None:
+ """
+ Reset state, go back to the given mode. INSERT by default.
+ """
+ # Go back to insert mode.
+ self.input_mode = InputMode.INSERT
+
+ self.waiting_for_digraph = False
+ self.operator_func = None
+ self.operator_arg = None
+
+ # Reset recording state.
+ self.recording_register = None
+ self.current_recording = ""
diff --git a/src/prompt_toolkit/keys.py b/src/prompt_toolkit/keys.py
new file mode 100644
index 0000000..ee52aee
--- /dev/null
+++ b/src/prompt_toolkit/keys.py
@@ -0,0 +1,222 @@
+from __future__ import annotations
+
+from enum import Enum
+
+__all__ = [
+ "Keys",
+ "ALL_KEYS",
+]
+
+
+class Keys(str, Enum):
+ """
+ List of keys for use in key bindings.
+
+ Note that this is an "StrEnum", all values can be compared against
+ strings.
+ """
+
+ value: str
+
+ Escape = "escape" # Also Control-[
+ ShiftEscape = "s-escape"
+
+ ControlAt = "c-@" # Also Control-Space.
+
+ ControlA = "c-a"
+ ControlB = "c-b"
+ ControlC = "c-c"
+ ControlD = "c-d"
+ ControlE = "c-e"
+ ControlF = "c-f"
+ ControlG = "c-g"
+ ControlH = "c-h"
+ ControlI = "c-i" # Tab
+ ControlJ = "c-j" # Newline
+ ControlK = "c-k"
+ ControlL = "c-l"
+ ControlM = "c-m" # Carriage return
+ ControlN = "c-n"
+ ControlO = "c-o"
+ ControlP = "c-p"
+ ControlQ = "c-q"
+ ControlR = "c-r"
+ ControlS = "c-s"
+ ControlT = "c-t"
+ ControlU = "c-u"
+ ControlV = "c-v"
+ ControlW = "c-w"
+ ControlX = "c-x"
+ ControlY = "c-y"
+ ControlZ = "c-z"
+
+ Control1 = "c-1"
+ Control2 = "c-2"
+ Control3 = "c-3"
+ Control4 = "c-4"
+ Control5 = "c-5"
+ Control6 = "c-6"
+ Control7 = "c-7"
+ Control8 = "c-8"
+ Control9 = "c-9"
+ Control0 = "c-0"
+
+ ControlShift1 = "c-s-1"
+ ControlShift2 = "c-s-2"
+ ControlShift3 = "c-s-3"
+ ControlShift4 = "c-s-4"
+ ControlShift5 = "c-s-5"
+ ControlShift6 = "c-s-6"
+ ControlShift7 = "c-s-7"
+ ControlShift8 = "c-s-8"
+ ControlShift9 = "c-s-9"
+ ControlShift0 = "c-s-0"
+
+ ControlBackslash = "c-\\"
+ ControlSquareClose = "c-]"
+ ControlCircumflex = "c-^"
+ ControlUnderscore = "c-_"
+
+ Left = "left"
+ Right = "right"
+ Up = "up"
+ Down = "down"
+ Home = "home"
+ End = "end"
+ Insert = "insert"
+ Delete = "delete"
+ PageUp = "pageup"
+ PageDown = "pagedown"
+
+ ControlLeft = "c-left"
+ ControlRight = "c-right"
+ ControlUp = "c-up"
+ ControlDown = "c-down"
+ ControlHome = "c-home"
+ ControlEnd = "c-end"
+ ControlInsert = "c-insert"
+ ControlDelete = "c-delete"
+ ControlPageUp = "c-pageup"
+ ControlPageDown = "c-pagedown"
+
+ ShiftLeft = "s-left"
+ ShiftRight = "s-right"
+ ShiftUp = "s-up"
+ ShiftDown = "s-down"
+ ShiftHome = "s-home"
+ ShiftEnd = "s-end"
+ ShiftInsert = "s-insert"
+ ShiftDelete = "s-delete"
+ ShiftPageUp = "s-pageup"
+ ShiftPageDown = "s-pagedown"
+
+ ControlShiftLeft = "c-s-left"
+ ControlShiftRight = "c-s-right"
+ ControlShiftUp = "c-s-up"
+ ControlShiftDown = "c-s-down"
+ ControlShiftHome = "c-s-home"
+ ControlShiftEnd = "c-s-end"
+ ControlShiftInsert = "c-s-insert"
+ ControlShiftDelete = "c-s-delete"
+ ControlShiftPageUp = "c-s-pageup"
+ ControlShiftPageDown = "c-s-pagedown"
+
+ BackTab = "s-tab" # shift + tab
+
+ F1 = "f1"
+ F2 = "f2"
+ F3 = "f3"
+ F4 = "f4"
+ F5 = "f5"
+ F6 = "f6"
+ F7 = "f7"
+ F8 = "f8"
+ F9 = "f9"
+ F10 = "f10"
+ F11 = "f11"
+ F12 = "f12"
+ F13 = "f13"
+ F14 = "f14"
+ F15 = "f15"
+ F16 = "f16"
+ F17 = "f17"
+ F18 = "f18"
+ F19 = "f19"
+ F20 = "f20"
+ F21 = "f21"
+ F22 = "f22"
+ F23 = "f23"
+ F24 = "f24"
+
+ ControlF1 = "c-f1"
+ ControlF2 = "c-f2"
+ ControlF3 = "c-f3"
+ ControlF4 = "c-f4"
+ ControlF5 = "c-f5"
+ ControlF6 = "c-f6"
+ ControlF7 = "c-f7"
+ ControlF8 = "c-f8"
+ ControlF9 = "c-f9"
+ ControlF10 = "c-f10"
+ ControlF11 = "c-f11"
+ ControlF12 = "c-f12"
+ ControlF13 = "c-f13"
+ ControlF14 = "c-f14"
+ ControlF15 = "c-f15"
+ ControlF16 = "c-f16"
+ ControlF17 = "c-f17"
+ ControlF18 = "c-f18"
+ ControlF19 = "c-f19"
+ ControlF20 = "c-f20"
+ ControlF21 = "c-f21"
+ ControlF22 = "c-f22"
+ ControlF23 = "c-f23"
+ ControlF24 = "c-f24"
+
+ # Matches any key.
+ Any = "<any>"
+
+ # Special.
+ ScrollUp = "<scroll-up>"
+ ScrollDown = "<scroll-down>"
+
+ CPRResponse = "<cursor-position-response>"
+ Vt100MouseEvent = "<vt100-mouse-event>"
+ WindowsMouseEvent = "<windows-mouse-event>"
+ BracketedPaste = "<bracketed-paste>"
+
+ SIGINT = "<sigint>"
+
+ # For internal use: key which is ignored.
+ # (The key binding for this key should not do anything.)
+ Ignore = "<ignore>"
+
+ # Some 'Key' aliases (for backwards-compatibility).
+ ControlSpace = ControlAt
+ Tab = ControlI
+ Enter = ControlM
+ Backspace = ControlH
+
+ # ShiftControl was renamed to ControlShift in
+ # 888fcb6fa4efea0de8333177e1bbc792f3ff3c24 (20 Feb 2020).
+ ShiftControlLeft = ControlShiftLeft
+ ShiftControlRight = ControlShiftRight
+ ShiftControlHome = ControlShiftHome
+ ShiftControlEnd = ControlShiftEnd
+
+
+ALL_KEYS: list[str] = [k.value for k in Keys]
+
+
+# Aliases.
+KEY_ALIASES: dict[str, str] = {
+ "backspace": "c-h",
+ "c-space": "c-@",
+ "enter": "c-m",
+ "tab": "c-i",
+ # ShiftControl was renamed to ControlShift.
+ "s-c-left": "c-s-left",
+ "s-c-right": "c-s-right",
+ "s-c-home": "c-s-home",
+ "s-c-end": "c-s-end",
+}
diff --git a/src/prompt_toolkit/layout/__init__.py b/src/prompt_toolkit/layout/__init__.py
new file mode 100644
index 0000000..c5fce46
--- /dev/null
+++ b/src/prompt_toolkit/layout/__init__.py
@@ -0,0 +1,146 @@
+"""
+Command line layout definitions
+-------------------------------
+
+The layout of a command line interface is defined by a Container instance.
+There are two main groups of classes here. Containers and controls:
+
+- A container can contain other containers or controls, it can have multiple
+ children and it decides about the dimensions.
+- A control is responsible for rendering the actual content to a screen.
+ A control can propose some dimensions, but it's the container who decides
+ about the dimensions -- or when the control consumes more space -- which part
+ of the control will be visible.
+
+
+Container classes::
+
+ - Container (Abstract base class)
+ |- HSplit (Horizontal split)
+ |- VSplit (Vertical split)
+ |- FloatContainer (Container which can also contain menus and other floats)
+ `- Window (Container which contains one actual control
+
+Control classes::
+
+ - UIControl (Abstract base class)
+ |- FormattedTextControl (Renders formatted text, or a simple list of text fragments)
+ `- BufferControl (Renders an input buffer.)
+
+
+Usually, you end up wrapping every control inside a `Window` object, because
+that's the only way to render it in a layout.
+
+There are some prepared toolbars which are ready to use::
+
+- SystemToolbar (Shows the 'system' input buffer, for entering system commands.)
+- ArgToolbar (Shows the input 'arg', for repetition of input commands.)
+- SearchToolbar (Shows the 'search' input buffer, for incremental search.)
+- CompletionsToolbar (Shows the completions of the current buffer.)
+- ValidationToolbar (Shows validation errors of the current buffer.)
+
+And one prepared menu:
+
+- CompletionsMenu
+
+"""
+from __future__ import annotations
+
+from .containers import (
+ AnyContainer,
+ ColorColumn,
+ ConditionalContainer,
+ Container,
+ DynamicContainer,
+ Float,
+ FloatContainer,
+ HorizontalAlign,
+ HSplit,
+ ScrollOffsets,
+ VerticalAlign,
+ VSplit,
+ Window,
+ WindowAlign,
+ WindowRenderInfo,
+ is_container,
+ to_container,
+ to_window,
+)
+from .controls import (
+ BufferControl,
+ DummyControl,
+ FormattedTextControl,
+ SearchBufferControl,
+ UIContent,
+ UIControl,
+)
+from .dimension import (
+ AnyDimension,
+ D,
+ Dimension,
+ is_dimension,
+ max_layout_dimensions,
+ sum_layout_dimensions,
+ to_dimension,
+)
+from .layout import InvalidLayoutError, Layout, walk
+from .margins import (
+ ConditionalMargin,
+ Margin,
+ NumberedMargin,
+ PromptMargin,
+ ScrollbarMargin,
+)
+from .menus import CompletionsMenu, MultiColumnCompletionsMenu
+from .scrollable_pane import ScrollablePane
+
+__all__ = [
+ # Layout.
+ "Layout",
+ "InvalidLayoutError",
+ "walk",
+ # Dimensions.
+ "AnyDimension",
+ "Dimension",
+ "D",
+ "sum_layout_dimensions",
+ "max_layout_dimensions",
+ "to_dimension",
+ "is_dimension",
+ # Containers.
+ "AnyContainer",
+ "Container",
+ "HorizontalAlign",
+ "VerticalAlign",
+ "HSplit",
+ "VSplit",
+ "FloatContainer",
+ "Float",
+ "WindowAlign",
+ "Window",
+ "WindowRenderInfo",
+ "ConditionalContainer",
+ "ScrollOffsets",
+ "ColorColumn",
+ "to_container",
+ "to_window",
+ "is_container",
+ "DynamicContainer",
+ "ScrollablePane",
+ # Controls.
+ "BufferControl",
+ "SearchBufferControl",
+ "DummyControl",
+ "FormattedTextControl",
+ "UIControl",
+ "UIContent",
+ # Margins.
+ "Margin",
+ "NumberedMargin",
+ "ScrollbarMargin",
+ "ConditionalMargin",
+ "PromptMargin",
+ # Menus.
+ "CompletionsMenu",
+ "MultiColumnCompletionsMenu",
+]
diff --git a/src/prompt_toolkit/layout/containers.py b/src/prompt_toolkit/layout/containers.py
new file mode 100644
index 0000000..100d4aa
--- /dev/null
+++ b/src/prompt_toolkit/layout/containers.py
@@ -0,0 +1,2743 @@
+"""
+Container for the layout.
+(Containers can contain other containers or user interface controls.)
+"""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from enum import Enum
+from functools import partial
+from typing import TYPE_CHECKING, Callable, Sequence, Union, cast
+
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.cache import SimpleCache
+from prompt_toolkit.data_structures import Point
+from prompt_toolkit.filters import (
+ FilterOrBool,
+ emacs_insert_mode,
+ to_filter,
+ vi_insert_mode,
+)
+from prompt_toolkit.formatted_text import (
+ AnyFormattedText,
+ StyleAndTextTuples,
+ to_formatted_text,
+)
+from prompt_toolkit.formatted_text.utils import (
+ fragment_list_to_text,
+ fragment_list_width,
+)
+from prompt_toolkit.key_binding import KeyBindingsBase
+from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
+from prompt_toolkit.utils import get_cwidth, take_using_weights, to_int, to_str
+
+from .controls import (
+ DummyControl,
+ FormattedTextControl,
+ GetLinePrefixCallable,
+ UIContent,
+ UIControl,
+)
+from .dimension import (
+ AnyDimension,
+ Dimension,
+ max_layout_dimensions,
+ sum_layout_dimensions,
+ to_dimension,
+)
+from .margins import Margin
+from .mouse_handlers import MouseHandlers
+from .screen import _CHAR_CACHE, Screen, WritePosition
+from .utils import explode_text_fragments
+
+if TYPE_CHECKING:
+ from typing_extensions import Protocol, TypeGuard
+
+ from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
+
+
+__all__ = [
+ "AnyContainer",
+ "Container",
+ "HorizontalAlign",
+ "VerticalAlign",
+ "HSplit",
+ "VSplit",
+ "FloatContainer",
+ "Float",
+ "WindowAlign",
+ "Window",
+ "WindowRenderInfo",
+ "ConditionalContainer",
+ "ScrollOffsets",
+ "ColorColumn",
+ "to_container",
+ "to_window",
+ "is_container",
+ "DynamicContainer",
+]
+
+
+class Container(metaclass=ABCMeta):
+ """
+ Base class for user interface layout.
+ """
+
+ @abstractmethod
+ def reset(self) -> None:
+ """
+ Reset the state of this container and all the children.
+ (E.g. reset scroll offsets, etc...)
+ """
+
+ @abstractmethod
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ """
+ Return a :class:`~prompt_toolkit.layout.Dimension` that represents the
+ desired width for this container.
+ """
+
+ @abstractmethod
+ 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.
+ """
+
+ @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:
+ """
+ Write the actual content to the screen.
+
+ :param screen: :class:`~prompt_toolkit.layout.screen.Screen`
+ :param mouse_handlers: :class:`~prompt_toolkit.layout.mouse_handlers.MouseHandlers`.
+ :param parent_style: Style string to pass to the :class:`.Window`
+ object. This will be applied to all content of the windows.
+ :class:`.VSplit` and :class:`.HSplit` can use it to pass their
+ style down to the windows that they contain.
+ :param z_index: Used for propagating z_index from parent to child.
+ """
+
+ def is_modal(self) -> bool:
+ """
+ When this container is modal, key bindings from parent containers are
+ not taken into account if a user control in this container is focused.
+ """
+ return False
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ """
+ Returns a :class:`.KeyBindings` object. These bindings become active when any
+ user control in this container has the focus, except if any containers
+ between this container and the focused user control is modal.
+ """
+ return None
+
+ @abstractmethod
+ def get_children(self) -> list[Container]:
+ """
+ Return the list of child :class:`.Container` objects.
+ """
+ return []
+
+
+if TYPE_CHECKING:
+
+ class MagicContainer(Protocol):
+ """
+ Any object that implements ``__pt_container__`` represents a container.
+ """
+
+ def __pt_container__(self) -> AnyContainer:
+ ...
+
+
+AnyContainer = Union[Container, "MagicContainer"]
+
+
+def _window_too_small() -> Window:
+ "Create a `Window` that displays the 'Window too small' text."
+ return Window(
+ FormattedTextControl(text=[("class:window-too-small", " Window too small... ")])
+ )
+
+
+class VerticalAlign(Enum):
+ "Alignment for `HSplit`."
+
+ TOP = "TOP"
+ CENTER = "CENTER"
+ BOTTOM = "BOTTOM"
+ JUSTIFY = "JUSTIFY"
+
+
+class HorizontalAlign(Enum):
+ "Alignment for `VSplit`."
+
+ LEFT = "LEFT"
+ CENTER = "CENTER"
+ RIGHT = "RIGHT"
+ JUSTIFY = "JUSTIFY"
+
+
+class _Split(Container):
+ """
+ The common parts of `VSplit` and `HSplit`.
+ """
+
+ def __init__(
+ self,
+ children: Sequence[AnyContainer],
+ window_too_small: Container | None = None,
+ padding: AnyDimension = Dimension.exact(0),
+ padding_char: str | None = None,
+ padding_style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: str | Callable[[], str] = "",
+ ) -> None:
+ self.children = [to_container(c) for c in children]
+ self.window_too_small = window_too_small or _window_too_small()
+ self.padding = padding
+ self.padding_char = padding_char
+ self.padding_style = padding_style
+
+ self.width = width
+ self.height = height
+ self.z_index = z_index
+
+ self.modal = modal
+ self.key_bindings = key_bindings
+ self.style = style
+
+ def is_modal(self) -> bool:
+ return self.modal
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ return self.key_bindings
+
+ def get_children(self) -> list[Container]:
+ return self.children
+
+
+class HSplit(_Split):
+ """
+ Several layouts, one stacked above/under the other. ::
+
+ +--------------------+
+ | |
+ +--------------------+
+ | |
+ +--------------------+
+
+ By default, this doesn't display a horizontal line between the children,
+ but if this is something you need, then create a HSplit as follows::
+
+ HSplit(children=[ ... ], padding_char='-',
+ padding=1, padding_style='#ffff00')
+
+ :param children: List of child :class:`.Container` objects.
+ :param window_too_small: A :class:`.Container` object that is displayed if
+ there is not enough space for all the children. By default, this is a
+ "Window too small" message.
+ :param align: `VerticalAlign` value.
+ :param width: When given, use this width instead of looking at the children.
+ :param height: When given, use this height instead of looking at the children.
+ :param z_index: (int or None) When specified, this can be used to bring
+ element in front of floating elements. `None` means: inherit from parent.
+ :param style: A style string.
+ :param modal: ``True`` or ``False``.
+ :param key_bindings: ``None`` or a :class:`.KeyBindings` object.
+
+ :param padding: (`Dimension` or int), size to be used for the padding.
+ :param padding_char: Character to be used for filling in the padding.
+ :param padding_style: Style to applied to the padding.
+ """
+
+ def __init__(
+ self,
+ children: Sequence[AnyContainer],
+ window_too_small: Container | None = None,
+ align: VerticalAlign = VerticalAlign.JUSTIFY,
+ padding: AnyDimension = 0,
+ padding_char: str | None = None,
+ padding_style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: str | Callable[[], str] = "",
+ ) -> None:
+ super().__init__(
+ children=children,
+ window_too_small=window_too_small,
+ padding=padding,
+ padding_char=padding_char,
+ padding_style=padding_style,
+ width=width,
+ height=height,
+ z_index=z_index,
+ modal=modal,
+ key_bindings=key_bindings,
+ style=style,
+ )
+
+ self.align = align
+
+ self._children_cache: SimpleCache[
+ tuple[Container, ...], list[Container]
+ ] = SimpleCache(maxsize=1)
+ self._remaining_space_window = Window() # Dummy window.
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ if self.width is not None:
+ return to_dimension(self.width)
+
+ if self.children:
+ dimensions = [c.preferred_width(max_available_width) for c in self.children]
+ return max_layout_dimensions(dimensions)
+ else:
+ return Dimension()
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ if self.height is not None:
+ return to_dimension(self.height)
+
+ dimensions = [
+ c.preferred_height(width, max_available_height) for c in self._all_children
+ ]
+ return sum_layout_dimensions(dimensions)
+
+ def reset(self) -> None:
+ for c in self.children:
+ c.reset()
+
+ @property
+ def _all_children(self) -> list[Container]:
+ """
+ List of child objects, including padding.
+ """
+
+ def get() -> list[Container]:
+ result: list[Container] = []
+
+ # Padding Top.
+ if self.align in (VerticalAlign.CENTER, VerticalAlign.BOTTOM):
+ result.append(Window(width=Dimension(preferred=0)))
+
+ # The children with padding.
+ for child in self.children:
+ result.append(child)
+ result.append(
+ Window(
+ height=self.padding,
+ char=self.padding_char,
+ style=self.padding_style,
+ )
+ )
+ if result:
+ result.pop()
+
+ # Padding right.
+ if self.align in (VerticalAlign.CENTER, VerticalAlign.TOP):
+ result.append(Window(width=Dimension(preferred=0)))
+
+ return result
+
+ return self._children_cache.get(tuple(self.children), get)
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ """
+ Render the prompt to a `Screen` instance.
+
+ :param screen: The :class:`~prompt_toolkit.layout.screen.Screen` class
+ to which the output has to be written.
+ """
+ sizes = self._divide_heights(write_position)
+ style = parent_style + " " + to_str(self.style)
+ z_index = z_index if self.z_index is None else self.z_index
+
+ if sizes is None:
+ self.window_too_small.write_to_screen(
+ screen, mouse_handlers, write_position, style, erase_bg, z_index
+ )
+ else:
+ #
+ ypos = write_position.ypos
+ xpos = write_position.xpos
+ width = write_position.width
+
+ # Draw child panes.
+ for s, c in zip(sizes, self._all_children):
+ c.write_to_screen(
+ screen,
+ mouse_handlers,
+ WritePosition(xpos, ypos, width, s),
+ style,
+ erase_bg,
+ z_index,
+ )
+ ypos += s
+
+ # Fill in the remaining space. This happens when a child control
+ # refuses to take more space and we don't have any padding. Adding a
+ # dummy child control for this (in `self._all_children`) is not
+ # desired, because in some situations, it would take more space, even
+ # when it's not required. This is required to apply the styling.
+ remaining_height = write_position.ypos + write_position.height - ypos
+ if remaining_height > 0:
+ self._remaining_space_window.write_to_screen(
+ screen,
+ mouse_handlers,
+ WritePosition(xpos, ypos, width, remaining_height),
+ style,
+ erase_bg,
+ z_index,
+ )
+
+ def _divide_heights(self, write_position: WritePosition) -> list[int] | None:
+ """
+ Return the heights for all rows.
+ Or None when there is not enough space.
+ """
+ if not self.children:
+ return []
+
+ width = write_position.width
+ height = write_position.height
+
+ # Calculate heights.
+ dimensions = [c.preferred_height(width, height) for c in self._all_children]
+
+ # Sum dimensions
+ sum_dimensions = sum_layout_dimensions(dimensions)
+
+ # If there is not enough space for both.
+ # Don't do anything.
+ if sum_dimensions.min > height:
+ return None
+
+ # Find optimal sizes. (Start with minimal size, increase until we cover
+ # the whole height.)
+ sizes = [d.min for d in dimensions]
+
+ child_generator = take_using_weights(
+ items=list(range(len(dimensions))), weights=[d.weight for d in dimensions]
+ )
+
+ i = next(child_generator)
+
+ # Increase until we meet at least the 'preferred' size.
+ preferred_stop = min(height, sum_dimensions.preferred)
+ preferred_dimensions = [d.preferred for d in dimensions]
+
+ while sum(sizes) < preferred_stop:
+ if sizes[i] < preferred_dimensions[i]:
+ sizes[i] += 1
+ i = next(child_generator)
+
+ # Increase until we use all the available space. (or until "max")
+ if not get_app().is_done:
+ max_stop = min(height, sum_dimensions.max)
+ max_dimensions = [d.max for d in dimensions]
+
+ while sum(sizes) < max_stop:
+ if sizes[i] < max_dimensions[i]:
+ sizes[i] += 1
+ i = next(child_generator)
+
+ return sizes
+
+
+class VSplit(_Split):
+ """
+ Several layouts, one stacked left/right of the other. ::
+
+ +---------+----------+
+ | | |
+ | | |
+ +---------+----------+
+
+ By default, this doesn't display a vertical line between the children, but
+ if this is something you need, then create a HSplit as follows::
+
+ VSplit(children=[ ... ], padding_char='|',
+ padding=1, padding_style='#ffff00')
+
+ :param children: List of child :class:`.Container` objects.
+ :param window_too_small: A :class:`.Container` object that is displayed if
+ there is not enough space for all the children. By default, this is a
+ "Window too small" message.
+ :param align: `HorizontalAlign` value.
+ :param width: When given, use this width instead of looking at the children.
+ :param height: When given, use this height instead of looking at the children.
+ :param z_index: (int or None) When specified, this can be used to bring
+ element in front of floating elements. `None` means: inherit from parent.
+ :param style: A style string.
+ :param modal: ``True`` or ``False``.
+ :param key_bindings: ``None`` or a :class:`.KeyBindings` object.
+
+ :param padding: (`Dimension` or int), size to be used for the padding.
+ :param padding_char: Character to be used for filling in the padding.
+ :param padding_style: Style to applied to the padding.
+ """
+
+ def __init__(
+ self,
+ children: Sequence[AnyContainer],
+ window_too_small: Container | None = None,
+ align: HorizontalAlign = HorizontalAlign.JUSTIFY,
+ padding: AnyDimension = 0,
+ padding_char: str | None = None,
+ padding_style: str = "",
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: str | Callable[[], str] = "",
+ ) -> None:
+ super().__init__(
+ children=children,
+ window_too_small=window_too_small,
+ padding=padding,
+ padding_char=padding_char,
+ padding_style=padding_style,
+ width=width,
+ height=height,
+ z_index=z_index,
+ modal=modal,
+ key_bindings=key_bindings,
+ style=style,
+ )
+
+ self.align = align
+
+ self._children_cache: SimpleCache[
+ tuple[Container, ...], list[Container]
+ ] = SimpleCache(maxsize=1)
+ self._remaining_space_window = Window() # Dummy window.
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ if self.width is not None:
+ return to_dimension(self.width)
+
+ dimensions = [
+ c.preferred_width(max_available_width) for c in self._all_children
+ ]
+
+ return sum_layout_dimensions(dimensions)
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ if self.height is not None:
+ return to_dimension(self.height)
+
+ # At the point where we want to calculate the heights, the widths have
+ # already been decided. So we can trust `width` to be the actual
+ # `width` that's going to be used for the rendering. So,
+ # `divide_widths` is supposed to use all of the available width.
+ # Using only the `preferred` width caused a bug where the reported
+ # height was more than required. (we had a `BufferControl` which did
+ # wrap lines because of the smaller width returned by `_divide_widths`.
+
+ sizes = self._divide_widths(width)
+ children = self._all_children
+
+ if sizes is None:
+ return Dimension()
+ else:
+ dimensions = [
+ c.preferred_height(s, max_available_height)
+ for s, c in zip(sizes, children)
+ ]
+ return max_layout_dimensions(dimensions)
+
+ def reset(self) -> None:
+ for c in self.children:
+ c.reset()
+
+ @property
+ def _all_children(self) -> list[Container]:
+ """
+ List of child objects, including padding.
+ """
+
+ def get() -> list[Container]:
+ result: list[Container] = []
+
+ # Padding left.
+ if self.align in (HorizontalAlign.CENTER, HorizontalAlign.RIGHT):
+ result.append(Window(width=Dimension(preferred=0)))
+
+ # The children with padding.
+ for child in self.children:
+ result.append(child)
+ result.append(
+ Window(
+ width=self.padding,
+ char=self.padding_char,
+ style=self.padding_style,
+ )
+ )
+ if result:
+ result.pop()
+
+ # Padding right.
+ if self.align in (HorizontalAlign.CENTER, HorizontalAlign.LEFT):
+ result.append(Window(width=Dimension(preferred=0)))
+
+ return result
+
+ return self._children_cache.get(tuple(self.children), get)
+
+ def _divide_widths(self, width: int) -> list[int] | None:
+ """
+ Return the widths for all columns.
+ Or None when there is not enough space.
+ """
+ children = self._all_children
+
+ if not children:
+ return []
+
+ # Calculate widths.
+ dimensions = [c.preferred_width(width) for c in children]
+ preferred_dimensions = [d.preferred for d in dimensions]
+
+ # Sum dimensions
+ sum_dimensions = sum_layout_dimensions(dimensions)
+
+ # If there is not enough space for both.
+ # Don't do anything.
+ if sum_dimensions.min > width:
+ return None
+
+ # Find optimal sizes. (Start with minimal size, increase until we cover
+ # the whole width.)
+ sizes = [d.min for d in dimensions]
+
+ child_generator = take_using_weights(
+ items=list(range(len(dimensions))), weights=[d.weight for d in dimensions]
+ )
+
+ i = next(child_generator)
+
+ # Increase until we meet at least the 'preferred' size.
+ preferred_stop = min(width, sum_dimensions.preferred)
+
+ while sum(sizes) < preferred_stop:
+ if sizes[i] < preferred_dimensions[i]:
+ sizes[i] += 1
+ i = next(child_generator)
+
+ # Increase until we use all the available space.
+ max_dimensions = [d.max for d in dimensions]
+ max_stop = min(width, sum_dimensions.max)
+
+ while sum(sizes) < max_stop:
+ if sizes[i] < max_dimensions[i]:
+ sizes[i] += 1
+ i = next(child_generator)
+
+ return sizes
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ """
+ Render the prompt to a `Screen` instance.
+
+ :param screen: The :class:`~prompt_toolkit.layout.screen.Screen` class
+ to which the output has to be written.
+ """
+ if not self.children:
+ return
+
+ children = self._all_children
+ sizes = self._divide_widths(write_position.width)
+ style = parent_style + " " + to_str(self.style)
+ z_index = z_index if self.z_index is None else self.z_index
+
+ # If there is not enough space.
+ if sizes is None:
+ self.window_too_small.write_to_screen(
+ screen, mouse_handlers, write_position, style, erase_bg, z_index
+ )
+ return
+
+ # Calculate heights, take the largest possible, but not larger than
+ # write_position.height.
+ heights = [
+ child.preferred_height(width, write_position.height).preferred
+ for width, child in zip(sizes, children)
+ ]
+ height = max(write_position.height, min(write_position.height, max(heights)))
+
+ #
+ ypos = write_position.ypos
+ xpos = write_position.xpos
+
+ # Draw all child panes.
+ for s, c in zip(sizes, children):
+ c.write_to_screen(
+ screen,
+ mouse_handlers,
+ WritePosition(xpos, ypos, s, height),
+ style,
+ erase_bg,
+ z_index,
+ )
+ xpos += s
+
+ # Fill in the remaining space. This happens when a child control
+ # refuses to take more space and we don't have any padding. Adding a
+ # dummy child control for this (in `self._all_children`) is not
+ # desired, because in some situations, it would take more space, even
+ # when it's not required. This is required to apply the styling.
+ remaining_width = write_position.xpos + write_position.width - xpos
+ if remaining_width > 0:
+ self._remaining_space_window.write_to_screen(
+ screen,
+ mouse_handlers,
+ WritePosition(xpos, ypos, remaining_width, height),
+ style,
+ erase_bg,
+ z_index,
+ )
+
+
+class FloatContainer(Container):
+ """
+ Container which can contain another container for the background, as well
+ as a list of floating containers on top of it.
+
+ Example Usage::
+
+ FloatContainer(content=Window(...),
+ floats=[
+ Float(xcursor=True,
+ ycursor=True,
+ content=CompletionsMenu(...))
+ ])
+
+ :param z_index: (int or None) When specified, this can be used to bring
+ element in front of floating elements. `None` means: inherit from parent.
+ This is the z_index for the whole `Float` container as a whole.
+ """
+
+ def __init__(
+ self,
+ content: AnyContainer,
+ floats: list[Float],
+ modal: bool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ style: str | Callable[[], str] = "",
+ z_index: int | None = None,
+ ) -> None:
+ self.content = to_container(content)
+ self.floats = floats
+
+ self.modal = modal
+ self.key_bindings = key_bindings
+ self.style = style
+ self.z_index = z_index
+
+ def reset(self) -> None:
+ self.content.reset()
+
+ for f in self.floats:
+ f.content.reset()
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ return self.content.preferred_width(max_available_width)
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ """
+ Return the preferred height of the float container.
+ (We don't care about the height of the floats, they should always fit
+ into the dimensions provided by the container.)
+ """
+ return self.content.preferred_height(width, max_available_height)
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ style = parent_style + " " + to_str(self.style)
+ z_index = z_index if self.z_index is None else self.z_index
+
+ self.content.write_to_screen(
+ screen, mouse_handlers, write_position, style, erase_bg, z_index
+ )
+
+ for number, fl in enumerate(self.floats):
+ # z_index of a Float is computed by summing the z_index of the
+ # container and the `Float`.
+ new_z_index = (z_index or 0) + fl.z_index
+ style = parent_style + " " + to_str(self.style)
+
+ # If the float that we have here, is positioned relative to the
+ # cursor position, but the Window that specifies the cursor
+ # position is not drawn yet, because it's a Float itself, we have
+ # to postpone this calculation. (This is a work-around, but good
+ # enough for now.)
+ postpone = fl.xcursor is not None or fl.ycursor is not None
+
+ if postpone:
+ new_z_index = (
+ number + 10**8
+ ) # Draw as late as possible, but keep the order.
+ screen.draw_with_z_index(
+ z_index=new_z_index,
+ draw_func=partial(
+ self._draw_float,
+ fl,
+ screen,
+ mouse_handlers,
+ write_position,
+ style,
+ erase_bg,
+ new_z_index,
+ ),
+ )
+ else:
+ self._draw_float(
+ fl,
+ screen,
+ mouse_handlers,
+ write_position,
+ style,
+ erase_bg,
+ new_z_index,
+ )
+
+ def _draw_float(
+ self,
+ fl: Float,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ "Draw a single Float."
+ # When a menu_position was given, use this instead of the cursor
+ # position. (These cursor positions are absolute, translate again
+ # relative to the write_position.)
+ # Note: This should be inside the for-loop, because one float could
+ # set the cursor position to be used for the next one.
+ cpos = screen.get_menu_position(
+ fl.attach_to_window or get_app().layout.current_window
+ )
+ cursor_position = Point(
+ x=cpos.x - write_position.xpos, y=cpos.y - write_position.ypos
+ )
+
+ fl_width = fl.get_width()
+ fl_height = fl.get_height()
+ width: int
+ height: int
+ xpos: int
+ ypos: int
+
+ # Left & width given.
+ if fl.left is not None and fl_width is not None:
+ xpos = fl.left
+ width = fl_width
+ # Left & right given -> calculate width.
+ elif fl.left is not None and fl.right is not None:
+ xpos = fl.left
+ width = write_position.width - fl.left - fl.right
+ # Width & right given -> calculate left.
+ elif fl_width is not None and fl.right is not None:
+ xpos = write_position.width - fl.right - fl_width
+ width = fl_width
+ # Near x position of cursor.
+ elif fl.xcursor:
+ if fl_width is None:
+ width = fl.content.preferred_width(write_position.width).preferred
+ width = min(write_position.width, width)
+ else:
+ width = fl_width
+
+ xpos = cursor_position.x
+ if xpos + width > write_position.width:
+ xpos = max(0, write_position.width - width)
+ # Only width given -> center horizontally.
+ elif fl_width:
+ xpos = int((write_position.width - fl_width) / 2)
+ width = fl_width
+ # Otherwise, take preferred width from float content.
+ else:
+ width = fl.content.preferred_width(write_position.width).preferred
+
+ if fl.left is not None:
+ xpos = fl.left
+ elif fl.right is not None:
+ xpos = max(0, write_position.width - width - fl.right)
+ else: # Center horizontally.
+ xpos = max(0, int((write_position.width - width) / 2))
+
+ # Trim.
+ width = min(width, write_position.width - xpos)
+
+ # Top & height given.
+ if fl.top is not None and fl_height is not None:
+ ypos = fl.top
+ height = fl_height
+ # Top & bottom given -> calculate height.
+ elif fl.top is not None and fl.bottom is not None:
+ ypos = fl.top
+ height = write_position.height - fl.top - fl.bottom
+ # Height & bottom given -> calculate top.
+ elif fl_height is not None and fl.bottom is not None:
+ ypos = write_position.height - fl_height - fl.bottom
+ height = fl_height
+ # Near cursor.
+ elif fl.ycursor:
+ ypos = cursor_position.y + (0 if fl.allow_cover_cursor else 1)
+
+ if fl_height is None:
+ height = fl.content.preferred_height(
+ width, write_position.height
+ ).preferred
+ else:
+ height = fl_height
+
+ # Reduce height if not enough space. (We can use the height
+ # when the content requires it.)
+ if height > write_position.height - ypos:
+ if write_position.height - ypos + 1 >= ypos:
+ # When the space below the cursor is more than
+ # the space above, just reduce the height.
+ height = write_position.height - ypos
+ else:
+ # Otherwise, fit the float above the cursor.
+ height = min(height, cursor_position.y)
+ ypos = cursor_position.y - height
+
+ # Only height given -> center vertically.
+ elif fl_height:
+ ypos = int((write_position.height - fl_height) / 2)
+ height = fl_height
+ # Otherwise, take preferred height from content.
+ else:
+ height = fl.content.preferred_height(width, write_position.height).preferred
+
+ if fl.top is not None:
+ ypos = fl.top
+ elif fl.bottom is not None:
+ ypos = max(0, write_position.height - height - fl.bottom)
+ else: # Center vertically.
+ ypos = max(0, int((write_position.height - height) / 2))
+
+ # Trim.
+ height = min(height, write_position.height - ypos)
+
+ # Write float.
+ # (xpos and ypos can be negative: a float can be partially visible.)
+ if height > 0 and width > 0:
+ wp = WritePosition(
+ xpos=xpos + write_position.xpos,
+ ypos=ypos + write_position.ypos,
+ width=width,
+ height=height,
+ )
+
+ if not fl.hide_when_covering_content or self._area_is_empty(screen, wp):
+ fl.content.write_to_screen(
+ screen,
+ mouse_handlers,
+ wp,
+ style,
+ erase_bg=not fl.transparent(),
+ z_index=z_index,
+ )
+
+ def _area_is_empty(self, screen: Screen, write_position: WritePosition) -> bool:
+ """
+ Return True when the area below the write position is still empty.
+ (For floats that should not hide content underneath.)
+ """
+ wp = write_position
+
+ for y in range(wp.ypos, wp.ypos + wp.height):
+ if y in screen.data_buffer:
+ row = screen.data_buffer[y]
+
+ for x in range(wp.xpos, wp.xpos + wp.width):
+ c = row[x]
+ if c.char != " ":
+ return False
+
+ return True
+
+ def is_modal(self) -> bool:
+ return self.modal
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ return self.key_bindings
+
+ def get_children(self) -> list[Container]:
+ children = [self.content]
+ children.extend(f.content for f in self.floats)
+ return children
+
+
+class Float:
+ """
+ Float for use in a :class:`.FloatContainer`.
+ Except for the `content` parameter, all other options are optional.
+
+ :param content: :class:`.Container` instance.
+
+ :param width: :class:`.Dimension` or callable which returns a :class:`.Dimension`.
+ :param height: :class:`.Dimension` or callable which returns a :class:`.Dimension`.
+
+ :param left: Distance to the left edge of the :class:`.FloatContainer`.
+ :param right: Distance to the right edge of the :class:`.FloatContainer`.
+ :param top: Distance to the top of the :class:`.FloatContainer`.
+ :param bottom: Distance to the bottom of the :class:`.FloatContainer`.
+
+ :param attach_to_window: Attach to the cursor from this window, instead of
+ the current window.
+ :param hide_when_covering_content: Hide the float when it covers content underneath.
+ :param allow_cover_cursor: When `False`, make sure to display the float
+ below the cursor. Not on top of the indicated position.
+ :param z_index: Z-index position. For a Float, this needs to be at least
+ one. It is relative to the z_index of the parent container.
+ :param transparent: :class:`.Filter` indicating whether this float needs to be
+ drawn transparently.
+ """
+
+ def __init__(
+ self,
+ content: AnyContainer,
+ top: int | None = None,
+ right: int | None = None,
+ bottom: int | None = None,
+ left: int | None = None,
+ width: int | Callable[[], int] | None = None,
+ height: int | Callable[[], int] | None = None,
+ xcursor: bool = False,
+ ycursor: bool = False,
+ attach_to_window: AnyContainer | None = None,
+ hide_when_covering_content: bool = False,
+ allow_cover_cursor: bool = False,
+ z_index: int = 1,
+ transparent: bool = False,
+ ) -> None:
+ assert z_index >= 1
+
+ self.left = left
+ self.right = right
+ self.top = top
+ self.bottom = bottom
+
+ self.width = width
+ self.height = height
+
+ self.xcursor = xcursor
+ self.ycursor = ycursor
+
+ self.attach_to_window = (
+ to_window(attach_to_window) if attach_to_window else None
+ )
+
+ self.content = to_container(content)
+ self.hide_when_covering_content = hide_when_covering_content
+ self.allow_cover_cursor = allow_cover_cursor
+ self.z_index = z_index
+ self.transparent = to_filter(transparent)
+
+ def get_width(self) -> int | None:
+ if callable(self.width):
+ return self.width()
+ return self.width
+
+ def get_height(self) -> int | None:
+ if callable(self.height):
+ return self.height()
+ return self.height
+
+ def __repr__(self) -> str:
+ return "Float(content=%r)" % self.content
+
+
+class WindowRenderInfo:
+ """
+ Render information for the last render time of this control.
+ It stores mapping information between the input buffers (in case of a
+ :class:`~prompt_toolkit.layout.controls.BufferControl`) and the actual
+ render position on the output screen.
+
+ (Could be used for implementation of the Vi 'H' and 'L' key bindings as
+ well as implementing mouse support.)
+
+ :param ui_content: The original :class:`.UIContent` instance that contains
+ the whole input, without clipping. (ui_content)
+ :param horizontal_scroll: The horizontal scroll of the :class:`.Window` instance.
+ :param vertical_scroll: The vertical scroll of the :class:`.Window` instance.
+ :param window_width: The width of the window that displays the content,
+ without the margins.
+ :param window_height: The height of the window that displays the content.
+ :param configured_scroll_offsets: The scroll offsets as configured for the
+ :class:`Window` instance.
+ :param visible_line_to_row_col: Mapping that maps the row numbers on the
+ displayed screen (starting from zero for the first visible line) to
+ (row, col) tuples pointing to the row and column of the :class:`.UIContent`.
+ :param rowcol_to_yx: Mapping that maps (row, column) tuples representing
+ coordinates of the :class:`UIContent` to (y, x) absolute coordinates at
+ the rendered screen.
+ """
+
+ def __init__(
+ self,
+ window: Window,
+ ui_content: UIContent,
+ horizontal_scroll: int,
+ vertical_scroll: int,
+ window_width: int,
+ window_height: int,
+ configured_scroll_offsets: ScrollOffsets,
+ visible_line_to_row_col: dict[int, tuple[int, int]],
+ rowcol_to_yx: dict[tuple[int, int], tuple[int, int]],
+ x_offset: int,
+ y_offset: int,
+ wrap_lines: bool,
+ ) -> None:
+ self.window = window
+ self.ui_content = ui_content
+ self.vertical_scroll = vertical_scroll
+ self.window_width = window_width # Width without margins.
+ self.window_height = window_height
+
+ self.configured_scroll_offsets = configured_scroll_offsets
+ self.visible_line_to_row_col = visible_line_to_row_col
+ self.wrap_lines = wrap_lines
+
+ self._rowcol_to_yx = rowcol_to_yx # row/col from input to absolute y/x
+ # screen coordinates.
+ self._x_offset = x_offset
+ self._y_offset = y_offset
+
+ @property
+ 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.
+ """
+ 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:
+ """
+ Return a :class:`.ScrollOffsets` instance that indicates the actual
+ offset. This can be less than or equal to what's configured. E.g, when
+ the cursor is completely at the top, the top offset will be zero rather
+ than what's configured.
+ """
+ if self.displayed_lines[0] == 0:
+ top = 0
+ else:
+ # Get row where the cursor is displayed.
+ y = self.input_line_to_visible_line[self.ui_content.cursor_position.y]
+ top = min(y, self.configured_scroll_offsets.top)
+
+ return ScrollOffsets(
+ top=top,
+ bottom=min(
+ self.ui_content.line_count - self.displayed_lines[-1] - 1,
+ self.configured_scroll_offsets.bottom,
+ ),
+ # For left/right, it probably doesn't make sense to return something.
+ # (We would have to calculate the widths of all the lines and keep
+ # double width characters in mind.)
+ left=0,
+ right=0,
+ )
+
+ @property
+ def displayed_lines(self) -> list[int]:
+ """
+ List of all the visible rows. (Line numbers of the input buffer.)
+ The last line may not be entirely visible.
+ """
+ return sorted(row for row, col in self.visible_line_to_row_col.values())
+
+ @property
+ def input_line_to_visible_line(self) -> dict[int, int]:
+ """
+ Return the dictionary mapping the line numbers of the input buffer to
+ the lines of the screen. When a line spans several rows at the screen,
+ the first row appears in the dictionary.
+ """
+ result: dict[int, int] = {}
+ for k, v in self.visible_line_to_input_line.items():
+ if v in result:
+ result[v] = min(result[v], k)
+ else:
+ result[v] = k
+ return result
+
+ def first_visible_line(self, after_scroll_offset: bool = False) -> int:
+ """
+ Return the line number (0 based) of the input document that corresponds
+ with the first visible line.
+ """
+ if after_scroll_offset:
+ return self.displayed_lines[self.applied_scroll_offsets.top]
+ else:
+ return self.displayed_lines[0]
+
+ def last_visible_line(self, before_scroll_offset: bool = False) -> int:
+ """
+ Like `first_visible_line`, but for the last visible line.
+ """
+ if before_scroll_offset:
+ return self.displayed_lines[-1 - self.applied_scroll_offsets.bottom]
+ else:
+ return self.displayed_lines[-1]
+
+ def center_visible_line(
+ self, before_scroll_offset: bool = False, after_scroll_offset: bool = False
+ ) -> int:
+ """
+ Like `first_visible_line`, but for the center visible line.
+ """
+ return (
+ self.first_visible_line(after_scroll_offset)
+ + (
+ self.last_visible_line(before_scroll_offset)
+ - self.first_visible_line(after_scroll_offset)
+ )
+ // 2
+ )
+
+ @property
+ def content_height(self) -> int:
+ """
+ The full height of the user control.
+ """
+ return self.ui_content.line_count
+
+ @property
+ def full_height_visible(self) -> bool:
+ """
+ True when the full height is visible (There is no vertical scroll.)
+ """
+ return (
+ self.vertical_scroll == 0
+ and self.last_visible_line() == self.content_height
+ )
+
+ @property
+ def top_visible(self) -> bool:
+ """
+ True when the top of the buffer is visible.
+ """
+ return self.vertical_scroll == 0
+
+ @property
+ def bottom_visible(self) -> bool:
+ """
+ True when the bottom of the buffer is visible.
+ """
+ return self.last_visible_line() == self.content_height - 1
+
+ @property
+ def vertical_scroll_percentage(self) -> int:
+ """
+ Vertical scroll as a percentage. (0 means: the top is visible,
+ 100 means: the bottom is visible.)
+ """
+ if self.bottom_visible:
+ return 100
+ else:
+ return 100 * self.vertical_scroll // self.content_height
+
+ def get_height_for_line(self, lineno: int) -> int:
+ """
+ Return the height of the given line.
+ (The height that it would take, if this line became visible.)
+ """
+ if self.wrap_lines:
+ return self.ui_content.get_height_for_line(
+ lineno, self.window_width, self.window.get_line_prefix
+ )
+ else:
+ return 1
+
+
+class ScrollOffsets:
+ """
+ Scroll offsets for the :class:`.Window` class.
+
+ Note that left/right offsets only make sense if line wrapping is disabled.
+ """
+
+ def __init__(
+ self,
+ top: int | Callable[[], int] = 0,
+ bottom: int | Callable[[], int] = 0,
+ left: int | Callable[[], int] = 0,
+ right: int | Callable[[], int] = 0,
+ ) -> None:
+ self._top = top
+ self._bottom = bottom
+ self._left = left
+ self._right = right
+
+ @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:
+ """
+ Column for a :class:`.Window` to be colored.
+ """
+
+ def __init__(self, position: int, style: str = "class:color-column") -> None:
+ self.position = position
+ self.style = style
+
+
+_in_insert_mode = vi_insert_mode | emacs_insert_mode
+
+
+class WindowAlign(Enum):
+ """
+ Alignment of the Window content.
+
+ Note that this is different from `HorizontalAlign` and `VerticalAlign`,
+ which are used for the alignment of the child containers in respectively
+ `VSplit` and `HSplit`.
+ """
+
+ LEFT = "LEFT"
+ RIGHT = "RIGHT"
+ CENTER = "CENTER"
+
+
+class Window(Container):
+ """
+ Container that holds a control.
+
+ :param content: :class:`.UIControl` instance.
+ :param width: :class:`.Dimension` instance or callable.
+ :param height: :class:`.Dimension` instance or callable.
+ :param z_index: When specified, this can be used to bring element in front
+ of floating elements.
+ :param dont_extend_width: When `True`, don't take up more width then the
+ preferred width reported by the control.
+ :param dont_extend_height: When `True`, don't take up more width then the
+ preferred height reported by the control.
+ :param ignore_content_width: A `bool` or :class:`.Filter` instance. Ignore
+ the :class:`.UIContent` width when calculating the dimensions.
+ :param ignore_content_height: A `bool` or :class:`.Filter` instance. Ignore
+ the :class:`.UIContent` height when calculating the dimensions.
+ :param left_margins: A list of :class:`.Margin` instance to be displayed on
+ the left. For instance: :class:`~prompt_toolkit.layout.NumberedMargin`
+ can be one of them in order to show line numbers.
+ :param right_margins: Like `left_margins`, but on the other side.
+ :param scroll_offsets: :class:`.ScrollOffsets` instance, representing the
+ preferred amount of lines/columns to be always visible before/after the
+ cursor. When both top and bottom are a very high number, the cursor
+ will be centered vertically most of the time.
+ :param allow_scroll_beyond_bottom: A `bool` or
+ :class:`.Filter` instance. When True, allow scrolling so far, that the
+ top part of the content is not visible anymore, while there is still
+ empty space available at the bottom of the window. In the Vi editor for
+ instance, this is possible. You will see tildes while the top part of
+ the body is hidden.
+ :param wrap_lines: A `bool` or :class:`.Filter` instance. When True, don't
+ scroll horizontally, but wrap lines instead.
+ :param get_vertical_scroll: Callable that takes this window
+ instance as input and returns a preferred vertical scroll.
+ (When this is `None`, the scroll is only determined by the last and
+ current cursor position.)
+ :param get_horizontal_scroll: Callable that takes this window
+ instance as input and returns a preferred vertical scroll.
+ :param always_hide_cursor: A `bool` or
+ :class:`.Filter` instance. When True, never display the cursor, even
+ when the user control specifies a cursor position.
+ :param cursorline: A `bool` or :class:`.Filter` instance. When True,
+ display a cursorline.
+ :param cursorcolumn: A `bool` or :class:`.Filter` instance. When True,
+ display a cursorcolumn.
+ :param colorcolumns: A list of :class:`.ColorColumn` instances that
+ describe the columns to be highlighted, or a callable that returns such
+ a list.
+ :param align: :class:`.WindowAlign` value or callable that returns an
+ :class:`.WindowAlign` value. alignment of content.
+ :param style: A style string. Style to be applied to all the cells in this
+ window. (This can be a callable that returns a string.)
+ :param char: (string) Character to be used for filling the background. This
+ can also be a callable that returns a character.
+ :param get_line_prefix: None or a callable that returns formatted text to
+ be inserted before a line. It takes a line number (int) and a
+ wrap_count and returns formatted text. This can be used for
+ implementation of line continuations, things like Vim "breakindent" and
+ so on.
+ """
+
+ def __init__(
+ self,
+ content: UIControl | None = None,
+ width: AnyDimension = None,
+ height: AnyDimension = None,
+ z_index: int | None = None,
+ dont_extend_width: FilterOrBool = False,
+ dont_extend_height: FilterOrBool = False,
+ ignore_content_width: FilterOrBool = False,
+ ignore_content_height: FilterOrBool = False,
+ left_margins: Sequence[Margin] | None = None,
+ right_margins: Sequence[Margin] | None = None,
+ scroll_offsets: ScrollOffsets | None = None,
+ allow_scroll_beyond_bottom: FilterOrBool = False,
+ wrap_lines: FilterOrBool = False,
+ get_vertical_scroll: Callable[[Window], int] | None = None,
+ get_horizontal_scroll: Callable[[Window], int] | None = None,
+ always_hide_cursor: FilterOrBool = False,
+ cursorline: FilterOrBool = False,
+ cursorcolumn: FilterOrBool = False,
+ colorcolumns: (
+ None | list[ColorColumn] | Callable[[], list[ColorColumn]]
+ ) = None,
+ align: WindowAlign | Callable[[], WindowAlign] = WindowAlign.LEFT,
+ style: str | Callable[[], str] = "",
+ char: None | str | Callable[[], str] = None,
+ get_line_prefix: GetLinePrefixCallable | None = None,
+ ) -> None:
+ self.allow_scroll_beyond_bottom = to_filter(allow_scroll_beyond_bottom)
+ self.always_hide_cursor = to_filter(always_hide_cursor)
+ self.wrap_lines = to_filter(wrap_lines)
+ self.cursorline = to_filter(cursorline)
+ self.cursorcolumn = to_filter(cursorcolumn)
+
+ self.content = content or DummyControl()
+ self.dont_extend_width = to_filter(dont_extend_width)
+ self.dont_extend_height = to_filter(dont_extend_height)
+ self.ignore_content_width = to_filter(ignore_content_width)
+ self.ignore_content_height = to_filter(ignore_content_height)
+ self.left_margins = left_margins or []
+ self.right_margins = right_margins or []
+ self.scroll_offsets = scroll_offsets or ScrollOffsets()
+ self.get_vertical_scroll = get_vertical_scroll
+ self.get_horizontal_scroll = get_horizontal_scroll
+ self.colorcolumns = colorcolumns or []
+ self.align = align
+ self.style = style
+ self.char = char
+ self.get_line_prefix = get_line_prefix
+
+ self.width = width
+ self.height = height
+ self.z_index = z_index
+
+ # Cache for the screens generated by the margin.
+ self._ui_content_cache: SimpleCache[
+ tuple[int, int, int], UIContent
+ ] = SimpleCache(maxsize=8)
+ self._margin_width_cache: SimpleCache[tuple[Margin, int], int] = SimpleCache(
+ maxsize=1
+ )
+
+ self.reset()
+
+ def __repr__(self) -> str:
+ return "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
+
+ #: Keep render information (mappings between buffer input and render
+ #: output.)
+ self.render_info: WindowRenderInfo | None = None
+
+ def _get_margin_width(self, margin: Margin) -> int:
+ """
+ Return the width for this margin.
+ (Calculate only once per render time.)
+ """
+
+ # Margin.get_width, needs to have a UIContent instance.
+ def get_ui_content() -> UIContent:
+ return self._get_ui_content(width=0, height=0)
+
+ def get_width() -> int:
+ return margin.get_width(get_ui_content)
+
+ key = (margin, get_app().render_counter)
+ return self._margin_width_cache.get(key, get_width)
+
+ def _get_total_margin_width(self) -> int:
+ """
+ Calculate and return the width of the margin (left + right).
+ """
+ return sum(self._get_margin_width(m) for m in self.left_margins) + sum(
+ self._get_margin_width(m) for m in self.right_margins
+ )
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ """
+ Calculate the preferred width for this window.
+ """
+
+ def preferred_content_width() -> int | None:
+ """Content width: is only calculated if no exact width for the
+ window was given."""
+ if self.ignore_content_width():
+ return None
+
+ # Calculate the width of the margin.
+ total_margin_width = self._get_total_margin_width()
+
+ # Window of the content. (Can be `None`.)
+ preferred_width = self.content.preferred_width(
+ max_available_width - total_margin_width
+ )
+
+ if preferred_width is not None:
+ # Include width of the margins.
+ preferred_width += total_margin_width
+ return preferred_width
+
+ # Merge.
+ return self._merge_dimensions(
+ dimension=to_dimension(self.width),
+ get_preferred=preferred_content_width,
+ dont_extend=self.dont_extend_width(),
+ )
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ """
+ Calculate the preferred height for this window.
+ """
+
+ def preferred_content_height() -> int | None:
+ """Content height: is only calculated if no exact height for the
+ window was given."""
+ if self.ignore_content_height():
+ return None
+
+ total_margin_width = self._get_total_margin_width()
+ wrap_lines = self.wrap_lines()
+
+ return self.content.preferred_height(
+ width - total_margin_width,
+ max_available_height,
+ wrap_lines,
+ self.get_line_prefix,
+ )
+
+ return self._merge_dimensions(
+ dimension=to_dimension(self.height),
+ get_preferred=preferred_content_height,
+ dont_extend=self.dont_extend_height(),
+ )
+
+ @staticmethod
+ def _merge_dimensions(
+ dimension: Dimension | None,
+ get_preferred: Callable[[], int | None],
+ dont_extend: bool = False,
+ ) -> Dimension:
+ """
+ Take the Dimension from this `Window` class and the received preferred
+ size from the `UIControl` and return a `Dimension` to report to the
+ parent container.
+ """
+ dimension = dimension or Dimension()
+
+ # When a preferred dimension was explicitly given to the Window,
+ # ignore the UIControl.
+ preferred: int | None
+
+ if dimension.preferred_specified:
+ preferred = dimension.preferred
+ else:
+ # Otherwise, calculate the preferred dimension from the UI control
+ # content.
+ preferred = get_preferred()
+
+ # When a 'preferred' dimension is given by the UIControl, make sure
+ # that it stays within the bounds of the Window.
+ if preferred is not None:
+ if dimension.max_specified:
+ preferred = min(preferred, dimension.max)
+
+ if dimension.min_specified:
+ preferred = max(preferred, dimension.min)
+
+ # When a `dont_extend` flag has been given, use the preferred dimension
+ # also as the max dimension.
+ max_: int | None
+ min_: int | None
+
+ if dont_extend and preferred is not None:
+ max_ = min(dimension.max, preferred)
+ else:
+ max_ = dimension.max if dimension.max_specified else None
+
+ min_ = dimension.min if dimension.min_specified else None
+
+ return Dimension(
+ min=min_, max=max_, preferred=preferred, weight=dimension.weight
+ )
+
+ def _get_ui_content(self, width: int, height: int) -> UIContent:
+ """
+ Create a `UIContent` instance.
+ """
+
+ def get_content() -> UIContent:
+ return self.content.create_content(width=width, height=height)
+
+ key = (get_app().render_counter, width, height)
+ return self._ui_content_cache.get(key, get_content)
+
+ def _get_digraph_char(self) -> str | None:
+ "Return `False`, or the Digraph symbol to be used."
+ app = get_app()
+ if app.quoted_insert:
+ return "^"
+ if app.vi_state.waiting_for_digraph:
+ if app.vi_state.digraph_symbol1:
+ return app.vi_state.digraph_symbol1
+ return "?"
+ return None
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ """
+ Write window to screen. This renders the user control, the margins and
+ copies everything over to the absolute position at the given screen.
+ """
+ # If dont_extend_width/height was given. Then reduce width/height in
+ # WritePosition if the parent wanted us to paint in a bigger area.
+ # (This happens if this window is bundled with another window in a
+ # HSplit/VSplit, but with different size requirements.)
+ write_position = WritePosition(
+ xpos=write_position.xpos,
+ ypos=write_position.ypos,
+ width=write_position.width,
+ height=write_position.height,
+ )
+
+ if self.dont_extend_width():
+ write_position.width = min(
+ write_position.width,
+ self.preferred_width(write_position.width).preferred,
+ )
+
+ if self.dont_extend_height():
+ write_position.height = min(
+ write_position.height,
+ self.preferred_height(
+ write_position.width, write_position.height
+ ).preferred,
+ )
+
+ # Draw
+ z_index = z_index if self.z_index is None else self.z_index
+
+ draw_func = partial(
+ self._write_to_screen_at_index,
+ screen,
+ mouse_handlers,
+ write_position,
+ parent_style,
+ erase_bg,
+ )
+
+ if z_index is None or z_index <= 0:
+ # When no z_index is given, draw right away.
+ draw_func()
+ else:
+ # Otherwise, postpone.
+ screen.draw_with_z_index(z_index=z_index, draw_func=draw_func)
+
+ def _write_to_screen_at_index(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ ) -> None:
+ # Don't bother writing invisible windows.
+ # (We save some time, but also avoid applying last-line styling.)
+ if write_position.height <= 0 or write_position.width <= 0:
+ return
+
+ # Calculate margin sizes.
+ left_margin_widths = [self._get_margin_width(m) for m in self.left_margins]
+ right_margin_widths = [self._get_margin_width(m) for m in self.right_margins]
+ total_margin_width = sum(left_margin_widths + right_margin_widths)
+
+ # Render UserControl.
+ ui_content = self.content.create_content(
+ write_position.width - total_margin_width, write_position.height
+ )
+ assert isinstance(ui_content, UIContent)
+
+ # Scroll content.
+ wrap_lines = self.wrap_lines()
+ self._scroll(
+ ui_content, write_position.width - total_margin_width, write_position.height
+ )
+
+ # Erase background and fill with `char`.
+ self._fill_bg(screen, write_position, erase_bg)
+
+ # Resolve `align` attribute.
+ align = self.align() if callable(self.align) else self.align
+
+ # Write body
+ visible_line_to_row_col, rowcol_to_yx = self._copy_body(
+ ui_content,
+ screen,
+ write_position,
+ sum(left_margin_widths),
+ write_position.width - total_margin_width,
+ self.vertical_scroll,
+ self.horizontal_scroll,
+ wrap_lines=wrap_lines,
+ highlight_lines=True,
+ vertical_scroll_2=self.vertical_scroll_2,
+ always_hide_cursor=self.always_hide_cursor(),
+ has_focus=get_app().layout.current_control == self.content,
+ align=align,
+ get_line_prefix=self.get_line_prefix,
+ )
+
+ # Remember render info. (Set before generating the margins. They need this.)
+ x_offset = write_position.xpos + sum(left_margin_widths)
+ y_offset = write_position.ypos
+
+ render_info = WindowRenderInfo(
+ window=self,
+ ui_content=ui_content,
+ horizontal_scroll=self.horizontal_scroll,
+ vertical_scroll=self.vertical_scroll,
+ window_width=write_position.width - total_margin_width,
+ window_height=write_position.height,
+ configured_scroll_offsets=self.scroll_offsets,
+ visible_line_to_row_col=visible_line_to_row_col,
+ rowcol_to_yx=rowcol_to_yx,
+ x_offset=x_offset,
+ y_offset=y_offset,
+ wrap_lines=wrap_lines,
+ )
+ self.render_info = render_info
+
+ # Set mouse handlers.
+ def mouse_handler(mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Wrapper around the mouse_handler of the `UIControl` that turns
+ screen coordinates into line coordinates.
+ Returns `NotImplemented` if no UI invalidation should be done.
+ """
+ # Don't handle mouse events outside of the current modal part of
+ # the UI.
+ if self not in get_app().layout.walk_through_modal_area():
+ return NotImplemented
+
+ # Find row/col position first.
+ yx_to_rowcol = {v: k for k, v in rowcol_to_yx.items()}
+ y = mouse_event.position.y
+ x = mouse_event.position.x
+
+ # If clicked below the content area, look for a position in the
+ # last line instead.
+ max_y = write_position.ypos + len(visible_line_to_row_col) - 1
+ y = min(max_y, y)
+ result: NotImplementedOrNone
+
+ while x >= 0:
+ try:
+ row, col = yx_to_rowcol[y, x]
+ except KeyError:
+ # Try again. (When clicking on the right side of double
+ # width characters, or on the right side of the input.)
+ x -= 1
+ else:
+ # Found position, call handler of UIControl.
+ result = self.content.mouse_handler(
+ MouseEvent(
+ position=Point(x=col, y=row),
+ event_type=mouse_event.event_type,
+ button=mouse_event.button,
+ modifiers=mouse_event.modifiers,
+ )
+ )
+ break
+ else:
+ # nobreak.
+ # (No x/y coordinate found for the content. This happens in
+ # case of a DummyControl, that does not have any content.
+ # Report (0,0) instead.)
+ result = self.content.mouse_handler(
+ MouseEvent(
+ position=Point(x=0, y=0),
+ event_type=mouse_event.event_type,
+ button=mouse_event.button,
+ modifiers=mouse_event.modifiers,
+ )
+ )
+
+ # If it returns NotImplemented, handle it here.
+ if result == NotImplemented:
+ result = self._mouse_handler(mouse_event)
+
+ return result
+
+ mouse_handlers.set_mouse_handler_for_range(
+ x_min=write_position.xpos + sum(left_margin_widths),
+ x_max=write_position.xpos + write_position.width - total_margin_width,
+ y_min=write_position.ypos,
+ y_max=write_position.ypos + write_position.height,
+ handler=mouse_handler,
+ )
+
+ # Render and copy margins.
+ move_x = 0
+
+ def render_margin(m: Margin, width: int) -> UIContent:
+ "Render margin. Return `Screen`."
+ # Retrieve margin fragments.
+ fragments = m.create_margin(render_info, width, write_position.height)
+
+ # Turn it into a UIContent object.
+ # already rendered those fragments using this size.)
+ return FormattedTextControl(fragments).create_content(
+ width + 1, write_position.height
+ )
+
+ for m, width in zip(self.left_margins, left_margin_widths):
+ if width > 0: # (ConditionalMargin returns a zero width. -- Don't render.)
+ # Create screen for margin.
+ margin_content = render_margin(m, width)
+
+ # Copy and shift X.
+ self._copy_margin(margin_content, screen, write_position, move_x, width)
+ move_x += width
+
+ move_x = write_position.width - sum(right_margin_widths)
+
+ for m, width in zip(self.right_margins, right_margin_widths):
+ # Create screen for margin.
+ margin_content = render_margin(m, width)
+
+ # Copy and shift X.
+ self._copy_margin(margin_content, screen, write_position, move_x, width)
+ move_x += width
+
+ # Apply 'self.style'
+ self._apply_style(screen, write_position, parent_style)
+
+ # Tell the screen that this user control has been painted at this
+ # position.
+ screen.visible_windows_to_write_positions[self] = write_position
+
+ def _copy_body(
+ self,
+ ui_content: UIContent,
+ new_screen: Screen,
+ write_position: WritePosition,
+ move_x: int,
+ width: int,
+ vertical_scroll: int = 0,
+ horizontal_scroll: int = 0,
+ wrap_lines: bool = False,
+ highlight_lines: bool = False,
+ vertical_scroll_2: int = 0,
+ always_hide_cursor: bool = False,
+ has_focus: bool = False,
+ align: WindowAlign = WindowAlign.LEFT,
+ get_line_prefix: Callable[[int, int], AnyFormattedText] | None = None,
+ ) -> tuple[dict[int, tuple[int, int]], dict[tuple[int, int], tuple[int, int]]]:
+ """
+ Copy the UIContent into the output screen.
+ Return (visible_line_to_row_col, rowcol_to_yx) tuple.
+
+ :param get_line_prefix: None or a callable that takes a line number
+ (int) and a wrap_count (int) and returns formatted text.
+ """
+ xpos = write_position.xpos + move_x
+ ypos = write_position.ypos
+ line_count = ui_content.line_count
+ new_buffer = new_screen.data_buffer
+ empty_char = _CHAR_CACHE["", ""]
+
+ # Map visible line number to (row, col) of input.
+ # 'col' will always be zero if line wrapping is off.
+ visible_line_to_row_col: dict[int, tuple[int, int]] = {}
+
+ # Maps (row, col) from the input to (y, x) screen coordinates.
+ rowcol_to_yx: dict[tuple[int, int], tuple[int, int]] = {}
+
+ def copy_line(
+ line: StyleAndTextTuples,
+ lineno: int,
+ x: int,
+ y: int,
+ is_input: bool = False,
+ ) -> tuple[int, int]:
+ """
+ Copy over a single line to the output screen. This can wrap over
+ multiple lines in the output. It will call the prefix (prompt)
+ function before every line.
+ """
+ if is_input:
+ current_rowcol_to_yx = rowcol_to_yx
+ else:
+ current_rowcol_to_yx = {} # Throwaway dictionary.
+
+ # Draw line prefix.
+ if is_input and get_line_prefix:
+ prompt = to_formatted_text(get_line_prefix(lineno, 0))
+ x, y = copy_line(prompt, lineno, x, y, is_input=False)
+
+ # Scroll horizontally.
+ skipped = 0 # Characters skipped because of horizontal scrolling.
+ if horizontal_scroll and is_input:
+ h_scroll = horizontal_scroll
+ line = explode_text_fragments(line)
+ while h_scroll > 0 and line:
+ h_scroll -= get_cwidth(line[0][1])
+ skipped += 1
+ del line[:1] # Remove first character.
+
+ x -= h_scroll # When scrolling over double width character,
+ # this can end up being negative.
+
+ # Align this line. (Note that this doesn't work well when we use
+ # get_line_prefix and that function returns variable width prefixes.)
+ if align == WindowAlign.CENTER:
+ line_width = fragment_list_width(line)
+ if line_width < width:
+ x += (width - line_width) // 2
+ elif align == WindowAlign.RIGHT:
+ line_width = fragment_list_width(line)
+ if line_width < width:
+ x += width - line_width
+
+ col = 0
+ wrap_count = 0
+ for style, text, *_ in line:
+ new_buffer_row = new_buffer[y + ypos]
+
+ # Remember raw VT escape sequences. (E.g. FinalTerm's
+ # escape sequences.)
+ if "[ZeroWidthEscape]" in style:
+ new_screen.zero_width_escapes[y + ypos][x + xpos] += text
+ continue
+
+ for c in text:
+ char = _CHAR_CACHE[c, style]
+ char_width = char.width
+
+ # Wrap when the line width is exceeded.
+ if wrap_lines and x + char_width > width:
+ visible_line_to_row_col[y + 1] = (
+ lineno,
+ visible_line_to_row_col[y][1] + x,
+ )
+ y += 1
+ wrap_count += 1
+ x = 0
+
+ # Insert line prefix (continuation prompt).
+ if is_input and get_line_prefix:
+ prompt = to_formatted_text(
+ get_line_prefix(lineno, wrap_count)
+ )
+ x, y = copy_line(prompt, lineno, x, y, is_input=False)
+
+ new_buffer_row = new_buffer[y + ypos]
+
+ if y >= write_position.height:
+ return x, y # Break out of all for loops.
+
+ # Set character in screen and shift 'x'.
+ if x >= 0 and y >= 0 and x < width:
+ new_buffer_row[x + xpos] = char
+
+ # When we print a multi width character, make sure
+ # to erase the neighbors positions in the screen.
+ # (The empty string if different from everything,
+ # so next redraw this cell will repaint anyway.)
+ if char_width > 1:
+ for i in range(1, char_width):
+ new_buffer_row[x + xpos + i] = empty_char
+
+ # If this is a zero width characters, then it's
+ # probably part of a decomposed unicode character.
+ # See: https://en.wikipedia.org/wiki/Unicode_equivalence
+ # Merge it in the previous cell.
+ elif char_width == 0:
+ # Handle all character widths. If the previous
+ # character is a multiwidth character, then
+ # merge it two positions back.
+ for pw in [2, 1]: # Previous character width.
+ if (
+ x - pw >= 0
+ and new_buffer_row[x + xpos - pw].width == pw
+ ):
+ prev_char = new_buffer_row[x + xpos - pw]
+ char2 = _CHAR_CACHE[
+ prev_char.char + c, prev_char.style
+ ]
+ new_buffer_row[x + xpos - pw] = char2
+
+ # Keep track of write position for each character.
+ current_rowcol_to_yx[lineno, col + skipped] = (
+ y + ypos,
+ x + xpos,
+ )
+
+ col += 1
+ x += char_width
+ return x, y
+
+ # Copy content.
+ def copy() -> int:
+ y = -vertical_scroll_2
+ lineno = vertical_scroll
+
+ while y < write_position.height and lineno < line_count:
+ # Take the next line and copy it in the real screen.
+ line = ui_content.get_line(lineno)
+
+ visible_line_to_row_col[y] = (lineno, horizontal_scroll)
+
+ # Copy margin and actual line.
+ x = 0
+ x, y = copy_line(line, lineno, x, y, is_input=True)
+
+ lineno += 1
+ y += 1
+ return y
+
+ copy()
+
+ def cursor_pos_to_screen_pos(row: int, col: int) -> Point:
+ "Translate row/col from UIContent to real Screen coordinates."
+ try:
+ y, x = rowcol_to_yx[row, col]
+ except KeyError:
+ # Normally this should never happen. (It is a bug, if it happens.)
+ # But to be sure, return (0, 0)
+ return Point(x=0, y=0)
+
+ # raise ValueError(
+ # 'Invalid position. row=%r col=%r, vertical_scroll=%r, '
+ # 'horizontal_scroll=%r, height=%r' %
+ # (row, col, vertical_scroll, horizontal_scroll, write_position.height))
+ else:
+ return Point(x=x, y=y)
+
+ # Set cursor and menu positions.
+ if ui_content.cursor_position:
+ screen_cursor_position = cursor_pos_to_screen_pos(
+ ui_content.cursor_position.y, ui_content.cursor_position.x
+ )
+
+ if has_focus:
+ new_screen.set_cursor_position(self, screen_cursor_position)
+
+ if always_hide_cursor:
+ new_screen.show_cursor = False
+ else:
+ new_screen.show_cursor = ui_content.show_cursor
+
+ self._highlight_digraph(new_screen)
+
+ if highlight_lines:
+ self._highlight_cursorlines(
+ new_screen,
+ screen_cursor_position,
+ xpos,
+ ypos,
+ width,
+ write_position.height,
+ )
+
+ # Draw input characters from the input processor queue.
+ if has_focus and ui_content.cursor_position:
+ self._show_key_processor_key_buffer(new_screen)
+
+ # Set menu position.
+ if ui_content.menu_position:
+ new_screen.set_menu_position(
+ self,
+ cursor_pos_to_screen_pos(
+ ui_content.menu_position.y, ui_content.menu_position.x
+ ),
+ )
+
+ # Update output screen height.
+ new_screen.height = max(new_screen.height, ypos + write_position.height)
+
+ return visible_line_to_row_col, rowcol_to_yx
+
+ def _fill_bg(
+ self, screen: Screen, write_position: WritePosition, erase_bg: bool
+ ) -> None:
+ """
+ Erase/fill the background.
+ (Useful for floats and when a `char` has been given.)
+ """
+ char: str | None
+ if callable(self.char):
+ char = self.char()
+ else:
+ char = self.char
+
+ if erase_bg or char:
+ wp = write_position
+ char_obj = _CHAR_CACHE[char or " ", ""]
+
+ for y in range(wp.ypos, wp.ypos + wp.height):
+ row = screen.data_buffer[y]
+ for x in range(wp.xpos, wp.xpos + wp.width):
+ row[x] = char_obj
+
+ def _apply_style(
+ self, new_screen: Screen, write_position: WritePosition, parent_style: str
+ ) -> None:
+ # Apply `self.style`.
+ style = parent_style + " " + to_str(self.style)
+
+ new_screen.fill_area(write_position, style=style, after=False)
+
+ # Apply the 'last-line' class to the last line of each Window. This can
+ # be used to apply an 'underline' to the user control.
+ wp = WritePosition(
+ write_position.xpos,
+ write_position.ypos + write_position.height - 1,
+ write_position.width,
+ 1,
+ )
+ new_screen.fill_area(wp, "class:last-line", after=True)
+
+ def _highlight_digraph(self, new_screen: Screen) -> None:
+ """
+ When we are in Vi digraph mode, put a question mark underneath the
+ cursor.
+ """
+ digraph_char = self._get_digraph_char()
+ if digraph_char:
+ cpos = new_screen.get_cursor_position(self)
+ new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[
+ digraph_char, "class:digraph"
+ ]
+
+ def _show_key_processor_key_buffer(self, new_screen: Screen) -> None:
+ """
+ When the user is typing a key binding that consists of several keys,
+ display the last pressed key if the user is in insert mode and the key
+ is meaningful to be displayed.
+ E.g. Some people want to bind 'jj' to escape in Vi insert mode. But the
+ first 'j' needs to be displayed in order to get some feedback.
+ """
+ app = get_app()
+ key_buffer = app.key_processor.key_buffer
+
+ if key_buffer and _in_insert_mode() and not app.is_done:
+ # The textual data for the given key. (Can be a VT100 escape
+ # sequence.)
+ data = key_buffer[-1].data
+
+ # Display only if this is a 1 cell width character.
+ if get_cwidth(data) == 1:
+ cpos = new_screen.get_cursor_position(self)
+ new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[
+ data, "class:partial-key-binding"
+ ]
+
+ def _highlight_cursorlines(
+ self, new_screen: Screen, cpos: Point, x: int, y: int, width: int, height: int
+ ) -> None:
+ """
+ Highlight cursor row/column.
+ """
+ cursor_line_style = " class:cursor-line "
+ cursor_column_style = " class:cursor-column "
+
+ data_buffer = new_screen.data_buffer
+
+ # Highlight cursor line.
+ if self.cursorline():
+ row = data_buffer[cpos.y]
+ for x in range(x, x + width):
+ original_char = row[x]
+ row[x] = _CHAR_CACHE[
+ original_char.char, original_char.style + cursor_line_style
+ ]
+
+ # Highlight cursor column.
+ if self.cursorcolumn():
+ for y2 in range(y, y + height):
+ row = data_buffer[y2]
+ original_char = row[cpos.x]
+ row[cpos.x] = _CHAR_CACHE[
+ original_char.char, original_char.style + cursor_column_style
+ ]
+
+ # Highlight color columns
+ colorcolumns = self.colorcolumns
+ if callable(colorcolumns):
+ colorcolumns = colorcolumns()
+
+ for cc in colorcolumns:
+ assert isinstance(cc, ColorColumn)
+ column = cc.position
+
+ if column < x + width: # Only draw when visible.
+ color_column_style = " " + cc.style
+
+ for y2 in range(y, y + height):
+ row = data_buffer[y2]
+ original_char = row[column + x]
+ row[column + x] = _CHAR_CACHE[
+ original_char.char, original_char.style + color_column_style
+ ]
+
+ def _copy_margin(
+ self,
+ margin_content: UIContent,
+ new_screen: Screen,
+ write_position: WritePosition,
+ move_x: int,
+ width: int,
+ ) -> None:
+ """
+ Copy characters from the margin screen to the real screen.
+ """
+ xpos = write_position.xpos + move_x
+ ypos = write_position.ypos
+
+ margin_write_position = WritePosition(xpos, ypos, width, write_position.height)
+ self._copy_body(margin_content, new_screen, margin_write_position, 0, width)
+
+ def _scroll(self, ui_content: UIContent, width: int, height: int) -> None:
+ """
+ Scroll body. Ensure that the cursor is visible.
+ """
+ if self.wrap_lines():
+ func = self._scroll_when_linewrapping
+ else:
+ func = self._scroll_without_linewrapping
+
+ func(ui_content, width, height)
+
+ def _scroll_when_linewrapping(
+ self, ui_content: UIContent, width: int, height: int
+ ) -> None:
+ """
+ Scroll to make sure the cursor position is visible and that we maintain
+ the requested scroll offset.
+
+ Set `self.horizontal_scroll/vertical_scroll`.
+ """
+ scroll_offsets_bottom = self.scroll_offsets.bottom
+ scroll_offsets_top = self.scroll_offsets.top
+
+ # We don't have horizontal scrolling.
+ self.horizontal_scroll = 0
+
+ def get_line_height(lineno: int) -> int:
+ return ui_content.get_height_for_line(lineno, width, self.get_line_prefix)
+
+ # When there is no space, reset `vertical_scroll_2` to zero and abort.
+ # This can happen if the margin is bigger than the window width.
+ # Otherwise the text height will become "infinite" (a big number) and
+ # the copy_line will spend a huge amount of iterations trying to render
+ # nothing.
+ if width <= 0:
+ self.vertical_scroll = ui_content.cursor_position.y
+ self.vertical_scroll_2 = 0
+ return
+
+ # If the current line consumes more than the whole window height,
+ # then we have to scroll vertically inside this line. (We don't take
+ # the scroll offsets into account for this.)
+ # Also, ignore the scroll offsets in this case. Just set the vertical
+ # scroll to this line.
+ line_height = get_line_height(ui_content.cursor_position.y)
+ if line_height > height - scroll_offsets_top:
+ # Calculate the height of the text before the cursor (including
+ # line prefixes).
+ text_before_height = ui_content.get_height_for_line(
+ ui_content.cursor_position.y,
+ width,
+ self.get_line_prefix,
+ slice_stop=ui_content.cursor_position.x,
+ )
+
+ # Adjust scroll offset.
+ self.vertical_scroll = ui_content.cursor_position.y
+ self.vertical_scroll_2 = min(
+ text_before_height - 1, # Keep the cursor visible.
+ line_height
+ - height, # Avoid blank lines at the bottom when scrolling up again.
+ self.vertical_scroll_2,
+ )
+ self.vertical_scroll_2 = max(
+ 0, text_before_height - height, self.vertical_scroll_2
+ )
+ return
+ else:
+ self.vertical_scroll_2 = 0
+
+ # Current line doesn't consume the whole height. Take scroll offsets into account.
+ def get_min_vertical_scroll() -> int:
+ # Make sure that the cursor line is not below the bottom.
+ # (Calculate how many lines can be shown between the cursor and the .)
+ used_height = 0
+ prev_lineno = ui_content.cursor_position.y
+
+ for lineno in range(ui_content.cursor_position.y, -1, -1):
+ used_height += get_line_height(lineno)
+
+ if used_height > height - scroll_offsets_bottom:
+ return prev_lineno
+ else:
+ prev_lineno = lineno
+ return 0
+
+ def get_max_vertical_scroll() -> int:
+ # Make sure that the cursor line is not above the top.
+ prev_lineno = ui_content.cursor_position.y
+ used_height = 0
+
+ for lineno in range(ui_content.cursor_position.y - 1, -1, -1):
+ used_height += get_line_height(lineno)
+
+ if used_height > scroll_offsets_top:
+ return prev_lineno
+ else:
+ prev_lineno = lineno
+ return prev_lineno
+
+ def get_topmost_visible() -> int:
+ """
+ Calculate the upper most line that can be visible, while the bottom
+ is still visible. We should not allow scroll more than this if
+ `allow_scroll_beyond_bottom` is false.
+ """
+ prev_lineno = ui_content.line_count - 1
+ used_height = 0
+ for lineno in range(ui_content.line_count - 1, -1, -1):
+ used_height += get_line_height(lineno)
+ if used_height > height:
+ return prev_lineno
+ else:
+ prev_lineno = lineno
+ return prev_lineno
+
+ # Scroll vertically. (Make sure that the whole line which contains the
+ # cursor is visible.
+ topmost_visible = get_topmost_visible()
+
+ # Note: the `min(topmost_visible, ...)` is to make sure that we
+ # don't require scrolling up because of the bottom scroll offset,
+ # when we are at the end of the document.
+ self.vertical_scroll = max(
+ self.vertical_scroll, min(topmost_visible, get_min_vertical_scroll())
+ )
+ self.vertical_scroll = min(self.vertical_scroll, get_max_vertical_scroll())
+
+ # Disallow scrolling beyond bottom?
+ if not self.allow_scroll_beyond_bottom():
+ self.vertical_scroll = min(self.vertical_scroll, topmost_visible)
+
+ def _scroll_without_linewrapping(
+ self, ui_content: UIContent, width: int, height: int
+ ) -> None:
+ """
+ Scroll to make sure the cursor position is visible and that we maintain
+ the requested scroll offset.
+
+ Set `self.horizontal_scroll/vertical_scroll`.
+ """
+ cursor_position = ui_content.cursor_position or Point(x=0, y=0)
+
+ # Without line wrapping, we will never have to scroll vertically inside
+ # a single line.
+ self.vertical_scroll_2 = 0
+
+ if ui_content.line_count == 0:
+ self.vertical_scroll = 0
+ self.horizontal_scroll = 0
+ return
+ else:
+ current_line_text = fragment_list_to_text(
+ ui_content.get_line(cursor_position.y)
+ )
+
+ def do_scroll(
+ current_scroll: int,
+ scroll_offset_start: int,
+ scroll_offset_end: int,
+ cursor_pos: int,
+ window_size: int,
+ content_size: int,
+ ) -> int:
+ "Scrolling algorithm. Used for both horizontal and vertical scrolling."
+ # Calculate the scroll offset to apply.
+ # This can obviously never be more than have the screen size. Also, when the
+ # cursor appears at the top or bottom, we don't apply the offset.
+ scroll_offset_start = int(
+ min(scroll_offset_start, window_size / 2, cursor_pos)
+ )
+ scroll_offset_end = int(
+ min(scroll_offset_end, window_size / 2, content_size - 1 - cursor_pos)
+ )
+
+ # Prevent negative scroll offsets.
+ if current_scroll < 0:
+ current_scroll = 0
+
+ # Scroll back if we scrolled to much and there's still space to show more of the document.
+ if (
+ not self.allow_scroll_beyond_bottom()
+ and current_scroll > content_size - window_size
+ ):
+ current_scroll = max(0, content_size - window_size)
+
+ # Scroll up if cursor is before visible part.
+ if current_scroll > cursor_pos - scroll_offset_start:
+ current_scroll = max(0, cursor_pos - scroll_offset_start)
+
+ # Scroll down if cursor is after visible part.
+ if current_scroll < (cursor_pos + 1) - window_size + scroll_offset_end:
+ current_scroll = (cursor_pos + 1) - window_size + scroll_offset_end
+
+ return current_scroll
+
+ # When a preferred scroll is given, take that first into account.
+ if self.get_vertical_scroll:
+ self.vertical_scroll = self.get_vertical_scroll(self)
+ assert isinstance(self.vertical_scroll, int)
+ if self.get_horizontal_scroll:
+ self.horizontal_scroll = self.get_horizontal_scroll(self)
+ assert isinstance(self.horizontal_scroll, int)
+
+ # Update horizontal/vertical scroll to make sure that the cursor
+ # remains visible.
+ offsets = self.scroll_offsets
+
+ self.vertical_scroll = do_scroll(
+ current_scroll=self.vertical_scroll,
+ scroll_offset_start=offsets.top,
+ scroll_offset_end=offsets.bottom,
+ cursor_pos=ui_content.cursor_position.y,
+ window_size=height,
+ content_size=ui_content.line_count,
+ )
+
+ if self.get_line_prefix:
+ current_line_prefix_width = fragment_list_width(
+ to_formatted_text(self.get_line_prefix(ui_content.cursor_position.y, 0))
+ )
+ else:
+ current_line_prefix_width = 0
+
+ self.horizontal_scroll = do_scroll(
+ current_scroll=self.horizontal_scroll,
+ scroll_offset_start=offsets.left,
+ scroll_offset_end=offsets.right,
+ cursor_pos=get_cwidth(current_line_text[: ui_content.cursor_position.x]),
+ window_size=width - current_line_prefix_width,
+ # We can only analyze the current line. Calculating the width off
+ # all the lines is too expensive.
+ content_size=max(
+ get_cwidth(current_line_text), self.horizontal_scroll + width
+ ),
+ )
+
+ def _mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Mouse handler. Called when the UI control doesn't handle this
+ particular event.
+
+ Return `NotImplemented` if nothing was done as a consequence of this
+ key binding (no UI invalidate required in that case).
+ """
+ if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
+ self._scroll_down()
+ return None
+ elif mouse_event.event_type == MouseEventType.SCROLL_UP:
+ self._scroll_up()
+ return None
+
+ return NotImplemented
+
+ def _scroll_down(self) -> None:
+ "Scroll window down."
+ info = self.render_info
+
+ if info is None:
+ return
+
+ if self.vertical_scroll < info.content_height - info.window_height:
+ if info.cursor_position.y <= info.configured_scroll_offsets.top:
+ self.content.move_cursor_down()
+
+ self.vertical_scroll += 1
+
+ def _scroll_up(self) -> None:
+ "Scroll window up."
+ info = self.render_info
+
+ if info is None:
+ return
+
+ if info.vertical_scroll > 0:
+ # TODO: not entirely correct yet in case of line wrapping and long lines.
+ if (
+ info.cursor_position.y
+ >= info.window_height - 1 - info.configured_scroll_offsets.bottom
+ ):
+ self.content.move_cursor_up()
+
+ self.vertical_scroll -= 1
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ return self.content.get_key_bindings()
+
+ def get_children(self) -> list[Container]:
+ return []
+
+
+class ConditionalContainer(Container):
+ """
+ Wrapper around any other container that can change the visibility. The
+ received `filter` determines whether the given container should be
+ displayed or not.
+
+ :param content: :class:`.Container` instance.
+ :param filter: :class:`.Filter` instance.
+ """
+
+ def __init__(self, content: AnyContainer, filter: FilterOrBool) -> None:
+ self.content = to_container(content)
+ self.filter = to_filter(filter)
+
+ def __repr__(self) -> str:
+ return f"ConditionalContainer({self.content!r}, filter={self.filter!r})"
+
+ def reset(self) -> None:
+ self.content.reset()
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ if self.filter():
+ return self.content.preferred_width(max_available_width)
+ else:
+ return Dimension.zero()
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ if self.filter():
+ return self.content.preferred_height(width, max_available_height)
+ else:
+ return Dimension.zero()
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ if self.filter():
+ return self.content.write_to_screen(
+ screen, mouse_handlers, write_position, parent_style, erase_bg, z_index
+ )
+
+ def get_children(self) -> list[Container]:
+ return [self.content]
+
+
+class DynamicContainer(Container):
+ """
+ Container class that dynamically returns any Container.
+
+ :param get_container: Callable that returns a :class:`.Container` instance
+ or any widget with a ``__pt_container__`` method.
+ """
+
+ def __init__(self, get_container: Callable[[], AnyContainer]) -> None:
+ self.get_container = get_container
+
+ def _get_container(self) -> Container:
+ """
+ Return the current container object.
+
+ We call `to_container`, because `get_container` can also return a
+ widget with a ``__pt_container__`` method.
+ """
+ obj = self.get_container()
+ return to_container(obj)
+
+ def reset(self) -> None:
+ self._get_container().reset()
+
+ def preferred_width(self, max_available_width: int) -> Dimension:
+ return self._get_container().preferred_width(max_available_width)
+
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
+ return self._get_container().preferred_height(width, max_available_height)
+
+ def write_to_screen(
+ self,
+ screen: Screen,
+ mouse_handlers: MouseHandlers,
+ write_position: WritePosition,
+ parent_style: str,
+ erase_bg: bool,
+ z_index: int | None,
+ ) -> None:
+ self._get_container().write_to_screen(
+ screen, mouse_handlers, write_position, parent_style, erase_bg, z_index
+ )
+
+ def is_modal(self) -> bool:
+ return False
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ # Key bindings will be collected when `layout.walk()` finds the child
+ # container.
+ return None
+
+ def get_children(self) -> list[Container]:
+ # Here we have to return the current active container itself, not its
+ # children. Otherwise, we run into issues where `layout.walk()` will
+ # never see an object of type `Window` if this contains a window. We
+ # can't/shouldn't proxy the "isinstance" check.
+ return [self._get_container()]
+
+
+def to_container(container: AnyContainer) -> Container:
+ """
+ Make sure that the given object is a :class:`.Container`.
+ """
+ if isinstance(container, Container):
+ return container
+ elif hasattr(container, "__pt_container__"):
+ return to_container(container.__pt_container__())
+ else:
+ raise ValueError(f"Not a container object: {container!r}")
+
+
+def to_window(container: AnyContainer) -> Window:
+ """
+ Make sure that the given argument is a :class:`.Window`.
+ """
+ if isinstance(container, Window):
+ return container
+ elif hasattr(container, "__pt_container__"):
+ return to_window(cast("MagicContainer", container).__pt_container__())
+ else:
+ raise ValueError(f"Not a Window object: {container!r}.")
+
+
+def is_container(value: object) -> TypeGuard[AnyContainer]:
+ """
+ Checks whether the given value is a container object
+ (for use in assert statements).
+ """
+ if isinstance(value, Container):
+ return True
+ if hasattr(value, "__pt_container__"):
+ return is_container(cast("MagicContainer", value).__pt_container__())
+ return False
diff --git a/src/prompt_toolkit/layout/controls.py b/src/prompt_toolkit/layout/controls.py
new file mode 100644
index 0000000..c30c0ef
--- /dev/null
+++ b/src/prompt_toolkit/layout/controls.py
@@ -0,0 +1,944 @@
+"""
+User interface Controls for the layout.
+"""
+from __future__ import annotations
+
+import time
+from abc import ABCMeta, abstractmethod
+from typing import TYPE_CHECKING, Callable, Hashable, Iterable, NamedTuple
+
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.buffer import Buffer
+from prompt_toolkit.cache import SimpleCache
+from prompt_toolkit.data_structures import Point
+from prompt_toolkit.document import Document
+from prompt_toolkit.filters import FilterOrBool, to_filter
+from prompt_toolkit.formatted_text import (
+ AnyFormattedText,
+ StyleAndTextTuples,
+ to_formatted_text,
+)
+from prompt_toolkit.formatted_text.utils import (
+ fragment_list_to_text,
+ fragment_list_width,
+ split_lines,
+)
+from prompt_toolkit.lexers import Lexer, SimpleLexer
+from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
+from prompt_toolkit.search import SearchState
+from prompt_toolkit.selection import SelectionType
+from prompt_toolkit.utils import get_cwidth
+
+from .processors import (
+ DisplayMultipleCursors,
+ HighlightIncrementalSearchProcessor,
+ HighlightSearchProcessor,
+ HighlightSelectionProcessor,
+ Processor,
+ TransformationInput,
+ merge_processors,
+)
+
+if TYPE_CHECKING:
+ from prompt_toolkit.key_binding.key_bindings import (
+ KeyBindingsBase,
+ NotImplementedOrNone,
+ )
+ from prompt_toolkit.utils import Event
+
+
+__all__ = [
+ "BufferControl",
+ "SearchBufferControl",
+ "DummyControl",
+ "FormattedTextControl",
+ "UIControl",
+ "UIContent",
+]
+
+GetLinePrefixCallable = Callable[[int, int], AnyFormattedText]
+
+
+class UIControl(metaclass=ABCMeta):
+ """
+ Base class for all user interface controls.
+ """
+
+ def reset(self) -> None:
+ # Default reset. (Doesn't have to be implemented.)
+ pass
+
+ def preferred_width(self, max_available_width: int) -> int | None:
+ return None
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ return None
+
+ def is_focusable(self) -> bool:
+ """
+ Tell whether this user control is focusable.
+ """
+ return False
+
+ @abstractmethod
+ def create_content(self, width: int, height: int) -> UIContent:
+ """
+ Generate the content for this user control.
+
+ Returns a :class:`.UIContent` instance.
+ """
+
+ def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Handle mouse events.
+
+ When `NotImplemented` is returned, it means that the given event is not
+ handled by the `UIControl` itself. The `Window` or key bindings can
+ decide to handle this event as scrolling or changing focus.
+
+ :param mouse_event: `MouseEvent` instance.
+ """
+ return NotImplemented
+
+ def move_cursor_down(self) -> None:
+ """
+ Request to move the cursor down.
+ This happens when scrolling down and the cursor is completely at the
+ top.
+ """
+
+ def move_cursor_up(self) -> None:
+ """
+ Request to move the cursor up.
+ """
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ """
+ The key bindings that are specific for this user control.
+
+ Return a :class:`.KeyBindings` object if some key bindings are
+ specified, or `None` otherwise.
+ """
+
+ def get_invalidate_events(self) -> Iterable[Event[object]]:
+ """
+ Return a list of `Event` objects. This can be a generator.
+ (The application collects all these events, in order to bind redraw
+ handlers to these events.)
+ """
+ return []
+
+
+class UIContent:
+ """
+ Content generated by a user control. This content consists of a list of
+ lines.
+
+ :param get_line: Callable that takes a line number and returns the current
+ line. This is a list of (style_str, text) tuples.
+ :param line_count: The number of lines.
+ :param cursor_position: a :class:`.Point` for the cursor position.
+ :param menu_position: a :class:`.Point` for the menu position.
+ :param show_cursor: Make the cursor visible.
+ """
+
+ def __init__(
+ self,
+ get_line: Callable[[int], StyleAndTextTuples] = (lambda i: []),
+ line_count: int = 0,
+ cursor_position: Point | None = None,
+ menu_position: Point | None = None,
+ show_cursor: bool = True,
+ ):
+ self.get_line = get_line
+ self.line_count = line_count
+ self.cursor_position = cursor_position or Point(x=0, y=0)
+ self.menu_position = menu_position
+ self.show_cursor = show_cursor
+
+ # Cache for line heights. Maps cache key -> height
+ self._line_heights_cache: dict[Hashable, int] = {}
+
+ def __getitem__(self, lineno: int) -> StyleAndTextTuples:
+ "Make it iterable (iterate line by line)."
+ if lineno < self.line_count:
+ return self.get_line(lineno)
+ else:
+ raise IndexError
+
+ def get_height_for_line(
+ self,
+ lineno: int,
+ width: int,
+ get_line_prefix: GetLinePrefixCallable | None,
+ slice_stop: int | None = None,
+ ) -> int:
+ """
+ Return the height that a given line would need if it is rendered in a
+ space with the given width (using line wrapping).
+
+ :param get_line_prefix: None or a `Window.get_line_prefix` callable
+ that returns the prefix to be inserted before this line.
+ :param slice_stop: Wrap only "line[:slice_stop]" and return that
+ partial result. This is needed for scrolling the window correctly
+ when line wrapping.
+ :returns: The computed height.
+ """
+ # Instead of using `get_line_prefix` as key, we use render_counter
+ # instead. This is more reliable, because this function could still be
+ # the same, while the content would change over time.
+ key = get_app().render_counter, lineno, width, slice_stop
+
+ try:
+ return self._line_heights_cache[key]
+ except KeyError:
+ if width == 0:
+ height = 10**8
+ else:
+ # Calculate line width first.
+ line = fragment_list_to_text(self.get_line(lineno))[:slice_stop]
+ text_width = get_cwidth(line)
+
+ if get_line_prefix:
+ # Add prefix width.
+ text_width += fragment_list_width(
+ to_formatted_text(get_line_prefix(lineno, 0))
+ )
+
+ # Slower path: compute path when there's a line prefix.
+ height = 1
+
+ # Keep wrapping as long as the line doesn't fit.
+ # Keep adding new prefixes for every wrapped line.
+ while text_width > width:
+ height += 1
+ text_width -= width
+
+ fragments2 = to_formatted_text(
+ get_line_prefix(lineno, height - 1)
+ )
+ prefix_width = get_cwidth(fragment_list_to_text(fragments2))
+
+ if prefix_width >= width: # Prefix doesn't fit.
+ height = 10**8
+ break
+
+ text_width += prefix_width
+ else:
+ # Fast path: compute height when there's no line prefix.
+ try:
+ quotient, remainder = divmod(text_width, width)
+ except ZeroDivisionError:
+ height = 10**8
+ else:
+ if remainder:
+ quotient += 1 # Like math.ceil.
+ height = max(1, quotient)
+
+ # Cache and return
+ self._line_heights_cache[key] = height
+ return height
+
+
+class FormattedTextControl(UIControl):
+ """
+ Control that displays formatted text. This can be either plain text, an
+ :class:`~prompt_toolkit.formatted_text.HTML` object an
+ :class:`~prompt_toolkit.formatted_text.ANSI` object, a list of ``(style_str,
+ text)`` tuples or a callable that takes no argument and returns one of
+ those, depending on how you prefer to do the formatting. See
+ ``prompt_toolkit.layout.formatted_text`` for more information.
+
+ (It's mostly optimized for rather small widgets, like toolbars, menus, etc...)
+
+ When this UI control has the focus, the cursor will be shown in the upper
+ left corner of this control by default. There are two ways for specifying
+ the cursor position:
+
+ - Pass a `get_cursor_position` function which returns a `Point` instance
+ with the current cursor position.
+
+ - If the (formatted) text is passed as a list of ``(style, text)`` tuples
+ and there is one that looks like ``('[SetCursorPosition]', '')``, then
+ this will specify the cursor position.
+
+ Mouse support:
+
+ The list of fragments can also contain tuples of three items, looking like:
+ (style_str, text, handler). When mouse support is enabled and the user
+ clicks on this fragment, then the given handler is called. That handler
+ should accept two inputs: (Application, MouseEvent) and it should
+ either handle the event or return `NotImplemented` in case we want the
+ containing Window to handle this event.
+
+ :param focusable: `bool` or :class:`.Filter`: Tell whether this control is
+ focusable.
+
+ :param text: Text or formatted text to be displayed.
+ :param style: Style string applied to the content. (If you want to style
+ the whole :class:`~prompt_toolkit.layout.Window`, pass the style to the
+ :class:`~prompt_toolkit.layout.Window` instead.)
+ :param key_bindings: a :class:`.KeyBindings` object.
+ :param get_cursor_position: A callable that returns the cursor position as
+ a `Point` instance.
+ """
+
+ def __init__(
+ self,
+ text: AnyFormattedText = "",
+ style: str = "",
+ focusable: FilterOrBool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ show_cursor: bool = True,
+ modal: bool = False,
+ get_cursor_position: Callable[[], Point | None] | None = None,
+ ) -> None:
+ self.text = text # No type check on 'text'. This is done dynamically.
+ self.style = style
+ self.focusable = to_filter(focusable)
+
+ # Key bindings.
+ self.key_bindings = key_bindings
+ self.show_cursor = show_cursor
+ self.modal = modal
+ self.get_cursor_position = get_cursor_position
+
+ #: Cache for the content.
+ self._content_cache: SimpleCache[Hashable, UIContent] = SimpleCache(maxsize=18)
+ self._fragment_cache: SimpleCache[int, StyleAndTextTuples] = SimpleCache(
+ maxsize=1
+ )
+ # Only cache one fragment list. We don't need the previous item.
+
+ # Render info for the mouse support.
+ self._fragments: StyleAndTextTuples | None = None
+
+ def reset(self) -> None:
+ self._fragments = None
+
+ def is_focusable(self) -> bool:
+ return self.focusable()
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.text!r})"
+
+ def _get_formatted_text_cached(self) -> StyleAndTextTuples:
+ """
+ Get fragments, but only retrieve fragments once during one render run.
+ (This function is called several times during one rendering, because
+ we also need those for calculating the dimensions.)
+ """
+ return self._fragment_cache.get(
+ get_app().render_counter, lambda: to_formatted_text(self.text, self.style)
+ )
+
+ def preferred_width(self, max_available_width: int) -> int:
+ """
+ Return the preferred width for this control.
+ That is the width of the longest line.
+ """
+ text = fragment_list_to_text(self._get_formatted_text_cached())
+ line_lengths = [get_cwidth(l) for l in text.split("\n")]
+ return max(line_lengths)
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ """
+ Return the preferred height for this control.
+ """
+ content = self.create_content(width, None)
+ if wrap_lines:
+ height = 0
+ for i in range(content.line_count):
+ height += content.get_height_for_line(i, width, get_line_prefix)
+ if height >= max_available_height:
+ return max_available_height
+ return height
+ else:
+ return content.line_count
+
+ def create_content(self, width: int, height: int | None) -> UIContent:
+ # Get fragments
+ fragments_with_mouse_handlers = self._get_formatted_text_cached()
+ fragment_lines_with_mouse_handlers = list(
+ split_lines(fragments_with_mouse_handlers)
+ )
+
+ # Strip mouse handlers from fragments.
+ fragment_lines: list[StyleAndTextTuples] = [
+ [(item[0], item[1]) for item in line]
+ for line in fragment_lines_with_mouse_handlers
+ ]
+
+ # Keep track of the fragments with mouse handler, for later use in
+ # `mouse_handler`.
+ self._fragments = fragments_with_mouse_handlers
+
+ # If there is a `[SetCursorPosition]` in the fragment list, set the
+ # cursor position here.
+ def get_cursor_position(
+ fragment: str = "[SetCursorPosition]",
+ ) -> Point | None:
+ for y, line in enumerate(fragment_lines):
+ x = 0
+ for style_str, text, *_ in line:
+ if fragment in style_str:
+ return Point(x=x, y=y)
+ x += len(text)
+ return None
+
+ # If there is a `[SetMenuPosition]`, set the menu over here.
+ def get_menu_position() -> Point | None:
+ return get_cursor_position("[SetMenuPosition]")
+
+ cursor_position = (self.get_cursor_position or get_cursor_position)()
+
+ # Create content, or take it from the cache.
+ key = (tuple(fragments_with_mouse_handlers), width, cursor_position)
+
+ def get_content() -> UIContent:
+ return UIContent(
+ get_line=lambda i: fragment_lines[i],
+ line_count=len(fragment_lines),
+ show_cursor=self.show_cursor,
+ cursor_position=cursor_position,
+ menu_position=get_menu_position(),
+ )
+
+ return self._content_cache.get(key, get_content)
+
+ def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Handle mouse events.
+
+ (When the fragment list contained mouse handlers and the user clicked on
+ on any of these, the matching handler is called. This handler can still
+ return `NotImplemented` in case we want the
+ :class:`~prompt_toolkit.layout.Window` to handle this particular
+ event.)
+ """
+ if self._fragments:
+ # Read the generator.
+ fragments_for_line = list(split_lines(self._fragments))
+
+ try:
+ fragments = fragments_for_line[mouse_event.position.y]
+ except IndexError:
+ return NotImplemented
+ else:
+ # Find position in the fragment list.
+ xpos = mouse_event.position.x
+
+ # Find mouse handler for this character.
+ count = 0
+ for item in fragments:
+ count += len(item[1])
+ if count > xpos:
+ if len(item) >= 3:
+ # Handler found. Call it.
+ # (Handler can return NotImplemented, so return
+ # that result.)
+ handler = item[2]
+ return handler(mouse_event)
+ else:
+ break
+
+ # Otherwise, don't handle here.
+ return NotImplemented
+
+ def is_modal(self) -> bool:
+ return self.modal
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ return self.key_bindings
+
+
+class DummyControl(UIControl):
+ """
+ A dummy control object that doesn't paint any content.
+
+ Useful for filling a :class:`~prompt_toolkit.layout.Window`. (The
+ `fragment` and `char` attributes of the `Window` class can be used to
+ define the filling.)
+ """
+
+ def create_content(self, width: int, height: int) -> UIContent:
+ def get_line(i: int) -> StyleAndTextTuples:
+ return []
+
+ return UIContent(get_line=get_line, line_count=100**100) # Something very big.
+
+ def is_focusable(self) -> bool:
+ return False
+
+
+class _ProcessedLine(NamedTuple):
+ fragments: StyleAndTextTuples
+ source_to_display: Callable[[int], int]
+ display_to_source: Callable[[int], int]
+
+
+class BufferControl(UIControl):
+ """
+ Control for visualizing the content of a :class:`.Buffer`.
+
+ :param buffer: The :class:`.Buffer` object to be displayed.
+ :param input_processors: A list of
+ :class:`~prompt_toolkit.layout.processors.Processor` objects.
+ :param include_default_input_processors: When True, include the default
+ processors for highlighting of selection, search and displaying of
+ multiple cursors.
+ :param lexer: :class:`.Lexer` instance for syntax highlighting.
+ :param preview_search: `bool` or :class:`.Filter`: Show search while
+ typing. When this is `True`, probably you want to add a
+ ``HighlightIncrementalSearchProcessor`` as well. Otherwise only the
+ cursor position will move, but the text won't be highlighted.
+ :param focusable: `bool` or :class:`.Filter`: Tell whether this control is focusable.
+ :param focus_on_click: Focus this buffer when it's click, but not yet focused.
+ :param key_bindings: a :class:`.KeyBindings` object.
+ """
+
+ def __init__(
+ self,
+ buffer: Buffer | None = None,
+ input_processors: list[Processor] | None = None,
+ include_default_input_processors: bool = True,
+ lexer: Lexer | None = None,
+ preview_search: FilterOrBool = False,
+ focusable: FilterOrBool = True,
+ search_buffer_control: (
+ None | SearchBufferControl | Callable[[], SearchBufferControl]
+ ) = None,
+ menu_position: Callable[[], int | None] | None = None,
+ focus_on_click: FilterOrBool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ ):
+ self.input_processors = input_processors
+ self.include_default_input_processors = include_default_input_processors
+
+ self.default_input_processors = [
+ HighlightSearchProcessor(),
+ HighlightIncrementalSearchProcessor(),
+ HighlightSelectionProcessor(),
+ DisplayMultipleCursors(),
+ ]
+
+ self.preview_search = to_filter(preview_search)
+ self.focusable = to_filter(focusable)
+ self.focus_on_click = to_filter(focus_on_click)
+
+ self.buffer = buffer or Buffer()
+ self.menu_position = menu_position
+ self.lexer = lexer or SimpleLexer()
+ self.key_bindings = key_bindings
+ self._search_buffer_control = search_buffer_control
+
+ #: Cache for the lexer.
+ #: Often, due to cursor movement, undo/redo and window resizing
+ #: operations, it happens that a short time, the same document has to be
+ #: lexed. This is a fairly easy way to cache such an expensive operation.
+ self._fragment_cache: SimpleCache[
+ Hashable, Callable[[int], StyleAndTextTuples]
+ ] = SimpleCache(maxsize=8)
+
+ self._last_click_timestamp: float | None = None
+ self._last_get_processed_line: Callable[[int], _ProcessedLine] | None = None
+
+ def __repr__(self) -> str:
+ return f"<{self.__class__.__name__} buffer={self.buffer!r} at {id(self)!r}>"
+
+ @property
+ def search_buffer_control(self) -> SearchBufferControl | None:
+ result: SearchBufferControl | None
+
+ if callable(self._search_buffer_control):
+ result = self._search_buffer_control()
+ else:
+ result = self._search_buffer_control
+
+ assert result is None or isinstance(result, SearchBufferControl)
+ return result
+
+ @property
+ def search_buffer(self) -> Buffer | None:
+ control = self.search_buffer_control
+ if control is not None:
+ return control.buffer
+ return None
+
+ @property
+ def search_state(self) -> SearchState:
+ """
+ Return the `SearchState` for searching this `BufferControl`. This is
+ always associated with the search control. If one search bar is used
+ for searching multiple `BufferControls`, then they share the same
+ `SearchState`.
+ """
+ search_buffer_control = self.search_buffer_control
+ if search_buffer_control:
+ return search_buffer_control.searcher_search_state
+ else:
+ return SearchState()
+
+ def is_focusable(self) -> bool:
+ return self.focusable()
+
+ def preferred_width(self, max_available_width: int) -> int | None:
+ """
+ This should return the preferred width.
+
+ Note: We don't specify a preferred width according to the content,
+ because it would be too expensive. Calculating the preferred
+ width can be done by calculating the longest line, but this would
+ require applying all the processors to each line. This is
+ unfeasible for a larger document, and doing it for small
+ documents only would result in inconsistent behavior.
+ """
+ return None
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ # Calculate the content height, if it was drawn on a screen with the
+ # given width.
+ height = 0
+ content = self.create_content(width, height=1) # Pass a dummy '1' as height.
+
+ # When line wrapping is off, the height should be equal to the amount
+ # of lines.
+ if not wrap_lines:
+ return content.line_count
+
+ # When the number of lines exceeds the max_available_height, just
+ # return max_available_height. No need to calculate anything.
+ if content.line_count >= max_available_height:
+ return max_available_height
+
+ for i in range(content.line_count):
+ height += content.get_height_for_line(i, width, get_line_prefix)
+
+ if height >= max_available_height:
+ return max_available_height
+
+ return height
+
+ def _get_formatted_text_for_line_func(
+ self, document: Document
+ ) -> Callable[[int], StyleAndTextTuples]:
+ """
+ Create a function that returns the fragments for a given line.
+ """
+
+ # Cache using `document.text`.
+ def get_formatted_text_for_line() -> Callable[[int], StyleAndTextTuples]:
+ return self.lexer.lex_document(document)
+
+ key = (document.text, self.lexer.invalidation_hash())
+ return self._fragment_cache.get(key, get_formatted_text_for_line)
+
+ def _create_get_processed_line_func(
+ self, document: Document, width: int, height: int
+ ) -> Callable[[int], _ProcessedLine]:
+ """
+ Create a function that takes a line number of the current document and
+ returns a _ProcessedLine(processed_fragments, source_to_display, display_to_source)
+ tuple.
+ """
+ # Merge all input processors together.
+ input_processors = self.input_processors or []
+ if self.include_default_input_processors:
+ input_processors = self.default_input_processors + input_processors
+
+ merged_processor = merge_processors(input_processors)
+
+ def transform(lineno: int, fragments: StyleAndTextTuples) -> _ProcessedLine:
+ "Transform the fragments for a given line number."
+
+ # Get cursor position at this line.
+ def source_to_display(i: int) -> int:
+ """X position from the buffer to the x position in the
+ processed fragment list. By default, we start from the 'identity'
+ operation."""
+ return i
+
+ transformation = merged_processor.apply_transformation(
+ TransformationInput(
+ self, document, lineno, source_to_display, fragments, width, height
+ )
+ )
+
+ return _ProcessedLine(
+ transformation.fragments,
+ transformation.source_to_display,
+ transformation.display_to_source,
+ )
+
+ def create_func() -> Callable[[int], _ProcessedLine]:
+ get_line = self._get_formatted_text_for_line_func(document)
+ cache: dict[int, _ProcessedLine] = {}
+
+ def get_processed_line(i: int) -> _ProcessedLine:
+ try:
+ return cache[i]
+ except KeyError:
+ processed_line = transform(i, get_line(i))
+ cache[i] = processed_line
+ return processed_line
+
+ return get_processed_line
+
+ return create_func()
+
+ def create_content(
+ self, width: int, height: int, preview_search: bool = False
+ ) -> UIContent:
+ """
+ Create a UIContent.
+ """
+ buffer = self.buffer
+
+ # Trigger history loading of the buffer. We do this during the
+ # rendering of the UI here, because it needs to happen when an
+ # `Application` with its event loop is running. During the rendering of
+ # the buffer control is the earliest place we can achieve this, where
+ # we're sure the right event loop is active, and don't require user
+ # interaction (like in a key binding).
+ buffer.load_history_if_not_yet_loaded()
+
+ # Get the document to be shown. If we are currently searching (the
+ # search buffer has focus, and the preview_search filter is enabled),
+ # then use the search document, which has possibly a different
+ # text/cursor position.)
+ search_control = self.search_buffer_control
+ preview_now = preview_search or bool(
+ # Only if this feature is enabled.
+ self.preview_search()
+ and
+ # And something was typed in the associated search field.
+ search_control
+ and search_control.buffer.text
+ and
+ # And we are searching in this control. (Many controls can point to
+ # the same search field, like in Pyvim.)
+ get_app().layout.search_target_buffer_control == self
+ )
+
+ if preview_now and search_control is not None:
+ ss = self.search_state
+
+ document = buffer.document_for_search(
+ SearchState(
+ text=search_control.buffer.text,
+ direction=ss.direction,
+ ignore_case=ss.ignore_case,
+ )
+ )
+ else:
+ document = buffer.document
+
+ get_processed_line = self._create_get_processed_line_func(
+ document, width, height
+ )
+ self._last_get_processed_line = get_processed_line
+
+ def translate_rowcol(row: int, col: int) -> Point:
+ "Return the content column for this coordinate."
+ return Point(x=get_processed_line(row).source_to_display(col), y=row)
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ "Return the fragments for a given line number."
+ fragments = get_processed_line(i).fragments
+
+ # Add a space at the end, because that is a possible cursor
+ # position. (When inserting after the input.) We should do this on
+ # all the lines, not just the line containing the cursor. (Because
+ # otherwise, line wrapping/scrolling could change when moving the
+ # cursor around.)
+ fragments = fragments + [("", " ")]
+ return fragments
+
+ content = UIContent(
+ get_line=get_line,
+ line_count=document.line_count,
+ cursor_position=translate_rowcol(
+ document.cursor_position_row, document.cursor_position_col
+ ),
+ )
+
+ # If there is an auto completion going on, use that start point for a
+ # pop-up menu position. (But only when this buffer has the focus --
+ # there is only one place for a menu, determined by the focused buffer.)
+ if get_app().layout.current_control == self:
+ menu_position = self.menu_position() if self.menu_position else None
+ if menu_position is not None:
+ assert isinstance(menu_position, int)
+ menu_row, menu_col = buffer.document.translate_index_to_position(
+ menu_position
+ )
+ content.menu_position = translate_rowcol(menu_row, menu_col)
+ elif buffer.complete_state:
+ # Position for completion menu.
+ # Note: We use 'min', because the original cursor position could be
+ # behind the input string when the actual completion is for
+ # some reason shorter than the text we had before. (A completion
+ # can change and shorten the input.)
+ menu_row, menu_col = buffer.document.translate_index_to_position(
+ min(
+ buffer.cursor_position,
+ buffer.complete_state.original_document.cursor_position,
+ )
+ )
+ content.menu_position = translate_rowcol(menu_row, menu_col)
+ else:
+ content.menu_position = None
+
+ return content
+
+ def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Mouse handler for this control.
+ """
+ buffer = self.buffer
+ position = mouse_event.position
+
+ # Focus buffer when clicked.
+ if get_app().layout.current_control == self:
+ if self._last_get_processed_line:
+ processed_line = self._last_get_processed_line(position.y)
+
+ # Translate coordinates back to the cursor position of the
+ # original input.
+ xpos = processed_line.display_to_source(position.x)
+ index = buffer.document.translate_row_col_to_index(position.y, xpos)
+
+ # Set the cursor position.
+ if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
+ buffer.exit_selection()
+ buffer.cursor_position = index
+
+ elif (
+ mouse_event.event_type == MouseEventType.MOUSE_MOVE
+ and mouse_event.button != MouseButton.NONE
+ ):
+ # Click and drag to highlight a selection
+ if (
+ buffer.selection_state is None
+ and abs(buffer.cursor_position - index) > 0
+ ):
+ buffer.start_selection(selection_type=SelectionType.CHARACTERS)
+ buffer.cursor_position = index
+
+ elif mouse_event.event_type == MouseEventType.MOUSE_UP:
+ # When the cursor was moved to another place, select the text.
+ # (The >1 is actually a small but acceptable workaround for
+ # selecting text in Vi navigation mode. In navigation mode,
+ # the cursor can never be after the text, so the cursor
+ # will be repositioned automatically.)
+ if abs(buffer.cursor_position - index) > 1:
+ if buffer.selection_state is None:
+ buffer.start_selection(
+ selection_type=SelectionType.CHARACTERS
+ )
+ buffer.cursor_position = index
+
+ # Select word around cursor on double click.
+ # Two MOUSE_UP events in a short timespan are considered a double click.
+ double_click = (
+ self._last_click_timestamp
+ and time.time() - self._last_click_timestamp < 0.3
+ )
+ self._last_click_timestamp = time.time()
+
+ if double_click:
+ start, end = buffer.document.find_boundaries_of_current_word()
+ buffer.cursor_position += start
+ buffer.start_selection(selection_type=SelectionType.CHARACTERS)
+ buffer.cursor_position += end - start
+ else:
+ # Don't handle scroll events here.
+ return NotImplemented
+
+ # Not focused, but focusing on click events.
+ else:
+ if (
+ self.focus_on_click()
+ and mouse_event.event_type == MouseEventType.MOUSE_UP
+ ):
+ # Focus happens on mouseup. (If we did this on mousedown, the
+ # up event will be received at the point where this widget is
+ # focused and be handled anyway.)
+ get_app().layout.current_control = self
+ else:
+ return NotImplemented
+
+ return None
+
+ def move_cursor_down(self) -> None:
+ b = self.buffer
+ b.cursor_position += b.document.get_cursor_down_position()
+
+ def move_cursor_up(self) -> None:
+ b = self.buffer
+ b.cursor_position += b.document.get_cursor_up_position()
+
+ def get_key_bindings(self) -> KeyBindingsBase | None:
+ """
+ When additional key bindings are given. Return these.
+ """
+ return self.key_bindings
+
+ def get_invalidate_events(self) -> Iterable[Event[object]]:
+ """
+ Return the Window invalidate events.
+ """
+ # Whenever the buffer changes, the UI has to be updated.
+ yield self.buffer.on_text_changed
+ yield self.buffer.on_cursor_position_changed
+
+ yield self.buffer.on_completions_changed
+ yield self.buffer.on_suggestion_set
+
+
+class SearchBufferControl(BufferControl):
+ """
+ :class:`.BufferControl` which is used for searching another
+ :class:`.BufferControl`.
+
+ :param ignore_case: Search case insensitive.
+ """
+
+ def __init__(
+ self,
+ buffer: Buffer | None = None,
+ input_processors: list[Processor] | None = None,
+ lexer: Lexer | None = None,
+ focus_on_click: FilterOrBool = False,
+ key_bindings: KeyBindingsBase | None = None,
+ ignore_case: FilterOrBool = False,
+ ):
+ super().__init__(
+ buffer=buffer,
+ input_processors=input_processors,
+ lexer=lexer,
+ focus_on_click=focus_on_click,
+ key_bindings=key_bindings,
+ )
+
+ # If this BufferControl is used as a search field for one or more other
+ # BufferControls, then represents the search state.
+ self.searcher_search_state = SearchState(ignore_case=ignore_case)
diff --git a/src/prompt_toolkit/layout/dimension.py b/src/prompt_toolkit/layout/dimension.py
new file mode 100644
index 0000000..c1f05f9
--- /dev/null
+++ b/src/prompt_toolkit/layout/dimension.py
@@ -0,0 +1,219 @@
+"""
+Layout dimensions are used to give the minimum, maximum and preferred
+dimensions for containers and controls.
+"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Callable, Union
+
+__all__ = [
+ "Dimension",
+ "D",
+ "sum_layout_dimensions",
+ "max_layout_dimensions",
+ "AnyDimension",
+ "to_dimension",
+ "is_dimension",
+]
+
+if TYPE_CHECKING:
+ from typing_extensions import TypeGuard
+
+
+class Dimension:
+ """
+ Specified dimension (width/height) of a user control or window.
+
+ The layout engine tries to honor the preferred size. If that is not
+ possible, because the terminal is larger or smaller, it tries to keep in
+ between min and max.
+
+ :param min: Minimum size.
+ :param max: Maximum size.
+ :param weight: For a VSplit/HSplit, the actual size will be determined
+ by taking the proportion of weights from all the children.
+ E.g. When there are two children, one with a weight of 1,
+ and the other with a weight of 2, the second will always be
+ twice as big as the first, if the min/max values allow it.
+ :param preferred: Preferred size.
+ """
+
+ def __init__(
+ self,
+ min: int | None = None,
+ max: int | None = None,
+ weight: int | None = None,
+ preferred: int | None = None,
+ ) -> None:
+ if weight is not None:
+ assert weight >= 0 # Also cannot be a float.
+
+ assert min is None or min >= 0
+ assert max is None or max >= 0
+ assert preferred is None or preferred >= 0
+
+ self.min_specified = min is not None
+ self.max_specified = max is not None
+ self.preferred_specified = preferred is not None
+ self.weight_specified = weight is not None
+
+ if min is None:
+ min = 0 # Smallest possible value.
+ if max is None: # 0-values are allowed, so use "is None"
+ max = 1000**10 # Something huge.
+ if preferred is None:
+ preferred = min
+ if weight is None:
+ weight = 1
+
+ self.min = min
+ self.max = max
+ self.preferred = preferred
+ self.weight = weight
+
+ # Don't allow situations where max < min. (This would be a bug.)
+ if max < min:
+ raise ValueError("Invalid Dimension: max < min.")
+
+ # Make sure that the 'preferred' size is always in the min..max range.
+ if self.preferred < self.min:
+ self.preferred = self.min
+
+ if self.preferred > self.max:
+ self.preferred = self.max
+
+ @classmethod
+ def exact(cls, amount: int) -> Dimension:
+ """
+ Return a :class:`.Dimension` with an exact size. (min, max and
+ preferred set to ``amount``).
+ """
+ return cls(min=amount, max=amount, preferred=amount)
+
+ @classmethod
+ def zero(cls) -> Dimension:
+ """
+ Create a dimension that represents a zero size. (Used for 'invisible'
+ controls.)
+ """
+ return cls.exact(amount=0)
+
+ def is_zero(self) -> bool:
+ "True if this `Dimension` represents a zero size."
+ return self.preferred == 0 or self.max == 0
+
+ def __repr__(self) -> str:
+ fields = []
+ if self.min_specified:
+ fields.append("min=%r" % self.min)
+ if self.max_specified:
+ fields.append("max=%r" % self.max)
+ if self.preferred_specified:
+ fields.append("preferred=%r" % self.preferred)
+ if self.weight_specified:
+ fields.append("weight=%r" % self.weight)
+
+ return "Dimension(%s)" % ", ".join(fields)
+
+
+def sum_layout_dimensions(dimensions: list[Dimension]) -> Dimension:
+ """
+ Sum a list of :class:`.Dimension` instances.
+ """
+ min = sum(d.min for d in dimensions)
+ max = sum(d.max for d in dimensions)
+ preferred = sum(d.preferred for d in dimensions)
+
+ return Dimension(min=min, max=max, preferred=preferred)
+
+
+def max_layout_dimensions(dimensions: list[Dimension]) -> Dimension:
+ """
+ Take the maximum of a list of :class:`.Dimension` instances.
+ Used when we have a HSplit/VSplit, and we want to get the best width/height.)
+ """
+ if not len(dimensions):
+ return Dimension.zero()
+
+ # If all dimensions are size zero. Return zero.
+ # (This is important for HSplit/VSplit, to report the right values to their
+ # parent when all children are invisible.)
+ if all(d.is_zero() for d in dimensions):
+ return dimensions[0]
+
+ # Ignore empty dimensions. (They should not reduce the size of others.)
+ dimensions = [d for d in dimensions if not d.is_zero()]
+
+ if dimensions:
+ # Take the highest minimum dimension.
+ min_ = max(d.min for d in dimensions)
+
+ # For the maximum, we would prefer not to go larger than then smallest
+ # 'max' value, unless other dimensions have a bigger preferred value.
+ # This seems to work best:
+ # - We don't want that a widget with a small height in a VSplit would
+ # shrink other widgets in the split.
+ # If it doesn't work well enough, then it's up to the UI designer to
+ # explicitly pass dimensions.
+ max_ = min(d.max for d in dimensions)
+ max_ = max(max_, max(d.preferred for d in dimensions))
+
+ # Make sure that min>=max. In some scenarios, when certain min..max
+ # ranges don't have any overlap, we can end up in such an impossible
+ # situation. In that case, give priority to the max value.
+ # E.g. taking (1..5) and (8..9) would return (8..5). Instead take (8..8).
+ if min_ > max_:
+ max_ = min_
+
+ preferred = max(d.preferred for d in dimensions)
+
+ return Dimension(min=min_, max=max_, preferred=preferred)
+ else:
+ return Dimension()
+
+
+# Anything that can be converted to a dimension.
+AnyDimension = Union[
+ None, # None is a valid dimension that will fit anything.
+ int,
+ Dimension,
+ # Callable[[], 'AnyDimension'] # Recursive definition not supported by mypy.
+ Callable[[], Any],
+]
+
+
+def to_dimension(value: AnyDimension) -> Dimension:
+ """
+ Turn the given object into a `Dimension` object.
+ """
+ if value is None:
+ return Dimension()
+ if isinstance(value, int):
+ return Dimension.exact(value)
+ if isinstance(value, Dimension):
+ return value
+ if callable(value):
+ return to_dimension(value())
+
+ raise ValueError("Not an integer or Dimension object.")
+
+
+def is_dimension(value: object) -> TypeGuard[AnyDimension]:
+ """
+ Test whether the given value could be a valid dimension.
+ (For usage in an assertion. It's not guaranteed in case of a callable.)
+ """
+ if value is None:
+ return True
+ if callable(value):
+ return True # Assume it's a callable that doesn't take arguments.
+ if isinstance(value, (int, Dimension)):
+ return True
+ return False
+
+
+# Common alias.
+D = Dimension
+
+# For backward-compatibility.
+LayoutDimension = Dimension
diff --git a/src/prompt_toolkit/layout/dummy.py b/src/prompt_toolkit/layout/dummy.py
new file mode 100644
index 0000000..139f311
--- /dev/null
+++ b/src/prompt_toolkit/layout/dummy.py
@@ -0,0 +1,39 @@
+"""
+Dummy layout. Used when somebody creates an `Application` without specifying a
+`Layout`.
+"""
+from __future__ import annotations
+
+from prompt_toolkit.formatted_text import HTML
+from prompt_toolkit.key_binding import KeyBindings
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+
+from .containers import Window
+from .controls import FormattedTextControl
+from .dimension import D
+from .layout import Layout
+
+__all__ = [
+ "create_dummy_layout",
+]
+
+E = KeyPressEvent
+
+
+def create_dummy_layout() -> Layout:
+ """
+ Create a dummy layout for use in an 'Application' that doesn't have a
+ layout specified. When ENTER is pressed, the application quits.
+ """
+ kb = KeyBindings()
+
+ @kb.add("enter")
+ def enter(event: E) -> None:
+ event.app.exit()
+
+ control = FormattedTextControl(
+ HTML("No layout specified. Press <reverse>ENTER</reverse> to quit."),
+ key_bindings=kb,
+ )
+ window = Window(content=control, height=D(min=1))
+ return Layout(container=window, focused_element=window)
diff --git a/src/prompt_toolkit/layout/layout.py b/src/prompt_toolkit/layout/layout.py
new file mode 100644
index 0000000..a5e7a80
--- /dev/null
+++ b/src/prompt_toolkit/layout/layout.py
@@ -0,0 +1,411 @@
+"""
+Wrapper for the layout.
+"""
+from __future__ import annotations
+
+from typing import Generator, Iterable, Union
+
+from prompt_toolkit.buffer import Buffer
+
+from .containers import (
+ AnyContainer,
+ ConditionalContainer,
+ Container,
+ Window,
+ to_container,
+)
+from .controls import BufferControl, SearchBufferControl, UIControl
+
+__all__ = [
+ "Layout",
+ "InvalidLayoutError",
+ "walk",
+]
+
+FocusableElement = Union[str, Buffer, UIControl, AnyContainer]
+
+
+class Layout:
+ """
+ The layout for a prompt_toolkit
+ :class:`~prompt_toolkit.application.Application`.
+ This also keeps track of which user control is focused.
+
+ :param container: The "root" container for the layout.
+ :param focused_element: element to be focused initially. (Can be anything
+ the `focus` function accepts.)
+ """
+
+ def __init__(
+ self,
+ container: AnyContainer,
+ focused_element: FocusableElement | None = None,
+ ) -> None:
+ self.container = to_container(container)
+ self._stack: list[Window] = []
+
+ # Map search BufferControl back to the original BufferControl.
+ # This is used to keep track of when exactly we are searching, and for
+ # applying the search.
+ # When a link exists in this dictionary, that means the search is
+ # currently active.
+ # Map: search_buffer_control -> original buffer control.
+ self.search_links: dict[SearchBufferControl, BufferControl] = {}
+
+ # Mapping that maps the children in the layout to their parent.
+ # This relationship is calculated dynamically, each time when the UI
+ # is rendered. (UI elements have only references to their children.)
+ self._child_to_parent: dict[Container, Container] = {}
+
+ if focused_element is None:
+ try:
+ self._stack.append(next(self.find_all_windows()))
+ except StopIteration as e:
+ raise InvalidLayoutError(
+ "Invalid layout. The layout does not contain any Window object."
+ ) from e
+ else:
+ self.focus(focused_element)
+
+ # List of visible windows.
+ self.visible_windows: list[Window] = [] # List of `Window` objects.
+
+ def __repr__(self) -> str:
+ return f"Layout({self.container!r}, current_window={self.current_window!r})"
+
+ def find_all_windows(self) -> Generator[Window, None, None]:
+ """
+ Find all the :class:`.UIControl` objects in this layout.
+ """
+ for item in self.walk():
+ if isinstance(item, Window):
+ yield item
+
+ def find_all_controls(self) -> Iterable[UIControl]:
+ for container in self.find_all_windows():
+ yield container.content
+
+ def focus(self, value: FocusableElement) -> None:
+ """
+ Focus the given UI element.
+
+ `value` can be either:
+
+ - a :class:`.UIControl`
+ - a :class:`.Buffer` instance or the name of a :class:`.Buffer`
+ - a :class:`.Window`
+ - Any container object. In this case we will focus the :class:`.Window`
+ from this container that was focused most recent, or the very first
+ focusable :class:`.Window` of the container.
+ """
+ # BufferControl by buffer name.
+ if isinstance(value, str):
+ for control in self.find_all_controls():
+ if isinstance(control, BufferControl) and control.buffer.name == value:
+ self.focus(control)
+ return
+ raise ValueError(f"Couldn't find Buffer in the current layout: {value!r}.")
+
+ # BufferControl by buffer object.
+ elif isinstance(value, Buffer):
+ for control in self.find_all_controls():
+ if isinstance(control, BufferControl) and control.buffer == value:
+ self.focus(control)
+ return
+ raise ValueError(f"Couldn't find Buffer in the current layout: {value!r}.")
+
+ # Focus UIControl.
+ elif isinstance(value, UIControl):
+ if value not in self.find_all_controls():
+ raise ValueError(
+ "Invalid value. Container does not appear in the layout."
+ )
+ if not value.is_focusable():
+ raise ValueError("Invalid value. UIControl is not focusable.")
+
+ self.current_control = value
+
+ # Otherwise, expecting any Container object.
+ else:
+ value = to_container(value)
+
+ if isinstance(value, Window):
+ # This is a `Window`: focus that.
+ if value not in self.find_all_windows():
+ raise ValueError(
+ f"Invalid value. Window does not appear in the layout: {value!r}"
+ )
+
+ self.current_window = value
+ else:
+ # Focus a window in this container.
+ # If we have many windows as part of this container, and some
+ # of them have been focused before, take the last focused
+ # item. (This is very useful when the UI is composed of more
+ # complex sub components.)
+ windows = []
+ for c in walk(value, skip_hidden=True):
+ if isinstance(c, Window) and c.content.is_focusable():
+ windows.append(c)
+
+ # Take the first one that was focused before.
+ for w in reversed(self._stack):
+ if w in windows:
+ self.current_window = w
+ return
+
+ # None was focused before: take the very first focusable window.
+ if windows:
+ self.current_window = windows[0]
+ return
+
+ raise ValueError(
+ f"Invalid value. Container cannot be focused: {value!r}"
+ )
+
+ def has_focus(self, value: FocusableElement) -> bool:
+ """
+ Check whether the given control has the focus.
+ :param value: :class:`.UIControl` or :class:`.Window` instance.
+ """
+ if isinstance(value, str):
+ if self.current_buffer is None:
+ return False
+ return self.current_buffer.name == value
+ if isinstance(value, Buffer):
+ return self.current_buffer == value
+ if isinstance(value, UIControl):
+ return self.current_control == value
+ else:
+ value = to_container(value)
+ if isinstance(value, Window):
+ return self.current_window == value
+ else:
+ # Check whether this "container" is focused. This is true if
+ # one of the elements inside is focused.
+ for element in walk(value):
+ if element == self.current_window:
+ return True
+ return False
+
+ @property
+ def current_control(self) -> UIControl:
+ """
+ Get the :class:`.UIControl` to currently has the focus.
+ """
+ return self._stack[-1].content
+
+ @current_control.setter
+ def current_control(self, control: UIControl) -> None:
+ """
+ Set the :class:`.UIControl` to receive the focus.
+ """
+ for window in self.find_all_windows():
+ if window.content == control:
+ self.current_window = window
+ return
+
+ raise ValueError("Control not found in the user interface.")
+
+ @property
+ def current_window(self) -> Window:
+ "Return the :class:`.Window` object that is currently focused."
+ return self._stack[-1]
+
+ @current_window.setter
+ def current_window(self, value: Window) -> None:
+ "Set the :class:`.Window` object to be currently focused."
+ self._stack.append(value)
+
+ @property
+ def is_searching(self) -> bool:
+ "True if we are searching right now."
+ return self.current_control in self.search_links
+
+ @property
+ def search_target_buffer_control(self) -> BufferControl | None:
+ """
+ Return the :class:`.BufferControl` in which we are searching or `None`.
+ """
+ # Not every `UIControl` is a `BufferControl`. This only applies to
+ # `BufferControl`.
+ control = self.current_control
+
+ if isinstance(control, SearchBufferControl):
+ return self.search_links.get(control)
+ else:
+ return None
+
+ def get_focusable_windows(self) -> Iterable[Window]:
+ """
+ Return all the :class:`.Window` objects which are focusable (in the
+ 'modal' area).
+ """
+ for w in self.walk_through_modal_area():
+ if isinstance(w, Window) and w.content.is_focusable():
+ yield w
+
+ def get_visible_focusable_windows(self) -> list[Window]:
+ """
+ Return a list of :class:`.Window` objects that are focusable.
+ """
+ # focusable windows are windows that are visible, but also part of the
+ # modal container. Make sure to keep the ordering.
+ visible_windows = self.visible_windows
+ return [w for w in self.get_focusable_windows() if w in visible_windows]
+
+ @property
+ def current_buffer(self) -> Buffer | None:
+ """
+ The currently focused :class:`~.Buffer` or `None`.
+ """
+ ui_control = self.current_control
+ if isinstance(ui_control, BufferControl):
+ return ui_control.buffer
+ return None
+
+ def get_buffer_by_name(self, buffer_name: str) -> Buffer | None:
+ """
+ Look in the layout for a buffer with the given name.
+ Return `None` when nothing was found.
+ """
+ for w in self.walk():
+ if isinstance(w, Window) and isinstance(w.content, BufferControl):
+ if w.content.buffer.name == buffer_name:
+ return w.content.buffer
+ return None
+
+ @property
+ def buffer_has_focus(self) -> bool:
+ """
+ Return `True` if the currently focused control is a
+ :class:`.BufferControl`. (For instance, used to determine whether the
+ default key bindings should be active or not.)
+ """
+ ui_control = self.current_control
+ return isinstance(ui_control, BufferControl)
+
+ @property
+ def previous_control(self) -> UIControl:
+ """
+ Get the :class:`.UIControl` to previously had the focus.
+ """
+ try:
+ return self._stack[-2].content
+ except IndexError:
+ return self._stack[-1].content
+
+ def focus_last(self) -> None:
+ """
+ Give the focus to the last focused control.
+ """
+ if len(self._stack) > 1:
+ self._stack = self._stack[:-1]
+
+ def focus_next(self) -> None:
+ """
+ Focus the next visible/focusable Window.
+ """
+ windows = self.get_visible_focusable_windows()
+
+ if len(windows) > 0:
+ try:
+ index = windows.index(self.current_window)
+ except ValueError:
+ index = 0
+ else:
+ index = (index + 1) % len(windows)
+
+ self.focus(windows[index])
+
+ def focus_previous(self) -> None:
+ """
+ Focus the previous visible/focusable Window.
+ """
+ windows = self.get_visible_focusable_windows()
+
+ if len(windows) > 0:
+ try:
+ index = windows.index(self.current_window)
+ except ValueError:
+ index = 0
+ else:
+ index = (index - 1) % len(windows)
+
+ self.focus(windows[index])
+
+ def walk(self) -> Iterable[Container]:
+ """
+ Walk through all the layout nodes (and their children) and yield them.
+ """
+ yield from walk(self.container)
+
+ def walk_through_modal_area(self) -> Iterable[Container]:
+ """
+ Walk through all the containers which are in the current 'modal' part
+ of the layout.
+ """
+ # Go up in the tree, and find the root. (it will be a part of the
+ # layout, if the focus is in a modal part.)
+ root: Container = self.current_window
+ while not root.is_modal() and root in self._child_to_parent:
+ root = self._child_to_parent[root]
+
+ yield from walk(root)
+
+ def update_parents_relations(self) -> None:
+ """
+ Update child->parent relationships mapping.
+ """
+ parents = {}
+
+ def walk(e: Container) -> None:
+ for c in e.get_children():
+ parents[c] = e
+ walk(c)
+
+ walk(self.container)
+
+ self._child_to_parent = parents
+
+ def reset(self) -> None:
+ # Remove all search links when the UI starts.
+ # (Important, for instance when control-c is been pressed while
+ # searching. The prompt cancels, but next `run()` call the search
+ # links are still there.)
+ self.search_links.clear()
+
+ self.container.reset()
+
+ def get_parent(self, container: Container) -> Container | None:
+ """
+ Return the parent container for the given container, or ``None``, if it
+ wasn't found.
+ """
+ try:
+ return self._child_to_parent[container]
+ except KeyError:
+ return None
+
+
+class InvalidLayoutError(Exception):
+ pass
+
+
+def walk(container: Container, skip_hidden: bool = False) -> Iterable[Container]:
+ """
+ Walk through layout, starting at this container.
+ """
+ # When `skip_hidden` is set, don't go into disabled ConditionalContainer containers.
+ if (
+ skip_hidden
+ and isinstance(container, ConditionalContainer)
+ and not container.filter()
+ ):
+ return
+
+ yield container
+
+ for c in container.get_children():
+ # yield from walk(c)
+ yield from walk(c, skip_hidden=skip_hidden)
diff --git a/src/prompt_toolkit/layout/margins.py b/src/prompt_toolkit/layout/margins.py
new file mode 100644
index 0000000..cc9dd96
--- /dev/null
+++ b/src/prompt_toolkit/layout/margins.py
@@ -0,0 +1,303 @@
+"""
+Margin implementations for a :class:`~prompt_toolkit.layout.containers.Window`.
+"""
+from __future__ import annotations
+
+from abc import ABCMeta, abstractmethod
+from typing import TYPE_CHECKING, Callable
+
+from prompt_toolkit.filters import FilterOrBool, to_filter
+from prompt_toolkit.formatted_text import (
+ StyleAndTextTuples,
+ fragment_list_to_text,
+ to_formatted_text,
+)
+from prompt_toolkit.utils import get_cwidth
+
+from .controls import UIContent
+
+if TYPE_CHECKING:
+ from .containers import WindowRenderInfo
+
+__all__ = [
+ "Margin",
+ "NumberedMargin",
+ "ScrollbarMargin",
+ "ConditionalMargin",
+ "PromptMargin",
+]
+
+
+class Margin(metaclass=ABCMeta):
+ """
+ Base interface for a margin.
+ """
+
+ @abstractmethod
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+ """
+ Return the width that this margin is going to consume.
+
+ :param get_ui_content: Callable that asks the user control to create
+ a :class:`.UIContent` instance. This can be used for instance to
+ obtain the number of lines.
+ """
+ return 0
+
+ @abstractmethod
+ def create_margin(
+ self, window_render_info: WindowRenderInfo, width: int, height: int
+ ) -> StyleAndTextTuples:
+ """
+ Creates a margin.
+ This should return a list of (style_str, text) tuples.
+
+ :param window_render_info:
+ :class:`~prompt_toolkit.layout.containers.WindowRenderInfo`
+ instance, generated after rendering and copying the visible part of
+ the :class:`~prompt_toolkit.layout.controls.UIControl` into the
+ :class:`~prompt_toolkit.layout.containers.Window`.
+ :param width: The width that's available for this margin. (As reported
+ by :meth:`.get_width`.)
+ :param height: The height that's available for this margin. (The height
+ of the :class:`~prompt_toolkit.layout.containers.Window`.)
+ """
+ return []
+
+
+class NumberedMargin(Margin):
+ """
+ Margin that displays the line numbers.
+
+ :param relative: Number relative to the cursor position. Similar to the Vi
+ 'relativenumber' option.
+ :param display_tildes: Display tildes after the end of the document, just
+ like Vi does.
+ """
+
+ def __init__(
+ self, relative: FilterOrBool = False, display_tildes: FilterOrBool = False
+ ) -> None:
+ self.relative = to_filter(relative)
+ self.display_tildes = to_filter(display_tildes)
+
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+ line_count = get_ui_content().line_count
+ return max(3, len("%s" % line_count) + 1)
+
+ def create_margin(
+ self, window_render_info: WindowRenderInfo, width: int, height: int
+ ) -> StyleAndTextTuples:
+ relative = self.relative()
+
+ style = "class:line-number"
+ style_current = "class:line-number.current"
+
+ # Get current line number.
+ current_lineno = window_render_info.ui_content.cursor_position.y
+
+ # Construct margin.
+ result: StyleAndTextTuples = []
+ last_lineno = None
+
+ for y, lineno in enumerate(window_render_info.displayed_lines):
+ # Only display line number if this line is not a continuation of the previous line.
+ if lineno != last_lineno:
+ if lineno is None:
+ pass
+ elif lineno == current_lineno:
+ # Current line.
+ if relative:
+ # Left align current number in relative mode.
+ result.append((style_current, "%i" % (lineno + 1)))
+ else:
+ result.append(
+ (style_current, ("%i " % (lineno + 1)).rjust(width))
+ )
+ else:
+ # Other lines.
+ if relative:
+ lineno = abs(lineno - current_lineno) - 1
+
+ result.append((style, ("%i " % (lineno + 1)).rjust(width)))
+
+ last_lineno = lineno
+ result.append(("", "\n"))
+
+ # Fill with tildes.
+ if self.display_tildes():
+ while y < window_render_info.window_height:
+ result.append(("class:tilde", "~\n"))
+ y += 1
+
+ return result
+
+
+class ConditionalMargin(Margin):
+ """
+ Wrapper around other :class:`.Margin` classes to show/hide them.
+ """
+
+ def __init__(self, margin: Margin, filter: FilterOrBool) -> None:
+ self.margin = margin
+ self.filter = to_filter(filter)
+
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+ if self.filter():
+ return self.margin.get_width(get_ui_content)
+ else:
+ return 0
+
+ def create_margin(
+ self, window_render_info: WindowRenderInfo, width: int, height: int
+ ) -> StyleAndTextTuples:
+ if width and self.filter():
+ return self.margin.create_margin(window_render_info, width, height)
+ else:
+ return []
+
+
+class ScrollbarMargin(Margin):
+ """
+ Margin displaying a scrollbar.
+
+ :param display_arrows: Display scroll up/down arrows.
+ """
+
+ def __init__(
+ self,
+ display_arrows: FilterOrBool = False,
+ up_arrow_symbol: str = "^",
+ down_arrow_symbol: str = "v",
+ ) -> None:
+ self.display_arrows = to_filter(display_arrows)
+ self.up_arrow_symbol = up_arrow_symbol
+ self.down_arrow_symbol = down_arrow_symbol
+
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+ return 1
+
+ def create_margin(
+ self, window_render_info: WindowRenderInfo, width: int, height: int
+ ) -> StyleAndTextTuples:
+ content_height = window_render_info.content_height
+ window_height = window_render_info.window_height
+ display_arrows = self.display_arrows()
+
+ if display_arrows:
+ window_height -= 2
+
+ try:
+ fraction_visible = len(window_render_info.displayed_lines) / float(
+ content_height
+ )
+ fraction_above = window_render_info.vertical_scroll / float(content_height)
+
+ scrollbar_height = int(
+ min(window_height, max(1, window_height * fraction_visible))
+ )
+ scrollbar_top = int(window_height * fraction_above)
+ except ZeroDivisionError:
+ return []
+ else:
+
+ def is_scroll_button(row: int) -> bool:
+ "True if we should display a button on this row."
+ return scrollbar_top <= row <= scrollbar_top + scrollbar_height
+
+ # Up arrow.
+ result: StyleAndTextTuples = []
+ if display_arrows:
+ result.extend(
+ [
+ ("class:scrollbar.arrow", self.up_arrow_symbol),
+ ("class:scrollbar", "\n"),
+ ]
+ )
+
+ # Scrollbar body.
+ scrollbar_background = "class:scrollbar.background"
+ scrollbar_background_start = "class:scrollbar.background,scrollbar.start"
+ scrollbar_button = "class:scrollbar.button"
+ scrollbar_button_end = "class:scrollbar.button,scrollbar.end"
+
+ for i in range(window_height):
+ if is_scroll_button(i):
+ if not is_scroll_button(i + 1):
+ # Give the last cell a different style, because we
+ # want to underline this.
+ result.append((scrollbar_button_end, " "))
+ else:
+ result.append((scrollbar_button, " "))
+ else:
+ if is_scroll_button(i + 1):
+ result.append((scrollbar_background_start, " "))
+ else:
+ result.append((scrollbar_background, " "))
+ result.append(("", "\n"))
+
+ # Down arrow
+ if display_arrows:
+ result.append(("class:scrollbar.arrow", self.down_arrow_symbol))
+
+ return result
+
+
+class PromptMargin(Margin):
+ """
+ [Deprecated]
+
+ Create margin that displays a prompt.
+ This can display one prompt at the first line, and a continuation prompt
+ (e.g, just dots) on all the following lines.
+
+ This `PromptMargin` implementation has been largely superseded in favor of
+ the `get_line_prefix` attribute of `Window`. The reason is that a margin is
+ always a fixed width, while `get_line_prefix` can return a variable width
+ prefix in front of every line, making it more powerful, especially for line
+ continuations.
+
+ :param get_prompt: Callable returns formatted text or a list of
+ `(style_str, type)` tuples to be shown as the prompt at the first line.
+ :param get_continuation: Callable that takes three inputs. The width (int),
+ line_number (int), and is_soft_wrap (bool). It should return formatted
+ text or a list of `(style_str, type)` tuples for the next lines of the
+ input.
+ """
+
+ def __init__(
+ self,
+ get_prompt: Callable[[], StyleAndTextTuples],
+ get_continuation: None
+ | (Callable[[int, int, bool], StyleAndTextTuples]) = None,
+ ) -> None:
+ self.get_prompt = get_prompt
+ self.get_continuation = get_continuation
+
+ def get_width(self, get_ui_content: Callable[[], UIContent]) -> int:
+ "Width to report to the `Window`."
+ # Take the width from the first line.
+ text = fragment_list_to_text(self.get_prompt())
+ return get_cwidth(text)
+
+ def create_margin(
+ self, window_render_info: WindowRenderInfo, width: int, height: int
+ ) -> StyleAndTextTuples:
+ get_continuation = self.get_continuation
+ result: StyleAndTextTuples = []
+
+ # First line.
+ result.extend(to_formatted_text(self.get_prompt()))
+
+ # Next lines.
+ if get_continuation:
+ last_y = None
+
+ for y in window_render_info.displayed_lines[1:]:
+ result.append(("", "\n"))
+ result.extend(
+ to_formatted_text(get_continuation(width, y, y == last_y))
+ )
+ last_y = y
+
+ return result
diff --git a/src/prompt_toolkit/layout/menus.py b/src/prompt_toolkit/layout/menus.py
new file mode 100644
index 0000000..2c2ccb6
--- /dev/null
+++ b/src/prompt_toolkit/layout/menus.py
@@ -0,0 +1,751 @@
+from __future__ import annotations
+
+import math
+from itertools import zip_longest
+from typing import TYPE_CHECKING, Callable, Iterable, Sequence, TypeVar, cast
+from weakref import WeakKeyDictionary
+
+from prompt_toolkit.application.current import get_app
+from prompt_toolkit.buffer import CompletionState
+from prompt_toolkit.completion import Completion
+from prompt_toolkit.data_structures import Point
+from prompt_toolkit.filters import (
+ Condition,
+ FilterOrBool,
+ has_completions,
+ is_done,
+ to_filter,
+)
+from prompt_toolkit.formatted_text import (
+ StyleAndTextTuples,
+ fragment_list_width,
+ to_formatted_text,
+)
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.layout.utils import explode_text_fragments
+from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
+from prompt_toolkit.utils import get_cwidth
+
+from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window
+from .controls import GetLinePrefixCallable, UIContent, UIControl
+from .dimension import Dimension
+from .margins import ScrollbarMargin
+
+if TYPE_CHECKING:
+ from prompt_toolkit.key_binding.key_bindings import (
+ KeyBindings,
+ NotImplementedOrNone,
+ )
+
+
+__all__ = [
+ "CompletionsMenu",
+ "MultiColumnCompletionsMenu",
+]
+
+E = KeyPressEvent
+
+
+class CompletionsMenuControl(UIControl):
+ """
+ Helper for drawing the complete menu to the screen.
+
+ :param scroll_offset: Number (integer) representing the preferred amount of
+ completions to be displayed before and after the current one. When this
+ is a very high number, the current completion will be shown in the
+ middle most of the time.
+ """
+
+ # Preferred minimum size of the menu control.
+ # The CompletionsMenu class defines a width of 8, and there is a scrollbar
+ # of 1.)
+ MIN_WIDTH = 7
+
+ def has_focus(self) -> bool:
+ return False
+
+ def preferred_width(self, max_available_width: int) -> int | None:
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state:
+ menu_width = self._get_menu_width(500, complete_state)
+ menu_meta_width = self._get_menu_meta_width(500, complete_state)
+
+ return menu_width + menu_meta_width
+ else:
+ return 0
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state:
+ return len(complete_state.completions)
+ else:
+ return 0
+
+ def create_content(self, width: int, height: int) -> UIContent:
+ """
+ Create a UIContent object for this control.
+ """
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state:
+ completions = complete_state.completions
+ index = complete_state.complete_index # Can be None!
+
+ # Calculate width of completions menu.
+ menu_width = self._get_menu_width(width, complete_state)
+ menu_meta_width = self._get_menu_meta_width(
+ width - menu_width, complete_state
+ )
+ show_meta = self._show_meta(complete_state)
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ c = completions[i]
+ is_current_completion = i == index
+ result = _get_menu_item_fragments(
+ c, is_current_completion, menu_width, space_after=True
+ )
+
+ if show_meta:
+ result += self._get_menu_item_meta_fragments(
+ c, is_current_completion, menu_meta_width
+ )
+ return result
+
+ return UIContent(
+ get_line=get_line,
+ cursor_position=Point(x=0, y=index or 0),
+ line_count=len(completions),
+ )
+
+ return UIContent()
+
+ def _show_meta(self, complete_state: CompletionState) -> bool:
+ """
+ Return ``True`` if we need to show a column with meta information.
+ """
+ return any(c.display_meta_text for c in complete_state.completions)
+
+ def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int:
+ """
+ Return the width of the main column.
+ """
+ return min(
+ max_width,
+ max(
+ self.MIN_WIDTH,
+ max(get_cwidth(c.display_text) for c in complete_state.completions) + 2,
+ ),
+ )
+
+ def _get_menu_meta_width(
+ self, max_width: int, complete_state: CompletionState
+ ) -> int:
+ """
+ Return the width of the meta column.
+ """
+
+ def meta_width(completion: Completion) -> int:
+ return get_cwidth(completion.display_meta_text)
+
+ if self._show_meta(complete_state):
+ # If the amount of completions is over 200, compute the width based
+ # on the first 200 completions, otherwise this can be very slow.
+ completions = complete_state.completions
+ if len(completions) > 200:
+ completions = completions[:200]
+
+ return min(max_width, max(meta_width(c) for c in completions) + 2)
+ else:
+ return 0
+
+ def _get_menu_item_meta_fragments(
+ self, completion: Completion, is_current_completion: bool, width: int
+ ) -> StyleAndTextTuples:
+ if is_current_completion:
+ style_str = "class:completion-menu.meta.completion.current"
+ else:
+ style_str = "class:completion-menu.meta.completion"
+
+ text, tw = _trim_formatted_text(completion.display_meta, width - 2)
+ padding = " " * (width - 1 - tw)
+
+ return to_formatted_text(
+ cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
+ style=style_str,
+ )
+
+ def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Handle mouse events: clicking and scrolling.
+ """
+ b = get_app().current_buffer
+
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
+ # Select completion.
+ b.go_to_completion(mouse_event.position.y)
+ b.complete_state = None
+
+ elif mouse_event.event_type == MouseEventType.SCROLL_DOWN:
+ # Scroll up.
+ b.complete_next(count=3, disable_wrap_around=True)
+
+ elif mouse_event.event_type == MouseEventType.SCROLL_UP:
+ # Scroll down.
+ b.complete_previous(count=3, disable_wrap_around=True)
+
+ return None
+
+
+def _get_menu_item_fragments(
+ completion: Completion,
+ is_current_completion: bool,
+ width: int,
+ space_after: bool = False,
+) -> StyleAndTextTuples:
+ """
+ Get the style/text tuples for a menu item, styled and trimmed to the given
+ width.
+ """
+ if is_current_completion:
+ style_str = "class:completion-menu.completion.current {} {}".format(
+ completion.style,
+ completion.selected_style,
+ )
+ else:
+ style_str = "class:completion-menu.completion " + completion.style
+
+ text, tw = _trim_formatted_text(
+ completion.display, (width - 2 if space_after else width - 1)
+ )
+
+ padding = " " * (width - 1 - tw)
+
+ return to_formatted_text(
+ cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)],
+ style=style_str,
+ )
+
+
+def _trim_formatted_text(
+ formatted_text: StyleAndTextTuples, max_width: int
+) -> tuple[StyleAndTextTuples, int]:
+ """
+ Trim the text to `max_width`, append dots when the text is too long.
+ Returns (text, width) tuple.
+ """
+ width = fragment_list_width(formatted_text)
+
+ # When the text is too wide, trim it.
+ if width > max_width:
+ result = [] # Text fragments.
+ remaining_width = max_width - 3
+
+ for style_and_ch in explode_text_fragments(formatted_text):
+ ch_width = get_cwidth(style_and_ch[1])
+
+ if ch_width <= remaining_width:
+ result.append(style_and_ch)
+ remaining_width -= ch_width
+ else:
+ break
+
+ result.append(("", "..."))
+
+ return result, max_width - remaining_width
+ else:
+ return formatted_text, width
+
+
+class CompletionsMenu(ConditionalContainer):
+ # NOTE: We use a pretty big z_index by default. Menus are supposed to be
+ # above anything else. We also want to make sure that the content is
+ # visible at the point where we draw this menu.
+ def __init__(
+ self,
+ max_height: int | None = None,
+ scroll_offset: int | Callable[[], int] = 0,
+ extra_filter: FilterOrBool = True,
+ display_arrows: FilterOrBool = False,
+ z_index: int = 10**8,
+ ) -> None:
+ extra_filter = to_filter(extra_filter)
+ display_arrows = to_filter(display_arrows)
+
+ super().__init__(
+ content=Window(
+ content=CompletionsMenuControl(),
+ width=Dimension(min=8),
+ height=Dimension(min=1, max=max_height),
+ scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset),
+ right_margins=[ScrollbarMargin(display_arrows=display_arrows)],
+ dont_extend_width=True,
+ style="class:completion-menu",
+ z_index=z_index,
+ ),
+ # Show when there are completions but not at the point we are
+ # returning the input.
+ filter=extra_filter & has_completions & ~is_done,
+ )
+
+
+class MultiColumnCompletionMenuControl(UIControl):
+ """
+ Completion menu that displays all the completions in several columns.
+ When there are more completions than space for them to be displayed, an
+ arrow is shown on the left or right side.
+
+ `min_rows` indicates how many rows will be available in any possible case.
+ When this is larger than one, it will try to use less columns and more
+ rows until this value is reached.
+ Be careful passing in a too big value, if less than the given amount of
+ rows are available, more columns would have been required, but
+ `preferred_width` doesn't know about that and reports a too small value.
+ This results in less completions displayed and additional scrolling.
+ (It's a limitation of how the layout engine currently works: first the
+ widths are calculated, then the heights.)
+
+ :param suggested_max_column_width: The suggested max width of a column.
+ The column can still be bigger than this, but if there is place for two
+ columns of this width, we will display two columns. This to avoid that
+ if there is one very wide completion, that it doesn't significantly
+ reduce the amount of columns.
+ """
+
+ _required_margin = 3 # One extra padding on the right + space for arrows.
+
+ def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None:
+ assert min_rows >= 1
+
+ self.min_rows = min_rows
+ self.suggested_max_column_width = suggested_max_column_width
+ self.scroll = 0
+
+ # Cache for column width computations. This computation is not cheap,
+ # so we don't want to do it over and over again while the user
+ # navigates through the completions.
+ # (map `completion_state` to `(completion_count, width)`. We remember
+ # the count, because a completer can add new completions to the
+ # `CompletionState` while loading.)
+ self._column_width_for_completion_state: WeakKeyDictionary[
+ CompletionState, tuple[int, int]
+ ] = WeakKeyDictionary()
+
+ # Info of last rendering.
+ self._rendered_rows = 0
+ self._rendered_columns = 0
+ self._total_columns = 0
+ self._render_pos_to_completion: dict[tuple[int, int], Completion] = {}
+ self._render_left_arrow = False
+ self._render_right_arrow = False
+ self._render_width = 0
+
+ def reset(self) -> None:
+ self.scroll = 0
+
+ def has_focus(self) -> bool:
+ return False
+
+ def preferred_width(self, max_available_width: int) -> int | None:
+ """
+ Preferred width: prefer to use at least min_rows, but otherwise as much
+ as possible horizontally.
+ """
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state is None:
+ return 0
+
+ column_width = self._get_column_width(complete_state)
+ result = int(
+ column_width
+ * math.ceil(len(complete_state.completions) / float(self.min_rows))
+ )
+
+ # When the desired width is still more than the maximum available,
+ # reduce by removing columns until we are less than the available
+ # width.
+ while (
+ result > column_width
+ and result > max_available_width - self._required_margin
+ ):
+ result -= column_width
+ return result + self._required_margin
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ """
+ Preferred height: as much as needed in order to display all the completions.
+ """
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state is None:
+ return 0
+
+ column_width = self._get_column_width(complete_state)
+ column_count = max(1, (width - self._required_margin) // column_width)
+
+ return int(math.ceil(len(complete_state.completions) / float(column_count)))
+
+ def create_content(self, width: int, height: int) -> UIContent:
+ """
+ Create a UIContent object for this menu.
+ """
+ complete_state = get_app().current_buffer.complete_state
+ if complete_state is None:
+ return UIContent()
+
+ column_width = self._get_column_width(complete_state)
+ self._render_pos_to_completion = {}
+
+ _T = TypeVar("_T")
+
+ def grouper(
+ n: int, iterable: Iterable[_T], fillvalue: _T | None = None
+ ) -> Iterable[Sequence[_T | None]]:
+ "grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx"
+ args = [iter(iterable)] * n
+ return zip_longest(fillvalue=fillvalue, *args)
+
+ def is_current_completion(completion: Completion) -> bool:
+ "Returns True when this completion is the currently selected one."
+ return (
+ complete_state is not None
+ and complete_state.complete_index is not None
+ and c == complete_state.current_completion
+ )
+
+ # Space required outside of the regular columns, for displaying the
+ # left and right arrow.
+ HORIZONTAL_MARGIN_REQUIRED = 3
+
+ # There should be at least one column, but it cannot be wider than
+ # the available width.
+ column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width)
+
+ # However, when the columns tend to be very wide, because there are
+ # some very wide entries, shrink it anyway.
+ if column_width > self.suggested_max_column_width:
+ # `column_width` can still be bigger that `suggested_max_column_width`,
+ # but if there is place for two columns, we divide by two.
+ column_width //= column_width // self.suggested_max_column_width
+
+ visible_columns = max(1, (width - self._required_margin) // column_width)
+
+ columns_ = list(grouper(height, complete_state.completions))
+ rows_ = list(zip(*columns_))
+
+ # Make sure the current completion is always visible: update scroll offset.
+ selected_column = (complete_state.complete_index or 0) // height
+ self.scroll = min(
+ selected_column, max(self.scroll, selected_column - visible_columns + 1)
+ )
+
+ render_left_arrow = self.scroll > 0
+ render_right_arrow = self.scroll < len(rows_[0]) - visible_columns
+
+ # Write completions to screen.
+ fragments_for_line = []
+
+ for row_index, row in enumerate(rows_):
+ fragments: StyleAndTextTuples = []
+ middle_row = row_index == len(rows_) // 2
+
+ # Draw left arrow if we have hidden completions on the left.
+ if render_left_arrow:
+ fragments.append(("class:scrollbar", "<" if middle_row else " "))
+ elif render_right_arrow:
+ # Reserve one column empty space. (If there is a right
+ # arrow right now, there can be a left arrow as well.)
+ fragments.append(("", " "))
+
+ # Draw row content.
+ for column_index, c in enumerate(row[self.scroll :][:visible_columns]):
+ if c is not None:
+ fragments += _get_menu_item_fragments(
+ c, is_current_completion(c), column_width, space_after=False
+ )
+
+ # Remember render position for mouse click handler.
+ for x in range(column_width):
+ self._render_pos_to_completion[
+ (column_index * column_width + x, row_index)
+ ] = c
+ else:
+ fragments.append(("class:completion", " " * column_width))
+
+ # Draw trailing padding for this row.
+ # (_get_menu_item_fragments only returns padding on the left.)
+ if render_left_arrow or render_right_arrow:
+ fragments.append(("class:completion", " "))
+
+ # Draw right arrow if we have hidden completions on the right.
+ if render_right_arrow:
+ fragments.append(("class:scrollbar", ">" if middle_row else " "))
+ elif render_left_arrow:
+ fragments.append(("class:completion", " "))
+
+ # Add line.
+ fragments_for_line.append(
+ to_formatted_text(fragments, style="class:completion-menu")
+ )
+
+ self._rendered_rows = height
+ self._rendered_columns = visible_columns
+ self._total_columns = len(columns_)
+ self._render_left_arrow = render_left_arrow
+ self._render_right_arrow = render_right_arrow
+ self._render_width = (
+ column_width * visible_columns + render_left_arrow + render_right_arrow + 1
+ )
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ return fragments_for_line[i]
+
+ return UIContent(get_line=get_line, line_count=len(rows_))
+
+ def _get_column_width(self, completion_state: CompletionState) -> int:
+ """
+ Return the width of each column.
+ """
+ try:
+ count, width = self._column_width_for_completion_state[completion_state]
+ if count != len(completion_state.completions):
+ # Number of completions changed, recompute.
+ raise KeyError
+ return width
+ except KeyError:
+ result = (
+ max(get_cwidth(c.display_text) for c in completion_state.completions)
+ + 1
+ )
+ self._column_width_for_completion_state[completion_state] = (
+ len(completion_state.completions),
+ result,
+ )
+ return result
+
+ def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ Handle scroll and click events.
+ """
+ b = get_app().current_buffer
+
+ def scroll_left() -> None:
+ b.complete_previous(count=self._rendered_rows, disable_wrap_around=True)
+ self.scroll = max(0, self.scroll - 1)
+
+ def scroll_right() -> None:
+ b.complete_next(count=self._rendered_rows, disable_wrap_around=True)
+ self.scroll = min(
+ self._total_columns - self._rendered_columns, self.scroll + 1
+ )
+
+ if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
+ scroll_right()
+
+ elif mouse_event.event_type == MouseEventType.SCROLL_UP:
+ scroll_left()
+
+ elif mouse_event.event_type == MouseEventType.MOUSE_UP:
+ x = mouse_event.position.x
+ y = mouse_event.position.y
+
+ # Mouse click on left arrow.
+ if x == 0:
+ if self._render_left_arrow:
+ scroll_left()
+
+ # Mouse click on right arrow.
+ elif x == self._render_width - 1:
+ if self._render_right_arrow:
+ scroll_right()
+
+ # Mouse click on completion.
+ else:
+ completion = self._render_pos_to_completion.get((x, y))
+ if completion:
+ b.apply_completion(completion)
+
+ return None
+
+ def get_key_bindings(self) -> KeyBindings:
+ """
+ Expose key bindings that handle the left/right arrow keys when the menu
+ is displayed.
+ """
+ from prompt_toolkit.key_binding.key_bindings import KeyBindings
+
+ kb = KeyBindings()
+
+ @Condition
+ def filter() -> bool:
+ "Only handle key bindings if this menu is visible."
+ app = get_app()
+ complete_state = app.current_buffer.complete_state
+
+ # There need to be completions, and one needs to be selected.
+ if complete_state is None or complete_state.complete_index is None:
+ return False
+
+ # This menu needs to be visible.
+ return any(window.content == self for window in app.layout.visible_windows)
+
+ def move(right: bool = False) -> None:
+ buff = get_app().current_buffer
+ complete_state = buff.complete_state
+
+ if complete_state is not None and complete_state.complete_index is not None:
+ # Calculate new complete index.
+ new_index = complete_state.complete_index
+ if right:
+ new_index += self._rendered_rows
+ else:
+ new_index -= self._rendered_rows
+
+ if 0 <= new_index < len(complete_state.completions):
+ buff.go_to_completion(new_index)
+
+ # NOTE: the is_global is required because the completion menu will
+ # never be focussed.
+
+ @kb.add("left", is_global=True, filter=filter)
+ def _left(event: E) -> None:
+ move()
+
+ @kb.add("right", is_global=True, filter=filter)
+ def _right(event: E) -> None:
+ move(True)
+
+ return kb
+
+
+class MultiColumnCompletionsMenu(HSplit):
+ """
+ Container that displays the completions in several columns.
+ When `show_meta` (a :class:`~prompt_toolkit.filters.Filter`) evaluates
+ to True, it shows the meta information at the bottom.
+ """
+
+ def __init__(
+ self,
+ min_rows: int = 3,
+ suggested_max_column_width: int = 30,
+ show_meta: FilterOrBool = True,
+ extra_filter: FilterOrBool = True,
+ z_index: int = 10**8,
+ ) -> None:
+ show_meta = to_filter(show_meta)
+ extra_filter = to_filter(extra_filter)
+
+ # Display filter: show when there are completions but not at the point
+ # we are returning the input.
+ full_filter = extra_filter & has_completions & ~is_done
+
+ @Condition
+ def any_completion_has_meta() -> bool:
+ complete_state = get_app().current_buffer.complete_state
+ return complete_state is not None and any(
+ c.display_meta for c in complete_state.completions
+ )
+
+ # Create child windows.
+ # NOTE: We don't set style='class:completion-menu' to the
+ # `MultiColumnCompletionMenuControl`, because this is used in a
+ # Float that is made transparent, and the size of the control
+ # doesn't always correspond exactly with the size of the
+ # generated content.
+ completions_window = ConditionalContainer(
+ content=Window(
+ content=MultiColumnCompletionMenuControl(
+ min_rows=min_rows,
+ suggested_max_column_width=suggested_max_column_width,
+ ),
+ width=Dimension(min=8),
+ height=Dimension(min=1),
+ ),
+ filter=full_filter,
+ )
+
+ meta_window = ConditionalContainer(
+ content=Window(content=_SelectedCompletionMetaControl()),
+ filter=full_filter & show_meta & any_completion_has_meta,
+ )
+
+ # Initialize split.
+ super().__init__([completions_window, meta_window], z_index=z_index)
+
+
+class _SelectedCompletionMetaControl(UIControl):
+ """
+ Control that shows the meta information of the selected completion.
+ """
+
+ def preferred_width(self, max_available_width: int) -> int | None:
+ """
+ Report the width of the longest meta text as the preferred width of this control.
+
+ It could be that we use less width, but this way, we're sure that the
+ layout doesn't change when we select another completion (E.g. that
+ completions are suddenly shown in more or fewer columns.)
+ """
+ app = get_app()
+ if app.current_buffer.complete_state:
+ state = app.current_buffer.complete_state
+
+ if len(state.completions) >= 30:
+ # When there are many completions, calling `get_cwidth` for
+ # every `display_meta_text` is too expensive. In this case,
+ # just return the max available width. There will be enough
+ # columns anyway so that the whole screen is filled with
+ # completions and `create_content` will then take up as much
+ # space as needed.
+ return max_available_width
+
+ return 2 + max(
+ get_cwidth(c.display_meta_text) for c in state.completions[:100]
+ )
+ else:
+ return 0
+
+ def preferred_height(
+ self,
+ width: int,
+ max_available_height: int,
+ wrap_lines: bool,
+ get_line_prefix: GetLinePrefixCallable | None,
+ ) -> int | None:
+ return 1
+
+ def create_content(self, width: int, height: int) -> UIContent:
+ fragments = self._get_text_fragments()
+
+ def get_line(i: int) -> StyleAndTextTuples:
+ return fragments
+
+ return UIContent(get_line=get_line, line_count=1 if fragments else 0)
+
+ def _get_text_fragments(self) -> StyleAndTextTuples:
+ style = "class:completion-menu.multi-column-meta"
+ state = get_app().current_buffer.complete_state
+
+ if (
+ state
+ and state.current_completion
+ and state.current_completion.display_meta_text
+ ):
+ return to_formatted_text(
+ cast(StyleAndTextTuples, [("", " ")])
+ + state.current_completion.display_meta
+ + [("", " ")],
+ style=style,
+ )
+
+ return []
diff --git a/src/prompt_toolkit/layout/mouse_handlers.py b/src/prompt_toolkit/layout/mouse_handlers.py
new file mode 100644
index 0000000..56a4edd
--- /dev/null
+++ b/src/prompt_toolkit/layout/mouse_handlers.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import TYPE_CHECKING, Callable
+
+from prompt_toolkit.mouse_events import MouseEvent
+
+if TYPE_CHECKING:
+ from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone
+
+__all__ = [
+ "MouseHandler",
+ "MouseHandlers",
+]
+
+
+MouseHandler = Callable[[MouseEvent], "NotImplementedOrNone"]
+
+
+class MouseHandlers:
+ """
+ Two dimensional raster of callbacks for mouse events.
+ """
+
+ def __init__(self) -> None:
+ def dummy_callback(mouse_event: MouseEvent) -> NotImplementedOrNone:
+ """
+ :param mouse_event: `MouseEvent` instance.
+ """
+ return NotImplemented
+
+ # NOTE: Previously, the data structure was a dictionary mapping (x,y)
+ # to the handlers. This however would be more inefficient when copying
+ # over the mouse handlers of the visible region in the scrollable pane.
+
+ # Map y (row) to x (column) to handlers.
+ self.mouse_handlers: defaultdict[
+ int, defaultdict[int, MouseHandler]
+ ] = defaultdict(lambda: defaultdict(lambda: dummy_callback))
+
+ def set_mouse_handler_for_range(
+ self,
+ x_min: int,
+ x_max: int,
+ y_min: int,
+ y_max: int,
+ handler: Callable[[MouseEvent], NotImplementedOrNone],
+ ) -> None:
+ """
+ Set mouse handler for a region.
+ """
+ for y in range(y_min, y_max):
+ row = self.mouse_handlers[y]
+
+ for x in range(x_min, x_max):
+ row[x] = handler
diff --git a/src/prompt_toolkit/layout/processors.py b/src/prompt_toolkit/layout/processors.py
new file mode 100644
index 0000000..b737611
--- /dev/null
+++ b/src/prompt_toolkit/layout/processors.py
@@ -0,0 +1,1013 @@
+"""
+Processors are little transformation blocks that transform the fragments list
+from a buffer before the BufferControl will render it to the screen.
+
+They can insert fragments before or after, or highlight fragments by replacing the
+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.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",
+]
+
+
+class Processor(metaclass=ABCMeta):
+ """
+ Manipulate the fragments for a given line in a
+ :class:`~prompt_toolkit.layout.controls.BufferControl`.
+ """
+
+ @abstractmethod
+ def apply_transformation(
+ self, transformation_input: TransformationInput
+ ) -> Transformation:
+ """
+ Apply transformation. Returns a :class:`.Transformation` instance.
+
+ :param transformation_input: :class:`.TransformationInput` object.
+ """
+ return Transformation(transformation_input.fragments)
+
+
+SourceToDisplay = Callable[[int], int]
+DisplayToSource = Callable[[int], int]
+
+
+class TransformationInput:
+ """
+ :param buffer_control: :class:`.BufferControl` instance.
+ :param lineno: The number of the line to which we apply the processor.
+ :param source_to_display: A function that returns the position in the
+ `fragments` for any position in the source string. (This takes
+ previous processors into account.)
+ :param fragments: List of fragments that we can transform. (Received from the
+ previous processor.)
+ """
+
+ 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
+ self.source_to_display = source_to_display
+ self.fragments = fragments
+ 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:
+ """
+ Transformation result, as returned by :meth:`.Processor.apply_transformation`.
+
+ Important: Always make sure that the length of `document.text` is equal to
+ the length of all the text in `fragments`!
+
+ :param fragments: The transformed fragments. To be displayed, or to pass to
+ the next processor.
+ :param source_to_display: Cursor position transformation from original
+ string to transformed string.
+ :param display_to_source: Cursor position transformed from source string to
+ original string.
+ """
+
+ 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)
+
+
+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):
+ """
+ Processor that highlights search matches in the document.
+ Note that this doesn't support multiline search matches yet.
+
+ 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:
+ """
+ The text we are searching for.
+ """
+ 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):
+ """
+ Highlight the search terms that are used for highlighting the incremental
+ search. The style class 'incsearch' will be applied to the content.
+
+ Important: this requires the `preview_search=True` flag to be set for the
+ `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:
+ """
+ The text we are searching for.
+ """
+ # 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):
+ """
+ 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):
+ """
+ Processor that masks the input. (For passwords.)
+
+ :param char: (string) Character to be used. "*" by default.
+ """
+
+ 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):
+ """
+ When the cursor is on or right after a bracket, it highlights the matching
+ bracket.
+
+ :param max_cursor_distance: Only highlight matching brackets when the
+ cursor is within this distance. (From inside a `Processor`, we can't
+ know which lines will be visible on the screen. But we also don't want
+ 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:
+ 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]]:
+ """
+ Return a list of (row, col) tuples that need to be highlighted.
+ """
+ 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):
+ """
+ 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):
+ """
+ Insert text before the input.
+
+ :param text: This can be either plain text or formatted text
+ (or a callable that returns any of those).
+ :param style: style to be applied to this prompt/prefix.
+ """
+
+ def __init__(self, text: AnyFormattedText, style: str = "") -> None:
+ self.text = text
+ self.style = style
+
+ 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):
+ """
+ Display the 'arg' in front of the input.
+
+ This was used by the `PromptSession`, but now it uses the
+ `Window.get_line_prefix` function instead.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(self._get_text_fragments)
+
+ 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):
+ """
+ Insert text after the input.
+
+ :param text: This can be either plain text or formatted text
+ (or a callable that returns any of those).
+ :param style: style to be applied to this prompt/prefix.
+ """
+
+ def __init__(self, text: AnyFormattedText, style: str = "") -> None:
+ self.text = text
+ self.style = style
+
+ 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):
+ """
+ Append the auto suggestion to the input.
+ (The user can then press the right arrow the insert the suggestion.)
+ """
+
+ 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):
+ """
+ Make leading whitespace visible.
+
+ :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 "\xb7".encode(get_app().output.encoding(), "replace") == b"?":
+ return "."
+ else:
+ 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):
+ """
+ Make trailing whitespace visible.
+
+ :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 "\xb7".encode(get_app().output.encoding(), "replace") == b"?":
+ return "."
+ else:
+ 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):
+ """
+ Render tabs as spaces (instead of ^I) or make them visible (for instance,
+ by replacing them with dots.)
+
+ :param tabstop: Horizontal space taken by a tab. (`int` or callable that
+ returns an `int`).
+ :param char1: Character or callable that returns a character (text of
+ length one). This one is used for the first space taken by the tab.
+ :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] = "\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):
+ """
+ Process to display the "(reverse-i-search)`...`:..." stuff around
+ the search buffer.
+
+ 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,
+ ]
+
+ 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):
+ """
+ Processor that applies another processor, according to a certain condition.
+ Example::
+
+ # Create a function that returns whether or not the processor should
+ # currently be applied.
+ def highlight_enabled():
+ return true_or_false
+
+ # Wrapped it in a `ConditionalProcessor` for usage in a `BufferControl`.
+ BufferControl(input_processors=[
+ ConditionalProcessor(HighlightSearchProcessor(),
+ Condition(highlight_enabled))])
+
+ :param processor: :class:`.Processor` instance.
+ :param filter: :class:`~prompt_toolkit.filters.Filter` instance.
+ """
+
+ def __init__(self, processor: Processor, filter: FilterOrBool) -> None:
+ self.processor = processor
+ self.filter = to_filter(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):
+ """
+ Processor class that dynamically returns any Processor.
+
+ :param get_processor: Callable that returns a :class:`.Processor` instance.
+ """
+
+ 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:
+ """
+ Merge multiple `Processor` objects into one.
+ """
+ if len(processors) == 0:
+ return DummyProcessor()
+
+ if len(processors) == 1:
+ return processors[0] # Nothing to merge.
+
+ return _MergedProcessor(processors)
+
+
+class _MergedProcessor(Processor):
+ """
+ Processor that groups multiple other `Processor` objects, but exposes an
+ API as if it is one `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
new file mode 100644
index 0000000..49aebbd
--- /dev/null
+++ b/src/prompt_toolkit/layout/screen.py
@@ -0,0 +1,329 @@
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import TYPE_CHECKING, Callable
+
+from prompt_toolkit.cache import FastDictCache
+from prompt_toolkit.data_structures import Point
+from prompt_toolkit.utils import get_cwidth
+
+if TYPE_CHECKING:
+ from .containers import Window
+
+
+__all__ = [
+ "Screen",
+ "Char",
+]
+
+
+class Char:
+ """
+ Represent a single character in a :class:`.Screen`.
+
+ This should be considered immutable.
+
+ :param char: A single character (can be a double-width character).
+ :param style: A style string. (Can contain classnames.)
+ """
+
+ __slots__ = ("char", "style", "width")
+
+ # If we end up having one of these special control sequences in the input string,
+ # we should display them as follows:
+ # Usually this happens after a "quoted insert".
+ display_mappings: dict[str, str] = {
+ "\x00": "^@", # Control space
+ "\x01": "^A",
+ "\x02": "^B",
+ "\x03": "^C",
+ "\x04": "^D",
+ "\x05": "^E",
+ "\x06": "^F",
+ "\x07": "^G",
+ "\x08": "^H",
+ "\x09": "^I",
+ "\x0a": "^J",
+ "\x0b": "^K",
+ "\x0c": "^L",
+ "\x0d": "^M",
+ "\x0e": "^N",
+ "\x0f": "^O",
+ "\x10": "^P",
+ "\x11": "^Q",
+ "\x12": "^R",
+ "\x13": "^S",
+ "\x14": "^T",
+ "\x15": "^U",
+ "\x16": "^V",
+ "\x17": "^W",
+ "\x18": "^X",
+ "\x19": "^Y",
+ "\x1a": "^Z",
+ "\x1b": "^[", # Escape
+ "\x1c": "^\\",
+ "\x1d": "^]",
+ "\x1e": "^^",
+ "\x1f": "^_",
+ "\x7f": "^?", # ASCII Delete (backspace).
+ # Special characters. All visualized like Vim does.
+ "\x80": "<80>",
+ "\x81": "<81>",
+ "\x82": "<82>",
+ "\x83": "<83>",
+ "\x84": "<84>",
+ "\x85": "<85>",
+ "\x86": "<86>",
+ "\x87": "<87>",
+ "\x88": "<88>",
+ "\x89": "<89>",
+ "\x8a": "<8a>",
+ "\x8b": "<8b>",
+ "\x8c": "<8c>",
+ "\x8d": "<8d>",
+ "\x8e": "<8e>",
+ "\x8f": "<8f>",
+ "\x90": "<90>",
+ "\x91": "<91>",
+ "\x92": "<92>",
+ "\x93": "<93>",
+ "\x94": "<94>",
+ "\x95": "<95>",
+ "\x96": "<96>",
+ "\x97": "<97>",
+ "\x98": "<98>",
+ "\x99": "<99>",
+ "\x9a": "<9a>",
+ "\x9b": "<9b>",
+ "\x9c": "<9c>",
+ "\x9d": "<9d>",
+ "\x9e": "<9e>",
+ "\x9f": "<9f>",
+ # For the non-breaking space: visualize like Emacs does by default.
+ # (Print a space, but attach the 'nbsp' class that applies the
+ # underline style.)
+ "\xa0": " ",
+ }
+
+ def __init__(self, char: str = " ", style: str = "") -> None:
+ # If this character has to be displayed otherwise, take that one.
+ if char in self.display_mappings:
+ if char == "\xa0":
+ style += " class:nbsp " # Will be underlined.
+ else:
+ style += " class:control-character "
+
+ char = self.display_mappings[char]
+
+ self.char = char
+ self.style = style
+
+ # Calculate width. (We always need this, so better to store it directly
+ # as a member for performance.)
+ self.width = get_cwidth(char)
+
+ # In theory, `other` can be any type of object, but because of performance
+ # we don't want to do an `isinstance` check every time. We assume "other"
+ # is always a "Char".
+ def _equal(self, other: Char) -> bool:
+ return self.char == other.char and self.style == other.style
+
+ def _not_equal(self, other: Char) -> bool:
+ # Not equal: We don't do `not char.__eq__` here, because of the
+ # performance of calling yet another function.
+ return self.char != other.char or self.style != other.style
+
+ if not TYPE_CHECKING:
+ __eq__ = _equal
+ __ne__ = _not_equal
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.char!r}, {self.style!r})"
+
+
+_CHAR_CACHE: FastDictCache[tuple[str, str], Char] = FastDictCache(
+ Char, size=1000 * 1000
+)
+Transparent = "[transparent]"
+
+
+class Screen:
+ """
+ Two dimensional buffer of :class:`.Char` instances.
+ """
+
+ def __init__(
+ self,
+ default_char: Char | None = None,
+ initial_width: int = 0,
+ initial_height: int = 0,
+ ) -> None:
+ if default_char is None:
+ default_char2 = _CHAR_CACHE[" ", Transparent]
+ else:
+ default_char2 = default_char
+
+ self.data_buffer: defaultdict[int, defaultdict[int, Char]] = defaultdict(
+ lambda: defaultdict(lambda: default_char2)
+ )
+
+ #: Escape sequences to be injected.
+ self.zero_width_escapes: defaultdict[int, defaultdict[int, str]] = defaultdict(
+ lambda: defaultdict(lambda: "")
+ )
+
+ #: Position of the cursor.
+ self.cursor_positions: dict[
+ Window, Point
+ ] = {} # Map `Window` objects to `Point` objects.
+
+ #: Visibility of the cursor.
+ self.show_cursor = True
+
+ #: (Optional) Where to position the menu. E.g. at the start of a completion.
+ #: (We can't use the cursor position, because we don't want the
+ #: completion menu to change its position when we browse through all the
+ #: completions.)
+ self.menu_positions: dict[
+ Window, Point
+ ] = {} # Map `Window` objects to `Point` objects.
+
+ #: Currently used width/height of the screen. This will increase when
+ #: data is written to the screen.
+ self.width = initial_width or 0
+ self.height = initial_height or 0
+
+ # Windows that have been drawn. (Each `Window` class will add itself to
+ # this list.)
+ self.visible_windows_to_write_positions: dict[Window, WritePosition] = {}
+
+ # List of (z_index, draw_func)
+ self._draw_float_functions: list[tuple[int, Callable[[], None]]] = []
+
+ @property
+ def visible_windows(self) -> list[Window]:
+ return list(self.visible_windows_to_write_positions.keys())
+
+ def set_cursor_position(self, window: Window, position: Point) -> None:
+ """
+ Set the cursor position for a given window.
+ """
+ self.cursor_positions[window] = position
+
+ def set_menu_position(self, window: Window, position: Point) -> None:
+ """
+ Set the cursor position for a given window.
+ """
+ self.menu_positions[window] = position
+
+ def get_cursor_position(self, window: Window) -> Point:
+ """
+ Get the cursor position for a given window.
+ Returns a `Point`.
+ """
+ try:
+ return self.cursor_positions[window]
+ except KeyError:
+ return Point(x=0, y=0)
+
+ def get_menu_position(self, window: Window) -> Point:
+ """
+ Get the menu position for a given window.
+ (This falls back to the cursor position if no menu position was set.)
+ """
+ try:
+ return self.menu_positions[window]
+ except KeyError:
+ try:
+ return self.cursor_positions[window]
+ except KeyError:
+ return Point(x=0, y=0)
+
+ def draw_with_z_index(self, z_index: int, draw_func: Callable[[], None]) -> None:
+ """
+ Add a draw-function for a `Window` which has a >= 0 z_index.
+ This will be postponed until `draw_all_floats` is called.
+ """
+ self._draw_float_functions.append((z_index, draw_func))
+
+ def draw_all_floats(self) -> None:
+ """
+ Draw all float functions in order of z-index.
+ """
+ # We keep looping because some draw functions could add new functions
+ # to this list. See `FloatContainer`.
+ while self._draw_float_functions:
+ # Sort the floats that we have so far by z_index.
+ functions = sorted(self._draw_float_functions, key=lambda item: item[0])
+
+ # Draw only one at a time, then sort everything again. Now floats
+ # might have been added.
+ self._draw_float_functions = functions[1:]
+ functions[0][1]()
+
+ def append_style_to_content(self, style_str: str) -> None:
+ """
+ For all the characters in the screen.
+ Set the style string to the given `style_str`.
+ """
+ b = self.data_buffer
+ char_cache = _CHAR_CACHE
+
+ append_style = " " + style_str
+
+ for y, row in b.items():
+ for x, char in row.items():
+ row[x] = char_cache[char.char, char.style + append_style]
+
+ def fill_area(
+ self, write_position: WritePosition, style: str = "", after: bool = False
+ ) -> None:
+ """
+ Fill the content of this area, using the given `style`.
+ The style is prepended before whatever was here before.
+ """
+ if not style.strip():
+ return
+
+ xmin = write_position.xpos
+ xmax = write_position.xpos + write_position.width
+ char_cache = _CHAR_CACHE
+ data_buffer = self.data_buffer
+
+ if after:
+ append_style = " " + style
+ prepend_style = ""
+ else:
+ append_style = ""
+ prepend_style = style + " "
+
+ for y in range(
+ write_position.ypos, write_position.ypos + write_position.height
+ ):
+ row = data_buffer[y]
+ for x in range(xmin, xmax):
+ cell = row[x]
+ row[x] = char_cache[
+ cell.char, prepend_style + cell.style + append_style
+ ]
+
+
+class WritePosition:
+ def __init__(self, xpos: int, ypos: int, width: int, height: int) -> None:
+ assert height >= 0
+ assert width >= 0
+ # xpos and ypos can be negative. (A float can be partially visible.)
+
+ self.xpos = xpos
+ self.ypos = ypos
+ self.width = width
+ self.height = height
+
+ def __repr__(self) -> str:
+ return "{}(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
new file mode 100644
index 0000000..e38fd76
--- /dev/null
+++ b/src/prompt_toolkit/layout/scrollable_pane.py
@@ -0,0 +1,494 @@
+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"]
+
+# Never go beyond this height, because performance will degrade.
+MAX_AVAILABLE_HEIGHT = 10_000
+
+
+class ScrollablePane(Container):
+ """
+ Container widget that exposes a larger virtual screen to its content and
+ displays it in a vertical scrollbale region.
+
+ Typically this is wrapped in a large `HSplit` container. Make sure in that
+ case to not specify a `height` dimension of the `HSplit`, so that it will
+ scale according to the content.
+
+ .. note::
+
+ If you want to display a completion menu for widgets in this
+ `ScrollablePane`, then it's still a good practice to use a
+ `FloatContainer` with a `CompletionsMenu` in a `Float` at the top-level
+ of the layout hierarchy, rather then nesting a `FloatContainer` in this
+ `ScrollablePane`. (Otherwise, it's possible that the completion menu
+ is clipped.)
+
+ :param content: The content container.
+ :param scrolloffset: Try to keep the cursor within this distance from the
+ top/bottom (left/right offset is not used).
+ :param keep_cursor_visible: When `True`, automatically scroll the pane so
+ that the cursor (of the focused window) is always visible.
+ :param keep_focused_window_visible: When `True`, automatically scroll the
+ pane so that the focused window is visible, or as much visible as
+ possible if it doesn't completely fit the screen.
+ :param max_available_height: Always constraint the height to this amount
+ for performance reasons.
+ :param width: When given, use this width instead of looking at the children.
+ :param height: When given, use this height instead of looking at the children.
+ :param 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:
+ 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.max_available_height = max_available_height
+ self.width = width
+ self.height = height
+ self.show_scrollbar = to_filter(show_scrollbar)
+ 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 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)
+
+ # 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.
+ """
+ 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,
+ )
+
+ # 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
+ """
+ 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:
+ """
+ Copy over visible screen content and "zero width escape sequences".
+ """
+ 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]
+
+ 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.
+ """
+ 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)
+
+ 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.
+ """
+ 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 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.
+
+ :param visible_height: Height of this `ScrollablePane` that is rendered.
+ :param virtual_height: Height of the virtual, temp screen.
+ :param visible_win_write_pos: `WritePosition` of the nested window on the
+ temp screen.
+ :param cursor_position: The location of the cursor position of this
+ window on the temp screen.
+ """
+ # 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))
+
+ 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.
+ """
+
+ 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
new file mode 100644
index 0000000..0f78f37
--- /dev/null
+++ b/src/prompt_toolkit/layout/utils.py
@@ -0,0 +1,82 @@
+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)
+
+
+class _ExplodedList(List[_T]):
+ """
+ Wrapper around a list, that marks it as 'exploded'.
+
+ As soon as items are added or the list is extended, the new items are
+ automatically exploded as well.
+ """
+
+ exploded = True
+
+ def append(self, item: _T) -> None:
+ self.extend([item])
+
+ def extend(self, lst: Iterable[_T]) -> None:
+ super().extend(explode_text_fragments(lst))
+
+ def insert(self, index: 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:
+ ...
+
+ @overload
+ def __setitem__(self, index: slice, value: 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.
+ """
+ if not isinstance(index, slice):
+ int_index = index.__index__()
+ index = slice(int_index, int_index + 1)
+ 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]:
+ """
+ Turn a list of (style_str, text) tuples into another list where each string is
+ exactly one character.
+
+ It should be fine to call this function several times. Calling this on a
+ list that is already exploded, is a null operation.
+
+ :param fragments: List of (style, text) tuples.
+ """
+ # When the fragments is already exploded, don't explode again.
+ if isinstance(fragments, _ExplodedList):
+ return fragments
+
+ result: list[_T] = []
+
+ for style, string, *rest in fragments:
+ for c in string:
+ result.append((style, c, *rest)) # type: ignore
+
+ return _ExplodedList(result)
diff --git a/src/prompt_toolkit/lexers/__init__.py b/src/prompt_toolkit/lexers/__init__.py
new file mode 100644
index 0000000..9bdc599
--- /dev/null
+++ b/src/prompt_toolkit/lexers/__init__.py
@@ -0,0 +1,20 @@
+"""
+Lexer interface and implementations.
+Used for syntax highlighting.
+"""
+from __future__ import annotations
+
+from .base import DynamicLexer, Lexer, SimpleLexer
+from .pygments import PygmentsLexer, RegexSync, SyncFromStart, SyntaxSync
+
+__all__ = [
+ # Base.
+ "Lexer",
+ "SimpleLexer",
+ "DynamicLexer",
+ # Pygments.
+ "PygmentsLexer",
+ "RegexSync",
+ "SyncFromStart",
+ "SyntaxSync",
+]
diff --git a/src/prompt_toolkit/lexers/base.py b/src/prompt_toolkit/lexers/base.py
new file mode 100644
index 0000000..3f65f8e
--- /dev/null
+++ b/src/prompt_toolkit/lexers/base.py
@@ -0,0 +1,84 @@
+"""
+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",
+]
+
+
+class Lexer(metaclass=ABCMeta):
+ """
+ Base class for all lexers.
+ """
+
+ @abstractmethod
+ 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
+ ``(style_str, text)`` tuples for that line.
+
+ XXX: Note that in the past, this was supposed to return a list
+ of ``(Token, text)`` tuples, just like a Pygments lexer.
+ """
+
+ def invalidation_hash(self) -> Hashable:
+ """
+ When this changes, `lex_document` could give a different output.
+ (Only used for `DynamicLexer`.)
+ """
+ return id(self)
+
+
+class SimpleLexer(Lexer):
+ """
+ Lexer that doesn't do any tokenizing and returns the whole input as one
+ token.
+
+ :param style: The style string for this lexer.
+ """
+
+ 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):
+ """
+ Lexer class that can dynamically returns any Lexer.
+
+ :param get_lexer: Callable that returns a :class:`.Lexer` instance.
+ """
+
+ 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
new file mode 100644
index 0000000..4721d73
--- /dev/null
+++ b/src/prompt_toolkit/lexers/pygments.py
@@ -0,0 +1,327 @@
+"""
+Adaptor classes for using Pygments lexers within prompt_toolkit.
+
+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",
+]
+
+
+class SyntaxSync(metaclass=ABCMeta):
+ """
+ Syntax synchronizer. This is a tool that finds a start position for the
+ lexer. This is especially important when editing big documents; we don't
+ want to start the highlighting by running the lexer from the beginning of
+ the file. That is very slow when editing.
+ """
+
+ @abstractmethod
+ 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.
+
+ :param document: `Document` instance that contains all the lines.
+ :param lineno: The line that we want to highlight. (We need to return
+ this line, or an earlier position.)
+ """
+
+
+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:
+ self._compiled_pattern = re.compile(pattern)
+
+ def get_sync_start_position(
+ self, document: Document, lineno: int
+ ) -> tuple[int, int]:
+ """
+ Scan backwards, and find a possible position to start.
+ """
+ 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:
+ """
+ Create a :class:`.RegexSync` instance for this Pygments lexer class.
+ """
+ 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]):
+ """
+ Cache that converts Pygments tokens into `prompt_toolkit` style objects.
+
+ ``Token.A.B.C`` will be converted into:
+ ``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)
+ self[key] = result
+ return result
+
+
+_token_cache = _TokenCache()
+
+
+class PygmentsLexer(Lexer):
+ """
+ Lexer that calls a pygments lexer.
+
+ Example::
+
+ from pygments.lexers.html import HtmlLexer
+ lexer = PygmentsLexer(HtmlLexer)
+
+ Note: Don't forget to also load a Pygments compatible style. E.g.::
+
+ from prompt_toolkit.styles.from_pygments import style_from_pygments_cls
+ from pygments.styles import get_style_by_name
+ style = style_from_pygments_cls(get_style_by_name('monokai'))
+
+ :param pygments_lexer_cls: A `Lexer` from Pygments.
+ :param sync_from_start: Start lexing at the start of the document. This
+ will always give the best results, but it will be slow for bigger
+ documents. (When the last part of the document is display, then the
+ whole document will be lexed by Pygments on every key stroke.) It is
+ recommended to disable this for inputs that are expected to be more
+ 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:
+ self.pygments_lexer_cls = pygments_lexer_cls
+ self.sync_from_start = to_filter(sync_from_start)
+
+ # 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
+ )
+
+ @classmethod
+ def from_filename(
+ cls, filename: str, sync_from_start: FilterOrBool = True
+ ) -> Lexer:
+ """
+ Create a `Lexer` from a filename.
+ """
+ # Inline imports: the Pygments dependency is optional!
+ from pygments.lexers import get_lexer_for_filename
+ from pygments.util import ClassNotFound
+
+ 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.
+ """
+ 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
new file mode 100644
index 0000000..adb5172
--- /dev/null
+++ b/src/prompt_toolkit/log.py
@@ -0,0 +1,12 @@
+"""
+Logging configuration.
+"""
+from __future__ import annotations
+
+import logging
+
+__all__ = [
+ "logger",
+]
+
+logger = logging.getLogger(__package__)
diff --git a/src/prompt_toolkit/mouse_events.py b/src/prompt_toolkit/mouse_events.py
new file mode 100644
index 0000000..743773b
--- /dev/null
+++ b/src/prompt_toolkit/mouse_events.py
@@ -0,0 +1,89 @@
+"""
+Mouse events.
+
+
+How it works
+------------
+
+The renderer has a 2 dimensional grid of mouse event handlers.
+(`prompt_toolkit.layout.MouseHandlers`.) When the layout is rendered, the
+`Window` class will make sure that this grid will also be filled with
+callbacks. For vt100 terminals, mouse events are received through stdin, just
+like any other key press. There is a handler among the key bindings that
+catches these events and forwards them to such a mouse event handler. It passes
+through the `Window` class where the coordinates are translated from absolute
+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"]
+
+
+class MouseEventType(Enum):
+ # 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"
+
+ # 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"
+
+
+class MouseEvent:
+ """
+ Mouse event, sent to `UIControl.mouse_handler`.
+
+ :param position: `Point` instance.
+ :param event_type: `MouseEventType`.
+ """
+
+ 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,
+ )
diff --git a/src/prompt_toolkit/output/__init__.py b/src/prompt_toolkit/output/__init__.py
new file mode 100644
index 0000000..6b4c5f3
--- /dev/null
+++ b/src/prompt_toolkit/output/__init__.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from .base import DummyOutput, Output
+from .color_depth import ColorDepth
+from .defaults import create_output
+
+__all__ = [
+ # Base.
+ "Output",
+ "DummyOutput",
+ # Color depth.
+ "ColorDepth",
+ # Defaults.
+ "create_output",
+]
diff --git a/src/prompt_toolkit/output/base.py b/src/prompt_toolkit/output/base.py
new file mode 100644
index 0000000..3c38cec
--- /dev/null
+++ b/src/prompt_toolkit/output/base.py
@@ -0,0 +1,331 @@
+"""
+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",
+]
+
+
+class Output(metaclass=ABCMeta):
+ """
+ Base class defining the output interface for a
+ :class:`~prompt_toolkit.renderer.Renderer`.
+
+ Actual implementations are
+ :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."
+
+ @abstractmethod
+ 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.)
+ """
+
+ @abstractmethod
+ 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."
+
+ @abstractmethod
+ def set_title(self, title: str) -> None:
+ "Set terminal title."
+
+ @abstractmethod
+ def clear_title(self) -> None:
+ "Clear title again. (or restore previous title.)"
+
+ @abstractmethod
+ def flush(self) -> None:
+ "Write to output stream and flush."
+
+ @abstractmethod
+ def erase_screen(self) -> None:
+ """
+ Erases the screen with the background color and moves the cursor to
+ home.
+ """
+
+ @abstractmethod
+ 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."
+
+ @abstractmethod
+ def enable_mouse_support(self) -> None:
+ "Enable mouse."
+
+ @abstractmethod
+ def disable_mouse_support(self) -> None:
+ "Disable mouse."
+
+ @abstractmethod
+ def erase_end_of_line(self) -> None:
+ """
+ Erases from the current cursor position to the end of the current line.
+ """
+
+ @abstractmethod
+ def erase_down(self) -> None:
+ """
+ Erases the screen from the current line down to the bottom of the
+ screen.
+ """
+
+ @abstractmethod
+ 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."
+
+ @abstractmethod
+ def disable_autowrap(self) -> None:
+ "Disable auto line wrapping."
+
+ @abstractmethod
+ def enable_autowrap(self) -> None:
+ "Enable auto line wrapping."
+
+ @abstractmethod
+ 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."
+
+ @abstractmethod
+ 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."
+
+ @abstractmethod
+ def cursor_backward(self, amount: int) -> None:
+ "Move cursor `amount` place backward."
+
+ @abstractmethod
+ def hide_cursor(self) -> None:
+ "Hide cursor."
+
+ @abstractmethod
+ 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."
+
+ @abstractmethod
+ def reset_cursor_shape(self) -> None:
+ "Reset cursor shape."
+
+ def ask_for_cpr(self) -> None:
+ """
+ Asks for a cursor position report (CPR).
+ (VT100 only.)
+ """
+
+ @property
+ 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
+ `Input`).
+
+ This is used to determine the amount of available rows we have below
+ the cursor position. In the first place, we have this so that the drop
+ down autocompletion menus are sized according to the available space.
+
+ On Windows, we don't need this, there we have
+ `get_rows_below_cursor_position`.
+ """
+ return False
+
+ @abstractmethod
+ def get_size(self) -> Size:
+ "Return the size of the output window."
+
+ def bell(self) -> None:
+ "Sound bell."
+
+ def enable_bracketed_paste(self) -> None:
+ "For vt100 only."
+
+ def disable_bracketed_paste(self) -> None:
+ "For vt100 only."
+
+ 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
+ """
+
+ def scroll_buffer_to_prompt(self) -> None:
+ "For Win32 only."
+
+ def get_rows_below_cursor_position(self) -> int:
+ "For Windows only."
+ raise NotImplementedError
+
+ @abstractmethod
+ def get_default_color_depth(self) -> ColorDepth:
+ """
+ Get default color depth for this output.
+
+ This value will be used if no color depth was explicitly passed to the
+ `Application`.
+
+ .. note::
+
+ If the `$PROMPT_TOOLKIT_COLOR_DEPTH` environment variable has been
+ set, then `outputs.defaults.create_output` will pass this value to
+ the implementation as the default_color_depth, which is returned
+ here. (This is not used when the output corresponds to a
+ prompt_toolkit SSH/Telnet session.)
+ """
+
+
+class DummyOutput(Output):
+ """
+ For testing. An output class that doesn't render anything.
+ """
+
+ 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
new file mode 100644
index 0000000..f66d2be
--- /dev/null
+++ b/src/prompt_toolkit/output/color_depth.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import os
+from enum import Enum
+
+__all__ = [
+ "ColorDepth",
+]
+
+
+class ColorDepth(str, Enum):
+ """
+ Possible color depth values for the output.
+ """
+
+ value: str
+
+ #: 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:
+ """
+ Return the color depth if the $PROMPT_TOOLKIT_COLOR_DEPTH environment
+ variable has been set.
+
+ This is a way to enforce a certain color depth in all prompt_toolkit
+ applications.
+ """
+ # 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:
+ """
+ Return the default color depth for the default output.
+ """
+ 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
new file mode 100644
index 0000000..6369944
--- /dev/null
+++ b/src/prompt_toolkit/output/conemu.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+import sys
+
+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",
+]
+
+
+class ConEmuOutput:
+ """
+ ConEmu (Windows) output abstraction.
+
+ ConEmu is a Windows console application, but it also supports ANSI escape
+ sequences. This output class is actually a proxy to both `Win32Output` and
+ `Vt100_Output`. It uses `Win32Output` for console sizing and scrolling, but
+ all cursor movements and scrolling happens through the `Vt100_Output`.
+
+ This way, we can have 256 colors in ConEmu and Cmder. Rendering will be
+ even a little faster as well.
+
+ http://conemu.github.io/
+ 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
+ )
+
+ @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)
+
+
+Output.register(ConEmuOutput)
diff --git a/src/prompt_toolkit/output/defaults.py b/src/prompt_toolkit/output/defaults.py
new file mode 100644
index 0000000..ed114e3
--- /dev/null
+++ b/src/prompt_toolkit/output/defaults.py
@@ -0,0 +1,102 @@
+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 .base import DummyOutput, Output
+from .color_depth import ColorDepth
+from .plain_text import PlainTextOutput
+
+__all__ = [
+ "create_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.
+
+ :param stdout: The stdout object
+ :param always_prefer_tty: When set, look for `sys.stderr` if `sys.stdout`
+ is not a TTY. Useful if `sys.stdout` is redirected to a file, but we
+ still want user input and output on the terminal.
+
+ By default, this is `False`. If `sys.stdout` is not a terminal (maybe
+ it's redirected to a file), then a `PlainTextOutput` will be returned.
+ That way, tools like `print_formatted_text` will write plain text into
+ that file.
+ """
+ # 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
new file mode 100644
index 0000000..daf58ef
--- /dev/null
+++ b/src/prompt_toolkit/output/flush_stdout.py
@@ -0,0 +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"]
+
+
+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]:
+ """
+ Ensure that the FD for `io` is set to blocking in here.
+ """
+ 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
new file mode 100644
index 0000000..4b24ad9
--- /dev/null
+++ b/src/prompt_toolkit/output/plain_text.py
@@ -0,0 +1,143 @@
+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"]
+
+
+class PlainTextOutput(Output):
+ """
+ Output that won't include any ANSI escape sequences.
+
+ Useful when stdout is not a terminal. Maybe stdout is redirected to a file.
+ In this case, if `print_formatted_text` is used, for instance, we don't
+ want to include formatting.
+
+ (The code is mostly identical to `Vt100_Output`, but without the
+ formatting.)
+ """
+
+ 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()."
+ 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
new file mode 100644
index 0000000..142deab
--- /dev/null
+++ b/src/prompt_toolkit/output/vt100.py
@@ -0,0 +1,747 @@
+"""
+Output for vt100 terminals.
+
+A lot of thanks, regarding outputting of colors, goes to the Pygments project:
+(We don't rely on Pygments anymore, because many things are very custom, and
+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,
+ # 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:
+ """
+ Find closest ANSI color. Return it by name.
+
+ :param r: Red (Between 0 and 255.)
+ :param g: Green (Between 0 and 255.)
+ :param b: Blue (Between 0 and 255.)
+ :param exclude: A tuple of color names to exclude. (E.g. ``('ansired', )``.)
+ """
+ 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]
+
+
+class _16ColorCache:
+ """
+ Cache which maps (r, g, b) tuples to 16 ansi colors.
+
+ :param bg: Cache for background colors, instead of foreground.
+ """
+
+ 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:
+ """
+ Return a (ansi_code, ansi_name) tuple. (E.g. ``(44, 'ansiblue')``.) for
+ a given (r,g,b) value.
+ """
+ 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]):
+ """
+ Cache which maps (r, g, b) tuples to 256 colors.
+ """
+
+ def __init__(self) -> None:
+ # Build color table.
+ colors: list[tuple[int, int, int]] = []
+
+ # 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]
+ 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:
+ r, g, b = value
+
+ # 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: # 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
+
+
+_16_fg_colors = _16ColorCache(bg=False)
+_16_bg_colors = _16ColorCache(bg=True)
+_256_colors = _256ColorCache()
+
+
+class _EscapeCodeCache(Dict[Attrs, str]):
+ """
+ Cache for VT100 escape codes. It maps
+ (fgcolor, bgcolor, bold, underline, strike, reverse) tuples to VT100
+ escape sequences.
+
+ :param true_color: When True, use 24bit colors instead of 256 colors.
+ """
+
+ 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
+ parts: list[str] = []
+
+ parts.extend(self._colors_to_code(fgcolor or "", bgcolor or ""))
+
+ if bold:
+ parts.append("1")
+ if italic:
+ parts.append("3")
+ if blink:
+ parts.append("5")
+ if underline:
+ parts.append("4")
+ if reverse:
+ parts.append("7")
+ if hidden:
+ parts.append("8")
+ if strike:
+ parts.append("9")
+
+ if parts:
+ result = "\x1b[0;" + ";".join(parts) + "m"
+ else:
+ 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)."
+ 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]:
+ """
+ Return a tuple with the vt100 values that represent this color.
+ """
+ # 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.
+ """
+ size = os.get_terminal_size(fileno)
+ return size.lines, size.columns
+
+
+class Vt100_Output(Output):
+ """
+ :param get_size: A callable which returns the `Size` of the output terminal.
+ :param stdout: Any object with has a `write` and `flush` method + an 'encoding' property.
+ :param term: The terminal environment variable. (xterm, xterm-256color, linux, ...)
+ :param enable_cpr: When `True` (the default), send "cursor position
+ request" escape sequences to the output in order to detect the cursor
+ position. That way, we can properly determine how much space there is
+ available for the UI (especially for drop down menus) to render. The
+ `Renderer` will still try to figure out whether the current terminal
+ 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"))
+
+ self._buffer: list[str] = []
+ self.stdout: TextIO = stdout
+ self.default_color_depth = default_color_depth
+ self._get_size = get_size
+ 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),
+ }
+
+ # 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:
+ """
+ Create an Output class from a pseudo terminal.
+ (This will take the dimensions by reading the pseudo
+ terminal attributes.)
+ """
+ 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.
+ """
+ self._buffer.append(data)
+
+ def write(self, data: str) -> None:
+ """
+ Write text to output.
+ (Removes vt100 escape codes. -- used for safely writing text.)
+ """
+ self._buffer.append(data.replace("\x1b", "?"))
+
+ def set_title(self, title: str) -> None:
+ """
+ Set terminal title.
+ """
+ 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.
+ """
+ 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")
+
+ # 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.
+ """
+ self.write_raw("\x1b[K")
+
+ def erase_down(self) -> None:
+ """
+ Erases the screen from the current line down to the bottom of the
+ screen.
+ """
+ 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:
+ """
+ Create new style and output.
+
+ :param attrs: `Attrs` instance.
+ """
+ # 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 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).
+ """
+ # 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:
+ """
+ Move cursor position.
+ """
+ self.write_raw("\x1b[%i;%iH" % (row, column))
+
+ 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 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.
+ """
+ if not self._buffer:
+ return
+
+ data = "".join(self._buffer)
+ self._buffer = []
+
+ flush_stdout(self.stdout, data)
+
+ def ask_for_cpr(self) -> None:
+ """
+ Asks for a cursor position report (CPR).
+ """
+ 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.
+
+ We prefer 256 colors almost always, because this is what most terminals
+ support these days, and is a good default.
+ """
+ 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
new file mode 100644
index 0000000..edeca09
--- /dev/null
+++ b/src/prompt_toolkit/output/win32.py
@@ -0,0 +1,683 @@
+from __future__ import annotations
+
+import sys
+
+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 ..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:
+ """
+ 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.)
+
+ When running ``ptipython`` is run (only with IPython), we often got the following error::
+
+ Error in 'SetConsoleCursorPosition'.
+ ArgumentError("argument 2: <class 'TypeError'>: wrong type",)
+ argument 2: <class 'TypeError'>: wrong type
+
+ It was solved by turning ``COORD`` parameters into a ``c_long`` like this.
+
+ More info: http://msdn.microsoft.com/en-us/library/windows/desktop/ms686025(v=vs.85).aspx
+ """
+ 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 = r"prompt-toolkit-windows-output.log"
+
+
+class NoConsoleScreenBufferError(Exception):
+ """
+ Raised when the application is not running inside a Windows Console, but
+ the user tries to instantiate Win32Output.
+ """
+
+ 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"]
+ )
+ else:
+ message = "No Windows console found. Are you running cmd.exe?"
+ super().__init__(message)
+
+
+_T = TypeVar("_T")
+
+
+class Win32Output(Output):
+ """
+ I/O abstraction for rendering to Windows consoles.
+ (cmd.exe and similar.)
+ """
+
+ 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")
+
+ 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(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()
+
+ # 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.
+ """
+ self.flush()
+
+ 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.
+ """
+ # 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:
+ """
+ Set terminal title.
+ """
+ self._winapi(windll.kernel32.SetConsoleTitleW, title)
+
+ def clear_title(self) -> None:
+ self._winapi(windll.kernel32.SetConsoleTitleW, "")
+
+ 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 enable_autowrap(self) -> None:
+ # Not supported by Windows.
+ pass
+
+ 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.
+ """
+ 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)
+
+ 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).
+ """
+ # 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
+
+ # 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.
+ """
+ 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:
+ """
+ Call win32 API to refresh the whole Window.
+
+ This is sometimes necessary when the application paints background
+ for completion menus. When the menu disappears, it leaves traces due
+ to a bug in the Windows Console. Sending a repaint request solves it.
+ """
+ # 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:
+ """
+ Return the default color depth for a windows terminal.
+
+ Contrary to the Vt100 implementation, this doesn't depend on a $TERM
+ variable.
+ """
+ if self.default_color_depth is not None:
+ return self.default_color_depth
+
+ return ColorDepth.DEPTH_4_BIT
+
+
+class FOREGROUND_COLOR:
+ 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 = 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)
+
+
+class ColorLookupTable:
+ """
+ Inspired by pygments/formatters/terminal256.py
+ """
+
+ 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]]:
+ """
+ Build an RGB-to-256 color conversion table
+ """
+ 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'
+ """
+ # 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:
+ """
+ Return the color for use in the
+ `windll.kernel32.SetConsoleTextAttribute` API call.
+
+ :param bg_color: Background as text. E.g. 'ffffff' or 'red'
+ """
+ # 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
new file mode 100644
index 0000000..c39f3ec
--- /dev/null
+++ b/src/prompt_toolkit/output/windows10.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+import sys
+
+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",
+]
+
+# 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:
+ """
+ Windows 10 output abstraction. This enables and uses vt100 escape sequences.
+ """
+
+ 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:
+ """
+ Write to output stream and flush.
+ """
+ original_mode = DWORD(0)
+
+ # Remember the previous console mode.
+ windll.kernel32.GetConsoleMode(self._hconsole, byref(original_mode))
+
+ # 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:
+ """
+ Return the default color depth for a windows terminal.
+
+ Contrary to the Vt100 implementation, this doesn't depend on a $TERM
+ variable.
+ """
+ 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:
+ """
+ Returns True when we're running Windows and VT100 escape sequences are
+ supported.
+ """
+ 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
new file mode 100644
index 0000000..528bec7
--- /dev/null
+++ b/src/prompt_toolkit/patch_stdout.py
@@ -0,0 +1,296 @@
+"""
+patch_stdout
+============
+
+This implements a context manager that ensures that print statements within
+it won't destroy the user interface. The context manager will replace
+`sys.stdout` by something that draws the output above the current prompt,
+rather than overwriting the UI.
+
+Usage::
+
+ with patch_stdout(application):
+ ...
+ application.run()
+ ...
+
+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
+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",
+]
+
+
+@contextmanager
+def patch_stdout(raw: bool = False) -> Generator[None, None, None]:
+ """
+ Replace `sys.stdout` by an :class:`_StdoutProxy` instance.
+
+ Writing to this proxy will make sure that the text appears above the
+ prompt, and that it doesn't destroy the output from the renderer. If no
+ application is curring, the behavior should be identical to writing to
+ `sys.stdout` directly.
+
+ Warning: If a new event loop is installed using `asyncio.set_event_loop()`,
+ then make sure that the context manager is applied after the event loop
+ is changed. Printing to stdout will be scheduled in the event loop
+ that's active when the context manager is created.
+
+ :param raw: (`bool`) When True, vt100 terminal escape sequences are not
+ removed/escaped.
+ """
+ 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."
+
+
+class StdoutProxy:
+ """
+ File-like object, which prints everything written to it, output above the
+ current application/prompt. This class is compatible with other file
+ objects and can be used as a drop-in replacement for `sys.stdout` or can
+ for instance be passed to `logging.StreamHandler`.
+
+ The current application, above which we print, is determined by looking
+ what application currently runs in the `AppSession` that is active during
+ the creation of this instance.
+
+ This class can be used as a context manager.
+
+ In order to avoid having to repaint the prompt continuously for every
+ little write, a short delay of `sleep_between_writes` seconds will be added
+ 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:
+ 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:
+ return self
+
+ def __exit__(self, *args: object) -> None:
+ self.close()
+
+ 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.
+ """
+ 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:
+ """
+ Return the event loop for the application currently running in our
+ `AppSession`.
+ """
+ app = self.app_session.app
+
+ 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`.
+ """
+
+ 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('\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.
+ """
+ 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 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.
+ """
+ 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/py.typed b/src/prompt_toolkit/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/prompt_toolkit/py.typed
diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py
new file mode 100644
index 0000000..5ad1dd6
--- /dev/null
+++ b/src/prompt_toolkit/renderer.py
@@ -0,0 +1,813 @@
+"""
+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
+from prompt_toolkit.filters import FilterOrBool, to_filter
+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,
+)
+
+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, # 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.
+
+ This takes two `Screen` instances. The one that represents the output like
+ it was during the last rendering and one that represents the current
+ output raster. Looking at these two `Screen` instances, this function will
+ render the difference by calling the appropriate methods of the `Output`
+ object that only paint the changes to the terminal.
+
+ This is some performance-critical code which is heavily optimized.
+ Don't change things without profiling first.
+
+ :param current_pos: Current cursor position.
+ :param last_style: The style string, used for drawing the last drawn
+ character. (Color/attributes.)
+ :param attrs_for_style_string: :class:`._StyleStringToAttrsCache` instance.
+ :param width: The width of the terminal.
+ :param previous_width: The width of the terminal during the last rendering.
+ """
+ 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."
+
+
+class _StyleStringToAttrsCache(Dict[str, Attrs]):
+ """
+ A cache structure that maps style strings to :class:`.Attr`.
+ (This is an important speed up.)
+ """
+
+ 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:
+ attrs = self.get_attrs_for_style_str(style_str)
+ attrs = self.style_transformation.transform_attrs(attrs)
+
+ self[style_str] = attrs
+ return attrs
+
+
+class _StyleStringHasStyleCache(Dict[str, bool]):
+ """
+ Cache for remember which style strings don't render the default output
+ style (default fg/bg, no underline and no reverse and no blink). That way
+ we know that we should render these cells, even when they're empty (when
+ they contain a space).
+
+ Note: we don't consider bold/italic/hidden because they don't change the
+ output if there's no text in the cell.
+ """
+
+ 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:
+ 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
+ )
+
+ 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"
+
+
+class Renderer:
+ """
+ Typical usage:
+
+ ::
+
+ output = Vt100_Output.from_pty(sys.stdout)
+ r = Renderer(style, output)
+ r.render(app, layout=...)
+ """
+
+ 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:
+ """
+ The `Screen` class that was generated during the last rendering.
+ This can be `None`.
+ """
+ return self._last_screen
+
+ @property
+ 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.)
+ """
+ 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:
+ """
+ Return the number of rows visible in the terminal above the layout.
+ """
+ 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.
+
+ We do this to calculate the minimum available height that we can
+ consume for rendering the prompt. This is the available space below te
+ cursor.
+
+ For vt100: Do CPR request. (answer will arrive later.)
+ For win32: Do API call. (Answer comes immediately.)
+ """
+ # 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()
+
+ 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.)
+ """
+ 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:
+ """
+ Waiting for CPR flag. True when we send the request, but didn't got a
+ response.
+ """
+ return bool(self._waiting_for_cpr_futures)
+
+ async def wait_for_cpr_responses(self, timeout: int = 1) -> None:
+ """
+ Wait for a CPR response.
+ """
+ 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.
+ """
+ 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
+ later resuming the same CLI.)
+
+ :param leave_alternate_screen: When True, and when inside an alternate
+ screen buffer, quit the alternate screen.
+ """
+ output = self.output
+
+ 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
+ """
+ # 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:
+ """
+ Print a list of (style_str, text) tuples in the given style to the output.
+ """
+ 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
new file mode 100644
index 0000000..fd90a04
--- /dev/null
+++ b/src/prompt_toolkit/search.py
@@ -0,0 +1,230 @@
+"""
+Search operations.
+
+For the key bindings implementation with attached filters, check
+`prompt_toolkit.key_binding.bindings.search`. (Use these for new key bindings
+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",
+]
+
+
+class SearchDirection(Enum):
+ FORWARD = "FORWARD"
+ BACKWARD = "BACKWARD"
+
+
+class SearchState:
+ """
+ A search 'query', associated with a search field (like a SearchToolbar).
+
+ Every searchable `BufferControl` points to a `search_buffer_control`
+ (another `BufferControls`) which represents the search field. The
+ `SearchState` attached to that search field is used for storing the current
+ search query.
+
+ It is possible to have one searchfield for multiple `BufferControls`. In
+ that case, they'll share the same `SearchState`.
+ If there are multiple `BufferControls` that display the same `Buffer`, then
+ 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:
+ 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 __invert__(self) -> SearchState:
+ """
+ Create a new SearchState where backwards becomes forwards and the other
+ way around.
+ """
+ if self.direction == SearchDirection.BACKWARD:
+ direction = SearchDirection.FORWARD
+ else:
+ direction = SearchDirection.BACKWARD
+
+ 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:
+ """
+ Start search through the given `buffer_control` using the
+ `search_buffer_control`.
+
+ :param buffer_control: Start search for this `BufferControl`. If not given,
+ search through the current control.
+ """
+ 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
+
+ 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`.
+ """
+ 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()
+
+ # 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.
+ """
+ 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:
+ """
+ Accept current search query. Focus original `BufferControl` again.
+ """
+ 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]:
+ """
+ Return mapping from BufferControl to SearchBufferControl.
+ """
+ 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
new file mode 100644
index 0000000..2158fa9
--- /dev/null
+++ b/src/prompt_toolkit/selection.py
@@ -0,0 +1,61 @@
+"""
+Data structures for the selection.
+"""
+from __future__ import annotations
+
+from enum import Enum
+
+__all__ = [
+ "SelectionType",
+ "PasteMode",
+ "SelectionState",
+]
+
+
+class SelectionType(Enum):
+ """
+ Type of selection.
+ """
+
+ #: 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" # Yank like emacs.
+ VI_AFTER = "VI_AFTER" # When pressing 'p' in Vi.
+ VI_BEFORE = "VI_BEFORE" # When pressing 'P' in Vi.
+
+
+class SelectionState:
+ """
+ State of the current selection.
+
+ :param original_cursor_position: int
+ :param type: :class:`~.SelectionType`
+ """
+
+ 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 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/__init__.py b/src/prompt_toolkit/shortcuts/__init__.py
new file mode 100644
index 0000000..49e5ac4
--- /dev/null
+++ b/src/prompt_toolkit/shortcuts/__init__.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from .dialogs import (
+ button_dialog,
+ checkboxlist_dialog,
+ input_dialog,
+ message_dialog,
+ progress_dialog,
+ radiolist_dialog,
+ yes_no_dialog,
+)
+from .progress_bar import ProgressBar, ProgressBarCounter
+from .prompt import (
+ CompleteStyle,
+ PromptSession,
+ confirm,
+ create_confirm_session,
+ prompt,
+)
+from .utils import clear, clear_title, print_container, print_formatted_text, set_title
+
+__all__ = [
+ # Dialogs.
+ "input_dialog",
+ "message_dialog",
+ "progress_dialog",
+ "checkboxlist_dialog",
+ "radiolist_dialog",
+ "yes_no_dialog",
+ "button_dialog",
+ # Prompts.
+ "PromptSession",
+ "prompt",
+ "confirm",
+ "create_confirm_session",
+ "CompleteStyle",
+ # Progress bars.
+ "ProgressBar",
+ "ProgressBarCounter",
+ # Utils.
+ "clear",
+ "clear_title",
+ "print_container",
+ "print_formatted_text",
+ "set_title",
+]
diff --git a/src/prompt_toolkit/shortcuts/dialogs.py b/src/prompt_toolkit/shortcuts/dialogs.py
new file mode 100644
index 0000000..d78e7db
--- /dev/null
+++ b/src/prompt_toolkit/shortcuts/dialogs.py
@@ -0,0 +1,330 @@
+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
+from prompt_toolkit.completion import Completer
+from prompt_toolkit.eventloop import run_in_executor_with_context
+from prompt_toolkit.filters import FilterOrBool
+from prompt_toolkit.formatted_text import AnyFormattedText
+from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
+from prompt_toolkit.key_binding.defaults import load_key_bindings
+from prompt_toolkit.key_binding.key_bindings import KeyBindings, merge_key_bindings
+from prompt_toolkit.layout import Layout
+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",
+]
+
+
+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.
+ """
+
+ 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,
+ )
+
+ return _create_app(dialog, style)
+
+
+_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.
+ """
+
+ 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]:
+ """
+ Display a text input box.
+ Return the given text, or None when cancelled.
+ """
+
+ 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,
+ )
+
+ 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.
+ """
+ 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]:
+ """
+ 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.
+ """
+ if values is None:
+ values = []
+
+ def ok_handler() -> None:
+ get_app().exit(result=radio_list.current_value)
+
+ radio_list = RadioList(values=values, default=default)
+
+ 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.
+ """
+ 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,
+ )
+
+ 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.
+ """
+ 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."
+ get_app().exit()
diff --git a/src/prompt_toolkit/shortcuts/progress_bar/__init__.py b/src/prompt_toolkit/shortcuts/progress_bar/__init__.py
new file mode 100644
index 0000000..2261a5b
--- /dev/null
+++ b/src/prompt_toolkit/shortcuts/progress_bar/__init__.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+from .base import ProgressBar, ProgressBarCounter
+from .formatters import (
+ Bar,
+ Formatter,
+ IterationsPerSecond,
+ Label,
+ Percentage,
+ Progress,
+ Rainbow,
+ SpinningWheel,
+ Text,
+ TimeElapsed,
+ TimeLeft,
+)
+
+__all__ = [
+ "ProgressBar",
+ "ProgressBarCounter",
+ # Formatters.
+ "Formatter",
+ "Text",
+ "Label",
+ "Percentage",
+ "Bar",
+ "Progress",
+ "TimeElapsed",
+ "TimeLeft",
+ "IterationsPerSecond",
+ "SpinningWheel",
+ "Rainbow",
+]
diff --git a/src/prompt_toolkit/shortcuts/progress_bar/base.py b/src/prompt_toolkit/shortcuts/progress_bar/base.py
new file mode 100644
index 0000000..21aa1be
--- /dev/null
+++ b/src/prompt_toolkit/shortcuts/progress_bar/base.py
@@ -0,0 +1,448 @@
+"""
+Progress bar implementation on top of prompt_toolkit.
+
+::
+
+ with ProgressBar(...) as pb:
+ for item in pb(data):
+ ...
+"""
+from __future__ import annotations
+
+import contextvars
+import datetime
+import functools
+import os
+import signal
+import threading
+import traceback
+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.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.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"]
+
+E = KeyPressEvent
+
+_SIGWINCH = getattr(signal, "SIGWINCH", None)
+
+
+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.)
+ """
+ kb = KeyBindings()
+
+ @kb.add("c-l")
+ def _clear(event: E) -> None:
+ event.app.renderer.clear()
+
+ 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:
+ """
+ Progress bar context manager.
+
+ Usage ::
+
+ with ProgressBar(...) as pb:
+ for item in pb(data):
+ ...
+
+ :param title: Text to be displayed above the progress bars. This can be a
+ callable or formatted text as well.
+ :param formatters: List of :class:`.Formatter` instances.
+ :param bottom_toolbar: Text to be displayed in the bottom toolbar. This
+ can be a callable or formatted text.
+ :param style: :class:`prompt_toolkit.styles.BaseStyle` instance.
+ :param key_bindings: :class:`.KeyBindings` instance.
+ :param cancel_callback: Callback function that's called when control-c is
+ pressed by the user. This can be used for instance to start "proper"
+ cancellation if the wrapped code supports it.
+ :param file: The file object used for rendering, by default `sys.stderr` is used.
+
+ :param color_depth: `prompt_toolkit` `ColorDepth` instance.
+ :param output: :class:`~prompt_toolkit.output.Output` instance.
+ :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:
+ self.title = title
+ self.formatters = formatters or create_default_formatters()
+ self.bottom_toolbar = bottom_toolbar
+ self.counters: list[ProgressBarCounter[object]] = []
+ 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:
+ 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:
+ # 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,
+ )
+
+ # 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:
+ # 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]:
+ """
+ Start a new counter.
+
+ :param label: Title text or description for this progress. (This can be
+ formatted text as well).
+ :param remove_when_done: When `True`, hide this progress bar.
+ :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
+ )
+ 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:
+ 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)
+
+
+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:
+ self.start_time = datetime.datetime.now()
+ self.stop_time: datetime.datetime | None = None
+ self.progress_bar = progress_bar
+ self.data = data
+ self.items_completed = 0
+ self.label = label
+ 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 # We don't know the total length.
+ else:
+ self.total = total
+
+ 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.")
+
+ 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.)
+ """
+ self.items_completed += 1
+ self.progress_bar.invalidate()
+
+ @property
+ def done(self) -> bool:
+ """Whether a counter has been completed.
+
+ Done counter have been stopped (see stopped) and removed depending on
+ remove_when_done value.
+
+ Contrast this with stopped. A stopped counter may be terminated before
+ 100% completion. A done counter has reached its 100% completion.
+ """
+ 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:
+ """Whether a counter has been stopped.
+
+ Stopped counters no longer have increasing time_elapsed. This distinction is
+ also used to prevent the Bar formatter with unknown totals from continuing to run.
+
+ A stopped counter (but not done) can be used to signal that a given counter has
+ encountered an error but allows other counters to continue
+ (e.g. download X of Y failed). Given how only done counters are removed
+ (see remove_when_done) this can help aggregate failures from a large number of
+ successes.
+
+ Contrast this with done. A done counter has reached its 100% completion.
+ A stopped counter may be terminated before 100% completion.
+ """
+ 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 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.
+ """
+ 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:
+ """
+ Timedelta representing the time left.
+ """
+ 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
new file mode 100644
index 0000000..dd0339c
--- /dev/null
+++ b/src/prompt_toolkit/shortcuts/progress_bar/formatters.py
@@ -0,0 +1,429 @@
+"""
+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.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",
+]
+
+
+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:
+ 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):
+ """
+ Display the name of the current task.
+
+ :param width: If a `width` is given, use this width. Scroll the text if it
+ doesn't fit in this width.
+ :param suffix: String suffix to be added after the task name, e.g. ': '.
+ If no task name was given, no suffix will be added.
+ """
+
+ 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>"
+
+ 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:
+ 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
+ self.sym_b = sym_b
+ 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>"
+
+ 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 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.
+ """
+ result = f"{timedelta}".split(".")[0]
+ if result.startswith("0:"):
+ result = result[2:]
+ return result
+
+
+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 = "?:??:??"
+
+ 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>"
+ )
+
+ 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 = 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]:
+ """
+ Take hue between 0 and 1, return (r, g, b).
+ """
+ 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:
+ 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]:
+ """
+ Return the list of default formatters.
+ """
+ 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
new file mode 100644
index 0000000..7274b5f
--- /dev/null
+++ b/src/prompt_toolkit/shortcuts/prompt.py
@@ -0,0 +1,1504 @@
+"""
+Line editing functionality.
+---------------------------
+
+This provides a UI for a line input, similar to GNU Readline, libedit and
+linenoise.
+
+Either call the `prompt` function for every line input. Or create an instance
+of the :class:`.PromptSession` class and call the `prompt` method from that
+class. In the second case, we'll have a 'session' that keeps all the state like
+the history in between several calls.
+
+There is a lot of overlap between the arguments taken by the `prompt` function
+and the `PromptSession` (like `completer`, `style`, etcetera). There we have
+the freedom to decide which settings we want for the whole 'session', and which
+we want for an individual `prompt`.
+
+Example::
+
+ # Simple `prompt` call.
+ result = prompt('Say something: ')
+
+ # Using a 'session'.
+ s = PromptSession()
+ 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.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.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.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.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.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.validation import DynamicValidator, Validator
+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", # 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
+]:
+ """
+ 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.
+ """
+
+ 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):
+ """
+ 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",
+ )
+
+
+class CompleteStyle(str, Enum):
+ """
+ How to display autocompletions for the prompt.
+ """
+
+ value: str
+
+ COLUMN = "COLUMN"
+ MULTI_COLUMN = "MULTI_COLUMN"
+ READLINE_LIKE = "READLINE_LIKE"
+
+
+# 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]):
+ """
+ PromptSession for a prompt application, which can be used as a GNU Readline
+ replacement.
+
+ This is a wrapper around a lot of ``prompt_toolkit`` functionality and can
+ be a replacement for `raw_input`.
+
+ All parameters that expect "formatted text" can take either just plain text
+ (a unicode object), a list of ``(style_str, text)`` tuples or an HTML object.
+
+ Example usage::
+
+ s = PromptSession(message='>')
+ text = s.prompt()
+
+ :param message: Plain text or formatted text to be shown before the prompt.
+ This can also be a callable that returns formatted text.
+ :param multiline: `bool` or :class:`~prompt_toolkit.filters.Filter`.
+ When True, prefer a layout that is more adapted for multiline input.
+ Text after newlines is automatically indented, and search/arg input is
+ shown below the input, instead of replacing the prompt.
+ :param wrap_lines: `bool` or :class:`~prompt_toolkit.filters.Filter`.
+ When True (the default), automatically wrap long lines instead of
+ scrolling horizontally.
+ :param is_password: Show asterisks instead of the actual typed characters.
+ :param editing_mode: ``EditingMode.VI`` or ``EditingMode.EMACS``.
+ :param vi_mode: `bool`, if True, Identical to ``editing_mode=EditingMode.VI``.
+ :param complete_while_typing: `bool` or
+ :class:`~prompt_toolkit.filters.Filter`. Enable autocompletion while
+ typing.
+ :param validate_while_typing: `bool` or
+ :class:`~prompt_toolkit.filters.Filter`. Enable input validation while
+ typing.
+ :param enable_history_search: `bool` or
+ :class:`~prompt_toolkit.filters.Filter`. Enable up-arrow parting
+ string matching.
+ :param search_ignore_case:
+ :class:`~prompt_toolkit.filters.Filter`. Search case insensitive.
+ :param lexer: :class:`~prompt_toolkit.lexers.Lexer` to be used for the
+ syntax highlighting.
+ :param validator: :class:`~prompt_toolkit.validation.Validator` instance
+ for input validation.
+ :param completer: :class:`~prompt_toolkit.completion.Completer` instance
+ for input completion.
+ :param complete_in_thread: `bool` or
+ :class:`~prompt_toolkit.filters.Filter`. Run the completer code in a
+ background thread in order to avoid blocking the user interface.
+ For ``CompleteStyle.READLINE_LIKE``, this setting has no effect. There
+ we always run the completions in the main thread.
+ :param reserve_space_for_menu: Space to be reserved for displaying the menu.
+ (0 means that no space needs to be reserved.)
+ :param auto_suggest: :class:`~prompt_toolkit.auto_suggest.AutoSuggest`
+ instance for input suggestions.
+ :param style: :class:`.Style` instance for the color scheme.
+ :param include_default_pygments_style: `bool` or
+ :class:`~prompt_toolkit.filters.Filter`. Tell whether the default
+ styling for Pygments lexers has to be included. By default, this is
+ true, but it is recommended to be disabled if another Pygments style is
+ passed as the `style` argument, otherwise, two Pygments styles will be
+ merged.
+ :param style_transformation:
+ :class:`~prompt_toolkit.style.StyleTransformation` instance.
+ :param swap_light_and_dark_colors: `bool` or
+ :class:`~prompt_toolkit.filters.Filter`. When enabled, apply
+ :class:`~prompt_toolkit.style.SwapLightAndDarkStyleTransformation`.
+ This is useful for switching between dark and light terminal
+ backgrounds.
+ :param enable_system_prompt: `bool` or
+ :class:`~prompt_toolkit.filters.Filter`. Pressing Meta+'!' will show
+ a system prompt.
+ :param enable_suspend: `bool` or :class:`~prompt_toolkit.filters.Filter`.
+ Enable Control-Z style suspension.
+ :param enable_open_in_editor: `bool` or
+ :class:`~prompt_toolkit.filters.Filter`. Pressing 'v' in Vi mode or
+ C-X C-E in emacs mode will open an external editor.
+ :param history: :class:`~prompt_toolkit.history.History` instance.
+ :param clipboard: :class:`~prompt_toolkit.clipboard.Clipboard` instance.
+ (e.g. :class:`~prompt_toolkit.clipboard.InMemoryClipboard`)
+ :param rprompt: Text or formatted text to be displayed on the right side.
+ This can also be a callable that returns (formatted) text.
+ :param bottom_toolbar: Formatted text or callable which is supposed to
+ return formatted text.
+ :param prompt_continuation: Text that needs to be displayed for a multiline
+ prompt continuation. This can either be formatted text or a callable
+ that takes a `prompt_width`, `line_number` and `wrap_count` as input
+ and returns formatted text. When this is `None` (the default), then
+ `prompt_width` spaces will be used.
+ :param complete_style: ``CompleteStyle.COLUMN``,
+ ``CompleteStyle.MULTI_COLUMN`` or ``CompleteStyle.READLINE_LIKE``.
+ :param mouse_support: `bool` or :class:`~prompt_toolkit.filters.Filter`
+ to enable mouse support.
+ :param placeholder: Text to be displayed when no input has been given
+ yet. Unlike the `default` parameter, this won't be returned as part of
+ the output ever. This can be formatted text or a callable that returns
+ formatted text.
+ :param refresh_interval: (number; in seconds) When given, refresh the UI
+ every so many seconds.
+ :param input: `Input` object. (Note that the preferred way to change the
+ 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:
+ 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
+ self.complete_in_thread = complete_in_thread
+ self.is_password = is_password
+ self.key_bindings = key_bindings
+ self.bottom_toolbar = bottom_toolbar
+ self.style = style
+ self.style_transformation = style_transformation
+ self.swap_light_and_dark_colors = swap_light_and_dark_colors
+ self.color_depth = color_depth
+ self.cursor = cursor
+ self.include_default_pygments_style = include_default_pygments_style
+ self.rprompt = rprompt
+ self.multiline = multiline
+ self.prompt_continuation = prompt_continuation
+ self.wrap_lines = wrap_lines
+ self.enable_history_search = enable_history_search
+ self.search_ignore_case = search_ignore_case
+ self.complete_while_typing = complete_while_typing
+ self.validate_while_typing = validate_while_typing
+ self.complete_style = complete_style
+ self.mouse_support = mouse_support
+ self.auto_suggest = auto_suggest
+ self.clipboard = clipboard
+ self.validator = validator
+ self.refresh_interval = refresh_interval
+ self.input_processors = input_processors
+ self.placeholder = placeholder
+ self.enable_system_prompt = enable_system_prompt
+ self.enable_suspend = enable_suspend
+ self.enable_open_in_editor = enable_open_in_editor
+ 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:
+ """
+ Dynamically take this setting from this 'PromptSession' class.
+ `attr_name` represents an attribute name of this class. Its value
+ can either be a boolean or a `Filter`.
+
+ This returns something that can be used as either a `Filter`
+ or `Filter`.
+ """
+
+ @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.
+ """
+ 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:
+ """
+ Create `Layout` for this prompt.
+ """
+ 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
+
+ # 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.
+ """
+ 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()
+
+ # 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.
+ """
+ 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")
+
+ @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.
+
+ The first set of arguments is a subset of the :class:`~.PromptSession`
+ class itself. For these, passing in ``None`` will keep the current
+ values that are active in the session. Passing in a value will set the
+ attribute for the session, which means that it applies to the current,
+ but also to the next prompts.
+
+ Note that in order to erase a ``Completer``, ``Validator`` or
+ ``AutoSuggest``, you can't use ``None``. Instead pass in a
+ ``DummyCompleter``, ``DummyValidator`` or ``DummyAutoSuggest`` instance
+ respectively. For a ``Lexer`` you can pass in an empty ``SimpleLexer``.
+
+ Additional arguments, specific for this prompt:
+
+ :param default: The default input text to be shown. (This can be edited
+ by the user).
+ :param accept_default: When `True`, automatically accept the default
+ value without allowing the user to edit the input.
+ :param pre_run: Callable, called at the start of `Application.run`.
+ :param in_thread: Run the prompt in a background thread; block the
+ current thread. This avoids interference with an event loop in the
+ current thread. Like `Application.run(in_thread=True)`.
+
+ This method will raise ``KeyboardInterrupt`` when control-c has been
+ pressed (for abort) and ``EOFError`` when control-d has been pressed
+ (for exit).
+ """
+ # 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]]:
+ """
+ Create prompt `Application` for prompt function for dumb terminals.
+
+ Dumb terminals have minimum rendering capabilities. We can only print
+ text to the screen. We can't use colors, and we can't do cursor
+ movements. The Emacs inferior shell is an example of a dumb terminal.
+
+ We will show the prompt, and wait for the input. We still handle arrow
+ keys, and all custom key bindings, but we don't really render the
+ cursor movements. Instead we only print the typed character that's
+ right before the cursor.
+ """
+ # 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)
+
+ @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.
+
+ :param width: The width that was used for the prompt. (more or less can
+ be used.)
+ :param line_number:
+ :param wrap_count: Amount of times that the line has been wrapped.
+ """
+ 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:
+ """
+ Return whatever needs to be inserted before every line.
+ (the prompt, or a line continuation.)
+ """
+ # 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()
+
+ # 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 _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"
+
+ 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.
+ """
+ # 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]:
+ """
+ Create a `PromptSession` object for the 'confirm' function.
+ """
+ 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:
+ """
+ Display a confirmation prompt that returns True/False.
+ """
+ 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
new file mode 100644
index 0000000..abf4fd2
--- /dev/null
+++ b/src/prompt_toolkit/shortcuts/utils.py
@@ -0,0 +1,239 @@
+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.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,
+)
+
+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:
+ """
+ ::
+
+ print_formatted_text(*values, sep=' ', end='\\n', file=None, flush=False, style=None, output=None)
+
+ Print text to stdout. This is supposed to be compatible with Python's print
+ function, but supports printing of formatted text. You can pass a
+ :class:`~prompt_toolkit.formatted_text.FormattedText`,
+ :class:`~prompt_toolkit.formatted_text.HTML` or
+ :class:`~prompt_toolkit.formatted_text.ANSI` object to print formatted
+ text.
+
+ * Print HTML as follows::
+
+ print_formatted_text(HTML('<i>Some italic text</i> <ansired>This is red!</ansired>'))
+
+ style = Style.from_dict({
+ 'hello': '#ff0066',
+ 'world': '#884444 italic',
+ })
+ print_formatted_text(HTML('<hello>Hello</hello> <world>world</world>!'), style=style)
+
+ * Print a list of (style_str, text) tuples in the given style to the
+ output. E.g.::
+
+ style = Style.from_dict({
+ 'hello': '#ff0066',
+ 'world': '#884444 italic',
+ })
+ fragments = FormattedText([
+ ('class:hello', 'Hello'),
+ ('class:world', 'World'),
+ ])
+ print_formatted_text(fragments, style=style)
+
+ If you want to print a list of Pygments tokens, wrap it in
+ :class:`~prompt_toolkit.formatted_text.PygmentsTokens` to do the
+ conversion.
+
+ If a prompt_toolkit `Application` is currently running, this will always
+ print above the application or prompt (similar to `patch_stdout`). So,
+ `print_formatted_text` will erase the current application, print the text,
+ and render the application again.
+
+ :param values: Any kind of printable object, or formatted string.
+ :param sep: String inserted between values, default a space.
+ :param end: String appended after the last value, default a newline.
+ :param style: :class:`.Style` instance for the color scheme.
+ :param include_default_pygments_style: `bool`. Include the default Pygments
+ style when set to `True` (the default).
+ """
+ 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.
+
+ Example usage::
+
+ from prompt_toolkit.widgets import Frame, TextArea
+ print_container(
+ Frame(TextArea(text='Hello world!')))
+ """
+ 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.
+ """
+ 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:
+ """
+ Clear the screen.
+ """
+ output = get_app_session().output
+ output.erase_screen()
+ output.cursor_goto(0, 0)
+ output.flush()
+
+
+def set_title(text: str) -> None:
+ """
+ Set the terminal title.
+ """
+ output = get_app_session().output
+ output.set_title(text)
+
+
+def clear_title() -> None:
+ """
+ Erase the current title.
+ """
+ set_title("")
diff --git a/src/prompt_toolkit/styles/__init__.py b/src/prompt_toolkit/styles/__init__.py
new file mode 100644
index 0000000..23f61bb
--- /dev/null
+++ b/src/prompt_toolkit/styles/__init__.py
@@ -0,0 +1,66 @@
+"""
+Styling for prompt_toolkit applications.
+"""
+from __future__ import annotations
+
+from .base import (
+ ANSI_COLOR_NAMES,
+ DEFAULT_ATTRS,
+ Attrs,
+ BaseStyle,
+ DummyStyle,
+ DynamicStyle,
+)
+from .defaults import default_pygments_style, default_ui_style
+from .named_colors import NAMED_COLORS
+from .pygments import (
+ pygments_token_to_classname,
+ style_from_pygments_cls,
+ style_from_pygments_dict,
+)
+from .style import Priority, Style, merge_styles, parse_color
+from .style_transformation import (
+ AdjustBrightnessStyleTransformation,
+ ConditionalStyleTransformation,
+ DummyStyleTransformation,
+ DynamicStyleTransformation,
+ ReverseStyleTransformation,
+ SetDefaultColorStyleTransformation,
+ StyleTransformation,
+ SwapLightAndDarkStyleTransformation,
+ merge_style_transformations,
+)
+
+__all__ = [
+ # Base.
+ "Attrs",
+ "DEFAULT_ATTRS",
+ "ANSI_COLOR_NAMES",
+ "BaseStyle",
+ "DummyStyle",
+ "DynamicStyle",
+ # Defaults.
+ "default_ui_style",
+ "default_pygments_style",
+ # Style.
+ "Style",
+ "Priority",
+ "merge_styles",
+ "parse_color",
+ # Style transformation.
+ "StyleTransformation",
+ "SwapLightAndDarkStyleTransformation",
+ "ReverseStyleTransformation",
+ "SetDefaultColorStyleTransformation",
+ "AdjustBrightnessStyleTransformation",
+ "DummyStyleTransformation",
+ "ConditionalStyleTransformation",
+ "DynamicStyleTransformation",
+ "merge_style_transformations",
+ # Pygments.
+ "style_from_pygments_cls",
+ "style_from_pygments_dict",
+ "pygments_token_to_classname",
+ # Named colors.
+ "NAMED_COLORS",
+]
diff --git a/src/prompt_toolkit/styles/base.py b/src/prompt_toolkit/styles/base.py
new file mode 100644
index 0000000..b50f3b0
--- /dev/null
+++ b/src/prompt_toolkit/styles/base.py
@@ -0,0 +1,183 @@
+"""
+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",
+]
+
+
+#: Style attributes.
+class Attrs(NamedTuple):
+ color: str | None
+ bgcolor: str | None
+ bold: bool | None
+ underline: bool | None
+ strike: bool | None
+ italic: bool | None
+ blink: bool | None
+ reverse: bool | None
+ hidden: bool | None
+
+
+"""
+:param color: Hexadecimal string. E.g. '000000' or Ansi color name: e.g. 'ansiblue'
+:param bgcolor: Hexadecimal string. E.g. 'ffffff' or Ansi color name: e.g. 'ansired'
+:param bold: Boolean
+:param underline: Boolean
+:param strike: Boolean
+:param italic: Boolean
+:param blink: Boolean
+:param reverse: Boolean
+:param hidden: Boolean
+"""
+
+#: 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))
+
+
+class BaseStyle(metaclass=ABCMeta):
+ """
+ Abstract base class for prompt_toolkit styles.
+ """
+
+ @abstractmethod
+ def get_attrs_for_style_str(
+ self, style_str: str, default: Attrs = DEFAULT_ATTRS
+ ) -> Attrs:
+ """
+ Return :class:`.Attrs` for the given style string.
+
+ :param style_str: The style string. This can contain inline styling as
+ well as classnames (e.g. "class:title").
+ :param default: `Attrs` to be used if no styling was defined.
+ """
+
+ @abstractproperty
+ 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.)
+ """
+ return []
+
+ @abstractmethod
+ 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.
+ """
+
+
+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):
+ """
+ Style class that can dynamically returns an other Style.
+
+ :param get_style: Callable that returns a :class:`.Style` instance.
+ """
+
+ 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
new file mode 100644
index 0000000..75b8dd2
--- /dev/null
+++ b/src/prompt_toolkit/styles/defaults.py
@@ -0,0 +1,235 @@
+"""
+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",
+]
+
+#: 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:
+ """
+ Create a default `Style` object.
+ """
+ return merge_styles(
+ [
+ Style(PROMPT_TOOLKIT_STYLE),
+ Style(COLORS_STYLE),
+ Style(WIDGETS_STYLE),
+ ]
+ )
+
+
+@memoized()
+def default_pygments_style() -> Style:
+ """
+ Create a `Style` object that contains the default Pygments style.
+ """
+ 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
new file mode 100644
index 0000000..0395c8b
--- /dev/null
+++ b/src/prompt_toolkit/styles/named_colors.py
@@ -0,0 +1,161 @@
+"""
+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",
+}
diff --git a/src/prompt_toolkit/styles/pygments.py b/src/prompt_toolkit/styles/pygments.py
new file mode 100644
index 0000000..3e101f1
--- /dev/null
+++ b/src/prompt_toolkit/styles/pygments.py
@@ -0,0 +1,69 @@
+"""
+Adaptor for building prompt_toolkit styles, starting from a Pygments style.
+
+Usage::
+
+ from pygments.styles.tango import TangoStyle
+ 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:
+ """
+ Shortcut to create a :class:`.Style` instance from a Pygments style class
+ and a style dictionary.
+
+ Example::
+
+ from prompt_toolkit.styles.from_pygments import style_from_pygments_cls
+ from pygments.styles import get_style_by_name
+ style = style_from_pygments_cls(get_style_by_name('monokai'))
+
+ :param pygments_style_cls: Pygments style class to start from.
+ """
+ # 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:
+ """
+ Create a :class:`.Style` instance from a Pygments style dictionary.
+ (One that maps Token objects to style strings.)
+ """
+ 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:
+ """
+ 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.)
+ """
+ parts = ("pygments",) + token
+ return ".".join(parts).lower()
diff --git a/src/prompt_toolkit/styles/style.py b/src/prompt_toolkit/styles/style.py
new file mode 100644
index 0000000..1abee0f
--- /dev/null
+++ b/src/prompt_toolkit/styles/style.py
@@ -0,0 +1,400 @@
+"""
+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 .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()}
+
+
+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.)
+ """
+ # 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)
+
+
+# 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]:
+ """
+ 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']
+ """
+ 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:
+ """
+ Take a style string, e.g. 'bg:red #88ff00 class:title'
+ and return a `Attrs` instance.
+ """
+ # 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):
+ """
+ The priority of the rules, when a style is created from a dictionary.
+
+ In a `Style`, rules that are defined later will always override previous
+ defined rules, however in a dictionary, the key order was arbitrary before
+ Python 3.6. This means that the style could change at random between rules.
+
+ We have two options:
+
+ - `DICT_KEY_ORDER`: This means, iterate through the dictionary, and take
+ the key/value pairs in order as they come. This is a good option if you
+ have Python >3.6. Rules at the end will override rules at the beginning.
+ - `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"
+
+
+# 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
+
+
+class Style(BaseStyle):
+ """
+ Create a ``Style`` instance from a list of style rules.
+
+ The `style_rules` is supposed to be a list of ('classnames', 'style') tuples.
+ The classnames are a whitespace separated string of class names and the
+ style string is just like a Pygments style definition, but with a few
+ additions: it supports 'reverse' and 'blink'.
+
+ Later rules always override previous rules.
+
+ Usage::
+
+ Style([
+ ('title', '#ff0000 bold underline'),
+ ('something-else', 'reverse'),
+ ('class1 class2', 'reverse'),
+ ])
+
+ The ``from_dict`` classmethod is similar, but takes a dictionary as input.
+ """
+
+ 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:
+ """
+ :param style_dict: Style dictionary.
+ :param priority: `Priority` value.
+ """
+ if priority == Priority.MOST_PRECISE:
+
+ 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.
+ """
+ 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)
+
+ # Process inline style.
+ else:
+ inline_attrs = _parse_style_str(part)
+ list_of_attrs.append(inline_attrs)
+
+ return _merge_attrs(list_of_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.
+ """
+
+ 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.
+ """
+ styles = [s for s in styles if s is not None]
+ return _MergedStyle(styles)
+
+
+class _MergedStyle(BaseStyle):
+ """
+ Merge multiple `Style` objects into one.
+ This is supposed to ensure consistency: if any of the given styles changes,
+ then this style will be updated.
+ """
+
+ # 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."
+
+ 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
new file mode 100644
index 0000000..fbb5a63
--- /dev/null
+++ b/src/prompt_toolkit/styles/style_transformation.py
@@ -0,0 +1,373 @@
+"""
+Collection of style transformations.
+
+Think of it as a kind of color post processing after the rendering is done.
+This could be used for instance to change the contrast/saturation; swap light
+and dark colors or even change certain colors for other colors.
+
+When the UI is rendered, these transformations can be applied right after the
+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",
+]
+
+
+class StyleTransformation(metaclass=ABCMeta):
+ """
+ Base class for any style transformation.
+ """
+
+ @abstractmethod
+ 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).
+ """
+
+ def invalidation_hash(self) -> Hashable:
+ """
+ When this changes, the cache should be invalidated.
+ """
+ return f"{self.__class__.__name__}-{id(self)}"
+
+
+class SwapLightAndDarkStyleTransformation(StyleTransformation):
+ """
+ Turn dark colors into light colors and the other way around.
+
+ This is meant to make color schemes that work on a dark background usable
+ on a light background (and the other way around).
+
+ Notice that this doesn't swap foreground and background like "reverse"
+ does. It turns light green into dark green and the other way around.
+ Foreground and background colors are considered individually.
+
+ Also notice that when <reverse> is used somewhere and no colors are given
+ in particular (like what is the default for the bottom toolbar), then this
+ doesn't change anything. This is what makes sense, because when the
+ 'default' color is chosen, it's what works best for the terminal, and
+ reverse works good with that.
+ """
+
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
+ """
+ Return the `Attrs` used when opposite luminosity should be used.
+ """
+ # 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):
+ """
+ Swap the 'reverse' attribute.
+
+ (This is still experimental.)
+ """
+
+ def transform_attrs(self, attrs: Attrs) -> Attrs:
+ return attrs._replace(reverse=not attrs.reverse)
+
+
+class SetDefaultColorStyleTransformation(StyleTransformation):
+ """
+ Set default foreground/background color for output that doesn't specify
+ anything. This is useful for overriding the terminal default colors.
+
+ :param fg: Color string or callable that returns a color string for the
+ foreground.
+ :param bg: Like `fg`, but for the background.
+ """
+
+ 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):
+ """
+ Adjust the brightness to improve the rendering on either dark or light
+ backgrounds.
+
+ For dark backgrounds, it's best to increase `min_brightness`. For light
+ backgrounds it's best to decrease `max_brightness`. Usually, only one
+ setting is adjusted.
+
+ This will only change the brightness for text that has a foreground color
+ defined, but no background color. It works best for 256 or true color
+ output.
+
+ .. note:: Notice that there is no universal way to detect whether the
+ application is running in a light or dark terminal. As a
+ developer of an command line application, you'll have to make
+ this configurable for the user.
+
+ :param min_brightness: Float between 0.0 and 1.0 or a callable that returns
+ a float.
+ :param max_brightness: Float between 0.0 and 1.0 or a callable that returns
+ a float.
+ """
+
+ 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 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.
+ """
+ # 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.
+ """
+ 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):
+ """
+ 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):
+ """
+ StyleTransformation class that can dynamically returns any
+ `StyleTransformation`.
+
+ :param get_style_transformation: Callable that returns a
+ :class:`.StyleTransformation` instance.
+ """
+
+ 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:
+ 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
+
+ def invalidation_hash(self) -> Hashable:
+ return (self.filter(), self.style_transformation.invalidation_hash())
+
+
+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:
+ """
+ Merge multiple transformations together.
+ """
+ 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:
+ """
+ Take a color name in either 'ansi...' format or 6 digit RGB, return the
+ color of opposite luminosity (same hue/saturation).
+
+ This is used for turning color schemes that work on a light background
+ usable on a dark background.
+ """
+ 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
new file mode 100644
index 0000000..a2c80e5
--- /dev/null
+++ b/src/prompt_toolkit/token.py
@@ -0,0 +1,10 @@
+"""
+"""
+
+from __future__ import annotations
+
+__all__ = [
+ "ZeroWidthEscape",
+]
+
+ZeroWidthEscape = "[ZeroWidthEscape]"
diff --git a/src/prompt_toolkit/utils.py b/src/prompt_toolkit/utils.py
new file mode 100644
index 0000000..1a99a28
--- /dev/null
+++ b/src/prompt_toolkit/utils.py
@@ -0,0 +1,327 @@
+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 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",
+]
+
+# 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]):
+ """
+ Simple event to which event handlers can be attached. For instance::
+
+ class Cls:
+ def __init__(self):
+ # Define event. The first parameter is the sender.
+ self.event = Event(self)
+
+ obj = Cls()
+
+ def handler(sender):
+ pass
+
+ # Add event handler by using the += operator.
+ obj.event += handler
+
+ # Fire event.
+ obj.event()
+ """
+
+ 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."
+ for handler in self._handlers:
+ handler(self.sender)
+
+ def fire(self) -> None:
+ "Alias for just calling the event."
+ self()
+
+ 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.)
+ """
+ # Add to list of event handlers.
+ self._handlers.append(handler)
+
+ def remove_handler(self, handler: Callable[[_Sender], None]) -> None:
+ """
+ Remove a handler from this callback.
+ """
+ if handler in self._handlers:
+ self._handlers.remove(handler)
+
+ 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]:
+ """
+ `event -= handler` notation for removing a handler.
+ """
+ self.remove_handler(handler)
+ return self
+
+
+class DummyContext(ContextManager[None]):
+ """
+ (contextlib.nested is not available on Py3)
+ """
+
+ def __enter__(self) -> None:
+ pass
+
+ def __exit__(self, *a: object) -> None:
+ pass
+
+
+class _CharSizesCache(Dict[str, int]):
+ """
+ Cache for wcwidth sizes.
+ """
+
+ 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:
+ # 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:
+ """
+ Return width of a string. Wrapper around ``wcwidth``.
+ """
+ return _CHAR_SIZES_CACHE[string]
+
+
+def suspend_to_background_supported() -> bool:
+ """
+ Returns `True` when the Python implementation supports
+ suspend-to-background. This is typically `False' on Windows systems.
+ """
+ return hasattr(signal, "SIGTSTP")
+
+
+def is_windows() -> bool:
+ """
+ True when we are using Windows.
+ """
+ return sys.platform == "win32" # Not 'darwin' or 'linux2'
+
+
+def is_windows_vt100_supported() -> bool:
+ """
+ True when we are using Windows, but VT100 escape sequences are supported.
+ """
+ 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:
+ """
+ True when the ConEmu Windows console is used.
+ """
+ return sys.platform == "win32" and os.environ.get("ConEmuANSI", "OFF") == "ON"
+
+
+def in_main_thread() -> bool:
+ """
+ True when the current thread is the main thread.
+ """
+ return threading.current_thread().__class__.__name__ == "_MainThread"
+
+
+def get_bell_environment_variable() -> bool:
+ """
+ True if env variable is set to true (true, TRUE, True, 1).
+ """
+ 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."
+ return os.environ.get("TERM", "")
+
+
+_T = TypeVar("_T")
+
+
+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::
+
+ # Getting the first 70 items from this generator should have yielded 10
+ # times A, 20 times B and 40 times C, all distributed equally..
+ take_using_weights(['A', 'B', 'C'], [5, 10, 20])
+
+ :param items: List of items to take from.
+ :param weights: Integers representing the weight. (Numbers have to be
+ integers, not floats.)
+ """
+ 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)
+
+ 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
+
+ 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."
+ if callable(value):
+ return to_float(value())
+ else:
+ return float(value)
+
+
+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.
+ """
+ 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
new file mode 100644
index 0000000..127445e
--- /dev/null
+++ b/src/prompt_toolkit/validation.py
@@ -0,0 +1,195 @@
+"""
+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",
+]
+
+
+class ValidationError(Exception):
+ """
+ Error raised by :meth:`.Validator.validate`.
+
+ :param cursor_position: The cursor position where the error occurred.
+ :param message: Text.
+ """
+
+ 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,
+ )
+
+
+class Validator(metaclass=ABCMeta):
+ """
+ Abstract base class for an input validator.
+
+ A validator is typically created in one of the following two ways:
+
+ - Either by overriding this class and implementing the `validate` method.
+ - Or by passing a callable to `Validator.from_callable`.
+
+ If the validation takes some time and needs to happen in a background
+ thread, this can be wrapped in a :class:`.ThreadedValidator`.
+ """
+
+ @abstractmethod
+ def validate(self, document: Document) -> None:
+ """
+ Validate the input.
+ If invalid, this should raise a :class:`.ValidationError`.
+
+ :param document: :class:`~prompt_toolkit.document.Document` instance.
+ """
+ pass
+
+ 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.
+ """
+ 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:
+ """
+ Create a validator from a simple validate callable. E.g.:
+
+ .. code:: python
+
+ def is_valid(text):
+ return text in ['hello', 'world']
+ Validator.from_callable(is_valid, error_message='Invalid input')
+
+ :param validate_func: Callable that takes the input string, and returns
+ `True` if the input is valid input.
+ :param error_message: Message to be displayed if the input is invalid.
+ :param move_cursor_to_end: Move the cursor to the end of the input, if
+ the input is invalid.
+ """
+ return _ValidatorFromCallable(validate_func, error_message, move_cursor_to_end)
+
+
+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:
+ 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 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):
+ """
+ Wrapper that runs input validation in a thread.
+ (Use this to prevent the user interface from becoming unresponsive if the
+ input validation takes too much time.)
+ """
+
+ def __init__(self, validator: Validator) -> None:
+ self.validator = validator
+
+ 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.
+ """
+
+ def run_validation_thread() -> None:
+ return self.validate(document)
+
+ await run_in_executor_with_context(run_validation_thread)
+
+
+class DummyValidator(Validator):
+ """
+ Validator class that accepts any input.
+ """
+
+ def validate(self, document: Document) -> None:
+ pass # Don't raise any exception.
+
+
+class ConditionalValidator(Validator):
+ """
+ Validator that can be switched on/off according to
+ a filter. (This wraps around another validator.)
+ """
+
+ 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):
+ """
+ Validator class that can dynamically returns any Validator.
+
+ :param get_validator: Callable that returns a :class:`.Validator` instance.
+ """
+
+ 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/__init__.py b/src/prompt_toolkit/widgets/__init__.py
new file mode 100644
index 0000000..9d1d4e3
--- /dev/null
+++ b/src/prompt_toolkit/widgets/__init__.py
@@ -0,0 +1,62 @@
+"""
+Collection of reusable components for building full screen applications.
+These are higher level abstractions on top of the `prompt_toolkit.layout`
+module.
+
+Most of these widgets implement the ``__pt_container__`` method, which makes it
+possible to embed these in the layout like any other container.
+"""
+from __future__ import annotations
+
+from .base import (
+ Box,
+ Button,
+ Checkbox,
+ CheckboxList,
+ Frame,
+ HorizontalLine,
+ Label,
+ ProgressBar,
+ RadioList,
+ Shadow,
+ TextArea,
+ VerticalLine,
+)
+from .dialogs import Dialog
+from .menus import MenuContainer, MenuItem
+from .toolbars import (
+ ArgToolbar,
+ CompletionsToolbar,
+ FormattedTextToolbar,
+ SearchToolbar,
+ SystemToolbar,
+ ValidationToolbar,
+)
+
+__all__ = [
+ # Base.
+ "TextArea",
+ "Label",
+ "Button",
+ "Frame",
+ "Shadow",
+ "Box",
+ "VerticalLine",
+ "HorizontalLine",
+ "CheckboxList",
+ "RadioList",
+ "Checkbox",
+ "ProgressBar",
+ # Toolbars.
+ "ArgToolbar",
+ "CompletionsToolbar",
+ "FormattedTextToolbar",
+ "SearchToolbar",
+ "SystemToolbar",
+ "ValidationToolbar",
+ # Dialogs.
+ "Dialog",
+ # Menus.
+ "MenuContainer",
+ "MenuItem",
+]
diff --git a/src/prompt_toolkit/widgets/base.py b/src/prompt_toolkit/widgets/base.py
new file mode 100644
index 0000000..f36a545
--- /dev/null
+++ b/src/prompt_toolkit/widgets/base.py
@@ -0,0 +1,981 @@
+"""
+Collection of reusable components for building full screen applications.
+
+All of these widgets implement the ``__pt_container__`` method, which makes
+them usable in any situation where we are expecting a `prompt_toolkit`
+container object.
+
+.. warning::
+
+ At this point, the API for these widgets is considered unstable, and can
+ potentially change between minor releases (we try not too, but no
+ guarantees are made yet). The public API in
+ `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.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.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.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", # backward compatibility
+ "ProgressBar",
+]
+
+E = KeyPressEvent
+
+
+class Border:
+ "Box drawing characters. (Thin)"
+
+ HORIZONTAL = "\u2500"
+ VERTICAL = "\u2502"
+ TOP_LEFT = "\u250c"
+ TOP_RIGHT = "\u2510"
+ BOTTOM_LEFT = "\u2514"
+ BOTTOM_RIGHT = "\u2518"
+
+
+class TextArea:
+ """
+ A simple input field.
+
+ This is a higher level abstraction on top of several other classes with
+ sane defaults.
+
+ This widget does have the most common options, but it does not intend to
+ cover every single use case. For more configurations options, you can
+ always build a text area manually, using a
+ :class:`~prompt_toolkit.buffer.Buffer`,
+ :class:`~prompt_toolkit.layout.BufferControl` and
+ :class:`~prompt_toolkit.layout.Window`.
+
+ Buffer attributes:
+
+ :param text: The initial text.
+ :param multiline: If True, allow multiline input.
+ :param completer: :class:`~prompt_toolkit.completion.Completer` instance
+ for auto completion.
+ :param complete_while_typing: Boolean.
+ :param accept_handler: Called when `Enter` is pressed (This should be a
+ callable that takes a buffer as input).
+ :param history: :class:`~prompt_toolkit.history.History` instance.
+ :param auto_suggest: :class:`~prompt_toolkit.auto_suggest.AutoSuggest`
+ instance for input suggestions.
+
+ BufferControl attributes:
+
+ :param password: When `True`, display using asterisks.
+ :param focusable: When `True`, allow this widget to receive the focus.
+ :param focus_on_click: When `True`, focus after mouse click.
+ :param input_processors: `None` or a list of
+ :class:`~prompt_toolkit.layout.Processor` objects.
+ :param validator: `None` or a :class:`~prompt_toolkit.validation.Validator`
+ object.
+
+ Window attributes:
+
+ :param lexer: :class:`~prompt_toolkit.lexers.Lexer` instance for syntax
+ highlighting.
+ :param wrap_lines: When `True`, don't scroll horizontally, but wrap lines.
+ :param width: Window width. (:class:`~prompt_toolkit.layout.Dimension` object.)
+ :param height: Window height. (:class:`~prompt_toolkit.layout.Dimension` object.)
+ :param scrollbar: When `True`, display a scroll bar.
+ :param style: A style string.
+ :param dont_extend_width: When `True`, don't take up more width then the
+ preferred width reported by the control.
+ :param dont_extend_height: When `True`, don't take up more width then the
+ preferred height reported by the control.
+ :param get_line_prefix: None or a callable that returns formatted text to
+ be inserted before a line. It takes a line number (int) and a
+ wrap_count and returns formatted text. This can be used for
+ implementation of line continuations, things like Vim "breakindent" and
+ so on.
+
+ Other attributes:
+
+ :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:
+ 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
+ self.auto_suggest = auto_suggest
+ 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,
+ )
+
+ if multiline:
+ if scrollbar:
+ right_margins = [ScrollbarMargin(display_arrows=True)]
+ else:
+ right_margins = []
+ if line_numbers:
+ left_margins = [NumberedMargin()]
+ else:
+ left_margins = []
+ else:
+ height = D.exact(1)
+ left_margins = []
+ right_margins = []
+
+ 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,
+ )
+
+ @property
+ def text(self) -> str:
+ """
+ The `Buffer` text.
+ """
+ return self.buffer.text
+
+ @text.setter
+ def text(self, value: str) -> None:
+ self.document = Document(value, 0)
+
+ @property
+ def document(self) -> Document:
+ """
+ The `Buffer` document (text + cursor position).
+ """
+ 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:
+ """
+ The accept handler. Called when the user accepts the input.
+ """
+ return self.buffer.accept_handler
+
+ @accept_handler.setter
+ def accept_handler(self, value: BufferAcceptHandler) -> None:
+ self.buffer.accept_handler = value
+
+ def __pt_container__(self) -> Container:
+ return self.window
+
+
+class Label:
+ """
+ Widget that displays the given text. It is not editable or focusable.
+
+ :param text: Text to display. Can be multiline. All value types accepted by
+ :class:`prompt_toolkit.layout.FormattedTextControl` are allowed,
+ including a callable.
+ :param style: A style string.
+ :param width: When given, use this width, rather than calculating it from
+ the text size.
+ :param dont_extend_width: When `True`, don't take up more width than
+ preferred, i.e. the length of the longest line of
+ the text, or value of `width` parameter, if
+ given. `True` by default
+ :param dont_extend_height: When `True`, don't take up more width than the
+ preferred height, i.e. the number of lines of
+ 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,
+ # 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:
+ 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())
+ 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:
+ return self.window
+
+
+class Button:
+ """
+ Clickable button.
+
+ :param text: The caption for the button.
+ :param handler: `None` or callable. Called when the button is clicked. No
+ parameters are passed to this callable. Use for instance Python's
+ `functools.partial` to pass parameters to this callable if needed.
+ :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:
+ 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,
+ )
+
+ def get_style() -> str:
+ if get_app().layout.has_focus(self):
+ return "class:button.focused"
+ else:
+ 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()
+
+ return kb
+
+ def __pt_container__(self) -> Container:
+ return self.window
+
+
+class Frame:
+ """
+ Draw a border around any container, optionally with a title text.
+
+ Changing the title and body of the frame is possible at runtime by
+ assigning to the `body` and `title` attributes of this class.
+
+ :param body: Another container object.
+ :param title: Text to be displayed in the top of the frame (can be formatted text).
+ :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:
+ 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="|"),
+ # 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:
+ 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 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
+
+
+class Shadow:
+ """
+ Draw a shadow underneath/behind this container.
+ (This applies `class:shadow` the the cells under the shadow. The Style
+ should define the colors for the 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:
+ return self.container
+
+
+class Box:
+ """
+ Add padding around a container.
+
+ This also makes sure that the parent can provide more space than required by
+ the child. This is very useful when wrapping a small element with a fixed
+ size into a ``VSplit`` or ``HSplit`` object. The ``HSplit`` and ``VSplit``
+ try to make sure to adapt respectively the width and height, possibly
+ shrinking other elements. Wrapping something in a ``Box`` makes it flexible.
+
+ :param body: Another container object.
+ :param padding: The margin to be used around the body. This can be
+ overridden by `padding_left`, padding_right`, `padding_top` and
+ `padding_bottom`.
+ :param style: A style string.
+ :param char: Character to be used for filling the space around the body.
+ (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:
+ if padding is None:
+ padding = D(preferred=0)
+
+ 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:
+ return self.container
+
+
+_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 = ""
+ multiple_selection: bool = False
+ show_scrollbar: bool = True
+
+ 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
+ # 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:
+ 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("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)
+ )
+
+ @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._handle_enter()
+
+ @kb.add(Keys.Any)
+ 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()
+
+ if text.startswith(event.data.lower()):
+ self._selected_index = self.values.index(value)
+ return
+
+ # 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
+
+
+class RadioList(_DialogList[_T]):
+ """
+ List of radio buttons. Only one can be checked at the same time.
+
+ :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"
+ multiple_selection = False
+
+ 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)
+
+
+class CheckboxList(_DialogList[_T]):
+ """
+ List of checkbox buttons. Several can be checked at the same time.
+
+ :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"
+ multiple_selection = True
+
+
+class Checkbox(CheckboxList[str]):
+ """Backward compatibility util: creates a 1-sized CheckboxList
+
+ :param text: the text
+ """
+
+ show_scrollbar = False
+
+ 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 __pt_container__(self) -> Container:
+ return self.window
+
+
+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 __pt_container__(self) -> Container:
+ return self.window
+
+
+class ProgressBar:
+ def __init__(self) -> None:
+ self._percentage = 60
+
+ 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
new file mode 100644
index 0000000..c47c15b
--- /dev/null
+++ b/src/prompt_toolkit/widgets/dialogs.py
@@ -0,0 +1,107 @@
+"""
+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.dimension import AnyDimension
+from prompt_toolkit.layout.dimension import Dimension as D
+
+from .base import Box, Button, Frame, Shadow
+
+__all__ = [
+ "Dialog",
+]
+
+
+class Dialog:
+ """
+ Simple dialog window. This is the base for input dialogs, message dialogs
+ and confirmation dialogs.
+
+ Changing the title and body of the dialog is possible at runtime by
+ assigning to the `body` and `title` attributes of this class.
+
+ :param body: Child container object.
+ :param title: Text to be displayed in the heading of the 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:
+ 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)
+
+ frame_body: AnyContainer
+ if buttons:
+ 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,
+ )
+ )
+
+ self.container: Box | Shadow
+ if with_background:
+ self.container = Box(body=frame, style="class:dialog", width=width)
+ else:
+ self.container = frame
+
+ def __pt_container__(self) -> AnyContainer:
+ return self.container
diff --git a/src/prompt_toolkit/widgets/menus.py b/src/prompt_toolkit/widgets/menus.py
new file mode 100644
index 0000000..c574c06
--- /dev/null
+++ b/src/prompt_toolkit/widgets/menus.py
@@ -0,0 +1,374 @@
+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.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",
+]
+
+E = KeyPressEvent
+
+
+class MenuContainer:
+ """
+ :param floats: List of extra Float objects to display.
+ :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:
+ self.body = body
+ self.menu_items = menu_items
+ self.selected_menu = [0]
+
+ # Key bindings.
+ kb = KeyBindings()
+
+ @Condition
+ def in_main_menu() -> bool:
+ return len(self.selected_menu) == 1
+
+ @Condition
+ def in_sub_menu() -> bool:
+ return len(self.selected_menu) > 1
+
+ # 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("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."
+ event.app.layout.focus_last()
+
+ # 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."
+ if self._get_menu(len(self.selected_menu) - 1).children:
+ self.selected_menu.append(0)
+
+ # 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."
+ # 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
+ ]
+
+ 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."
+ 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
+ ]
+
+ if next_indexes:
+ self.selected_menu[-1] = next_indexes[0]
+
+ @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()
+
+ # 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:
+ return get_app().layout.current_window == self.window
+
+ 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:
+ 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
new file mode 100644
index 0000000..deddf15
--- /dev/null
+++ b/src/prompt_toolkit/widgets/toolbars.py
@@ -0,0 +1,374 @@
+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.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.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",
+]
+
+E = KeyPressEvent
+
+
+class FormattedTextToolbar(Window):
+ 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:
+ """
+ Toolbar for a system prompt.
+
+ :param prompt: Prompt to be displayed to the user.
+ """
+
+ 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 _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"
+
+ 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)
+
+ def __pt_container__(self) -> Container:
+ return self.container
+
+
+class SearchToolbar:
+ """
+ :param vi_mode: Display '/' and '?' instead of I-search.
+ :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:
+ if search_buffer is None:
+ search_buffer = Buffer()
+
+ @Condition
+ def is_searching() -> bool:
+ return self.control in get_app().layout.search_links
+
+ 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
+ else:
+ 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:
+ return self.container
+
+
+class _CompletionsToolbarControl(UIControl):
+ 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!
+
+ # Width of the completions without the left/right arrows in the margins.
+ content_width = width - 6
+
+ # Booleans indicating whether we stripped from the left/right
+ cut_left = False
+ cut_right = False
+
+ # 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
+
+ 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 get_line(i: int) -> StyleAndTextTuples:
+ return all_fragments
+
+ 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
+ )
+
+ if show_position:
+ text = "{} (line={} column={})".format(
+ buff.validation_error.message,
+ row + 1,
+ column + 1,
+ )
+ else:
+ text = buff.validation_error.message
+
+ 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:
+ return self.container
diff --git a/src/prompt_toolkit/win32_types.py b/src/prompt_toolkit/win32_types.py
new file mode 100644
index 0000000..79283b8
--- /dev/null
+++ b/src/prompt_toolkit/win32_types.py
@@ -0,0 +1,229 @@
+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)
+
+
+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), # 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),
+ ]
+
+
+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
+ VirtualKeyCode: int
+ VirtualScanCode: int
+ uChar: UNICODE_OR_ASCII
+ ControlKeyState: int
+
+ _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), # 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)]
+
+
+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)] # 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)] # bool
+
+
+class EVENT_RECORD(Union):
+ if TYPE_CHECKING:
+ 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)] # word # Union.
+
+
+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),
+ ]
+
+
+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,
+ )
+
+
+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 # BOOL comes back as 'int'.
+
+ _fields_ = [
+ ("nLength", DWORD),
+ ("lpSecurityDescriptor", LPVOID),
+ ("bInheritHandle", BOOL),
+ ]