Skip to content

Commit b4d728e

Browse files
Added support for cursor shapes.
1 parent 4a66820 commit b4d728e

File tree

10 files changed

+238
-0
lines changed

10 files changed

+238
-0
lines changed

docs/pages/asking_for_input.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,30 @@ asterisks (``*`` characters).
923923
prompt('Enter password: ', is_password=True)
924924
925925
926+
Cursor shapes
927+
-------------
928+
929+
Many terminals support displaying different types of cursor shapes. The most
930+
common are block, beam or underscore. Either blinking or not. It is possible to
931+
decide which cursor to display while asking for input, or in case of Vi input
932+
mode, have a modal prompt for which its cursor shape changes according to the
933+
input mode.
934+
935+
.. code:: python
936+
937+
from prompt_toolkit import prompt
938+
from prompt_toolkit.cursor_shapes import CursorShape, ModalCursorShapeConfig
939+
940+
# Several possible values for the `cursor_shape_config` parameter:
941+
prompt('>', cursor=CursorShape.BLOCK)
942+
prompt('>', cursor=CursorShape.UNDERLINE)
943+
prompt('>', cursor=CursorShape.BEAM)
944+
prompt('>', cursor=CursorShape.BLINKING_BLOCK)
945+
prompt('>', cursor=CursorShape.BLINKING_UNDERLINE)
946+
prompt('>', cursor=CursorShape.BLINKING_BEAM)
947+
prompt('>', cursor=ModalCursorShapeConfig())
948+
949+
926950
Prompt in an `asyncio` application
927951
----------------------------------
928952

examples/prompts/cursor-shapes.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env python
2+
"""
3+
Example of cursor shape configurations.
4+
"""
5+
from prompt_toolkit import prompt
6+
from prompt_toolkit.cursor_shapes import CursorShape, ModalCursorShapeConfig
7+
8+
# NOTE: We pass `enable_suspend=True`, so that we can easily see what happens
9+
# to the cursor shapes when the application is suspended.
10+
11+
prompt("(block): ", cursor=CursorShape.BLOCK, enable_suspend=True)
12+
prompt("(underline): ", cursor=CursorShape.UNDERLINE, enable_suspend=True)
13+
prompt("(beam): ", cursor=CursorShape.BEAM, enable_suspend=True)
14+
prompt(
15+
"(modal - according to vi input mode): ",
16+
cursor=ModalCursorShapeConfig(),
17+
vi_mode=True,
18+
enable_suspend=True,
19+
)

