urwid 2.6.0.post0__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.

Potentially problematic release.


This version of urwid might be problematic. Click here for more details.

Files changed (75) hide show
  1. urwid/__init__.py +333 -0
  2. urwid/canvas.py +1413 -0
  3. urwid/command_map.py +137 -0
  4. urwid/container.py +59 -0
  5. urwid/decoration.py +65 -0
  6. urwid/display/__init__.py +97 -0
  7. urwid/display/_posix_raw_display.py +413 -0
  8. urwid/display/_raw_display_base.py +914 -0
  9. urwid/display/_web.css +12 -0
  10. urwid/display/_web.js +462 -0
  11. urwid/display/_win32.py +171 -0
  12. urwid/display/_win32_raw_display.py +269 -0
  13. urwid/display/common.py +1219 -0
  14. urwid/display/curses.py +690 -0
  15. urwid/display/escape.py +624 -0
  16. urwid/display/html_fragment.py +251 -0
  17. urwid/display/lcd.py +518 -0
  18. urwid/display/raw.py +37 -0
  19. urwid/display/web.py +636 -0
  20. urwid/event_loop/__init__.py +55 -0
  21. urwid/event_loop/abstract_loop.py +175 -0
  22. urwid/event_loop/asyncio_loop.py +231 -0
  23. urwid/event_loop/glib_loop.py +294 -0
  24. urwid/event_loop/main_loop.py +721 -0
  25. urwid/event_loop/select_loop.py +230 -0
  26. urwid/event_loop/tornado_loop.py +206 -0
  27. urwid/event_loop/trio_loop.py +302 -0
  28. urwid/event_loop/twisted_loop.py +269 -0
  29. urwid/event_loop/zmq_loop.py +275 -0
  30. urwid/font.py +695 -0
  31. urwid/graphics.py +96 -0
  32. urwid/highlight.css +19 -0
  33. urwid/listbox.py +1899 -0
  34. urwid/monitored_list.py +522 -0
  35. urwid/numedit.py +376 -0
  36. urwid/signals.py +330 -0
  37. urwid/split_repr.py +130 -0
  38. urwid/str_util.py +358 -0
  39. urwid/text_layout.py +632 -0
  40. urwid/treetools.py +515 -0
  41. urwid/util.py +557 -0
  42. urwid/version.py +16 -0
  43. urwid/vterm.py +1806 -0
  44. urwid/widget/__init__.py +181 -0
  45. urwid/widget/attr_map.py +161 -0
  46. urwid/widget/attr_wrap.py +140 -0
  47. urwid/widget/bar_graph.py +649 -0
  48. urwid/widget/big_text.py +77 -0
  49. urwid/widget/box_adapter.py +126 -0
  50. urwid/widget/columns.py +1145 -0
  51. urwid/widget/constants.py +574 -0
  52. urwid/widget/container.py +227 -0
  53. urwid/widget/divider.py +110 -0
  54. urwid/widget/edit.py +718 -0
  55. urwid/widget/filler.py +403 -0
  56. urwid/widget/frame.py +539 -0
  57. urwid/widget/grid_flow.py +539 -0
  58. urwid/widget/line_box.py +194 -0
  59. urwid/widget/overlay.py +829 -0
  60. urwid/widget/padding.py +597 -0
  61. urwid/widget/pile.py +971 -0
  62. urwid/widget/popup.py +170 -0
  63. urwid/widget/progress_bar.py +141 -0
  64. urwid/widget/scrollable.py +597 -0
  65. urwid/widget/solid_fill.py +44 -0
  66. urwid/widget/text.py +354 -0
  67. urwid/widget/widget.py +852 -0
  68. urwid/widget/widget_decoration.py +166 -0
  69. urwid/widget/wimp.py +792 -0
  70. urwid/wimp.py +23 -0
  71. urwid-2.6.0.post0.dist-info/COPYING +504 -0
  72. urwid-2.6.0.post0.dist-info/METADATA +332 -0
  73. urwid-2.6.0.post0.dist-info/RECORD +75 -0
  74. urwid-2.6.0.post0.dist-info/WHEEL +5 -0
  75. urwid-2.6.0.post0.dist-info/top_level.txt +1 -0
