diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 16:35:31 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 16:35:31 +0000 |
commit | 4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1 (patch) | |
tree | e5dee7be2f0d963da4faad6517278d03783e3adc /src | |
parent | Initial commit. (diff) | |
download | prompt-toolkit-4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1.tar.xz prompt-toolkit-4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1.zip |
Adding upstream version 3.0.43.upstream/3.0.43
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src')
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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +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), + ] |