prompt_toolkit/application/application.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from prompt_toolkit.buffer import Buffer
4242
from prompt_toolkit.cache import SimpleCache
4343
from prompt_toolkit.clipboard import Clipboard, InMemoryClipboard
44+
from prompt_toolkit.cursor_shapes import AnyCursorShapeConfig, to_cursor_shape_config
4445
from prompt_toolkit.data_structures import Size
4546
from prompt_toolkit.enums import EditingMode
4647
from prompt_toolkit.eventloop import (
@@ -216,6 +217,7 @@ def __init__(
216217
max_render_postpone_time: Union[float, int, None] = 0.01,
217218
refresh_interval: Optional[float] = None,
218219
terminal_size_polling_interval: Optional[float] = 0.5,
220+
cursor: AnyCursorShapeConfig = None,
219221
on_reset: Optional["ApplicationEventHandler[_AppResult]"] = None,
220222
on_invalidate: Optional["ApplicationEventHandler[_AppResult]"] = None,
221223
before_render: Optional["ApplicationEventHandler[_AppResult]"] = None,
@@ -266,6 +268,8 @@ def __init__(
266268
self.refresh_interval = refresh_interval
267269
self.terminal_size_polling_interval = terminal_size_polling_interval
268270

271+
self.cursor = to_cursor_shape_config(cursor)
272+
269273
# Events.
270274
self.on_invalidate = Event(self, on_invalidate)
271275
self.on_reset = Event(self, on_reset)

prompt_toolkit/cursor_shapes.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from abc import ABC, abstractmethod
2+
from enum import Enum
3+
from typing import TYPE_CHECKING, Any, Callable, Union
4+
5+
from prompt_toolkit.enums import EditingMode
6+
from prompt_toolkit.key_binding.vi_state import InputMode
7+
8+
if TYPE_CHECKING:
9+
from .application import Application
10+
11+
__all__ = [
12+
"CursorShape",
13+
"CursorShapeConfig",
14+
"SimpleCursorShapeConfig",
15+
"ModalCursorShapeConfig",
16+
]
17+
18+
19+
class CursorShape(Enum):
20+
# Default value that should tell the output implementation to never send
21+
# cursor shape escape sequences. This is the default right now, because
22+
# before this `CursorShape` functionality was introduced into
23+
# prompt_toolkit itself, people had workarounds to send cursor shapes
24+
# escapes into the terminal, by monkey patching some of prompt_toolkit's
25+
# internals. We don't want the default prompt_toolkit implemetation to
26+
# interefere with that. E.g., IPython patches the `ViState.input_mode`
27+
# property. See: https://github.com/ipython/ipython/pull/13501/files
28+
_NEVER_CHANGE = "_NEVER_CHANGE"
29+
30+
BLOCK = "BLOCK"
31+
BEAM = "BEAM"
32+
UNDERLINE = "UNDERLINE"
33+
BLINKING_BLOCK = "BLINKING_BLOCK"
34+
BLINKING_BEAM = "BLINKING_BEAM"
35+
BLINKING_UNDERLINE = "BLINKING_UNDERLINE"
36+
37+
38+
class CursorShapeConfig(ABC):
39+
@abstractmethod
40+
def get_cursor_shape(self, application: "Application[Any]") -> CursorShape:
41+
"""
42+
Return the cursor shape to be used in the current state.
43+
"""
44+
45+
46+
AnyCursorShapeConfig = Union[CursorShape, CursorShapeConfig, None]
47+
48+
49+
class SimpleCursorShapeConfig(CursorShapeConfig):
50+
"""
51+
Always show the given cursor shape.
52+
"""
53+
54+
def __init__(self, cursor_shape: CursorShape = CursorShape._NEVER_CHANGE) -> None:
55+
self.cursor_shape = cursor_shape
56+
57+
def get_cursor_shape(self, application: "Application[Any]") -> CursorShape:
58+
return self.cursor_shape
59+
60+
61+
class ModalCursorShapeConfig(CursorShapeConfig):
62+
"""
63+
Show cursor shape according to the current input mode.
64+
"""
65+
66+
def get_cursor_shape(self, application: "Application[Any]") -> CursorShape:
67+
if application.editing_mode == EditingMode.VI:
68+
if application.vi_state.input_mode == InputMode.INSERT:
69+
return CursorShape.BEAM
70+
if application.vi_state.input_mode == InputMode.REPLACE:
71+
return CursorShape.UNDERLINE
72+
73+
# Default
74+
return CursorShape.BLOCK
75+
76+
77+
class DynamicCursorShapeConfig(CursorShapeConfig):
78+
def __init__(
79+
self, get_cursor_shape_config: Callable[[], AnyCursorShapeConfig]
80+
) -> None:
81+
self.get_cursor_shape_config = get_cursor_shape_config
82+
83+
def get_cursor_shape(self, application: "Application[Any]") -> CursorShape:
84+
return to_cursor_shape_config(self.get_cursor_shape_config()).get_cursor_shape(
85+
application
86+
)
87+
88+
89+
def to_cursor_shape_config(value: AnyCursorShapeConfig) -> CursorShapeConfig:
90+
"""
91+
Take a `CursorShape` instance or `CursorShapeConfig` and turn it into a
92+
`CursorShapeConfig`.
93+
"""
94+
if value is None:
95+
return SimpleCursorShapeConfig()
96+
97+
if isinstance(value, CursorShape):
98+
return SimpleCursorShapeConfig(value)
99+
100+
return value

prompt_toolkit/output/base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from abc import ABCMeta, abstractmethod
55
from typing import Optional, TextIO
66

7+
from prompt_toolkit.cursor_shapes import CursorShape
78
from prompt_toolkit.data_structures import Size
89
from prompt_toolkit.styles import Attrs
910

@@ -140,6 +141,14 @@ def hide_cursor(self) -> None:
140141
def show_cursor(self) -> None:
141142
"Show cursor."
142143

144+
@abstractmethod
145+
def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
146+
"Set cursor shape to block, beam or underline."
147+
148+
@abstractmethod
149+
def reset_cursor_shape(self) -> None:
150+
"Reset cursor shape."
151+
143152
def ask_for_cpr(self) -> None:
144153
"""
145154
Asks for a cursor position report (CPR).
@@ -289,6 +298,12 @@ def hide_cursor(self) -> None:
289298
def show_cursor(self) -> None:
290299
pass
291300

301+
def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
302+
pass
303+
304+
def reset_cursor_shape(self) -> None:
305+
pass
306+
292307
def ask_for_cpr(self) -> None:
293308
pass
294309

prompt_toolkit/output/plain_text.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import List, TextIO
22

3+
from prompt_toolkit.cursor_shapes import CursorShape
34
from prompt_toolkit.data_structures import Size
45
from prompt_toolkit.styles import Attrs
56

@@ -113,6 +114,12 @@ def hide_cursor(self) -> None:
113114
def show_cursor(self) -> None:
114115
pass
115116

117+
def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
118+
pass
119+
120+
def reset_cursor_shape(self) -> None:
121+
pass
122+
116123
def ask_for_cpr(self) -> None:
117124
pass
118125

prompt_toolkit/output/vt100.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
cast,
2727
)
2828

29+
from prompt_toolkit.cursor_shapes import CursorShape
2930
from prompt_toolkit.data_structures import Size
3031
from prompt_toolkit.output import Output
3132
from prompt_toolkit.styles import ANSI_COLOR_NAMES, Attrs
@@ -442,6 +443,11 @@ def __init__(
442443
ColorDepth.DEPTH_24_BIT: _EscapeCodeCache(ColorDepth.DEPTH_24_BIT),
443444
}
444445

446+
# Keep track of whether the cursor shape was ever changed.
447+
# (We don't restore the cursor shape if it was never changed - by
448+
# default, we don't change them.)
449+
self._cursor_shape_changed = False
450+
445451
@classmethod
446452
def from_pty(
447453
cls,
@@ -662,6 +668,31 @@ def hide_cursor(self) -> None:
662668
def show_cursor(self) -> None:
663669
self.write_raw("\x1b[?12l\x1b[?25h") # Stop blinking cursor and show.
664670

671+
def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
672+
if cursor_shape == CursorShape._NEVER_CHANGE:
673+
return
674+
675+
self._cursor_shape_changed = True
676+
self.write_raw(
677+
{
678+
CursorShape.BLOCK: "\x1b[2 q",
679+
CursorShape.BEAM: "\x1b[6 q",
680+
CursorShape.UNDERLINE: "\x1b[4 q",
681+
CursorShape.BLINKING_BLOCK: "\x1b[1 q",
682+
CursorShape.BLINKING_BEAM: "\x1b[5 q",
683+
CursorShape.BLINKING_UNDERLINE: "\x1b[3 q",
684+
}.get(cursor_shape, "")
685+
)
686+
687+
def reset_cursor_shape(self) -> None:
688+
"Reset cursor shape."
689+
# (Only reset cursor shape, if we ever changed it.)
690+
if self._cursor_shape_changed:
691+
self._cursor_shape_changed = False
692+
693+
# Reset cursor shape.
694+
self.write_raw("\x1b[0 q")
695+
665696
def flush(self) -> None:
666697
"""
667698
Write to output stream and flush.

prompt_toolkit/output/win32.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ctypes.wintypes import DWORD, HANDLE
2121
from typing import Callable, Dict, List, Optional, TextIO, Tuple, Type, TypeVar, Union
2222

23+
from prompt_toolkit.cursor_shapes import CursorShape
2324
from prompt_toolkit.data_structures import Size
2425
from prompt_toolkit.styles import ANSI_COLOR_NAMES, Attrs
2526
from prompt_toolkit.utils import get_cwidth
@@ -498,6 +499,12 @@ def hide_cursor(self) -> None:
498499
def show_cursor(self) -> None:
499500
pass
500501

502+
def set_cursor_shape(self, cursor_shape: CursorShape) -> None:
503+
pass
504+
505+
def reset_cursor_shape(self) -> None:
506+
pass
507+
501508
@classmethod
502509
def win32_refresh_window(cls) -> None:
503510
"""

prompt_toolkit/renderer.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Hashable, Optional, Tuple
99

1010
from prompt_toolkit.application.current import get_app
11+
from prompt_toolkit.cursor_shapes import CursorShape
1112
from prompt_toolkit.data_structures import Point, Size
1213
from prompt_toolkit.filters import FilterOrBool, to_filter
1314
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
385386
self._last_screen: Optional[Screen] = None
386387
self._last_size: Optional[Size] = None
387388
self._last_style: Optional[str] = None
389+
self._last_cursor_shape: Optional[CursorShape] = None
388390

389391
# Default MouseHandlers. (Just empty.)
390392
self.mouse_handlers = MouseHandlers()
@@ -704,6 +706,16 @@ def render(
704706
self._last_size = size
705707
self.mouse_handlers = mouse_handlers
706708

709+
# Handle cursor shapes.
710+
new_cursor_shape = app.cursor.get_cursor_shape(app)
711+
if (
712+
self._last_cursor_shape is None
713+
or self._last_cursor_shape != new_cursor_shape
714+
):
715+
output.set_cursor_shape(new_cursor_shape)
716+
self._last_cursor_shape = new_cursor_shape
717+
718+
# Flush buffered output.
707719
output.flush()
708720

709721
# Set visible windows in layout.
@@ -728,6 +740,8 @@ def erase(self, leave_alternate_screen: bool = True) -> None:
728740
output.erase_down()
729741
output.reset_attributes()
730742
output.enable_autowrap()
743+
output.reset_cursor_shape()
744+
731745
output.flush()
732746

733747
self.reset(leave_alternate_screen=leave_alternate_screen)

0 commit comments

Comments
 (0)