diff --git a/docs/pages/asking_for_input.rst b/docs/pages/asking_for_input.rst index a2af34d4d..e53fc7f2f 100644 --- a/docs/pages/asking_for_input.rst +++ b/docs/pages/asking_for_input.rst @@ -923,6 +923,30 @@ asterisks (``*`` characters). prompt('Enter password: ', is_password=True) +Cursor shapes +------------- + +Many terminals support displaying different types of cursor shapes. The most +common are block, beam or underscore. Either blinking or not. It is possible to +decide which cursor to display while asking for input, or in case of Vi input +mode, have a modal prompt for which its cursor shape changes according to the +input mode. + +.. code:: python + + from prompt_toolkit import prompt + from prompt_toolkit.cursor_shapes import CursorShape, ModalCursorShapeConfig + + # Several possible values for the `cursor_shape_config` parameter: + prompt('>', cursor=CursorShape.BLOCK) + prompt('>', cursor=CursorShape.UNDERLINE) + prompt('>', cursor=CursorShape.BEAM) + prompt('>', cursor=CursorShape.BLINKING_BLOCK) + prompt('>', cursor=CursorShape.BLINKING_UNDERLINE) + prompt('>', cursor=CursorShape.BLINKING_BEAM) + prompt('>', cursor=ModalCursorShapeConfig()) + + Prompt in an `asyncio` application ---------------------------------- diff --git a/examples/prompts/cursor-shapes.py b/examples/prompts/cursor-shapes.py new file mode 100755 index 000000000..e668243a4 --- /dev/null +++ b/examples/prompts/cursor-shapes.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +""" +Example of cursor shape configurations. +""" +from prompt_toolkit import prompt +from prompt_toolkit.cursor_shapes import CursorShape, ModalCursorShapeConfig + +# NOTE: We pass `enable_suspend=True`, so that we can easily see what happens +# to the cursor shapes when the application is suspended. + +prompt("(block): ", cursor=CursorShape.BLOCK, enable_suspend=True) +prompt("(underline): ", cursor=CursorShape.UNDERLINE, enable_suspend=True) +prompt("(beam): ", cursor=CursorShape.BEAM, enable_suspend=True) +prompt( + "(modal - according to vi input mode): ", + cursor=ModalCursorShapeConfig(), + vi_mode=True, + enable_suspend=True, +) diff --git a/prompt_toolkit/application/application.py b/prompt_toolkit/application/application.py index aec0accae..5426ebfdf 100644 --- a/prompt_toolkit/application/application.py +++ b/prompt_toolkit/application/application.py @@ -41,6 +41,7 @@ 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 ( @@ -216,6 +217,7 @@ def __init__( max_render_postpone_time: Union[float, int, None] = 0.01, refresh_interval: Optional[float] = None, terminal_size_polling_interval: Optional[float] = 0.5, + cursor: AnyCursorShapeConfig = None, on_reset: Optional["ApplicationEventHandler[_AppResult]"] = None, on_invalidate: Optional["ApplicationEventHandler[_AppResult]"] = None, before_render: Optional["ApplicationEventHandler[_AppResult]"] = None, @@ -266,6 +268,8 @@ def __init__( 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) diff --git a/prompt_toolkit/contrib/regular_languages/compiler.py b/prompt_toolkit/contrib/regular_languages/compiler.py index 9d23490f1..a6eb77127 100644 --- a/prompt_toolkit/contrib/regular_languages/compiler.py +++ b/prompt_toolkit/contrib/regular_languages/compiler.py @@ -41,7 +41,7 @@ import re from typing import Callable, Dict, Iterable, Iterator, List from typing import Match as RegexMatch -from typing import Optional, Pattern, Tuple, cast +from typing import Optional, Pattern, Tuple from .regex_parser import ( AnyNode, diff --git a/prompt_toolkit/cursor_shapes.py b/prompt_toolkit/cursor_shapes.py new file mode 100644 index 000000000..d38b3505b --- /dev/null +++ b/prompt_toolkit/cursor_shapes.py @@ -0,0 +1,102 @@ +from abc import ABC, abstractmethod +from enum import Enum +from typing import TYPE_CHECKING, Any, Callable, Union + +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.key_binding.vi_state import InputMode + +if TYPE_CHECKING: + from .application import Application + +__all__ = [ + "CursorShape", + "CursorShapeConfig", + "SimpleCursorShapeConfig", + "ModalCursorShapeConfig", + "DynamicCursorShapeConfig", + "to_cursor_shape_config", +] + + +class CursorShape(Enum): + # Default value that should tell the output implementation to never send + # cursor shape escape sequences. This is the default right now, because + # before this `CursorShape` functionality was introduced into + # prompt_toolkit itself, people had workarounds to send cursor shapes + # escapes into the terminal, by monkey patching some of prompt_toolkit's + # internals. We don't want the default prompt_toolkit implemetation to + # interefere with that. E.g., IPython patches the `ViState.input_mode` + # property. See: https://github.com/ipython/ipython/pull/13501/files + _NEVER_CHANGE = "_NEVER_CHANGE" + + BLOCK = "BLOCK" + BEAM = "BEAM" + UNDERLINE = "UNDERLINE" + BLINKING_BLOCK = "BLINKING_BLOCK" + BLINKING_BEAM = "BLINKING_BEAM" + BLINKING_UNDERLINE = "BLINKING_UNDERLINE" + + +class CursorShapeConfig(ABC): + @abstractmethod + def get_cursor_shape(self, application: "Application[Any]") -> CursorShape: + """ + Return the cursor shape to be used in the current state. + """ + + +AnyCursorShapeConfig = Union[CursorShape, CursorShapeConfig, None] + + +class SimpleCursorShapeConfig(CursorShapeConfig): + """ + Always show the given cursor shape. + """ + + def __init__(self, cursor_shape: CursorShape = CursorShape._NEVER_CHANGE) -> None: + self.cursor_shape = cursor_shape + + def get_cursor_shape(self, application: "Application[Any]") -> CursorShape: + return self.cursor_shape + + +class ModalCursorShapeConfig(CursorShapeConfig): + """ + Show cursor shape according to the current input mode. + """ + + def get_cursor_shape(self, application: "Application[Any]") -> CursorShape: + if application.editing_mode == EditingMode.VI: + if application.vi_state.input_mode == InputMode.INSERT: + return CursorShape.BEAM + if application.vi_state.input_mode == InputMode.REPLACE: + return CursorShape.UNDERLINE + + # Default + return CursorShape.BLOCK + + +class DynamicCursorShapeConfig(CursorShapeConfig): + def __init__( + self, get_cursor_shape_config: Callable[[], AnyCursorShapeConfig] + ) -> None: + self.get_cursor_shape_config = get_cursor_shape_config + + def get_cursor_shape(self, application: "Application[Any]") -> CursorShape: + return to_cursor_shape_config(self.get_cursor_shape_config()).get_cursor_shape( + application + ) + + +def to_cursor_shape_config(value: AnyCursorShapeConfig) -> CursorShapeConfig: + """ + Take a `CursorShape` instance or `CursorShapeConfig` and turn it into a + `CursorShapeConfig`. + """ + if value is None: + return SimpleCursorShapeConfig() + + if isinstance(value, CursorShape): + return SimpleCursorShapeConfig(value) + + return value diff --git a/prompt_toolkit/eventloop/async_context_manager.py b/prompt_toolkit/eventloop/async_context_manager.py index 173751ab0..39146165a 100644 --- a/prompt_toolkit/eventloop/async_context_manager.py +++ b/prompt_toolkit/eventloop/async_context_manager.py @@ -6,7 +6,7 @@ # mypy: allow-untyped-defs import abc from functools import wraps -from typing import TYPE_CHECKING, AsyncContextManager, AsyncIterator, Callable, TypeVar +from typing import AsyncContextManager, AsyncIterator, Callable, TypeVar import _collections_abc diff --git a/prompt_toolkit/eventloop/inputhook.py b/prompt_toolkit/eventloop/inputhook.py index 7490d5b25..26228a2af 100644 --- a/prompt_toolkit/eventloop/inputhook.py +++ b/prompt_toolkit/eventloop/inputhook.py @@ -29,16 +29,7 @@ import threading from asyncio import AbstractEventLoop from selectors import BaseSelector, SelectorKey -from typing import ( - TYPE_CHECKING, - Any, - Callable, - List, - Mapping, - NamedTuple, - Optional, - Tuple, -) +from typing import TYPE_CHECKING, Any, Callable, List, Mapping, Optional, Tuple from prompt_toolkit.utils import is_windows @@ -52,7 +43,7 @@ ] if TYPE_CHECKING: - from _typeshed import FileDescriptor, FileDescriptorLike + from _typeshed import FileDescriptorLike _EventMask = int diff --git a/prompt_toolkit/filters/base.py b/prompt_toolkit/filters/base.py index a268a82b1..fd57cca6e 100644 --- a/prompt_toolkit/filters/base.py +++ b/prompt_toolkit/filters/base.py @@ -1,5 +1,5 @@ from abc import ABCMeta, abstractmethod -from typing import Callable, Dict, Iterable, List, Tuple, Union, cast +from typing import Callable, Dict, Iterable, List, Tuple, Union __all__ = ["Filter", "Never", "Always", "Condition", "FilterOrBool"] diff --git a/prompt_toolkit/key_binding/bindings/vi.py b/prompt_toolkit/key_binding/bindings/vi.py index 89870ee53..efbb107de 100644 --- a/prompt_toolkit/key_binding/bindings/vi.py +++ b/prompt_toolkit/key_binding/bindings/vi.py @@ -3,7 +3,7 @@ import string from enum import Enum from itertools import accumulate -from typing import Callable, Iterable, List, Optional, Tuple, TypeVar, Union, cast +from typing import Callable, Iterable, List, Optional, Tuple, TypeVar, Union from prompt_toolkit.application.current import get_app from prompt_toolkit.buffer import Buffer, indent, reshape_text, unindent diff --git a/prompt_toolkit/output/base.py b/prompt_toolkit/output/base.py index 0d6be3484..c78677bc8 100644 --- a/prompt_toolkit/output/base.py +++ b/prompt_toolkit/output/base.py @@ -4,6 +4,7 @@ from abc import ABCMeta, abstractmethod from typing import Optional, TextIO +from prompt_toolkit.cursor_shapes import CursorShape from prompt_toolkit.data_structures import Size from prompt_toolkit.styles import Attrs @@ -140,6 +141,14 @@ def hide_cursor(self) -> None: 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). @@ -289,6 +298,12 @@ def hide_cursor(self) -> None: 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 diff --git a/prompt_toolkit/output/plain_text.py b/prompt_toolkit/output/plain_text.py index c37b0cb4b..23c1e9453 100644 --- a/prompt_toolkit/output/plain_text.py +++ b/prompt_toolkit/output/plain_text.py @@ -1,5 +1,6 @@ from typing import List, TextIO +from prompt_toolkit.cursor_shapes import CursorShape from prompt_toolkit.data_structures import Size from prompt_toolkit.styles import Attrs @@ -113,6 +114,12 @@ def hide_cursor(self) -> None: 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 diff --git a/prompt_toolkit/output/vt100.py b/prompt_toolkit/output/vt100.py index 8cf2720df..058626728 100644 --- a/prompt_toolkit/output/vt100.py +++ b/prompt_toolkit/output/vt100.py @@ -6,26 +6,23 @@ everything has been highly optimized.) http://pygments.org/ """ -import array import io import os import sys from typing import ( - IO, Callable, Dict, Hashable, Iterable, - Iterator, List, Optional, Sequence, Set, TextIO, Tuple, - cast, ) +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 @@ -442,6 +439,11 @@ def __init__( 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, @@ -662,6 +664,31 @@ def hide_cursor(self) -> None: 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. diff --git a/prompt_toolkit/output/win32.py b/prompt_toolkit/output/win32.py index f26066cb9..abfd61774 100644 --- a/prompt_toolkit/output/win32.py +++ b/prompt_toolkit/output/win32.py @@ -1,14 +1,5 @@ import os -from ctypes import ( - ArgumentError, - byref, - c_char, - c_long, - c_short, - c_uint, - c_ulong, - pointer, -) +from ctypes import ArgumentError, byref, c_char, c_long, c_uint, c_ulong, pointer from ..utils import SPHINX_AUTODOC_RUNNING @@ -20,6 +11,7 @@ from ctypes.wintypes import DWORD, HANDLE from typing import Callable, Dict, List, Optional, TextIO, Tuple, Type, TypeVar, Union +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 @@ -498,6 +490,12 @@ def hide_cursor(self) -> None: 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: """ diff --git a/prompt_toolkit/renderer.py b/prompt_toolkit/renderer.py index 9d8255d71..d670c3c57 100644 --- a/prompt_toolkit/renderer.py +++ b/prompt_toolkit/renderer.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Hashable, Optional, Tuple 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 @@ -385,6 +386,7 @@ def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> N self._last_screen: Optional[Screen] = None self._last_size: Optional[Size] = None self._last_style: Optional[str] = None + self._last_cursor_shape: Optional[CursorShape] = None # Default MouseHandlers. (Just empty.) self.mouse_handlers = MouseHandlers() @@ -704,6 +706,16 @@ def render( 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. @@ -728,6 +740,8 @@ def erase(self, leave_alternate_screen: bool = True) -> None: output.erase_down() output.reset_attributes() output.enable_autowrap() + output.reset_cursor_shape() + output.flush() self.reset(leave_alternate_screen=leave_alternate_screen) diff --git a/prompt_toolkit/shortcuts/progress_bar/base.py b/prompt_toolkit/shortcuts/progress_bar/base.py index 0f08dbfd0..c22507e25 100644 --- a/prompt_toolkit/shortcuts/progress_bar/base.py +++ b/prompt_toolkit/shortcuts/progress_bar/base.py @@ -51,7 +51,6 @@ 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 diff --git a/prompt_toolkit/shortcuts/prompt.py b/prompt_toolkit/shortcuts/prompt.py index 9fff41142..a8d8a5855 100644 --- a/prompt_toolkit/shortcuts/prompt.py +++ b/prompt_toolkit/shortcuts/prompt.py @@ -46,6 +46,11 @@ 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 get_event_loop @@ -342,6 +347,7 @@ class PromptSession(Generic[_T]): "style_transformation", "swap_light_and_dark_colors", "color_depth", + "cursor", "include_default_pygments_style", "rprompt", "multiline", @@ -394,6 +400,7 @@ def __init__( style_transformation: Optional[StyleTransformation] = None, swap_light_and_dark_colors: FilterOrBool = False, color_depth: Optional[ColorDepth] = None, + cursor: AnyCursorShapeConfig = None, include_default_pygments_style: FilterOrBool = True, history: Optional[History] = None, clipboard: Optional[Clipboard] = None, @@ -436,6 +443,7 @@ def __init__( 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 @@ -751,6 +759,7 @@ def _create_application( 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, @@ -861,6 +870,7 @@ def prompt( bottom_toolbar: Optional[AnyFormattedText] = None, style: Optional[BaseStyle] = None, color_depth: Optional[ColorDepth] = None, + cursor: Optional[AnyCursorShapeConfig] = None, include_default_pygments_style: Optional[FilterOrBool] = None, style_transformation: Optional[StyleTransformation] = None, swap_light_and_dark_colors: Optional[FilterOrBool] = None, @@ -957,6 +967,8 @@ class itself. For these, passing in ``None`` will keep the current 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: @@ -1090,6 +1102,7 @@ async def prompt_async( bottom_toolbar: Optional[AnyFormattedText] = None, style: Optional[BaseStyle] = None, color_depth: Optional[ColorDepth] = None, + cursor: Optional[CursorShapeConfig] = None, include_default_pygments_style: Optional[FilterOrBool] = None, style_transformation: Optional[StyleTransformation] = None, swap_light_and_dark_colors: Optional[FilterOrBool] = None, @@ -1145,6 +1158,8 @@ async def prompt_async( 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: @@ -1358,6 +1373,7 @@ def prompt( bottom_toolbar: Optional[AnyFormattedText] = None, style: Optional[BaseStyle] = None, color_depth: Optional[ColorDepth] = None, + cursor: AnyCursorShapeConfig = None, include_default_pygments_style: Optional[FilterOrBool] = None, style_transformation: Optional[StyleTransformation] = None, swap_light_and_dark_colors: Optional[FilterOrBool] = None, @@ -1408,6 +1424,7 @@ def prompt( 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,