urwid/vterm.py ADDED
@@ -0,0 +1,1806 @@
1
+ # Urwid terminal emulation widget
2
+ # Copyright (C) 2010 aszlig
3
+ # Copyright (C) 2011 Ian Ward
4
+ #
5
+ # This library is free software; you can redistribute it and/or
6
+ # modify it under the terms of the GNU Lesser General Public
7
+ # License as published by the Free Software Foundation; either
8
+ # version 2.1 of the License, or (at your option) any later version.
9
+ #
10
+ # This library is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13
+ # Lesser General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU Lesser General Public
16
+ # License along with this library; if not, write to the Free Software
17
+ # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18
+ #
19
+ # Urwid web site: https://urwid.org/
20
+
21
+
22
+ from __future__ import annotations
23
+
24
+ import atexit
25
+ import copy
26
+ import errno
27
+ import fcntl
28
+ import os
29
+ import pty
30
+ import selectors
31
+ import signal
32
+ import struct
33
+ import sys
34
+ import termios
35
+ import time
36
+ import traceback
37
+ import typing
38
+ import warnings
39
+ from collections import deque
40
+ from contextlib import suppress
41
+ from dataclasses import dataclass
42
+
43
+ from urwid import event_loop, util
44
+ from urwid.canvas import Canvas
45
+ from urwid.display import AttrSpec, RealTerminal
46
+ from urwid.display.escape import ALT_DEC_SPECIAL_CHARS, DEC_SPECIAL_CHARS
47
+ from urwid.widget import Sizing, Widget
48
+
49
+ from .display.common import _BASIC_COLORS, _color_desc_256, _color_desc_true
50
+
51
+ if typing.TYPE_CHECKING:
52
+ from collections.abc import Callable, Iterable, Mapping, Sequence
53
+
54
+ from typing_extensions import Literal
55
+
56
+ EOF = b""
57
+ ESC = chr(27)
58
+ ESC_B = b"\x1b"
59
+
60
+ KEY_TRANSLATIONS = {
61
+ "enter": "\r",
62
+ "backspace": chr(127),
63
+ "tab": "\t",
64
+ "esc": ESC,
65
+ "up": f"{ESC}[A",
66
+ "down": f"{ESC}[B",
67
+ "right": f"{ESC}[C",
68
+ "left": f"{ESC}[D",
69
+ "home": f"{ESC}[1~",
70
+ "insert": f"{ESC}[2~",
71
+ "delete": f"{ESC}[3~",
72
+ "end": f"{ESC}[4~",
73
+ "page up": f"{ESC}[5~",
74
+ "page down": f"{ESC}[6~",
75
+ "begin paste": f"{ESC}[200~",
76
+ "end paste": f"{ESC}[201~",
77
+ "f1": f"{ESC}[[A",
78
+ "f2": f"{ESC}[[B",
79
+ "f3": f"{ESC}[[C",
80
+ "f4": f"{ESC}[[D",
81
+ "f5": f"{ESC}[[E",
82
+ "f6": f"{ESC}[17~",
83
+ "f7": f"{ESC}[18~",
84
+ "f8": f"{ESC}[19~",
85
+ "f9": f"{ESC}[20~",
86
+ "f10": f"{ESC}[21~",
87
+ "f11": f"{ESC}[23~",
88
+ "f12": f"{ESC}[24~",
89
+ }
90
+
91
+ KEY_TRANSLATIONS_DECCKM = {
92
+ "up": f"{ESC}OA",
93
+ "down": f"{ESC}OB",
94
+ "right": f"{ESC}OC",
95
+ "left": f"{ESC}OD",
96
+ "f1": f"{ESC}OP",
97
+ "f2": f"{ESC}OQ",
98
+ "f3": f"{ESC}OR",
99
+ "f4": f"{ESC}OS",
100
+ "f5": f"{ESC}[15~",
101
+ }
102
+
103
+
104
+ class CSIAlias(typing.NamedTuple):
105
+ alias_mark: str # can not have constructor with default first and non-default second arg
106
+ alias: bytes
107
+
108
+
109
+ class CSICommand(typing.NamedTuple):
110
+ num_args: int
111
+ default: int
112
+ callback: Callable[[TermCanvas, list[int], bool], typing.Any] # return value ignored
113
+
114
+
115
+ CSI_COMMANDS: dict[bytes, CSIAlias | CSICommand] = {
116
+ # possible values:
117
+ # None -> ignore sequence
118
+ # (<minimum number of args>, <fallback if no argument>, callback)
119
+ # ('alias', <symbol>)
120
+ #
121
+ # while callback is executed as:
122
+ # callback(<instance of TermCanvas>, arguments, has_question_mark)
123
+ b"@": CSICommand(1, 1, lambda s, number, q: s.insert_chars(chars=number[0])),
124
+ b"A": CSICommand(1, 1, lambda s, rows, q: s.move_cursor(0, -rows[0], relative=True)),
125
+ b"B": CSICommand(1, 1, lambda s, rows, q: s.move_cursor(0, rows[0], relative=True)),
126
+ b"C": CSICommand(1, 1, lambda s, cols, q: s.move_cursor(cols[0], 0, relative=True)),
127
+ b"D": CSICommand(1, 1, lambda s, cols, q: s.move_cursor(-cols[0], 0, relative=True)),
128
+ b"E": CSICommand(1, 1, lambda s, rows, q: s.move_cursor(0, rows[0], relative_y=True)),
129
+ b"F": CSICommand(1, 1, lambda s, rows, q: s.move_cursor(0, -rows[0], relative_y=True)),
130
+ b"G": CSICommand(1, 1, lambda s, col, q: s.move_cursor(col[0] - 1, 0, relative_y=True)),
131
+ b"H": CSICommand(2, 1, lambda s, x_y, q: s.move_cursor(x_y[1] - 1, x_y[0] - 1)),
132
+ b"J": CSICommand(1, 0, lambda s, mode, q: s.csi_erase_display(mode[0])),
133
+ b"K": CSICommand(1, 0, lambda s, mode, q: s.csi_erase_line(mode[0])),
134
+ b"L": CSICommand(1, 1, lambda s, number, q: s.insert_lines(lines=number[0])),
135
+ b"M": CSICommand(1, 1, lambda s, number, q: s.remove_lines(lines=number[0])),
136
+ b"P": CSICommand(1, 1, lambda s, number, q: s.remove_chars(chars=number[0])),
137
+ b"X": CSICommand(
138
+ 1,
139
+ 1,
140
+ lambda s, number, q: s.erase(s.term_cursor, (s.term_cursor[0] + number[0] - 1, s.term_cursor[1])),
141
+ ),
142
+ b"a": CSIAlias("alias", b"C"),
143
+ b"c": CSICommand(0, 0, lambda s, none, q: s.csi_get_device_attributes(q)),
144
+ b"d": CSICommand(1, 1, lambda s, row, q: s.move_cursor(0, row[0] - 1, relative_x=True)),
145
+ b"e": CSIAlias("alias", b"B"),
146
+ b"f": CSIAlias("alias", b"H"),
147
+ b"g": CSICommand(1, 0, lambda s, mode, q: s.csi_clear_tabstop(mode[0])),
148
+ b"h": CSICommand(1, 0, lambda s, modes, q: s.csi_set_modes(modes, q)),
149
+ b"l": CSICommand(1, 0, lambda s, modes, q: s.csi_set_modes(modes, q, reset=True)),
150
+ b"m": CSICommand(1, 0, lambda s, attrs, q: s.csi_set_attr(attrs)),
151
+ b"n": CSICommand(1, 0, lambda s, mode, q: s.csi_status_report(mode[0])),
152
+ b"q": CSICommand(1, 0, lambda s, mode, q: s.csi_set_keyboard_leds(mode[0])),
153
+ b"r": CSICommand(2, 0, lambda s, t_b, q: s.csi_set_scroll(t_b[0], t_b[1])),
154
+ b"s": CSICommand(0, 0, lambda s, none, q: s.save_cursor()),
155
+ b"u": CSICommand(0, 0, lambda s, none, q: s.restore_cursor()),
156
+ b"`": CSIAlias("alias", b"G"),
157
+ }
158
+
159
+ CHARSET_DEFAULT: Literal[1] = 1 # type annotated exclusively for buggy IDE
160
+ CHARSET_UTF8: Literal[2] = 2
161
+
162
+
163
+ @dataclass(eq=True, order=False)
164
+ class TermModes:
165
+ # ECMA-48
166
+ display_ctrl: bool = False
167
+ insert: bool = False
168
+ lfnl: bool = False
169
+
170
+ # DEC private modes
171
+ keys_decckm: bool = False
172
+ reverse_video: bool = False
173
+ constrain_scrolling: bool = False
174
+ autowrap: bool = True
175
+ visible_cursor: bool = True
176
+ bracketed_paste: bool = False
177
+
178
+ # charset stuff
179
+ main_charset: Literal[1, 2] = CHARSET_DEFAULT
180
+
181
+ def reset(self) -> None:
182
+ # ECMA-48
183
+ self.display_ctrl = False
184
+ self.insert = False
185
+ self.lfnl = False
186
+
187
+ # DEC private modes
188
+ self.keys_decckm = False
189
+ self.reverse_video = False
190
+ self.constrain_scrolling = False
191
+ self.autowrap = True
192
+ self.visible_cursor = True
193
+
194
+ # charset stuff
195
+ self.main_charset = CHARSET_DEFAULT
196
+
197
+
198
+ class TermCharset:
199
+ __slots__ = ("_g", "_sgr_mapping", "active", "current")
200
+
201
+ MAPPING: typing.ClassVar[dict[str, str | None]] = {
202
+ "default": None,
203
+ "vt100": "0",
204
+ "ibmpc": "U",
205
+ "user": None,
206
+ }
207
+
208
+ def __init__(self) -> None:
209
+ self._g = [
210
+ "default",
211
+ "vt100",
212
+ ]
213
+
214
+ self._sgr_mapping = False
215
+
216
+ # prepare defaults
217
+ self.active = 0
218
+ self.current: str | None = None
219
+
220
+ self.activate(0)
221
+
222
+ def define(self, g: int, charset: str) -> None:
223
+ """
224
+ Redefine G'g' with new mapping.
225
+ """
226
+ self._g[g] = charset
227
+ self.activate(g=self.active)
228
+
229
+ def activate(self, g: int) -> None:
230
+ """
231
+ Activate the given charset slot.
232
+ """
233
+ self.active = g
234
+ self.current = self.MAPPING.get(self._g[g], None)
235
+
236
+ def set_sgr_ibmpc(self) -> None:
237
+ """
238
+ Set graphics rendition mapping to IBM PC CP437.
239
+ """
240
+ self._sgr_mapping = True
241
+
242
+ def reset_sgr_ibmpc(self) -> None:
243
+ """
244
+ Reset graphics rendition mapping to IBM PC CP437.
245
+ """
246
+ self._sgr_mapping = False
247
+ self.activate(g=self.active)
248
+
249
+ def apply_mapping(self, char: bytes) -> bytes:
250
+ if self._sgr_mapping or self._g[self.active] == "ibmpc":
251
+ dec_pos = DEC_SPECIAL_CHARS.find(char.decode("cp437"))
252
+ if dec_pos >= 0:
253
+ self.current = "0"
254
+ return ALT_DEC_SPECIAL_CHARS[dec_pos].encode("cp437")
255
+
256
+ self.current = "U"
257
+ return char
258
+
259
+ return char
260
+
261
+
262
+ class TermScroller(list):
263
+ """
264
+ List subclass that handles the terminal scrollback buffer,
265
+ truncating it as necessary.
266
+ """
267
+
268
+ SCROLLBACK_LINES = 10000
269
+
270
+ def __init__(self, iterable: Iterable[typing.Any]) -> None:
271
+ warnings.warn(
272
+ "`TermScroller` is deprecated. Please use `collections.deque` with non-zero `maxlen` instead.",
273
+ DeprecationWarning,
274
+ stacklevel=3,
275
+ )
276
+ super().__init__(iterable)
277
+
278
+ def trunc(self) -> None:
279
+ if len(self) >= self.SCROLLBACK_LINES:
280
+ self.pop(0)
281
+
282
+ def append(self, obj) -> None:
283
+ self.trunc()
284
+ super().append(obj)
285
+
286
+ def insert(self, idx: typing.SupportsIndex, obj) -> None:
287
+ self.trunc()
288
+ super().insert(idx, obj)
289
+
290
+ def extend(self, seq) -> None:
291
+ self.trunc()
292
+ super().extend(seq)
293
+
294
+
295
+ class TermCanvas(Canvas):
296
+ cacheable = False
297
+
298
+ def __init__(self, width: int, height: int, widget: Terminal) -> None:
299
+ super().__init__()
300
+
301
+ self.width, self.height = width, height
302
+ self.widget = widget
303
+ self.modes: TermModes = widget.term_modes
304
+ self.has_focus = False
305
+
306
+ self.scrollback_buffer: deque[list[tuple[AttrSpec | None, str | None, bytes]]] = deque(maxlen=10000)
307
+ self.scrolling_up = 0
308
+
309
+ self.utf8_eat_bytes: int | None = None
310
+ self.utf8_buffer = bytearray()
311
+ self.escbuf = b""
312
+
313
+ self.coords["cursor"] = (0, 0, None)
314
+
315
+ self.term_cursor: tuple[int, int] = (0, 0) # do not allow to shoot in the leg at `set_term_cursor`
316
+
317
+ self.within_escape = False
318
+ self.parsestate = 0
319
+
320
+ self.attrspec: AttrSpec | None = None
321
+
322
+ self.charset = TermCharset()
323
+
324
+ self.saved_cursor: tuple[int, int] | None = None
325
+ self.saved_attrs: tuple[AttrSpec | None, TermCharset] | None = None
326
+
327
+ self.is_rotten_cursor = False
328
+
329
+ self.scrollregion_start = 0
330
+ self.scrollregion_end = self.height - 1
331
+
332
+ self.tabstops: list[int] = []
333
+ self.term: list[list[tuple[AttrSpec | None, str | None, bytes]]] = []
334
+
335
+ self.reset()
336
+
337
+ def set_term_cursor(self, x: int | None = None, y: int | None = None) -> None:
338
+ """
339
+ Set terminal cursor to x/y and update canvas cursor. If one or both axes
340
+ are omitted, use the values of the current position.
341
+ """
342
+ if x is None:
343
+ x = self.term_cursor[0]
344
+ if y is None:
345
+ y = self.term_cursor[1]
346
+
347
+ self.term_cursor = self.constrain_coords(x, y)
348
+
349
+ if self.has_focus and self.modes.visible_cursor and self.scrolling_up < self.height - y:
350
+ self.cursor = (x, y + self.scrolling_up)
351
+ else:
352
+ self.cursor = None
353
+
354
+ def reset_scroll(self) -> None:
355
+ """
356
+ Reset scrolling region to full terminal size.
357
+ """
358
+ self.scrollregion_start = 0
359
+ self.scrollregion_end = self.height - 1
360
+
361
+ def scroll_buffer(self, up: bool = True, reset: bool = False, lines: int | None = None) -> None:
362
+ """
363
+ Scroll the scrolling buffer up (up=True) or down (up=False) the given
364
+ amount of lines or half the screen height.
365
+
366
+ If just 'reset' is True, set the scrollbuffer view to the current
367
+ terminal content.
368
+ """
369
+ if reset:
370
+ self.scrolling_up = 0
371
+ self.set_term_cursor()
372
+ return
373
+
374
+ if lines is None:
375
+ lines = self.height // 2
376
+
377
+ if not up:
378
+ lines = -lines # pylint: disable=invalid-unary-operand-type # type already narrowed
379
+
380
+ maxscroll = len(self.scrollback_buffer)
381
+ self.scrolling_up += lines
382
+
383
+ if self.scrolling_up > maxscroll:
384
+ self.scrolling_up = maxscroll
385
+ elif self.scrolling_up < 0:
386
+ self.scrolling_up = 0
387
+
388
+ self.set_term_cursor()
389
+
390
+ def reset(self) -> None:
391
+ """
392
+ Reset the terminal.
393
+ """
394
+ self.escbuf = b""
395
+ self.within_escape = False
396
+ self.parsestate = 0
397
+
398
+ self.attrspec = None
399
+ self.charset = TermCharset()
400
+
401
+ self.saved_cursor = None
402
+ self.saved_attrs = None
403
+
404
+ self.is_rotten_cursor = False
405
+
406
+ self.reset_scroll()
407
+
408
+ self.init_tabstops()
409
+
410
+ # terminal modes
411
+ self.modes.reset()
412
+
413
+ # initialize self.term
414
+ self.clear()
415
+
416
+ def init_tabstops(self, extend: bool = False) -> None:
417
+ tablen, mod = divmod(self.width, 8)
418
+ if mod > 0:
419
+ tablen += 1
420
+
421
+ if extend:
422
+ while len(self.tabstops) < tablen:
423
+ self.tabstops.append(1 << 0)
424
+ else:
425
+ self.tabstops = [1 << 0] * tablen
426
+
427
+ def set_tabstop(self, x: int | None = None, remove: bool = False, clear: bool = False) -> None:
428
+ if clear:
429
+ for tab in range(len(self.tabstops)):
430
+ self.tabstops[tab] = 0
431
+ return
432
+
433
+ if x is None:
434
+ x = self.term_cursor[0]
435
+
436
+ div, mod = divmod(x, 8)
437
+ if remove:
438
+ self.tabstops[div] &= ~(1 << mod)
439
+ else:
440
+ self.tabstops[div] |= 1 << mod
441
+
442
+ def is_tabstop(self, x: int | None = None) -> bool:
443
+ if x is None:
444
+ x = self.term_cursor[0]
445
+
446
+ div, mod = divmod(x, 8)
447
+ return (self.tabstops[div] & (1 << mod)) > 0
448
+
449
+ def empty_line(self, char: bytes = b" ") -> list[tuple[AttrSpec | None, str | None, bytes]]:
450
+ return [self.empty_char(char)] * self.width
451
+
452
+ def empty_char(self, char: bytes = b" ") -> tuple[AttrSpec | None, str | None, bytes]:
453
+ return (self.attrspec, self.charset.current, char)
454
+
455
+ def addstr(self, data: Iterable[int]) -> None:
456
+ if self.width <= 0 or self.height <= 0:
457
+ # not displayable, do nothing!
458
+ return
459
+
460
+ for byte in data:
461
+ self.addbyte(byte)
462
+
463
+ def resize(self, width: int, height: int) -> None:
464
+ """
465
+ Resize the terminal to the given width and height.
466
+ """
467
+ x, y = self.term_cursor
468
+
469
+ if width > self.width:
470
+ # grow
471
+ for y in range(self.height):
472
+ self.term[y] += [self.empty_char()] * (width - self.width)
473
+ elif width < self.width:
474
+ # shrink
475
+ for y in range(self.height):
476
+ self.term[y] = self.term[y][:width]
477
+
478
+ self.width = width
479
+
480
+ if height > self.height:
481
+ # grow
482
+ for _y in range(self.height, height):
483
+ try:
484
+ last_line = self.scrollback_buffer.pop()
485
+ except IndexError:
486
+ # nothing in scrollback buffer, append an empty line
487
+ self.term.append(self.empty_line())
488
+ self.scrollregion_end += 1
489
+ continue
490
+
491
+ # adjust x axis of scrollback buffer to the current width
492
+ padding = self.width - len(last_line)
493
+ if padding > 0:
494
+ last_line += [self.empty_char()] * padding
495
+ else:
496
+ last_line = last_line[: self.width]
497
+
498
+ self.term.insert(0, last_line)
499
+ elif height < self.height:
500
+ # shrink
501
+ for _y in range(height, self.height):
502
+ self.scrollback_buffer.append(self.term.pop(0))
503
+
504
+ self.height = height
505
+
506
+ self.reset_scroll()
507
+
508
+ x, y = self.constrain_coords(x, y)
509
+ self.set_term_cursor(x, y)
510
+
511
+ # extend tabs
512
+ self.init_tabstops(extend=True)
513
+
514
+ def set_g01(self, char: bytes, mod: bytes) -> None:
515
+ """
516
+ Set G0 or G1 according to 'char' and modifier 'mod'.
517
+ """
518
+ if self.modes.main_charset != CHARSET_DEFAULT:
519
+ return
520
+
521
+ if mod == b"(":
522
+ g = 0
523
+ else:
524
+ g = 1
525
+
526
+ if char == b"0":
527
+ cset = "vt100"
528
+ elif char == b"U":
529
+ cset = "ibmpc"
530
+ elif char == b"K":
531
+ cset = "user"
532
+ else:
533
+ cset = "default"
534
+
535
+ self.charset.define(g, cset)
536
+
537
+ def parse_csi(self, char: bytes) -> None:
538
+ """
539
+ Parse ECMA-48 CSI (Control Sequence Introducer) sequences.
540
+ """
541
+ qmark = self.escbuf.startswith(b"?")
542
+
543
+ escbuf = []
544
+ for arg in self.escbuf[1 if qmark else 0 :].split(b";"):
545
+ try:
546
+ num = int(arg)
547
+ except ValueError:
548
+ num = None
549
+
550
+ escbuf.append(num)
551
+
552
+ cmd_ = CSI_COMMANDS[char]
553
+ if cmd_ is not None:
554
+ if isinstance(cmd_, CSIAlias):
555
+ csi_cmd: CSICommand = CSI_COMMANDS[cmd_.alias] # type: ignore[assignment]
556
+ elif isinstance(cmd_, CSICommand):
557
+ csi_cmd = cmd_
558
+ elif cmd_[0] == "alias": # fallback, hard deprecated
559
+ csi_cmd = CSI_COMMANDS[CSIAlias(*cmd_).alias]
560
+ else:
561
+ csi_cmd = CSICommand(*cmd_) # fallback, hard deprecated
562
+
563
+ number_of_args, default_value, cmd = csi_cmd
564
+ while len(escbuf) < number_of_args:
565
+ escbuf.append(default_value)
566
+ for i in range(len(escbuf)):
567
+ if escbuf[i] is None or escbuf[i] == 0:
568
+ escbuf[i] = default_value
569
+
570
+ with suppress(ValueError):
571
+ cmd(self, escbuf, qmark)
572
+ # ignore commands that don't match the
573
+ # unpacked tuples in CSI_COMMANDS.
574
+
575
+ def parse_noncsi(self, char: bytes, mod: bytes = b"") -> None:
576
+ """
577
+ Parse escape sequences which are not CSI.
578
+ """
579
+ if mod == b"#" and char == b"8":
580
+ self.decaln()
581
+ elif mod == b"%": # select main character set
582
+ if char == b"@":
583
+ self.modes.main_charset = CHARSET_DEFAULT
584
+ elif char in b"G8":
585
+ # 8 is obsolete and only for backwards compatibility
586
+ self.modes.main_charset = CHARSET_UTF8
587
+ elif mod in {b"(", b")"}: # define G0/G1
588
+ self.set_g01(char, mod)
589
+ elif char == b"M": # reverse line feed
590
+ self.linefeed(reverse=True)
591
+ elif char == b"D": # line feed
592
+ self.linefeed()
593
+ elif char == b"c": # reset terminal
594
+ self.reset()
595
+ elif char == b"E": # newline
596
+ self.newline()
597
+ elif char == b"H": # set tabstop
598
+ self.set_tabstop()
599
+ elif char == b"Z": # DECID
600
+ self.widget.respond(f"{ESC}[?6c")
601
+ elif char == b"7": # save current state
602
+ self.save_cursor(with_attrs=True)
603
+ elif char == b"8": # restore current state
604
+ self.restore_cursor(with_attrs=True)
605
+
606
+ def parse_osc(self, buf: bytes) -> None:
607
+ """
608
+ Parse operating system command.
609
+ """
610
+ if buf.startswith((b";", b"0;", b"2;")):
611
+ # set window title
612
+ self.widget.set_title(buf.decode().partition(";")[2])
613
+
614
+ def parse_escape(self, char: bytes) -> None:
615
+ if self.parsestate == 1:
616
+ # within CSI
617
+ if char in CSI_COMMANDS:
618
+ self.parse_csi(char)
619
+ self.parsestate = 0
620
+ elif char in b"0123456789;" or (not self.escbuf and char == b"?"):
621
+ self.escbuf += char
622
+ return
623
+ elif self.parsestate == 0 and char == b"]":
624
+ # start of OSC
625
+ self.escbuf = b""
626
+ self.parsestate = 2
627
+ return
628
+ elif self.parsestate == 2 and char == b"\a":
629
+ # end of OSC
630
+ self.parse_osc(self.escbuf.lstrip(b"0"))
631
+ elif self.parsestate == 2 and self.escbuf[-1:] + char == f"{ESC}\\".encode("iso8859-1"):
632
+ # end of OSC
633
+ self.parse_osc(self.escbuf[:-1].lstrip(b"0"))
634
+ elif self.parsestate == 2 and self.escbuf.startswith(b"P") and len(self.escbuf) == 8:
635
+ # set palette (ESC]Pnrrggbb)
636
+ pass
637
+ elif self.parsestate == 2 and not self.escbuf and char == b"R":
638
+ # reset palette
639
+ pass
640
+ elif self.parsestate == 2:
641
+ self.escbuf += char
642
+ return
643
+ elif self.parsestate == 0 and char == b"[":
644
+ # start of CSI
645
+ self.escbuf = b""
646
+ self.parsestate = 1
647
+ return
648
+ elif self.parsestate == 0 and char in {b"%", b"#", b"(", b")"}:
649
+ # non-CSI sequence
650
+ self.escbuf = char
651
+ self.parsestate = 3
652
+ return
653
+ elif self.parsestate == 3:
654
+ self.parse_noncsi(char, self.escbuf)
655
+ elif char in {b"c", b"D", b"E", b"H", b"M", b"Z", b"7", b"8", b">", b"="}:
656
+ self.parse_noncsi(char)
657
+
658
+ self.leave_escape()
659
+
660
+ def leave_escape(self) -> None:
661
+ self.within_escape = False
662
+ self.parsestate = 0
663
+ self.escbuf = b""
664
+
665
+ def get_utf8_len(self, bytenum: int) -> int:
666
+ """
667
+ Process startbyte and return the number of bytes following it to get a
668
+ valid UTF-8 multibyte sequence.
669
+
670
+ bytenum -- an integer ordinal
671
+ """
672
+ length = 0
673
+
674
+ while bytenum & 0x40:
675
+ bytenum <<= 1
676
+ length += 1
677
+
678
+ return length
679
+
680
+ def addbyte(self, byte: int) -> None:
681
+ """
682
+ Parse main charset and add the processed byte(s) to the terminal state
683
+ machine.
684
+
685
+ byte -- an integer ordinal
686
+ """
687
+ if self.modes.main_charset == CHARSET_UTF8 or util.get_encoding() == "utf8":
688
+ if byte >= 0xC0:
689
+ # start multibyte sequence
690
+ self.utf8_eat_bytes = self.get_utf8_len(byte)
691
+ self.utf8_buffer = bytearray([byte])
692
+ return
693
+ if 0x80 <= byte < 0xC0 and self.utf8_eat_bytes is not None:
694
+ if self.utf8_eat_bytes > 1:
695
+ # continue multibyte sequence
696
+ self.utf8_eat_bytes -= 1
697
+ self.utf8_buffer.append(byte)
698
+ return
699
+
700
+ # end multibyte sequence
701
+ self.utf8_eat_bytes = None
702
+ sequence = (self.utf8_buffer + bytes([byte])).decode("utf-8", "ignore")
703
+ if not sequence:
704
+ # invalid multibyte sequence, stop processing
705
+ return
706
+ char = sequence.encode(util.get_encoding(), "replace")
707
+ else:
708
+ self.utf8_eat_bytes = None
709
+ char = bytes([byte])
710
+ else:
711
+ char = bytes([byte])
712
+
713
+ self.process_char(char)
714
+
715
+ def process_char(self, char: int | bytes) -> None:
716
+ """
717
+ Process a single character (single- and multi-byte).
718
+
719
+ char -- a byte string
720
+ """
721
+ x, y = self.term_cursor
722
+
723
+ if isinstance(char, int):
724
+ char = chr(char)
725
+
726
+ dc = self.modes.display_ctrl
727
+
728
+ if char == ESC_B and self.parsestate != 2: # escape
729
+ self.within_escape = True
730
+ elif not dc and char == b"\r": # carriage return CR
731
+ self.carriage_return()
732
+ elif not dc and char == b"\x0f": # activate G0
733
+ self.charset.activate(0)
734
+ elif not dc and char == b"\x0e": # activate G1
735
+ self.charset.activate(1)
736
+ elif not dc and char in b"\n\v\f": # line feed LF/VT/FF
737
+ self.linefeed()
738
+ if self.modes.lfnl:
739
+ self.carriage_return()
740
+ elif not dc and char == b"\t": # char tab
741
+ self.tab()
742
+ elif not dc and char == b"\b": # backspace BS
743
+ if x > 0:
744
+ self.set_term_cursor(x - 1, y)
745
+ elif not dc and char == b"\a" and self.parsestate != 2: # BEL
746
+ # we need to check if we're in parsestate 2, as an OSC can be
747
+ # terminated by the BEL character!
748
+ self.widget.beep()
749
+ elif not dc and char in b"\x18\x1a": # CAN/SUB
750
+ self.leave_escape()
751
+ elif not dc and char in b"\x00\x7f": # NUL/DEL
752
+ pass # this is ignored
753
+ elif self.within_escape:
754
+ self.parse_escape(char)
755
+ elif not dc and char == b"\x9b": # CSI (equivalent to "ESC [")
756
+ self.within_escape = True
757
+ self.escbuf = b""
758
+ self.parsestate = 1
759
+ else:
760
+ self.push_cursor(char)
761
+
762
+ def set_char(self, char: bytes, x: int | None = None, y: int | None = None) -> None:
763
+ """
764
+ Set character of either the current cursor position
765
+ or a position given by 'x' and/or 'y' to 'char'.
766
+ """
767
+ if x is None:
768
+ x = self.term_cursor[0]
769
+ if y is None:
770
+ y = self.term_cursor[1]
771
+
772
+ x, y = self.constrain_coords(x, y)
773
+ self.term[y][x] = (self.attrspec, self.charset.current, char)
774
+
775
+ def constrain_coords(self, x: int, y: int, ignore_scrolling: bool = False) -> tuple[int, int]:
776
+ """
777
+ Checks if x/y are within the terminal and returns the corrected version.
778
+ If 'ignore_scrolling' is set, constrain within the full size of the
779
+ screen and not within scrolling region.
780
+ """
781
+ if x >= self.width:
782
+ x = self.width - 1
783
+ elif x < 0:
784
+ x = 0
785
+
786
+ if self.modes.constrain_scrolling and not ignore_scrolling:
787
+ if y > self.scrollregion_end:
788
+ y = self.scrollregion_end
789
+ elif y < self.scrollregion_start:
790
+ y = self.scrollregion_start
791
+ else: # noqa: PLR5501 # pylint: disable=else-if-used # readability
792
+ if y >= self.height:
793
+ y = self.height - 1
794
+ elif y < 0:
795
+ y = 0
796
+
797
+ return x, y
798
+
799
+ def linefeed(self, reverse: bool = False) -> None:
800
+ """
801
+ Move the cursor down (or up if reverse is True) one line but don't reset
802
+ horizontal position.
803
+ """
804
+ x, y = self.term_cursor
805
+
806
+ if reverse:
807
+ if y <= 0 < self.scrollregion_start:
808
+ pass
809
+ elif y == self.scrollregion_start:
810
+ self.scroll(reverse=True)
811
+ else:
812
+ y -= 1
813
+ else: # noqa: PLR5501 # pylint: disable=else-if-used # readability
814
+ if y >= self.height - 1 > self.scrollregion_end:
815
+ pass
816
+ elif y == self.scrollregion_end:
817
+ self.scroll()
818
+ else:
819
+ y += 1
820
+
821
+ self.set_term_cursor(x, y)
822
+
823
+ def carriage_return(self) -> None:
824
+ self.set_term_cursor(0, self.term_cursor[1])
825
+
826
+ def newline(self) -> None:
827
+ """
828
+ Do a carriage return followed by a line feed.
829
+ """
830
+ self.carriage_return()
831
+ self.linefeed()
832
+
833
+ def move_cursor(
834
+ self,
835
+ x: int,
836
+ y: int,
837
+ relative_x: bool = False,
838
+ relative_y: bool = False,
839
+ relative: bool = False,
840
+ ) -> None:
841
+ """
842
+ Move cursor to position x/y while constraining terminal sizes.
843
+ If 'relative' is True, x/y is relative to the current cursor
844
+ position. 'relative_x' and 'relative_y' is the same but just with
845
+ the corresponding axis.
846
+ """
847
+ if relative:
848
+ relative_y = relative_x = True
849
+
850
+ if relative_x:
851
+ x += self.term_cursor[0]
852
+
853
+ if relative_y:
854
+ y += self.term_cursor[1]
855
+ elif self.modes.constrain_scrolling:
856
+ y += self.scrollregion_start
857
+
858
+ self.set_term_cursor(x, y)
859
+
860
+ def push_char(self, char: bytes | None, x: int, y: int) -> None:
861
+ """
862
+ Push one character to current position and advance cursor to x/y.
863
+ """
864
+ if char is not None:
865
+ char = self.charset.apply_mapping(char)
866
+ if self.modes.insert:
867
+ self.insert_chars(char=char)
868
+ else:
869
+ self.set_char(char)
870
+
871
+ self.set_term_cursor(x, y)
872
+
873
+ def push_cursor(self, char: bytes | None = None) -> None:
874
+ """
875
+ Move cursor one character forward wrapping lines as needed.
876
+ If 'char' is given, put the character into the former position.
877
+ """
878
+ x, y = self.term_cursor
879
+
880
+ if self.modes.autowrap:
881
+ if x + 1 >= self.width and not self.is_rotten_cursor:
882
+ # "rotten cursor" - this is when the cursor gets to the rightmost
883
+ # position of the screen, the cursor position remains the same but
884
+ # one last set_char() is allowed for that piece of sh^H^H"border".
885
+ self.is_rotten_cursor = True
886
+ self.push_char(char, x, y)
887
+ else:
888
+ x += 1
889
+
890
+ if x >= self.width and self.is_rotten_cursor:
891
+ if y >= self.scrollregion_end:
892
+ self.scroll()
893
+ else:
894
+ y += 1
895
+
896
+ x = 1
897
+
898
+ self.set_term_cursor(0, y)
899
+
900
+ self.push_char(char, x, y)
901
+
902
+ self.is_rotten_cursor = False
903
+ else:
904
+ if x + 1 < self.width:
905
+ x += 1
906
+
907
+ self.is_rotten_cursor = False
908
+ self.push_char(char, x, y)
909
+
910
+ def save_cursor(self, with_attrs: bool = False) -> None:
911
+ self.saved_cursor = tuple(self.term_cursor)
912
+ if with_attrs:
913
+ self.saved_attrs = (copy.copy(self.attrspec), copy.copy(self.charset))
914
+
915
+ def restore_cursor(self, with_attrs: bool = False) -> None:
916
+ if self.saved_cursor is None:
917
+ return
918
+
919
+ x, y = self.saved_cursor
920
+ self.set_term_cursor(x, y)
921
+
922
+ if with_attrs and self.saved_attrs is not None:
923
+ self.attrspec, self.charset = (copy.copy(self.saved_attrs[0]), copy.copy(self.saved_attrs[1]))
924
+
925
+ def tab(self, tabstop: int = 8) -> None:
926
+ """
927
+ Moves cursor to the next 'tabstop' filling everything in between
928
+ with spaces.
929
+ """
930
+ x, y = self.term_cursor
931
+
932
+ while x < self.width - 1:
933
+ self.set_char(b" ")
934
+ x += 1
935
+
936
+ if self.is_tabstop(x):
937
+ break
938
+
939
+ self.is_rotten_cursor = False
940
+ self.set_term_cursor(x, y)
941
+
942
+ def scroll(self, reverse: bool = False) -> None:
943
+ """
944
+ Append a new line at the bottom and put the topmost line into the
945
+ scrollback buffer.
946
+
947
+ If reverse is True, do exactly the opposite, but don't save into
948
+ scrollback buffer.
949
+ """
950
+ if reverse:
951
+ self.term.pop(self.scrollregion_end)
952
+ self.term.insert(self.scrollregion_start, self.empty_line())
953
+ else:
954
+ killed = self.term.pop(self.scrollregion_start)
955
+ self.scrollback_buffer.append(killed)
956
+ self.term.insert(self.scrollregion_end, self.empty_line())
957
+
958
+ def decaln(self) -> None:
959
+ """
960
+ DEC screen alignment test: Fill screen with E's.
961
+ """
962
+ for row in range(self.height):
963
+ self.term[row] = self.empty_line(b"E")
964
+
965
+ def blank_line(self, row: int) -> None:
966
+ """
967
+ Blank a single line at the specified row, without modifying other lines.
968
+ """
969
+ self.term[row] = self.empty_line()
970
+
971
+ def insert_chars(
972
+ self,
973
+ position: tuple[int, int] | None = None,
974
+ chars: int = 1,
975
+ char: bytes | None = None,
976
+ ) -> None:
977
+ """
978
+ Insert 'chars' number of either empty characters - or those specified by
979
+ 'char' - before 'position' (or the current position if not specified)
980
+ pushing subsequent characters of the line to the right without wrapping.
981
+ """
982
+ if position is None:
983
+ position = self.term_cursor
984
+
985
+ if chars == 0:
986
+ chars = 1
987
+
988
+ if char is None:
989
+ char = self.empty_char()
990
+ else:
991
+ char = (self.attrspec, self.charset.current, char)
992
+
993
+ x, y = position
994
+
995
+ while chars > 0:
996
+ self.term[y].insert(x, char)
997
+ self.term[y].pop()
998
+ chars -= 1
999
+
1000
+ def remove_chars(self, position: tuple[int, int] | None = None, chars: int = 1) -> None:
1001
+ """
1002
+ Remove 'chars' number of empty characters from 'position' (or the current
1003
+ position if not specified) pulling subsequent characters of the line to
1004
+ the left without joining any subsequent lines.
1005
+ """
1006
+ if position is None:
1007
+ position = self.term_cursor
1008
+
1009
+ if chars == 0:
1010
+ chars = 1
1011
+
1012
+ x, y = position
1013
+
1014
+ while chars > 0:
1015
+ self.term[y].pop(x)
1016
+ self.term[y].append(self.empty_char())
1017
+ chars -= 1
1018
+
1019
+ def insert_lines(self, row: int | None = None, lines: int = 1) -> None:
1020
+ """
1021
+ Insert 'lines' of empty lines after the specified row, pushing all
1022
+ subsequent lines to the bottom. If no 'row' is specified, the current
1023
+ row is used.
1024
+ """
1025
+ if row is None:
1026
+ row = self.term_cursor[1]
1027
+ else:
1028
+ row = self.scrollregion_start
1029
+
1030
+ if lines == 0:
1031
+ lines = 1
1032
+
1033
+ while lines > 0:
1034
+ self.term.insert(row, self.empty_line())
1035
+ self.term.pop(self.scrollregion_end)
1036
+ lines -= 1
1037
+
1038
+ def remove_lines(self, row: int | None = None, lines: int = 1) -> None:
1039
+ """
1040
+ Remove 'lines' number of lines at the specified row, pulling all
1041
+ subsequent lines to the top. If no 'row' is specified, the current row
1042
+ is used.
1043
+ """
1044
+ if row is None:
1045
+ row = self.term_cursor[1]
1046
+ else:
1047
+ row = self.scrollregion_start
1048
+
1049
+ if lines == 0:
1050
+ lines = 1
1051
+
1052
+ while lines > 0:
1053
+ self.term.pop(row)
1054
+ self.term.insert(self.scrollregion_end, self.empty_line())
1055
+ lines -= 1
1056
+
1057
+ def erase(
1058
+ self,
1059
+ start: tuple[int, int] | tuple[int, int, bool],
1060
+ end: tuple[int, int] | tuple[int, int, bool],
1061
+ ) -> None:
1062
+ """
1063
+ Erase a region of the terminal. The 'start' tuple (x, y) defines the
1064
+ starting position of the erase, while end (x, y) the last position.
1065
+
1066
+ For example if the terminal size is 4x3, start=(1, 1) and end=(1, 2)
1067
+ would erase the following region:
1068
+
1069
+ ....
1070
+ .XXX
1071
+ XX..
1072
+ """
1073
+ sx, sy = self.constrain_coords(*start)
1074
+ ex, ey = self.constrain_coords(*end)
1075
+
1076
+ # within a single row
1077
+ if sy == ey:
1078
+ for x in range(sx, ex + 1):
1079
+ self.term[sy][x] = self.empty_char()
1080
+ return
1081
+
1082
+ # spans multiple rows
1083
+ y = sy
1084
+ while y <= ey:
1085
+ if y == sy:
1086
+ for x in range(sx, self.width):
1087
+ self.term[y][x] = self.empty_char()
1088
+ elif y == ey:
1089
+ for x in range(ex + 1):
1090
+ self.term[y][x] = self.empty_char()
1091
+ else:
1092
+ self.blank_line(y)
1093
+
1094
+ y += 1
1095
+
1096
+ def sgi_to_attrspec(
1097
+ self,
1098
+ attrs: Sequence[int],
1099
+ fg: int,
1100
+ bg: int,
1101
+ attributes: set[str],
1102
+ prev_colors: int,
1103
+ ) -> AttrSpec | None:
1104
+ """
1105
+ Parse SGI sequence and return an AttrSpec representing the sequence
1106
+ including all earlier sequences specified as 'fg', 'bg' and
1107
+ 'attributes'.
1108
+ """
1109
+
1110
+ idx = 0
1111
+ colors = prev_colors
1112
+
1113
+ while idx < len(attrs):
1114
+ attr = attrs[idx]
1115
+ if 30 <= attr <= 37:
1116
+ fg = attr - 30
1117
+ colors = max(16, colors)
1118
+ elif 40 <= attr <= 47:
1119
+ bg = attr - 40
1120
+ colors = max(16, colors)
1121
+ elif attr in {38, 48}:
1122
+ if idx + 2 < len(attrs) and attrs[idx + 1] == 5:
1123
+ # 8 bit color specification
1124
+ color = attrs[idx + 2]
1125
+ colors = max(256, colors)
1126
+ if attr == 38:
1127
+ fg = color
1128
+ else:
1129
+ bg = color
1130
+ idx += 2
1131
+ elif idx + 4 < len(attrs) and attrs[idx + 1] == 2:
1132
+ # 24 bit color specification
1133
+ color = (attrs[idx + 2] << 16) + (attrs[idx + 3] << 8) + attrs[idx + 4]
1134
+ colors = 2**24
1135
+ if attr == 38:
1136
+ fg = color
1137
+ else:
1138
+ bg = color
1139
+ idx += 4
1140
+ elif attr == 39:
1141
+ # set default foreground color
1142
+ fg = None
1143
+ elif attr == 49:
1144
+ # set default background color
1145
+ bg = None
1146
+ elif attr == 10:
1147
+ self.charset.reset_sgr_ibmpc()
1148
+ self.modes.display_ctrl = False
1149
+ elif attr in {11, 12}:
1150
+ self.charset.set_sgr_ibmpc()
1151
+ self.modes.display_ctrl = True
1152
+
1153
+ # set attributes
1154
+ elif attr == 1:
1155
+ attributes.add("bold")
1156
+ elif attr == 4:
1157
+ attributes.add("underline")
1158
+ elif attr == 5:
1159
+ attributes.add("blink")
1160
+ elif attr == 7:
1161
+ attributes.add("standout")
1162
+
1163
+ # unset attributes
1164
+ elif attr == 24:
1165
+ attributes.discard("underline")
1166
+ elif attr == 25:
1167
+ attributes.discard("blink")
1168
+ elif attr == 27:
1169
+ attributes.discard("standout")
1170
+ elif attr == 0:
1171
+ # clear all attributes
1172
+ fg = bg = None
1173
+ attributes.clear()
1174
+
1175
+ idx += 1
1176
+
1177
+ if "bold" in attributes and colors == 16 and fg is not None and fg < 8:
1178
+ fg += 8
1179
+
1180
+ def _defaulter(color: int | None, colors: int) -> str:
1181
+ if color is None:
1182
+ return "default"
1183
+ # Note: we can't detect 88 color mode
1184
+ if color > 255 or colors == 2**24:
1185
+ return _color_desc_true(color)
1186
+ if color > 15 or colors == 256:
1187
+ return _color_desc_256(color)
1188
+ return _BASIC_COLORS[color]
1189
+
1190
+ fg = _defaulter(fg, colors)
1191
+ bg = _defaulter(bg, colors)
1192
+
1193
+ if attributes:
1194
+ fg = ",".join((fg, *list(attributes)))
1195
+
1196
+ if fg == bg == "default":
1197
+ return None
1198
+
1199
+ if colors:
1200
+ return AttrSpec(fg, bg, colors=colors)
1201
+
1202
+ return AttrSpec(fg, bg)
1203
+
1204
+ def csi_set_attr(self, attrs: Sequence[int]) -> None:
1205
+ """
1206
+ Set graphics rendition.
1207
+ """
1208
+ if attrs[-1] == 0:
1209
+ self.attrspec = None
1210
+
1211
+ attributes = set()
1212
+ if self.attrspec is None:
1213
+ fg = bg = None
1214
+ else:
1215
+ # set default values from previous attrspec
1216
+ if "default" in self.attrspec.foreground:
1217
+ fg = None
1218
+ else:
1219
+ fg = self.attrspec.foreground_number
1220
+ if fg >= 8 and self.attrspec.colors == 16:
1221
+ fg -= 8
1222
+
1223
+ if "default" in self.attrspec.background:
1224
+ bg = None
1225
+ else:
1226
+ bg = self.attrspec.background_number
1227
+ if bg >= 8 and self.attrspec.colors == 16:
1228
+ bg -= 8
1229
+
1230
+ for attr in ("bold", "underline", "blink", "standout"):
1231
+ if not getattr(self.attrspec, attr):
1232
+ continue
1233
+
1234
+ attributes.add(attr)
1235
+
1236
+ attrspec = self.sgi_to_attrspec(attrs, fg, bg, attributes, self.attrspec.colors if self.attrspec else 1)
1237
+
1238
+ if self.modes.reverse_video:
1239
+ self.attrspec = self.reverse_attrspec(attrspec)
1240
+ else:
1241
+ self.attrspec = attrspec
1242
+
1243
+ def reverse_attrspec(self, attrspec: AttrSpec | None, undo: bool = False) -> AttrSpec:
1244
+ """
1245
+ Put standout mode to the 'attrspec' given and remove it if 'undo' is
1246
+ True.
1247
+ """
1248
+ if attrspec is None:
1249
+ attrspec = AttrSpec("default", "default")
1250
+ attrs = [fg.strip() for fg in attrspec.foreground.split(",")]
1251
+ if "standout" in attrs and undo:
1252
+ attrs.remove("standout")
1253
+ attrspec = attrspec.copy_modified(fg=",".join(attrs))
1254
+ elif "standout" not in attrs and not undo:
1255
+ attrs.append("standout")
1256
+ attrspec = attrspec.copy_modified(fg=",".join(attrs))
1257
+ return attrspec
1258
+
1259
+ def reverse_video(self, undo: bool = False) -> None:
1260
+ """
1261
+ Reverse video/scanmode (DECSCNM) by swapping fg and bg colors.
1262
+ """
1263
+ for y in range(self.height):
1264
+ for x in range(self.width):
1265
+ char = self.term[y][x]
1266
+ attrs = self.reverse_attrspec(char[0], undo=undo)
1267
+ self.term[y][x] = (attrs,) + char[1:]
1268
+
1269
+ def set_mode(
1270
+ self,
1271
+ mode: Literal[1, 3, 4, 5, 6, 7, 20, 25, 2004],
1272
+ flag: bool,
1273
+ qmark: bool,
1274
+ reset: bool,
1275
+ ) -> None:
1276
+ """
1277
+ Helper method for csi_set_modes: set single mode.
1278
+ """
1279
+ if qmark:
1280
+ # DEC private mode
1281
+ if mode == 1:
1282
+ # cursor keys send an ESC O prefix, rather than ESC [
1283
+ self.modes.keys_decckm = flag
1284
+ elif mode == 3:
1285
+ # deccolm just clears the screen
1286
+ self.clear()
1287
+ elif mode == 5:
1288
+ if self.modes.reverse_video != flag:
1289
+ self.reverse_video(undo=not flag)
1290
+ self.modes.reverse_video = flag
1291
+ elif mode == 6:
1292
+ self.modes.constrain_scrolling = flag
1293
+ self.set_term_cursor(0, 0)
1294
+ elif mode == 7:
1295
+ self.modes.autowrap = flag
1296
+ elif mode == 25:
1297
+ self.modes.visible_cursor = flag
1298
+ self.set_term_cursor()
1299
+ elif mode == 2004:
1300
+ self.modes.bracketed_paste = flag
1301
+ else: # noqa: PLR5501 # pylint: disable=else-if-used # readability
1302
+ # ECMA-48
1303
+ if mode == 3:
1304
+ self.modes.display_ctrl = flag
1305
+ elif mode == 4:
1306
+ self.modes.insert = flag
1307
+ elif mode == 20:
1308
+ self.modes.lfnl = flag
1309
+
1310
+ def csi_set_modes(self, modes: Iterable[int], qmark: bool, reset: bool = False) -> None:
1311
+ """
1312
+ Set (DECSET/ECMA-48) or reset modes (DECRST/ECMA-48) if reset is True.
1313
+ """
1314
+ flag = not reset
1315
+
1316
+ for mode in modes:
1317
+ self.set_mode(mode, flag, qmark, reset)
1318
+
1319
+ def csi_set_scroll(self, top: int = 0, bottom: int = 0) -> None:
1320
+ """
1321
+ Set scrolling region, 'top' is the line number of first line in the
1322
+ scrolling region. 'bottom' is the line number of bottom line. If both
1323
+ are set to 0, the whole screen will be used (default).
1324
+ """
1325
+ if not top:
1326
+ top = 1
1327
+ if not bottom:
1328
+ bottom = self.height
1329
+
1330
+ if top < bottom <= self.height:
1331
+ self.scrollregion_start = self.constrain_coords(0, top - 1, ignore_scrolling=True)[1]
1332
+ self.scrollregion_end = self.constrain_coords(0, bottom - 1, ignore_scrolling=True)[1]
1333
+
1334
+ self.set_term_cursor(0, 0)
1335
+
1336
+ def csi_clear_tabstop(self, mode: Literal[0, 3] = 0):
1337
+ """
1338
+ Clear tabstop at current position or if 'mode' is 3, delete all
1339
+ tabstops.
1340
+ """
1341
+ if mode == 0:
1342
+ self.set_tabstop(remove=True)
1343
+ elif mode == 3:
1344
+ self.set_tabstop(clear=True)
1345
+
1346
+ def csi_get_device_attributes(self, qmark: bool) -> None:
1347
+ """
1348
+ Report device attributes (what are you?). In our case, we'll report
1349
+ ourself as a VT102 terminal.
1350
+ """
1351
+ if not qmark:
1352
+ self.widget.respond(f"{ESC}[?6c")
1353
+
1354
+ def csi_status_report(self, mode: Literal[5, 6]) -> None:
1355
+ """
1356
+ Report various information about the terminal status.
1357
+ Information is queried by 'mode', where possible values are:
1358
+ 5 -> device status report
1359
+ 6 -> cursor position report
1360
+ """
1361
+ if mode == 5:
1362
+ # terminal OK
1363
+ self.widget.respond(f"{ESC}[0n")
1364
+ elif mode == 6:
1365
+ x, y = self.term_cursor
1366
+ self.widget.respond(ESC + f"[{y + 1:d};{x + 1:d}R")
1367
+
1368
+ def csi_erase_line(self, mode: Literal[0, 1, 2]) -> None:
1369
+ """
1370
+ Erase current line, modes are:
1371
+ 0 -> erase from cursor to end of line.
1372
+ 1 -> erase from start of line to cursor.
1373
+ 2 -> erase whole line.
1374
+ """
1375
+ x, y = self.term_cursor
1376
+
1377
+ if mode == 0:
1378
+ self.erase(self.term_cursor, (self.width - 1, y))
1379
+ elif mode == 1:
1380
+ self.erase((0, y), (x, y))
1381
+ elif mode == 2:
1382
+ self.blank_line(y)
1383
+
1384
+ def csi_erase_display(self, mode: Literal[0, 1, 2]) -> None:
1385
+ """
1386
+ Erase display, modes are:
1387
+ 0 -> erase from cursor to end of display.
1388
+ 1 -> erase from start to cursor.
1389
+ 2 -> erase the whole display.
1390
+ """
1391
+ if mode == 0:
1392
+ self.erase(self.term_cursor, (self.width - 1, self.height - 1))
1393
+ if mode == 1:
1394
+ self.erase((0, 0), (self.term_cursor[0] - 1, self.term_cursor[1]))
1395
+ elif mode == 2:
1396
+ self.clear(cursor=self.term_cursor)
1397
+
1398
+ def csi_set_keyboard_leds(self, mode: Literal[0, 1, 2, 3] = 0) -> None:
1399
+ """
1400
+ Set keyboard LEDs, modes are:
1401
+ 0 -> clear all LEDs
1402
+ 1 -> set scroll lock LED
1403
+ 2 -> set num lock LED
1404
+ 3 -> set caps lock LED
1405
+
1406
+ This currently just emits a signal, so it can be processed by another
1407
+ widget or the main application.
1408
+ """
1409
+ states = {
1410
+ 0: "clear",
1411
+ 1: "scroll_lock",
1412
+ 2: "num_lock",
1413
+ 3: "caps_lock",
1414
+ }
1415
+
1416
+ if mode in states:
1417
+ self.widget.leds(states[mode])
1418
+
1419
+ def clear(self, cursor: tuple[int, int] | None = None) -> None:
1420
+ """
1421
+ Clears the whole terminal screen and resets the cursor position
1422
+ to (0, 0) or to the coordinates given by 'cursor'.
1423
+ """
1424
+ self.term = [self.empty_line() for _ in range(self.height)]
1425
+
1426
+ if cursor is None:
1427
+ self.set_term_cursor(0, 0)
1428
+ else:
1429
+ self.set_term_cursor(*cursor)
1430
+
1431
+ def cols(self) -> int:
1432
+ return self.width
1433
+
1434
+ def rows(self) -> int:
1435
+ return self.height
1436
+
1437
+ def content(
1438
+ self,
1439
+ trim_left: int = 0,
1440
+ trim_top: int = 0,
1441
+ cols: int | None = None,
1442
+ rows: int | None = None,
1443
+ attr=None,
1444
+ ) -> Iterable[list[tuple[object, Literal["0", "U"] | None, bytes]]]:
1445
+ if self.scrolling_up == 0:
1446
+ yield from self.term
1447
+ else:
1448
+ buf = self.scrollback_buffer + self.term
1449
+ yield from buf[-(self.height + self.scrolling_up) : -self.scrolling_up]
1450
+
1451
+ def content_delta(self, other: Canvas):
1452
+ if other is self:
1453
+ return [self.cols()] * self.rows()
1454
+ return self.content()
1455
+
1456
+
1457
+ class Terminal(Widget):
1458
+ _selectable = True
1459
+ _sizing = frozenset([Sizing.BOX])
1460
+
1461
+ signals: typing.ClassVar[list[str]] = ["closed", "beep", "leds", "title", "resize"]
1462
+
1463
+ def __init__(
1464
+ self,
1465
+ command: Sequence[str | bytes] | Callable[[], typing.Any] | None,
1466
+ env: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
1467
+ main_loop: event_loop.EventLoop | None = None,
1468
+ escape_sequence: str | None = None,
1469
+ encoding: str = "utf-8",
1470
+ ):
1471
+ """
1472
+ A terminal emulator within a widget.
1473
+
1474
+ ``command`` is the command to execute inside the terminal,
1475
+ provided as a list of the command followed by its arguments.
1476
+ If 'command' is None, the command is the current user's shell.
1477
+ You can also provide a callable instead of a command, which will be executed in the subprocess.
1478
+
1479
+ ``env`` can be used to pass custom environment variables. If omitted,
1480
+ os.environ is used.
1481
+
1482
+ ``main_loop`` should be provided, because the canvas state machine needs
1483
+ to act on input from the PTY master device. This object must have
1484
+ watch_file and remove_watch_file methods.
1485
+
1486
+ ``escape_sequence`` is the urwid key symbol which should be used to break
1487
+ out of the terminal widget. If it's not specified, ``ctrl a`` is used.
1488
+
1489
+ ``encoding`` specifies the encoding that is being used when local
1490
+ keypresses in Unicode are encoded into raw bytes. UTF-8 is used by default.
1491
+ Set this to the encoding of your terminal if you need to transmit
1492
+ characters to the spawned process in non-UTF8 encoding.
1493
+ Applies to Python 3.x only.
1494
+
1495
+ .. note::
1496
+
1497
+ If you notice your Terminal instance is not printing unicode glyphs
1498
+ correctly, make sure the global encoding for urwid is set to
1499
+ ``utf8`` with ``urwid.set_encoding("utf8")``. See
1500
+ :ref:`text-encodings` for more details.
1501
+ """
1502
+ super().__init__()
1503
+
1504
+ self.escape_sequence: str = escape_sequence or "ctrl a"
1505
+
1506
+ self.env = dict(env or os.environ)
1507
+
1508
+ self.command = command or [self.env.get("SHELL", "/bin/sh")]
1509
+
1510
+ self.encoding = encoding
1511
+
1512
+ self.keygrab = False
1513
+ self.last_key: str | None = None
1514
+
1515
+ self.response_buffer: list[str] = []
1516
+
1517
+ self.term_modes = TermModes()
1518
+
1519
+ if main_loop is not None:
1520
+ self.main_loop = main_loop
1521
+ else:
1522
+ self.main_loop = event_loop.SelectEventLoop()
1523
+
1524
+ self.master: int | None = None
1525
+ self.pid: int | None = None
1526
+
1527
+ self.width: int | None = None
1528
+ self.height: int | None = None
1529
+ self.term: TermCanvas | None = None
1530
+ self.has_focus = False
1531
+ self.terminated = False
1532
+
1533
+ def get_cursor_coords(self, size: tuple[int, int]) -> tuple[int, int] | None:
1534
+ """Return the cursor coordinates for this terminal"""
1535
+ if self.term is None:
1536
+ return None
1537
+
1538
+ # temporarily set width/height to figure out the new cursor position
1539
+ # given the provided width/height
1540
+ orig_width, orig_height = self.term.width, self.term.height
1541
+
1542
+ self.term.width = size[0]
1543
+ self.term.height = size[1]
1544
+
1545
+ x, y = self.term.constrain_coords(
1546
+ self.term.term_cursor[0],
1547
+ self.term.term_cursor[1],
1548
+ )
1549
+
1550
+ self.term.width, self.term.height = orig_width, orig_height
1551
+
1552
+ return (x, y)
1553
+
1554
+ def spawn(self) -> None:
1555
+ env = self.env
1556
+ env["TERM"] = "linux"
1557
+
1558
+ self.pid, self.master = pty.fork()
1559
+
1560
+ if self.pid == 0:
1561
+ if callable(self.command):
1562
+ try:
1563
+ # noinspection PyBroadException
1564
+ try:
1565
+ self.command()
1566
+ except BaseException: # special case
1567
+ sys.stderr.write(traceback.format_exc())
1568
+ sys.stderr.flush()
1569
+ finally:
1570
+ os._exit(0)
1571
+ else:
1572
+ os.execvpe(self.command[0], self.command, env) # noqa: S606
1573
+
1574
+ if self.main_loop is None:
1575
+ fcntl.fcntl(self.master, fcntl.F_SETFL, os.O_NONBLOCK)
1576
+
1577
+ atexit.register(self.terminate)
1578
+
1579
+ def terminate(self) -> None:
1580
+ if self.terminated:
1581
+ return
1582
+
1583
+ self.terminated = True
1584
+ self.remove_watch()
1585
+ self.change_focus(False)
1586
+
1587
+ if self.pid > 0:
1588
+ self.set_termsize(0, 0)
1589
+ for sig in (signal.SIGHUP, signal.SIGCONT, signal.SIGINT, signal.SIGTERM, signal.SIGKILL):
1590
+ try:
1591
+ os.kill(self.pid, sig)
1592
+ pid, _status = os.waitpid(self.pid, os.WNOHANG)
1593
+ except OSError:
1594
+ break
1595
+
1596
+ if pid == 0:
1597
+ break
1598
+ time.sleep(0.1)
1599
+ with suppress(OSError):
1600
+ os.waitpid(self.pid, 0)
1601
+
1602
+ os.close(self.master)
1603
+
1604
+ def beep(self) -> None:
1605
+ self._emit("beep")
1606
+
1607
+ def leds(self, which: Literal["clear", "scroll_lock", "num_lock", "caps_lock"]) -> None:
1608
+ self._emit("leds", which)
1609
+
1610
+ def respond(self, string: str) -> None:
1611
+ """
1612
+ Respond to the underlying application with 'string'.
1613
+ """
1614
+ self.response_buffer.append(string)
1615
+
1616
+ def flush_responses(self) -> None:
1617
+ for string in self.response_buffer:
1618
+ os.write(self.master, string.encode("ascii"))
1619
+ self.response_buffer = []
1620
+
1621
+ def set_termsize(self, width: int, height: int) -> None:
1622
+ winsize = struct.pack("HHHH", height, width, 0, 0)
1623
+ fcntl.ioctl(self.master, termios.TIOCSWINSZ, winsize)
1624
+
1625
+ def touch_term(self, width: int, height: int) -> None:
1626
+ process_opened = False
1627
+
1628
+ if self.pid is None:
1629
+ self.spawn()
1630
+ process_opened = True
1631
+
1632
+ if self.width == width and self.height == height:
1633
+ return
1634
+
1635
+ self.set_termsize(width, height)
1636
+
1637
+ if not self.term:
1638
+ self.term = TermCanvas(width, height, self)
1639
+ else:
1640
+ self.term.resize(width, height)
1641
+
1642
+ self.width = width
1643
+ self.height = height
1644
+
1645
+ if process_opened:
1646
+ self.add_watch()
1647
+
1648
+ self._emit("resize", (width, height))
1649
+
1650
+ def set_title(self, title) -> None:
1651
+ self._emit("title", title)
1652
+
1653
+ def change_focus(self, has_focus) -> None:
1654
+ """
1655
+ Ignore SIGINT if this widget has focus.
1656
+ """
1657
+ if self.terminated:
1658
+ return
1659
+
1660
+ self.has_focus = has_focus
1661
+
1662
+ if self.term is not None:
1663
+ self.term.has_focus = has_focus
1664
+ self.term.set_term_cursor()
1665
+
1666
+ if has_focus:
1667
+ self.old_tios = RealTerminal().tty_signal_keys()
1668
+ RealTerminal().tty_signal_keys(*(["undefined"] * 5))
1669
+ elif hasattr(self, "old_tios"):
1670
+ RealTerminal().tty_signal_keys(*self.old_tios)
1671
+
1672
+ def render(self, size: tuple[int, int], focus: bool = False) -> TermCanvas:
1673
+ if not self.terminated:
1674
+ self.change_focus(focus)
1675
+
1676
+ width, height = size
1677
+ self.touch_term(width, height)
1678
+
1679
+ if self.main_loop is None:
1680
+ self.feed()
1681
+
1682
+ return self.term
1683
+
1684
+ def add_watch(self) -> None:
1685
+ if self.main_loop is None:
1686
+ return
1687
+ self.main_loop.watch_file(self.master, self.feed)
1688
+
1689
+ def remove_watch(self) -> None:
1690
+ if self.main_loop is None:
1691
+ return
1692
+ self.main_loop.remove_watch_file(self.master)
1693
+
1694
+ def wait_and_feed(self, timeout: float = 1.0) -> None:
1695
+ with selectors.DefaultSelector() as selector:
1696
+ selector.register(self.master, selectors.EVENT_READ)
1697
+
1698
+ selector.select(timeout)
1699
+
1700
+ self.feed()
1701
+
1702
+ def feed(self) -> None:
1703
+ data = EOF
1704
+
1705
+ try:
1706
+ data = os.read(self.master, 4096)
1707
+ except OSError as e:
1708
+ if e.errno == errno.EIO: # EIO, child terminated
1709
+ data = EOF
1710
+ elif e.errno == errno.EWOULDBLOCK: # empty buffer
1711
+ return
1712
+ else:
1713
+ raise
1714
+
1715
+ if data == EOF:
1716
+ self.terminate()
1717
+ self._emit("closed")
1718
+ return
1719
+
1720
+ self.term.addstr(data)
1721
+
1722
+ self.flush_responses()
1723
+
1724
+ def keypress(self, size: tuple[int, int], key: str) -> str | None:
1725
+ if self.terminated:
1726
+ return key
1727
+
1728
+ if key in {"begin paste", "end paste"}:
1729
+ if self.term_modes.bracketed_paste:
1730
+ pass # passthrough bracketed paste sequences
1731
+ else: # swallow bracketed paste sequences
1732
+ self.last_key = key
1733
+ return None
1734
+
1735
+ if key == "window resize":
1736
+ width, height = size
1737
+ self.touch_term(width, height)
1738
+ return None
1739
+
1740
+ if self.last_key == key == self.escape_sequence:
1741
+ # escape sequence pressed twice...
1742
+ self.last_key = key
1743
+ self.keygrab = True
1744
+ # ... so pass it to the terminal
1745
+ elif self.keygrab:
1746
+ if self.escape_sequence == key:
1747
+ # stop grabbing the terminal
1748
+ self.keygrab = False
1749
+ self.last_key = key
1750
+ return None
1751
+ else:
1752
+ if key == "page up":
1753
+ self.term.scroll_buffer()
1754
+ self.last_key = key
1755
+ self._invalidate()
1756
+ return None
1757
+
1758
+ if key == "page down":
1759
+ self.term.scroll_buffer(up=False)
1760
+ self.last_key = key
1761
+ self._invalidate()
1762
+ return None
1763
+
1764
+ if self.last_key == self.escape_sequence and key != self.escape_sequence:
1765
+ # hand down keypress directly after ungrab.
1766
+ self.last_key = key
1767
+ return key
1768
+
1769
+ if self.escape_sequence == key:
1770
+ # start grabbing the terminal
1771
+ self.keygrab = True
1772
+ self.last_key = key
1773
+ return None
1774
+
1775
+ if self._command_map[key] is None or key == "enter":
1776
+ # printable character or escape sequence means:
1777
+ # lock in terminal...
1778
+ self.keygrab = True
1779
+ # ... and do key processing
1780
+ else:
1781
+ # hand down keypress
1782
+ self.last_key = key
1783
+ return key
1784
+
1785
+ self.last_key = key
1786
+
1787
+ self.term.scroll_buffer(reset=True)
1788
+
1789
+ if key.startswith("ctrl "):
1790
+ if key[-1].islower():
1791
+ key = chr(ord(key[-1]) - ord("a") + 1)
1792
+ else:
1793
+ key = chr(ord(key[-1]) - ord("A") + 1)
1794
+ else: # noqa: PLR5501 # pylint: disable=else-if-used # readability
1795
+ if self.term_modes.keys_decckm and key in KEY_TRANSLATIONS_DECCKM:
1796
+ key = KEY_TRANSLATIONS_DECCKM[key]
1797
+ else:
1798
+ key = KEY_TRANSLATIONS.get(key, key)
1799
+
1800
+ # ENTER transmits both a carriage return and linefeed in LF/NL mode.
1801
+ if self.term_modes.lfnl and key == "\r":
1802
+ key += "\n"
1803
+
1804
+ os.write(self.master, key.encode(self.encoding, "ignore"))
1805
+
1806
+ return None