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.
Files changed (83) hide show
  1. zettcode/tui/__init__.py +422 -0
  2. zettcode/tui/_compat.py +32 -0
  3. zettcode/tui/capabilities.py +107 -0
  4. zettcode/tui/clipboard.py +91 -0
  5. zettcode/tui/console.py +229 -0
  6. zettcode/tui/core/__init__.py +63 -0
  7. zettcode/tui/core/app.py +658 -0
  8. zettcode/tui/core/events.py +134 -0
  9. zettcode/tui/core/focus.py +85 -0
  10. zettcode/tui/core/geometry.py +212 -0
  11. zettcode/tui/core/host.py +66 -0
  12. zettcode/tui/core/keymap.py +218 -0
  13. zettcode/tui/core/scheduler.py +105 -0
  14. zettcode/tui/core/screen.py +80 -0
  15. zettcode/tui/core/theme.py +248 -0
  16. zettcode/tui/core/widget.py +204 -0
  17. zettcode/tui/gallery.py +588 -0
  18. zettcode/tui/glyphs.py +73 -0
  19. zettcode/tui/input/__init__.py +18 -0
  20. zettcode/tui/input/bridge.py +46 -0
  21. zettcode/tui/input/decoder.py +234 -0
  22. zettcode/tui/input/events.py +64 -0
  23. zettcode/tui/input/keys.py +289 -0
  24. zettcode/tui/input/reader.py +147 -0
  25. zettcode/tui/layout/__init__.py +24 -0
  26. zettcode/tui/layout/box.py +153 -0
  27. zettcode/tui/layout/overlay.py +164 -0
  28. zettcode/tui/layout/scroll.py +477 -0
  29. zettcode/tui/layout/solver.py +95 -0
  30. zettcode/tui/layout/spacing.py +137 -0
  31. zettcode/tui/py.typed +0 -0
  32. zettcode/tui/render/__init__.py +71 -0
  33. zettcode/tui/render/ansi.py +371 -0
  34. zettcode/tui/render/canvas.py +153 -0
  35. zettcode/tui/render/code.py +456 -0
  36. zettcode/tui/render/color.py +177 -0
  37. zettcode/tui/render/renderer.py +151 -0
  38. zettcode/tui/render/rich_text.py +80 -0
  39. zettcode/tui/render/style.py +121 -0
  40. zettcode/tui/render/sweep.py +87 -0
  41. zettcode/tui/render/text.py +319 -0
  42. zettcode/tui/runner.py +316 -0
  43. zettcode/tui/terminal.py +222 -0
  44. zettcode/tui/testing/__init__.py +6 -0
  45. zettcode/tui/testing/harness.py +220 -0
  46. zettcode/tui/testing/snapshot.py +64 -0
  47. zettcode/tui/theme_file.py +300 -0
  48. zettcode/tui/themes/ayu-mirage.toml +44 -0
  49. zettcode/tui/themes/catppuccin-latte.toml +44 -0
  50. zettcode/tui/themes/catppuccin-mocha.toml +44 -0
  51. zettcode/tui/themes/dracula.toml +44 -0
  52. zettcode/tui/themes/github-dimmed.toml +44 -0
  53. zettcode/tui/themes/github-light.toml +44 -0
  54. zettcode/tui/themes/gruvbox-dark.toml +44 -0
  55. zettcode/tui/themes/gruvbox-light.toml +44 -0
  56. zettcode/tui/themes/kanagawa.toml +44 -0
  57. zettcode/tui/themes/nord.toml +44 -0
  58. zettcode/tui/themes/one-dark.toml +44 -0
  59. zettcode/tui/themes/rose-pine-dawn.toml +44 -0
  60. zettcode/tui/themes/rose-pine.toml +44 -0
  61. zettcode/tui/themes/solarized-dark.toml +44 -0
  62. zettcode/tui/themes/solarized-light.toml +44 -0
  63. zettcode/tui/themes/tokyo-night.toml +44 -0
  64. zettcode/tui/widgets/__init__.py +121 -0
  65. zettcode/tui/widgets/collapsible.py +116 -0
  66. zettcode/tui/widgets/completion.py +211 -0
  67. zettcode/tui/widgets/console.py +442 -0
  68. zettcode/tui/widgets/dialog.py +165 -0
  69. zettcode/tui/widgets/diff.py +236 -0
  70. zettcode/tui/widgets/list.py +293 -0
  71. zettcode/tui/widgets/list_page.py +121 -0
  72. zettcode/tui/widgets/markdown.py +657 -0
  73. zettcode/tui/widgets/progress.py +166 -0
  74. zettcode/tui/widgets/rich_text.py +84 -0
  75. zettcode/tui/widgets/status.py +106 -0
  76. zettcode/tui/widgets/table.py +101 -0
  77. zettcode/tui/widgets/tasks.py +130 -0
  78. zettcode/tui/widgets/text.py +111 -0
  79. zettcode/tui/widgets/textarea.py +671 -0
  80. zettcode/tui/widgets/toast.py +137 -0
  81. zettcode_tui-0.4.0.dist-info/METADATA +20 -0
  82. zettcode_tui-0.4.0.dist-info/RECORD +83 -0
  83. 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