Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for cursor shape configurations. #1558

Merged
merged 2 commits into from
Feb 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/pages/asking_for_input.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------------------

Expand Down
19 changes: 19 additions & 0 deletions examples/prompts/cursor-shapes.py
Original file line number Diff line number Diff line change
@@ -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,
)
4 changes: 4 additions & 0 deletions prompt_toolkit/application/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion prompt_toolkit/contrib/regular_languages/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
102 changes: 102 additions & 0 deletions prompt_toolkit/cursor_shapes.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion prompt_toolkit/eventloop/async_context_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 2 additions & 11 deletions prompt_toolkit/eventloop/inputhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -52,7 +43,7 @@
]

if TYPE_CHECKING:
from _typeshed import FileDescriptor, FileDescriptorLike
from _typeshed import FileDescriptorLike

_EventMask = int

Expand Down
2 changes: 1 addition & 1 deletion prompt_toolkit/filters/base.py
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down
2 changes: 1 addition & 1 deletion prompt_toolkit/key_binding/bindings/vi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions prompt_toolkit/output/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions prompt_toolkit/output/plain_text.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

Expand Down
35 changes: 31 additions & 4 deletions prompt_toolkit/output/vt100.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading