zettcode-tui 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/tui/__init__.py +422 -0
- zettcode/tui/_compat.py +32 -0
- zettcode/tui/capabilities.py +107 -0
- zettcode/tui/clipboard.py +91 -0
- zettcode/tui/console.py +229 -0
- zettcode/tui/core/__init__.py +63 -0
- zettcode/tui/core/app.py +658 -0
- zettcode/tui/core/events.py +134 -0
- zettcode/tui/core/focus.py +85 -0
- zettcode/tui/core/geometry.py +212 -0
- zettcode/tui/core/host.py +66 -0
- zettcode/tui/core/keymap.py +218 -0
- zettcode/tui/core/scheduler.py +105 -0
- zettcode/tui/core/screen.py +80 -0
- zettcode/tui/core/theme.py +248 -0
- zettcode/tui/core/widget.py +204 -0
- zettcode/tui/gallery.py +588 -0
- zettcode/tui/glyphs.py +73 -0
- zettcode/tui/input/__init__.py +18 -0
- zettcode/tui/input/bridge.py +46 -0
- zettcode/tui/input/decoder.py +234 -0
- zettcode/tui/input/events.py +64 -0
- zettcode/tui/input/keys.py +289 -0
- zettcode/tui/input/reader.py +147 -0
- zettcode/tui/layout/__init__.py +24 -0
- zettcode/tui/layout/box.py +153 -0
- zettcode/tui/layout/overlay.py +164 -0
- zettcode/tui/layout/scroll.py +477 -0
- zettcode/tui/layout/solver.py +95 -0
- zettcode/tui/layout/spacing.py +137 -0
- zettcode/tui/py.typed +0 -0
- zettcode/tui/render/__init__.py +71 -0
- zettcode/tui/render/ansi.py +371 -0
- zettcode/tui/render/canvas.py +153 -0
- zettcode/tui/render/code.py +456 -0
- zettcode/tui/render/color.py +177 -0
- zettcode/tui/render/renderer.py +151 -0
- zettcode/tui/render/rich_text.py +80 -0
- zettcode/tui/render/style.py +121 -0
- zettcode/tui/render/sweep.py +87 -0
- zettcode/tui/render/text.py +319 -0
- zettcode/tui/runner.py +316 -0
- zettcode/tui/terminal.py +222 -0
- zettcode/tui/testing/__init__.py +6 -0
- zettcode/tui/testing/harness.py +220 -0
- zettcode/tui/testing/snapshot.py +64 -0
- zettcode/tui/theme_file.py +300 -0
- zettcode/tui/themes/ayu-mirage.toml +44 -0
- zettcode/tui/themes/catppuccin-latte.toml +44 -0
- zettcode/tui/themes/catppuccin-mocha.toml +44 -0
- zettcode/tui/themes/dracula.toml +44 -0
- zettcode/tui/themes/github-dimmed.toml +44 -0
- zettcode/tui/themes/github-light.toml +44 -0
- zettcode/tui/themes/gruvbox-dark.toml +44 -0
- zettcode/tui/themes/gruvbox-light.toml +44 -0
- zettcode/tui/themes/kanagawa.toml +44 -0
- zettcode/tui/themes/nord.toml +44 -0
- zettcode/tui/themes/one-dark.toml +44 -0
- zettcode/tui/themes/rose-pine-dawn.toml +44 -0
- zettcode/tui/themes/rose-pine.toml +44 -0
- zettcode/tui/themes/solarized-dark.toml +44 -0
- zettcode/tui/themes/solarized-light.toml +44 -0
- zettcode/tui/themes/tokyo-night.toml +44 -0
- zettcode/tui/widgets/__init__.py +121 -0
- zettcode/tui/widgets/collapsible.py +116 -0
- zettcode/tui/widgets/completion.py +211 -0
- zettcode/tui/widgets/console.py +442 -0
- zettcode/tui/widgets/dialog.py +165 -0
- zettcode/tui/widgets/diff.py +236 -0
- zettcode/tui/widgets/list.py +293 -0
- zettcode/tui/widgets/list_page.py +121 -0
- zettcode/tui/widgets/markdown.py +657 -0
- zettcode/tui/widgets/progress.py +166 -0
- zettcode/tui/widgets/rich_text.py +84 -0
- zettcode/tui/widgets/status.py +106 -0
- zettcode/tui/widgets/table.py +101 -0
- zettcode/tui/widgets/tasks.py +130 -0
- zettcode/tui/widgets/text.py +111 -0
- zettcode/tui/widgets/textarea.py +671 -0
- zettcode/tui/widgets/toast.py +137 -0
- zettcode_tui-0.4.0.dist-info/METADATA +20 -0
- zettcode_tui-0.4.0.dist-info/RECORD +83 -0
- zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Translate decoded terminal input into the widget-facing event model.
|
|
2
|
+
|
|
3
|
+
The legacy ``InputEvent`` structure predates the widget contract. This bridge
|
|
4
|
+
is the only place that knows both shapes, so removing it later is a local
|
|
5
|
+
change rather than a hunt through every widget.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..core.events import (
|
|
11
|
+
AnyEvent,
|
|
12
|
+
FocusEvent,
|
|
13
|
+
KeyEvent,
|
|
14
|
+
MouseEvent,
|
|
15
|
+
PasteEvent,
|
|
16
|
+
ResizeEvent,
|
|
17
|
+
TextEvent,
|
|
18
|
+
)
|
|
19
|
+
from .events import EventType, InputEvent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def translate(event: InputEvent) -> AnyEvent | None:
|
|
23
|
+
"""Return the widget-facing event, or None for internal signals."""
|
|
24
|
+
match event.type:
|
|
25
|
+
case EventType.KEY:
|
|
26
|
+
return KeyEvent(key=event.key, shift=event.shift, alt=event.alt, control=event.control)
|
|
27
|
+
case EventType.TEXT:
|
|
28
|
+
return TextEvent(text=event.text, alt=event.alt, control=event.control)
|
|
29
|
+
case EventType.PASTE:
|
|
30
|
+
return PasteEvent(text=event.text)
|
|
31
|
+
case EventType.MOUSE if event.action is not None:
|
|
32
|
+
return MouseEvent(
|
|
33
|
+
x=event.x,
|
|
34
|
+
y=event.y,
|
|
35
|
+
button=event.button,
|
|
36
|
+
action=event.action,
|
|
37
|
+
shift=event.shift,
|
|
38
|
+
alt=event.alt,
|
|
39
|
+
control=event.control,
|
|
40
|
+
)
|
|
41
|
+
case EventType.RESIZE:
|
|
42
|
+
return ResizeEvent()
|
|
43
|
+
case EventType.FOCUS:
|
|
44
|
+
return FocusEvent(focused=event.focused)
|
|
45
|
+
case _:
|
|
46
|
+
return None
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Incremental terminal byte-stream decoder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ..core.events import MouseAction
|
|
8
|
+
from .events import EventType, InputEvent
|
|
9
|
+
from .keys import CONTROL_KEYS, KEY_SEQUENCES, csi_event, meta_csi, split_csi
|
|
10
|
+
|
|
11
|
+
# SGR mouse report: CSI < Cb ; Cx ; Cy M (press or motion) or m (release).
|
|
12
|
+
# Coordinates arrive 1-based, so the decoder subtracts one from each.
|
|
13
|
+
_MOUSE = re.compile(rb"^\x1b\[<(\d+);(\d+);(\d+)([Mm])")
|
|
14
|
+
|
|
15
|
+
#: An operating-system command, which is how a terminal answers a query.
|
|
16
|
+
_OSC = b"\x1b]"
|
|
17
|
+
|
|
18
|
+
#: Focus reporting, asked for with DECSET 1004: ``CSI I`` means the window gained focus, ``CSI O`` that it lost it.
|
|
19
|
+
_FOCUS_IN = b"\x1b[I"
|
|
20
|
+
_FOCUS_OUT = b"\x1b[O"
|
|
21
|
+
|
|
22
|
+
#: Control characters a paste may keep: tab indents code and newline separates
|
|
23
|
+
#: it. Everything else — the carriage return a clipboard adds per line, a stray
|
|
24
|
+
#: escape, a bell — would be drawn as a broken line or walked over silently.
|
|
25
|
+
_PASTE_KEEP = {"\t", "\n"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _paste_text(payload: bytes) -> str:
|
|
29
|
+
"""Decode one bracketed-paste payload into text an editor can hold.
|
|
30
|
+
|
|
31
|
+
Clipboard line endings are normalised to ``\\n`` and control characters
|
|
32
|
+
other than tab and newline are dropped, so pasted code cannot depend on the
|
|
33
|
+
terminal it came from.
|
|
34
|
+
"""
|
|
35
|
+
text = payload.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "\n")
|
|
36
|
+
return "".join(character for character in text if character in _PASTE_KEEP or character.isprintable())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InputDecoder:
|
|
40
|
+
"""Decode fragmented UTF-8, keyboard, paste, and SGR mouse input."""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
"""Start with an empty buffer."""
|
|
44
|
+
self.buffer = bytearray()
|
|
45
|
+
|
|
46
|
+
def feed(self, data: bytes) -> list[InputEvent]:
|
|
47
|
+
"""Decode everything buffered, holding back a sequence that is still incomplete.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
data: One raw read from the terminal; it may split a key sequence or
|
|
51
|
+
a UTF-8 character anywhere, so the leftover stays in ``buffer``
|
|
52
|
+
until the next call or ``flush_escape``.
|
|
53
|
+
"""
|
|
54
|
+
self.buffer.extend(data)
|
|
55
|
+
events: list[InputEvent] = []
|
|
56
|
+
while self.buffer:
|
|
57
|
+
event, consumed, incomplete = self._next()
|
|
58
|
+
if incomplete:
|
|
59
|
+
break
|
|
60
|
+
if consumed == 0:
|
|
61
|
+
consumed = 1
|
|
62
|
+
del self.buffer[:consumed]
|
|
63
|
+
if event is not None:
|
|
64
|
+
events.append(event)
|
|
65
|
+
return events
|
|
66
|
+
|
|
67
|
+
def flush_escape(self) -> InputEvent | None:
|
|
68
|
+
"""Resolve a pending escape once the terminal's sequence timeout lapsed.
|
|
69
|
+
|
|
70
|
+
One event at a time: a buffer holding several escapes gives one now, and the caller
|
|
71
|
+
asks again until nothing is left.
|
|
72
|
+
"""
|
|
73
|
+
if not self.buffer:
|
|
74
|
+
return None
|
|
75
|
+
data = bytes(self.buffer)
|
|
76
|
+
if data.startswith(b"\x1b\x1b"):
|
|
77
|
+
# Escape pressed more than once: the first is reported and the rest stays, so
|
|
78
|
+
# each press is its own event. The caller asks again while anything is left.
|
|
79
|
+
del self.buffer[:1]
|
|
80
|
+
return InputEvent(EventType.KEY, key="escape")
|
|
81
|
+
self.buffer.clear()
|
|
82
|
+
# A reply cut off mid-sequence is a terminal that stopped talking, not
|
|
83
|
+
# the Escape key followed by Alt-]: dropping it is the only reading that
|
|
84
|
+
# cannot type the answer into the reader's draft.
|
|
85
|
+
if data.startswith(_OSC):
|
|
86
|
+
return None
|
|
87
|
+
# "ESC <printable>" is how a terminal spells Alt-<key>; anything else
|
|
88
|
+
# left over is the Escape key on its own.
|
|
89
|
+
if len(data) >= 2 and 0x20 <= data[1] < 0x7F:
|
|
90
|
+
return InputEvent(EventType.KEY, key=chr(data[1]), alt=True)
|
|
91
|
+
return InputEvent(EventType.KEY, key="escape")
|
|
92
|
+
|
|
93
|
+
def _next(self) -> tuple[InputEvent | None, int, bool]:
|
|
94
|
+
"""Decode one event, reporting bytes consumed and whether more are needed."""
|
|
95
|
+
data = bytes(self.buffer)
|
|
96
|
+
# Bracketed paste: 200~ opens the payload, 201~ closes it. Until the
|
|
97
|
+
# closing guard arrives the payload is held back, so a paste containing
|
|
98
|
+
# newlines or escape bytes is delivered whole rather than replayed.
|
|
99
|
+
if data.startswith(b"\x1b[200~"):
|
|
100
|
+
end = data.find(b"\x1b[201~", 6)
|
|
101
|
+
if end < 0:
|
|
102
|
+
return None, 0, True
|
|
103
|
+
return (
|
|
104
|
+
InputEvent(EventType.PASTE, text=_paste_text(data[6:end])),
|
|
105
|
+
end + 6,
|
|
106
|
+
False,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Operating-system reply: ``ESC ]`` body, ended by BEL or by ST (ESC \).
|
|
110
|
+
# The body belongs to whoever asked the question — it is stripped of its
|
|
111
|
+
# framing and delivered as a REPLY, never decoded as keystrokes.
|
|
112
|
+
if data.startswith(_OSC):
|
|
113
|
+
bell = data.find(b"\x07", 2)
|
|
114
|
+
terminator = data.find(b"\x1b\\", 2)
|
|
115
|
+
if bell < 0 and terminator < 0:
|
|
116
|
+
return None, 0, True
|
|
117
|
+
if 0 <= bell and (terminator < 0 or bell < terminator):
|
|
118
|
+
end, length = bell, 1
|
|
119
|
+
else:
|
|
120
|
+
end, length = terminator, 2
|
|
121
|
+
return InputEvent(EventType.REPLY, text=data[2:end].decode("utf-8", "replace")), end + length, False
|
|
122
|
+
|
|
123
|
+
# Focus reporting arrives as bare ``CSI I`` / ``CSI O``, which the general control-sequence table has no name
|
|
124
|
+
# for; read before it, so the window losing focus is an event and not silence.
|
|
125
|
+
if data.startswith(_FOCUS_IN):
|
|
126
|
+
return InputEvent(EventType.FOCUS, focused=True), len(_FOCUS_IN), False
|
|
127
|
+
if data.startswith(_FOCUS_OUT):
|
|
128
|
+
return InputEvent(EventType.FOCUS, focused=False), len(_FOCUS_OUT), False
|
|
129
|
+
|
|
130
|
+
mouse = _MOUSE.match(data)
|
|
131
|
+
if mouse:
|
|
132
|
+
code, x, y = (int(mouse.group(index)) for index in (1, 2, 3))
|
|
133
|
+
suffix = mouse.group(4)
|
|
134
|
+
action = _mouse_action(code, suffix)
|
|
135
|
+
return (
|
|
136
|
+
InputEvent(
|
|
137
|
+
EventType.MOUSE,
|
|
138
|
+
x=max(0, x - 1),
|
|
139
|
+
y=max(0, y - 1),
|
|
140
|
+
button=code & 3, # low two bits: which button
|
|
141
|
+
action=action,
|
|
142
|
+
shift=bool(code & 4), # bit 2
|
|
143
|
+
alt=bool(code & 8), # bit 3, reported as "meta" by some terminals
|
|
144
|
+
control=bool(code & 16), # bit 4
|
|
145
|
+
),
|
|
146
|
+
mouse.end(),
|
|
147
|
+
False,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
for sequence, key in sorted(KEY_SEQUENCES.items(), key=lambda item: len(item[0]), reverse=True):
|
|
151
|
+
if data.startswith(sequence):
|
|
152
|
+
return InputEvent(EventType.KEY, key=key), len(sequence), False
|
|
153
|
+
if sequence.startswith(data):
|
|
154
|
+
return None, 0, True
|
|
155
|
+
|
|
156
|
+
# Two escapes may be Alt on a cursor key, which is spelled with a second ESC in front,
|
|
157
|
+
# so wait for the byte that says. Reading the first as Escape straight away would make
|
|
158
|
+
# the result depend on where the stream happened to be cut — and Escape stops a request.
|
|
159
|
+
if data == b"\x1b\x1b":
|
|
160
|
+
return None, 0, True
|
|
161
|
+
|
|
162
|
+
# Alt on a cursor key, before the general case below, which would read it as Escape
|
|
163
|
+
# followed by the bare key.
|
|
164
|
+
meta = meta_csi(data)
|
|
165
|
+
if meta is not None:
|
|
166
|
+
named, length = meta
|
|
167
|
+
if named is None:
|
|
168
|
+
return None, 0, True
|
|
169
|
+
return InputEvent(EventType.KEY, key=named), length, False
|
|
170
|
+
|
|
171
|
+
# The modified encoding: the key a terminal can only report after being asked,
|
|
172
|
+
# like Shift-Enter, and the keys whose names the fixed table does not spell.
|
|
173
|
+
# A complete sequence this program has no name for is consumed rather than
|
|
174
|
+
# typed into the draft one byte at a time.
|
|
175
|
+
csi = split_csi(data)
|
|
176
|
+
if csi is not None:
|
|
177
|
+
parameters, final, consumed = csi
|
|
178
|
+
named, text = csi_event(parameters, final)
|
|
179
|
+
if text is not None:
|
|
180
|
+
return InputEvent(EventType.TEXT, text=text), consumed, False
|
|
181
|
+
return (InputEvent(EventType.KEY, key=named) if named is not None else None), consumed, False
|
|
182
|
+
|
|
183
|
+
first = data[0]
|
|
184
|
+
if first in CONTROL_KEYS:
|
|
185
|
+
return InputEvent(EventType.KEY, key=CONTROL_KEYS[first]), 1, False
|
|
186
|
+
if first == 0x1B:
|
|
187
|
+
if len(data) == 1:
|
|
188
|
+
return None, 0, True
|
|
189
|
+
if data[1] in (0x0A, 0x0D):
|
|
190
|
+
return InputEvent(EventType.KEY, key="alt_enter"), 2, False
|
|
191
|
+
if data[1] in (0x08, 0x7F):
|
|
192
|
+
return InputEvent(EventType.KEY, key="alt_backspace"), 2, False
|
|
193
|
+
if data[1] in (ord("["), ord("O")):
|
|
194
|
+
# ``ESC [`` and ``ESC O`` may still grow into a control sequence
|
|
195
|
+
# the tables do not list, so wait for the timeout rather than
|
|
196
|
+
# guessing that the user pressed Alt with a bracket.
|
|
197
|
+
return None, 0, True
|
|
198
|
+
if 0x20 <= data[1] < 0x7F:
|
|
199
|
+
return InputEvent(EventType.KEY, key=chr(data[1]), alt=True), 2, False
|
|
200
|
+
return InputEvent(EventType.KEY, key="escape"), 1, False
|
|
201
|
+
if first < 0x20:
|
|
202
|
+
return None, 1, False
|
|
203
|
+
|
|
204
|
+
length = _utf8_length(first)
|
|
205
|
+
if len(data) < length:
|
|
206
|
+
return None, 0, True
|
|
207
|
+
try:
|
|
208
|
+
text = data[:length].decode("utf-8")
|
|
209
|
+
except UnicodeDecodeError:
|
|
210
|
+
text = "�"
|
|
211
|
+
length = 1
|
|
212
|
+
return InputEvent(EventType.TEXT, text=text), length, False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _utf8_length(first: int) -> int:
|
|
216
|
+
"""Return the byte length of a UTF-8 sequence from its lead byte."""
|
|
217
|
+
if first < 0x80:
|
|
218
|
+
return 1
|
|
219
|
+
if first & 0xE0 == 0xC0:
|
|
220
|
+
return 2
|
|
221
|
+
if first & 0xF0 == 0xE0:
|
|
222
|
+
return 3
|
|
223
|
+
if first & 0xF8 == 0xF0:
|
|
224
|
+
return 4
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _mouse_action(code: int, suffix: bytes) -> MouseAction:
|
|
229
|
+
"""Map an SGR mouse code and its suffix onto one action."""
|
|
230
|
+
if code & 64: # bit 6: wheel; bit 0 then picks up versus down
|
|
231
|
+
return MouseAction.SCROLL_DOWN if code & 1 else MouseAction.SCROLL_UP
|
|
232
|
+
if code & 32: # bit 5: motion with a button held
|
|
233
|
+
return MouseAction.MOVE
|
|
234
|
+
return MouseAction.UP if suffix == b"m" else MouseAction.DOWN # M presses, m releases
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Decoded terminal input shared by the decoder and the legacy component API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .._compat import StrEnum
|
|
8
|
+
from ..core.events import MouseAction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventType(StrEnum):
|
|
12
|
+
"""The kinds of input the decoder can produce."""
|
|
13
|
+
|
|
14
|
+
KEY = "key"
|
|
15
|
+
TEXT = "text"
|
|
16
|
+
PASTE = "paste"
|
|
17
|
+
MOUSE = "mouse"
|
|
18
|
+
RESIZE = "resize"
|
|
19
|
+
RENDER = "render"
|
|
20
|
+
#: The terminal window gained or lost focus (DECSET 1004 reports it as
|
|
21
|
+
#: ``CSI I`` and ``CSI O``).
|
|
22
|
+
FOCUS = "focus"
|
|
23
|
+
#: An answer to a query the application sent, such as the terminal's own
|
|
24
|
+
#: background colour. It is input, but no widget ever sees it.
|
|
25
|
+
REPLY = "reply"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class InputEvent:
|
|
30
|
+
"""One decoded terminal input, before it becomes a framework event.
|
|
31
|
+
|
|
32
|
+
Which fields matter depends on ``type``: keys fill ``key``, text and paste
|
|
33
|
+
payloads fill ``text``, mouse events fill ``x``/``y``/``button``/``action``,
|
|
34
|
+
and a resize carries no payload at all.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
type: Which decoder branch produced this event.
|
|
38
|
+
key: Canonical key name for ``KEY`` events.
|
|
39
|
+
text: Payload for ``TEXT``, ``PASTE``, and ``REPLY`` events; a reply
|
|
40
|
+
carries the sequence's body, without its introducer or terminator.
|
|
41
|
+
x: Column for ``MOUSE`` events, zero-based.
|
|
42
|
+
y: Row for ``MOUSE`` events, zero-based.
|
|
43
|
+
button: Button index reported by the terminal.
|
|
44
|
+
action: Gesture for ``MOUSE`` events.
|
|
45
|
+
shift: Shift modifier, where the terminal reports it.
|
|
46
|
+
alt: Alt modifier, where the terminal reports it.
|
|
47
|
+
control: Ctrl modifier, where the terminal reports it.
|
|
48
|
+
focused: Whether the terminal gained focus, for ``FOCUS`` events.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
type: EventType
|
|
52
|
+
key: str = ""
|
|
53
|
+
text: str = ""
|
|
54
|
+
x: int = 0
|
|
55
|
+
y: int = 0
|
|
56
|
+
button: int = 0
|
|
57
|
+
action: MouseAction | None = None
|
|
58
|
+
shift: bool = False
|
|
59
|
+
alt: bool = False
|
|
60
|
+
control: bool = False
|
|
61
|
+
focused: bool = True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["EventType", "InputEvent", "MouseAction"]
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Byte sequences terminals use to report keys and modifiers.
|
|
2
|
+
|
|
3
|
+
Two families are decoded here. The fixed table below covers what a terminal sends
|
|
4
|
+
without being asked. The rest is the *modified* encoding, which a terminal has to be
|
|
5
|
+
asked for: ``CSI <code> ; <modifiers> u`` (the kitty protocol, requested in
|
|
6
|
+
``Terminal._enter_sequences``) and ``CSI 27 ; <modifiers> ; <code> ~`` (xterm's
|
|
7
|
+
``modifyOtherKeys``, which some terminals are configured for). Without one of those,
|
|
8
|
+
a terminal cannot report Shift-Enter at all: it sends the same carriage return as
|
|
9
|
+
Enter, and no decoder can tell them apart.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
# Terminals do not send "up"; they send a Control Sequence Introducer (CSI,
|
|
15
|
+
# ``ESC [``) followed by parameters. These are the two families worth knowing:
|
|
16
|
+
# letter-final sequences (``CSI A`` is up) and tilde-final ones (``CSI 3 ~`` is
|
|
17
|
+
# delete), plus the modifier form ``CSI 1 ; <modifier> <letter>``.
|
|
18
|
+
KEY_SEQUENCES: dict[bytes, str] = {
|
|
19
|
+
# CSI A-D: the four arrows in normal cursor-key mode.
|
|
20
|
+
b"\x1b[A": "up",
|
|
21
|
+
b"\x1b[B": "down",
|
|
22
|
+
b"\x1b[C": "right",
|
|
23
|
+
b"\x1b[D": "left",
|
|
24
|
+
# CSI H / F: Home and End, with the tilde spellings some terminals prefer.
|
|
25
|
+
b"\x1b[H": "home",
|
|
26
|
+
b"\x1b[F": "end",
|
|
27
|
+
b"\x1b[1~": "home",
|
|
28
|
+
b"\x1b[4~": "end",
|
|
29
|
+
# CSI n ~: the keys that have no letter of their own.
|
|
30
|
+
b"\x1b[5~": "page_up",
|
|
31
|
+
b"\x1b[6~": "page_down",
|
|
32
|
+
b"\x1b[3~": "delete",
|
|
33
|
+
b"\x1b[2~": "insert",
|
|
34
|
+
b"\x1b[Z": "backtab", # CSI Z: Shift-Tab
|
|
35
|
+
# CSI 1 ; m <letter>, where m encodes the modifiers: 3 = Alt, 5 = Ctrl.
|
|
36
|
+
b"\x1b[1;5D": "ctrl_left",
|
|
37
|
+
b"\x1b[1;5C": "ctrl_right",
|
|
38
|
+
b"\x1b[1;3D": "alt_left",
|
|
39
|
+
b"\x1b[1;3C": "alt_right",
|
|
40
|
+
b"\x1b[1;5H": "ctrl_home",
|
|
41
|
+
b"\x1b[1;5F": "ctrl_end",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
CONTROL_KEYS: dict[int, str] = {
|
|
45
|
+
0x01: "ctrl_a",
|
|
46
|
+
0x02: "ctrl_b",
|
|
47
|
+
0x03: "ctrl_c",
|
|
48
|
+
0x04: "ctrl_d",
|
|
49
|
+
0x05: "ctrl_e",
|
|
50
|
+
0x06: "ctrl_f",
|
|
51
|
+
0x07: "ctrl_g",
|
|
52
|
+
0x09: "tab",
|
|
53
|
+
0x0A: "enter",
|
|
54
|
+
0x0B: "ctrl_k",
|
|
55
|
+
0x0C: "ctrl_l",
|
|
56
|
+
0x0D: "enter",
|
|
57
|
+
0x0E: "ctrl_n",
|
|
58
|
+
0x10: "ctrl_p",
|
|
59
|
+
0x12: "ctrl_r",
|
|
60
|
+
0x14: "ctrl_t",
|
|
61
|
+
0x15: "ctrl_u",
|
|
62
|
+
0x16: "ctrl_v",
|
|
63
|
+
0x17: "ctrl_w",
|
|
64
|
+
0x19: "ctrl_y",
|
|
65
|
+
0x1A: "ctrl_z",
|
|
66
|
+
0x1F: "ctrl_underscore",
|
|
67
|
+
0x7F: "backspace",
|
|
68
|
+
0x08: "backspace",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#: Modifier bits of a modified key, most significant first: the parameter a terminal
|
|
73
|
+
#: reports is one plus a mask, and the lock bits above these are dropped — whether a
|
|
74
|
+
#: program sees Ctrl depends on Caps Lock, which is not a modifier anyone binds.
|
|
75
|
+
MODIFIER_BITS = ((8, "super"), (4, "ctrl"), (2, "alt"), (1, "shift"))
|
|
76
|
+
|
|
77
|
+
#: Keys the modified encoding names by code point instead of by character.
|
|
78
|
+
NAMED_CODES: dict[int, str] = {
|
|
79
|
+
8: "backspace",
|
|
80
|
+
9: "tab",
|
|
81
|
+
10: "enter",
|
|
82
|
+
13: "enter",
|
|
83
|
+
27: "escape",
|
|
84
|
+
32: "space",
|
|
85
|
+
127: "backspace",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#: Tilde-final keys, by the number in front of the tilde.
|
|
89
|
+
TILDE_KEYS: dict[int, str] = {
|
|
90
|
+
1: "home",
|
|
91
|
+
2: "insert",
|
|
92
|
+
3: "delete",
|
|
93
|
+
4: "end",
|
|
94
|
+
5: "page_up",
|
|
95
|
+
6: "page_down",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#: Letter-final keys: the arrows and the Home/End family.
|
|
99
|
+
LETTER_KEYS: dict[bytes, str] = {
|
|
100
|
+
b"A": "up",
|
|
101
|
+
b"B": "down",
|
|
102
|
+
b"C": "right",
|
|
103
|
+
b"D": "left",
|
|
104
|
+
b"F": "end",
|
|
105
|
+
b"H": "home",
|
|
106
|
+
b"P": "f1",
|
|
107
|
+
b"Q": "f2",
|
|
108
|
+
b"R": "f3",
|
|
109
|
+
b"S": "f4",
|
|
110
|
+
b"Z": "backtab",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#: Longest modifier parameter this decoder reads. A terminal may report more bits
|
|
114
|
+
#: than there are names for; the extra ones are ignored rather than fatal.
|
|
115
|
+
_MODIFIER_LIMIT = 256
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def split_csi(data: bytes) -> tuple[bytes, bytes, int] | None:
|
|
119
|
+
"""Return ``(parameters, final, length)`` of one complete control sequence.
|
|
120
|
+
|
|
121
|
+
A control sequence is ``ESC [``, then parameter and intermediate bytes — all of
|
|
122
|
+
them below ``@`` — and a final byte at or above it (ECMA-48). A sequence whose
|
|
123
|
+
final byte has not arrived is not returned: the caller keeps waiting for it,
|
|
124
|
+
which is what a sequence split across two reads looks like.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The parts and the number of bytes they take, or ``None`` when ``data`` does
|
|
128
|
+
not hold a complete control sequence.
|
|
129
|
+
"""
|
|
130
|
+
if not data.startswith(b"\x1b["):
|
|
131
|
+
return None
|
|
132
|
+
index = 2
|
|
133
|
+
while index < len(data) and data[index] < 0x40:
|
|
134
|
+
index += 1
|
|
135
|
+
if index >= len(data):
|
|
136
|
+
return None
|
|
137
|
+
return data[2:index], data[index : index + 1], index + 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def modifiers_of(parameter: bytes) -> tuple[bool, bool, bool, bool]:
|
|
141
|
+
"""Return the (shift, alt, ctrl, super) flags one modifier parameter carries.
|
|
142
|
+
|
|
143
|
+
An empty parameter means one, which is "no modifiers": every terminal omits it
|
|
144
|
+
rather than writing a one.
|
|
145
|
+
"""
|
|
146
|
+
# A terminal that reports event types writes "3:1" — the modifiers, then what the
|
|
147
|
+
# key did — and only the part before the colon says which modifiers are held.
|
|
148
|
+
parameter = parameter.split(b":")[0]
|
|
149
|
+
try:
|
|
150
|
+
value = int(parameter) if parameter else 1
|
|
151
|
+
except ValueError:
|
|
152
|
+
return (False, False, False, False)
|
|
153
|
+
mask = max(0, min(value, _MODIFIER_LIMIT) - 1)
|
|
154
|
+
flags = {name: bool(mask & bit) for bit, name in MODIFIER_BITS}
|
|
155
|
+
return (flags["shift"], flags["alt"], flags["ctrl"], flags["super"])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def modified(base: str, *, shift: bool = False, alt: bool = False, ctrl: bool = False, super_: bool = False) -> str:
|
|
159
|
+
"""Return the key name a modified key is spelled with, as its bindings expect.
|
|
160
|
+
|
|
161
|
+
The prefixes are the ones this framework already uses — ``shift_enter``,
|
|
162
|
+
``alt_backspace``, ``ctrl_left`` — so a key reached through the modified
|
|
163
|
+
encoding lands on the same binding as one reached through the fixed table.
|
|
164
|
+
Shift-Tab keeps its own name, which is what the table calls it.
|
|
165
|
+
"""
|
|
166
|
+
if base == "tab" and shift and not (alt or ctrl or super_):
|
|
167
|
+
return "backtab"
|
|
168
|
+
prefix = "".join(name for name, on in (("ctrl", ctrl), ("alt", alt), ("shift", shift), ("super", super_)) if on)
|
|
169
|
+
return f"{prefix}_{base}" if prefix else base
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def control_name(character: str) -> str:
|
|
173
|
+
"""Return the name a Ctrl-<character> would have arrived as without the encoding.
|
|
174
|
+
|
|
175
|
+
A terminal that cannot say "Ctrl-C" sends byte 3, and this framework already has
|
|
176
|
+
a name for every one of those: the same name is what a modified encoding has to
|
|
177
|
+
produce, or the key would reach a binding that no longer exists. Where the
|
|
178
|
+
legacy byte has no name — Ctrl-Space, Ctrl-/, which a terminal may or may not
|
|
179
|
+
send at all — the name is spelled out, and being unbound it does nothing.
|
|
180
|
+
"""
|
|
181
|
+
code = ord(character)
|
|
182
|
+
if character == "@" or code == 0x20:
|
|
183
|
+
legacy = 0x00
|
|
184
|
+
elif character == "?":
|
|
185
|
+
legacy = 0x7F
|
|
186
|
+
elif 0x40 <= code <= 0x5F:
|
|
187
|
+
legacy = code - 0x40
|
|
188
|
+
elif 0x61 <= code <= 0x7A:
|
|
189
|
+
legacy = code - 0x60
|
|
190
|
+
else:
|
|
191
|
+
return f"ctrl_{character}"
|
|
192
|
+
return CONTROL_KEYS.get(legacy, f"ctrl_{character}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
#: Keys a terminal that sends Alt as a leading ESC can put Alt on: the cursor keys.
|
|
196
|
+
META_KEYS = frozenset({"up", "down", "left", "right", "home", "end", "page_up", "page_down", "delete"})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def meta_csi(data: bytes) -> tuple[str | None, int] | None:
|
|
200
|
+
"""Read ``ESC ESC [ …``: how a terminal in "Option sends Esc+" mode spells Alt on a cursor key.
|
|
201
|
+
|
|
202
|
+
The same key without Alt is ``ESC [ A``, and with Alt the terminal puts one more ``ESC``
|
|
203
|
+
in front of it. Read as two keys that is *Escape, then Up* — and Escape stops a running
|
|
204
|
+
request, so an Alt-Up that was read that way would cancel the work it was pressed over.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
``None`` when ``data`` is not this shape, ``(None, 0)`` while the sequence is still
|
|
208
|
+
arriving, and ``(key, length)`` once it is complete.
|
|
209
|
+
"""
|
|
210
|
+
if not data.startswith(b"\x1b\x1b["):
|
|
211
|
+
return None
|
|
212
|
+
csi = split_csi(data[1:])
|
|
213
|
+
if csi is None:
|
|
214
|
+
return (None, 0)
|
|
215
|
+
parameters, final, length = csi
|
|
216
|
+
named, _ = csi_event(parameters, final)
|
|
217
|
+
if named not in META_KEYS:
|
|
218
|
+
return None
|
|
219
|
+
return (f"alt_{named}", length + 1)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def csi_event(parameters: bytes, final: bytes) -> tuple[str | None, str | None]:
|
|
223
|
+
"""Return ``(key, text)`` for one control sequence: a name, or text it spells.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
parameters: Everything between ``ESC [`` and the final byte.
|
|
227
|
+
final: The final byte.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
A key name, or the text a printable key spells, or ``(None, None)`` for a
|
|
231
|
+
sequence this program has no name for.
|
|
232
|
+
"""
|
|
233
|
+
fields = parameters.split(b";")
|
|
234
|
+
if final == b"u":
|
|
235
|
+
return _code_event(_number(fields[0]), modifiers_of(fields[1] if len(fields) > 1 else b""))
|
|
236
|
+
if final == b"~":
|
|
237
|
+
if len(fields) == 3 and _number(fields[0], default=-1) == 27:
|
|
238
|
+
# xterm's modifyOtherKeys form: 27 ; modifiers ; code ~
|
|
239
|
+
return _code_event(_number(fields[2]), modifiers_of(fields[1]))
|
|
240
|
+
base = TILDE_KEYS.get(_number(fields[0], default=-1))
|
|
241
|
+
if base is None:
|
|
242
|
+
return (None, None)
|
|
243
|
+
shift, alt, ctrl, super_ = modifiers_of(fields[1] if len(fields) > 1 else b"")
|
|
244
|
+
return (modified(base, shift=shift, alt=alt, ctrl=ctrl, super_=super_), None)
|
|
245
|
+
base = LETTER_KEYS.get(final)
|
|
246
|
+
if base is None:
|
|
247
|
+
return (None, None)
|
|
248
|
+
shift, alt, ctrl, super_ = modifiers_of(fields[1] if len(fields) > 1 else b"")
|
|
249
|
+
return (modified(base, shift=shift, alt=alt, ctrl=ctrl, super_=super_), None)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _code_event(code: int, mods: tuple[bool, bool, bool, bool]) -> tuple[str | None, str | None]:
|
|
253
|
+
"""Return (key, text) for a code point and its modifiers, or (None, None) if unusable."""
|
|
254
|
+
shift, alt, ctrl, super_ = mods
|
|
255
|
+
if code < 0 or code > 0x10FFFF:
|
|
256
|
+
return (None, None)
|
|
257
|
+
character = chr(code)
|
|
258
|
+
if ctrl or super_:
|
|
259
|
+
if code in NAMED_CODES:
|
|
260
|
+
return (modified(NAMED_CODES[code], shift=shift, alt=alt, ctrl=ctrl, super_=super_), None)
|
|
261
|
+
# A control key means a letter or a punctuation mark: Ctrl with a character
|
|
262
|
+
# outside ASCII is not something a terminal reports, and naming one after a
|
|
263
|
+
# letter it is not would be worse than ignoring it.
|
|
264
|
+
if character.isascii() and character.isprintable():
|
|
265
|
+
return (control_name(character), None)
|
|
266
|
+
return (None, None)
|
|
267
|
+
if code in NAMED_CODES:
|
|
268
|
+
base = NAMED_CODES[code]
|
|
269
|
+
if base == "space":
|
|
270
|
+
return (None, " ") if not alt else (modified(base, alt=alt, shift=shift), None)
|
|
271
|
+
return (modified(base, shift=shift, alt=alt, super_=super_), None)
|
|
272
|
+
if not character.isprintable():
|
|
273
|
+
return (None, None)
|
|
274
|
+
if alt:
|
|
275
|
+
# Alt with a printable key is not text: without the encoding a terminal spells
|
|
276
|
+
# it "ESC <key>", which this decoder reads as the key with Alt held, and a
|
|
277
|
+
# binding is what acts on it.
|
|
278
|
+
return (modified(character.lower(), alt=True, shift=shift, super_=super_), None)
|
|
279
|
+
return (None, character.upper() if shift and character.isalpha() else character)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _number(field: bytes, *, default: int = 0) -> int:
|
|
283
|
+
"""Return the number a control-sequence parameter holds, or ``default``."""
|
|
284
|
+
if not field:
|
|
285
|
+
return default
|
|
286
|
+
try:
|
|
287
|
+
return int(field)
|
|
288
|
+
except ValueError:
|
|
289
|
+
return default
|