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
@@ -0,0 +1,914 @@
1
+ # Urwid raw display module
2
+ # Copyright (C) 2004-2009 Ian Ward
3
+ #
4
+ # This library is free software; you can redistribute it and/or
5
+ # modify it under the terms of the GNU Lesser General Public
6
+ # License as published by the Free Software Foundation; either
7
+ # version 2.1 of the License, or (at your option) any later version.
8
+ #
9
+ # This library is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12
+ # Lesser General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Lesser General Public
15
+ # License along with this library; if not, write to the Free Software
16
+ # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17
+ #
18
+ # Urwid web site: https://urwid.org/
19
+
20
+
21
+ """
22
+ Direct terminal UI implementation
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import abc
28
+ import contextlib
29
+ import functools
30
+ import os
31
+ import platform
32
+ import selectors
33
+ import signal
34
+ import socket
35
+ import sys
36
+ import typing
37
+
38
+ from urwid import signals, str_util, util
39
+
40
+ from . import escape
41
+ from .common import UNPRINTABLE_TRANS_TABLE, UPDATE_PALETTE_ENTRY, AttrSpec, BaseScreen, RealTerminal
42
+
43
+ if typing.TYPE_CHECKING:
44
+ import io
45
+ from collections.abc import Callable, Iterable
46
+ from types import FrameType
47
+
48
+ from typing_extensions import Literal
49
+
50
+ from urwid import Canvas, EventLoop
51
+
52
+ IS_WINDOWS = sys.platform == "win32"
53
+ IS_WSL = (sys.platform == "linux") and ("wsl" in platform.platform().lower())
54
+
55
+
56
+ class Screen(BaseScreen, RealTerminal):
57
+ def __init__(self, input: io.IOBase, output: io.IOBase): # noqa: A002 # pylint: disable=redefined-builtin
58
+ """Initialize a screen that directly prints escape codes to an output
59
+ terminal.
60
+ """
61
+ super().__init__()
62
+
63
+ self._partial_codes: list[int] = []
64
+ self._pal_escape: dict[str | None, str] = {}
65
+ self._pal_attrspec: dict[str | None, AttrSpec] = {}
66
+ self._alternate_buffer: bool = False
67
+ signals.connect_signal(self, UPDATE_PALETTE_ENTRY, self._on_update_palette_entry)
68
+ self.colors: Literal[1, 16, 88, 256, 16777216] = 16 # FIXME: detect this
69
+ self.has_underline = True # FIXME: detect this
70
+ self.prev_input_resize = 0
71
+ self.set_input_timeouts()
72
+ self.screen_buf = None
73
+ self._screen_buf_canvas = None
74
+ self._resized = False
75
+ self.maxrow = None
76
+ self._mouse_tracking_enabled = False
77
+ self.last_bstate = 0
78
+ self._setup_G1_done = False
79
+ self._rows_used = None
80
+ self._cy = 0
81
+ self.term = os.environ.get("TERM", "")
82
+ self.fg_bright_is_bold = not self.term.startswith("xterm")
83
+ self.bg_bright_is_blink = self.term == "linux"
84
+ self.back_color_erase = not self.term.startswith("screen")
85
+ self.register_palette_entry(None, "default", "default")
86
+ self._next_timeout = None
87
+ self.signal_handler_setter = signal.signal
88
+
89
+ # Our connections to the world
90
+ self._term_output_file = output
91
+ self._term_input_file = input
92
+
93
+ # pipe for signalling external event loops about resize events
94
+ self._resize_pipe_rd, self._resize_pipe_wr = socket.socketpair()
95
+ self._resize_pipe_rd.setblocking(False)
96
+
97
+ def __del__(self) -> None:
98
+ self._resize_pipe_rd.close()
99
+ self._resize_pipe_wr.close()
100
+
101
+ def __repr__(self) -> str:
102
+ return f"<{self.__class__.__name__}(input={self._term_input_file}, output={self._term_output_file})>"
103
+
104
+ def _sigwinch_handler(self, signum: int = 28, frame: FrameType | None = None) -> None:
105
+ """
106
+ frame -- will always be None when the GLib event loop is being used.
107
+ """
108
+ logger = self.logger.getChild("signal_handlers")
109
+
110
+ logger.debug(f"SIGWINCH handler called with signum={signum!r}, frame={frame!r}")
111
+
112
+ if IS_WINDOWS or not self._resized:
113
+ self._resize_pipe_wr.send(b"R")
114
+ logger.debug("Sent fake resize input to the pipe")
115
+ self._resized = True
116
+ self.screen_buf = None
117
+
118
+ @property
119
+ def _term_input_io(self) -> io.IOBase | None:
120
+ if hasattr(self._term_input_file, "fileno"):
121
+ return self._term_input_file
122
+ return None
123
+
124
+ def _input_fileno(self) -> int | None:
125
+ """Returns the fileno of the input stream, or None if it doesn't have one.
126
+
127
+ A stream without a fileno can't participate in whatever.
128
+ """
129
+ if hasattr(self._term_input_file, "fileno"):
130
+ return self._term_input_file.fileno()
131
+
132
+ return None
133
+
134
+ def _on_update_palette_entry(self, name: str | None, *attrspecs: AttrSpec):
135
+ # copy the attribute to a dictionary containing the escape seqences
136
+ a: AttrSpec = attrspecs[{16: 0, 1: 1, 88: 2, 256: 3, 2**24: 4}[self.colors]]
137
+ self._pal_attrspec[name] = a
138
+ self._pal_escape[name] = self._attrspec_to_escape(a)
139
+
140
+ def set_input_timeouts(
141
+ self,
142
+ max_wait: float | None = None,
143
+ complete_wait: float = 0.125,
144
+ resize_wait: float = 0.125,
145
+ ) -> None:
146
+ """
147
+ Set the get_input timeout values. All values are in floating
148
+ point numbers of seconds.
149
+
150
+ max_wait -- amount of time in seconds to wait for input when
151
+ there is no input pending, wait forever if None
152
+ complete_wait -- amount of time in seconds to wait when
153
+ get_input detects an incomplete escape sequence at the
154
+ end of the available input
155
+ resize_wait -- amount of time in seconds to wait for more input
156
+ after receiving two screen resize requests in a row to
157
+ stop Urwid from consuming 100% cpu during a gradual
158
+ window resize operation
159
+ """
160
+ self.max_wait = max_wait
161
+ if max_wait is not None:
162
+ if self._next_timeout is None:
163
+ self._next_timeout = max_wait
164
+ else:
165
+ self._next_timeout = min(self._next_timeout, self.max_wait)
166
+ self.complete_wait = complete_wait
167
+ self.resize_wait = resize_wait
168
+
169
+ def set_mouse_tracking(self, enable: bool = True) -> None:
170
+ """
171
+ Enable (or disable) mouse tracking.
172
+
173
+ After calling this function get_input will include mouse
174
+ click events along with keystrokes.
175
+ """
176
+ enable = bool(enable) # noqa: FURB123,RUF100
177
+ if enable == self._mouse_tracking_enabled:
178
+ return
179
+
180
+ self._mouse_tracking(enable)
181
+ self._mouse_tracking_enabled = enable
182
+
183
+ def _mouse_tracking(self, enable: bool) -> None:
184
+ if enable:
185
+ self.write(escape.MOUSE_TRACKING_ON)
186
+ else:
187
+ self.write(escape.MOUSE_TRACKING_OFF)
188
+
189
+ @abc.abstractmethod
190
+ def _start(self, alternate_buffer: bool = True) -> None:
191
+ """
192
+ Initialize the screen and input mode.
193
+
194
+ alternate_buffer -- use alternate screen buffer
195
+ """
196
+
197
+ def _stop_mouse_restore_buffer(self) -> None:
198
+ """Stop mouse tracking and restore the screen."""
199
+ self._mouse_tracking(False)
200
+
201
+ move_cursor = ""
202
+ if self._alternate_buffer:
203
+ move_cursor = escape.RESTORE_NORMAL_BUFFER
204
+ elif self.maxrow is not None:
205
+ move_cursor = escape.set_cursor_position(0, self.maxrow)
206
+ self.write(self._attrspec_to_escape(AttrSpec("", "")) + escape.SI + move_cursor + escape.SHOW_CURSOR)
207
+ self.flush()
208
+
209
+ @abc.abstractmethod
210
+ def _stop(self) -> None:
211
+ """
212
+ Restore the screen.
213
+ """
214
+
215
+ def write(self, data):
216
+ """Write some data to the terminal.
217
+
218
+ You may wish to override this if you're using something other than
219
+ regular files for input and output.
220
+ """
221
+ self._term_output_file.write(data)
222
+
223
+ def flush(self):
224
+ """Flush the output buffer.
225
+
226
+ You may wish to override this if you're using something other than
227
+ regular files for input and output.
228
+ """
229
+ self._term_output_file.flush()
230
+
231
+ @typing.overload
232
+ def get_input(self, raw_keys: Literal[False]) -> list[str]: ...
233
+
234
+ @typing.overload
235
+ def get_input(self, raw_keys: Literal[True]) -> tuple[list[str], list[int]]: ...
236
+
237
+ def get_input(self, raw_keys: bool = False) -> list[str] | tuple[list[str], list[int]]:
238
+ """Return pending input as a list.
239
+
240
+ raw_keys -- return raw keycodes as well as translated versions
241
+
242
+ This function will immediately return all the input since the
243
+ last time it was called. If there is no input pending it will
244
+ wait before returning an empty list. The wait time may be
245
+ configured with the set_input_timeouts function.
246
+
247
+ If raw_keys is False (default) this function will return a list
248
+ of keys pressed. If raw_keys is True this function will return
249
+ a ( keys pressed, raw keycodes ) tuple instead.
250
+
251
+ Examples of keys returned:
252
+
253
+ * ASCII printable characters: " ", "a", "0", "A", "-", "/"
254
+ * ASCII control characters: "tab", "enter"
255
+ * Escape sequences: "up", "page up", "home", "insert", "f1"
256
+ * Key combinations: "shift f1", "meta a", "ctrl b"
257
+ * Window events: "window resize"
258
+
259
+ When a narrow encoding is not enabled:
260
+
261
+ * "Extended ASCII" characters: "\\xa1", "\\xb2", "\\xfe"
262
+
263
+ When a wide encoding is enabled:
264
+
265
+ * Double-byte characters: "\\xa1\\xea", "\\xb2\\xd4"
266
+
267
+ When utf8 encoding is enabled:
268
+
269
+ * Unicode characters: u"\\u00a5", u'\\u253c"
270
+
271
+ Examples of mouse events returned:
272
+
273
+ * Mouse button press: ('mouse press', 1, 15, 13),
274
+ ('meta mouse press', 2, 17, 23)
275
+ * Mouse drag: ('mouse drag', 1, 16, 13),
276
+ ('mouse drag', 1, 17, 13),
277
+ ('ctrl mouse drag', 1, 18, 13)
278
+ * Mouse button release: ('mouse release', 0, 18, 13),
279
+ ('ctrl mouse release', 0, 17, 23)
280
+ """
281
+ logger = self.logger.getChild("get_input")
282
+ if not self._started:
283
+ raise RuntimeError
284
+
285
+ self._wait_for_input_ready(self._next_timeout)
286
+ keys, raw = self.parse_input(None, None, self.get_available_raw_input())
287
+
288
+ # Avoid pegging CPU at 100% when slowly resizing
289
+ if keys == ["window resize"] and self.prev_input_resize:
290
+ logger.debug('get_input: got "window resize" > 1 times. Enable throttling for resize.')
291
+ for _ in range(2):
292
+ self._wait_for_input_ready(self.resize_wait)
293
+ new_keys, new_raw = self.parse_input(None, None, self.get_available_raw_input())
294
+ raw += new_raw
295
+ if new_keys and new_keys != ["window resize"]:
296
+ if "window resize" in new_keys:
297
+ keys = new_keys
298
+ else:
299
+ keys.extend(new_keys)
300
+ break
301
+
302
+ if keys == ["window resize"]:
303
+ self.prev_input_resize = 2
304
+ elif self.prev_input_resize == 2 and not keys:
305
+ self.prev_input_resize = 1
306
+ else:
307
+ self.prev_input_resize = 0
308
+
309
+ if raw_keys:
310
+ return keys, raw
311
+ return keys
312
+
313
+ def get_input_descriptors(self) -> list[socket.socket | io.IOBase | typing.IO | int]:
314
+ """
315
+ Return a list of integer file descriptors that should be
316
+ polled in external event loops to check for user input.
317
+
318
+ Use this method if you are implementing your own event loop.
319
+
320
+ This method is only called by `hook_event_loop`, so if you override
321
+ that, you can safely ignore this.
322
+ """
323
+ if not self._started:
324
+ return []
325
+
326
+ fd_list = [self._resize_pipe_rd]
327
+ input_io = self._term_input_io
328
+ if input_io is not None:
329
+ fd_list.append(input_io)
330
+ return fd_list
331
+
332
+ _current_event_loop_handles = ()
333
+
334
+ @abc.abstractmethod
335
+ def unhook_event_loop(self, event_loop: EventLoop) -> None:
336
+ """
337
+ Remove any hooks added by hook_event_loop.
338
+ """
339
+
340
+ @abc.abstractmethod
341
+ def hook_event_loop(
342
+ self,
343
+ event_loop: EventLoop,
344
+ callback: Callable[[list[str], list[int]], typing.Any],
345
+ ) -> None:
346
+ """
347
+ Register the given callback with the event loop, to be called with new
348
+ input whenever it's available. The callback should be passed a list of
349
+ processed keys and a list of unprocessed keycodes.
350
+
351
+ Subclasses may wish to use parse_input to wrap the callback.
352
+ """
353
+
354
+ _input_timeout = None
355
+
356
+ def _make_legacy_input_wrapper(self, event_loop, callback):
357
+ """
358
+ Support old Screen classes that still have a get_input_nonblocking and expect it to work.
359
+ """
360
+
361
+ @functools.wraps(callback)
362
+ def wrapper():
363
+ if self._input_timeout:
364
+ event_loop.remove_alarm(self._input_timeout)
365
+ self._input_timeout = None
366
+ timeout, keys, raw = self.get_input_nonblocking() # pylint: disable=no-member # should we deprecate?
367
+ if timeout is not None:
368
+ self._input_timeout = event_loop.alarm(timeout, wrapper)
369
+
370
+ callback(keys, raw)
371
+
372
+ return wrapper
373
+
374
+ def _get_input_codes(self) -> list[int]:
375
+ return list(self._get_keyboard_codes())
376
+
377
+ def get_available_raw_input(self) -> list[int]:
378
+ """
379
+ Return any currently available input. Does not block.
380
+
381
+ This method is only used by the default `hook_event_loop`
382
+ implementation; you can safely ignore it if you implement your own.
383
+ """
384
+ logger = self.logger.getChild("get_available_raw_input")
385
+ codes = [*self._partial_codes, *self._get_input_codes()]
386
+ self._partial_codes = []
387
+
388
+ # clean out the pipe used to signal external event loops
389
+ # that a resize has occurred
390
+ with selectors.DefaultSelector() as selector:
391
+ selector.register(self._resize_pipe_rd, selectors.EVENT_READ)
392
+ present_resize_flag = selector.select(0) # nonblocking
393
+ while present_resize_flag:
394
+ logger.debug("Resize signal received. Cleaning socket.")
395
+ # Argument "size" is maximum buffer size to read. Since we're emptying, set it reasonably big.
396
+ self._resize_pipe_rd.recv(128)
397
+ present_resize_flag = selector.select(0)
398
+
399
+ return codes
400
+
401
+ @typing.overload
402
+ def parse_input(
403
+ self,
404
+ event_loop: None,
405
+ callback: None,
406
+ codes: list[int],
407
+ wait_for_more: bool = ...,
408
+ ) -> tuple[list[str], list[int]]: ...
409
+
410
+ @typing.overload
411
+ def parse_input(
412
+ self,
413
+ event_loop: EventLoop,
414
+ callback: None,
415
+ codes: list[int],
416
+ wait_for_more: bool = ...,
417
+ ) -> tuple[list[str], list[int]]: ...
418
+
419
+ @typing.overload
420
+ def parse_input(
421
+ self,
422
+ event_loop: EventLoop,
423
+ callback: Callable[[list[str], list[int]], typing.Any],
424
+ codes: list[int],
425
+ wait_for_more: bool = ...,
426
+ ) -> None: ...
427
+
428
+ def parse_input(
429
+ self,
430
+ event_loop: EventLoop | None,
431
+ callback: Callable[[list[str], list[int]], typing.Any] | None,
432
+ codes: list[int],
433
+ wait_for_more: bool = True,
434
+ ) -> tuple[list[str], list[int]] | None:
435
+ """
436
+ Read any available input from get_available_raw_input, parses it into
437
+ keys, and calls the given callback.
438
+
439
+ The current implementation tries to avoid any assumptions about what
440
+ the screen or event loop look like; it only deals with parsing keycodes
441
+ and setting a timeout when an incomplete one is detected.
442
+
443
+ `codes` should be a sequence of keycodes, i.e. bytes. A bytearray is
444
+ appropriate, but beware of using bytes, which only iterates as integers
445
+ on Python 3.
446
+ """
447
+
448
+ logger = self.logger.getChild("parse_input")
449
+
450
+ # Note: event_loop may be None for 100% synchronous support, only used
451
+ # by get_input. Not documented because you shouldn't be doing it.
452
+ if self._input_timeout and event_loop:
453
+ event_loop.remove_alarm(self._input_timeout)
454
+ self._input_timeout = None
455
+
456
+ original_codes = codes
457
+ decoded_codes = []
458
+ try:
459
+ while codes:
460
+ run, codes = escape.process_keyqueue(codes, wait_for_more)
461
+ decoded_codes.extend(run)
462
+ except escape.MoreInputRequired:
463
+ # Set a timer to wait for the rest of the input; if it goes off
464
+ # without any new input having come in, use the partial input
465
+ k = len(original_codes) - len(codes)
466
+ raw_codes = original_codes[:k]
467
+ self._partial_codes = codes
468
+
469
+ def _parse_incomplete_input():
470
+ self._input_timeout = None
471
+ self._partial_codes = []
472
+ self.parse_input(event_loop, callback, codes, wait_for_more=False)
473
+
474
+ if event_loop:
475
+ self._input_timeout = event_loop.alarm(self.complete_wait, _parse_incomplete_input)
476
+
477
+ else:
478
+ raw_codes = original_codes
479
+ self._partial_codes = []
480
+
481
+ logger.debug(f"Decoded codes: {decoded_codes!r}, raw codes: {raw_codes!r}")
482
+
483
+ if self._resized:
484
+ decoded_codes.append("window resize")
485
+ logger.debug('Added "window resize" to the codes')
486
+ self._resized = False
487
+
488
+ if callback:
489
+ callback(decoded_codes, raw_codes)
490
+ return None
491
+
492
+ # For get_input
493
+ return decoded_codes, raw_codes
494
+
495
+ def _wait_for_input_ready(self, timeout: float | None) -> list[int]:
496
+ logger = self.logger.getChild("wait_for_input_ready")
497
+ fd_list = self.get_input_descriptors()
498
+
499
+ logger.debug(f"Waiting for input: descriptors={fd_list!r}, timeout={timeout!r}")
500
+ with selectors.DefaultSelector() as selector:
501
+ for fd in fd_list:
502
+ selector.register(fd, selectors.EVENT_READ)
503
+
504
+ ready = selector.select(timeout)
505
+
506
+ logger.debug(f"Input ready: {ready}")
507
+
508
+ return [event.fd for event, _ in ready]
509
+
510
+ @abc.abstractmethod
511
+ def _read_raw_input(self, timeout: int) -> Iterable[int]: ...
512
+
513
+ def _get_keyboard_codes(self) -> Iterable[int]:
514
+ return self._read_raw_input(0)
515
+
516
+ def _setup_G1(self) -> None:
517
+ """
518
+ Initialize the G1 character set to graphics mode if required.
519
+ """
520
+ if self._setup_G1_done:
521
+ return
522
+
523
+ while True:
524
+ with contextlib.suppress(OSError):
525
+ self.write(escape.DESIGNATE_G1_SPECIAL)
526
+ self.flush()
527
+ break
528
+ self._setup_G1_done = True
529
+
530
+ def draw_screen(self, size: tuple[int, int], canvas: Canvas) -> None:
531
+ """Paint screen with rendered canvas."""
532
+
533
+ def set_cursor_home() -> str:
534
+ if not partial_display():
535
+ return escape.set_cursor_position(0, 0)
536
+ return escape.CURSOR_HOME_COL + escape.move_cursor_up(cy)
537
+
538
+ def set_cursor_position(x: int, y: int) -> str:
539
+ if not partial_display():
540
+ return escape.set_cursor_position(x, y)
541
+ if cy > y:
542
+ return "\b" + escape.CURSOR_HOME_COL + escape.move_cursor_up(cy - y) + escape.move_cursor_right(x)
543
+ return "\b" + escape.CURSOR_HOME_COL + escape.move_cursor_down(y - cy) + escape.move_cursor_right(x)
544
+
545
+ def is_blank_row(row: list[tuple[object, Literal["0", "U"] | None], bytes]) -> bool:
546
+ if len(row) > 1:
547
+ return False
548
+ if row[0][2].strip():
549
+ return False
550
+ return True
551
+
552
+ def attr_to_escape(a: AttrSpec | str) -> str:
553
+ if a in self._pal_escape:
554
+ return self._pal_escape[a]
555
+ if isinstance(a, AttrSpec):
556
+ return self._attrspec_to_escape(a)
557
+ # undefined attributes use default/default
558
+ self.logger.debug(f"Undefined attribute: {a!r}")
559
+ return self._attrspec_to_escape(AttrSpec("default", "default"))
560
+
561
+ def using_standout_or_underline(a: AttrSpec | str) -> bool:
562
+ a = self._pal_attrspec.get(a, a)
563
+ return isinstance(a, AttrSpec) and (a.standout or a.underline)
564
+
565
+ encoding = util.get_encoding()
566
+
567
+ logger = self.logger.getChild("draw_screen")
568
+
569
+ (maxcol, maxrow) = size
570
+
571
+ if not self._started:
572
+ raise RuntimeError
573
+
574
+ if maxrow != canvas.rows():
575
+ raise ValueError(maxrow)
576
+
577
+ # quick return if nothing has changed
578
+ if self.screen_buf and canvas is self._screen_buf_canvas:
579
+ return
580
+
581
+ self._setup_G1()
582
+
583
+ if self._resized:
584
+ # handle resize before trying to draw screen
585
+ logger.debug("Not drawing screen: screen resized and resize was not handled")
586
+ return
587
+
588
+ logger.debug(f"Drawing screen with size {size!r}")
589
+
590
+ last_attributes = None # Default = empty
591
+
592
+ output: list[str] = [escape.HIDE_CURSOR, attr_to_escape(last_attributes)]
593
+
594
+ def partial_display() -> bool:
595
+ # returns True if the screen is in partial display mode ie. only some rows belong to the display
596
+ return self._rows_used is not None
597
+
598
+ if not partial_display():
599
+ output.append(escape.CURSOR_HOME)
600
+
601
+ if self.screen_buf:
602
+ osb = self.screen_buf
603
+ else:
604
+ osb = []
605
+ sb = []
606
+ cy = self._cy
607
+ y = -1
608
+
609
+ ins = None
610
+ output.append(set_cursor_home())
611
+ cy = 0
612
+
613
+ first = True
614
+ last_charset_flag = None
615
+
616
+ for row in canvas.content():
617
+ y += 1
618
+ if osb and y < len(osb) and osb[y] == row:
619
+ # this row of the screen buffer matches what is
620
+ # currently displayed, so we can skip this line
621
+ sb.append(osb[y])
622
+ continue
623
+
624
+ sb.append(row)
625
+
626
+ # leave blank lines off display when we are using
627
+ # the default screen buffer (allows partial screen)
628
+ if partial_display() and y > self._rows_used:
629
+ if is_blank_row(row):
630
+ continue
631
+ self._rows_used = y
632
+
633
+ if y or partial_display():
634
+ output.append(set_cursor_position(0, y))
635
+ # after updating the line we will be just over the
636
+ # edge, but terminals still treat this as being
637
+ # on the same line
638
+ cy = y
639
+
640
+ whitespace_at_end = False
641
+ if row:
642
+ a, cs, run = row[-1]
643
+ if run[-1:] == b" " and self.back_color_erase and not using_standout_or_underline(a):
644
+ whitespace_at_end = True
645
+ row = row[:-1] + [(a, cs, run.rstrip(b" "))] # noqa: PLW2901
646
+ elif y == maxrow - 1 and maxcol > 1:
647
+ row, back, ins = self._last_row(row) # noqa: PLW2901
648
+
649
+ for a, cs, run in row:
650
+ if not isinstance(run, bytes): # canvases render with bytes
651
+ raise TypeError(run)
652
+
653
+ if cs != "U":
654
+ run = run.translate(UNPRINTABLE_TRANS_TABLE) # noqa: PLW2901
655
+
656
+ if last_attributes != a:
657
+ output.append(attr_to_escape(a))
658
+ last_attributes = a
659
+
660
+ if encoding != "utf-8" and (first or last_charset_flag != cs):
661
+ if cs not in {None, "0", "U"}:
662
+ raise ValueError(cs)
663
+ if last_charset_flag == "U":
664
+ output.append(escape.IBMPC_OFF)
665
+
666
+ if cs is None:
667
+ output.append(escape.SI)
668
+ elif cs == "U":
669
+ output.append(escape.IBMPC_ON)
670
+ else:
671
+ output.append(escape.SO)
672
+ last_charset_flag = cs
673
+
674
+ output.append(run.decode(encoding, "replace"))
675
+ first = False
676
+
677
+ if ins:
678
+ (inserta, insertcs, inserttext) = ins
679
+ ias = attr_to_escape(inserta)
680
+ if insertcs not in {None, "0", "U"}:
681
+ raise ValueError(insertcs)
682
+
683
+ if isinstance(inserttext, bytes):
684
+ inserttext = inserttext.decode(encoding)
685
+
686
+ output.extend(("\x08" * back, ias))
687
+
688
+ if encoding != "utf-8":
689
+ if cs is None:
690
+ icss = escape.SI
691
+ elif cs == "U":
692
+ icss = escape.IBMPC_ON
693
+ else:
694
+ icss = escape.SO
695
+
696
+ output.append(icss)
697
+
698
+ if not IS_WINDOWS:
699
+ output += [escape.INSERT_ON, inserttext, escape.INSERT_OFF]
700
+ else:
701
+ output += [f"{escape.ESC}[{str_util.calc_width(inserttext, 0, len(inserttext))}@", inserttext]
702
+
703
+ if encoding != "utf-8" and cs == "U":
704
+ output.append(escape.IBMPC_OFF)
705
+
706
+ if whitespace_at_end:
707
+ output.append(escape.ERASE_IN_LINE_RIGHT)
708
+
709
+ if canvas.cursor is not None:
710
+ x, y = canvas.cursor
711
+ output += [set_cursor_position(x, y), escape.SHOW_CURSOR]
712
+ self._cy = y
713
+
714
+ if self._resized:
715
+ # handle resize before trying to draw screen
716
+ return
717
+ try:
718
+ for line in output:
719
+ if isinstance(line, bytes):
720
+ line = line.decode(encoding, "replace") # noqa: PLW2901
721
+ self.write(line)
722
+ self.flush()
723
+ except OSError as e:
724
+ # ignore interrupted syscall
725
+ if e.args[0] != 4:
726
+ raise
727
+
728
+ self.screen_buf = sb
729
+ self._screen_buf_canvas = canvas
730
+
731
+ def _last_row(self, row):
732
+ """On the last row we need to slide the bottom right character
733
+ into place. Calculate the new line, attr and an insert sequence
734
+ to do that.
735
+
736
+ eg. last row:
737
+ XXXXXXXXXXXXXXXXXXXXYZ
738
+
739
+ Y will be drawn after Z, shifting Z into position.
740
+ """
741
+
742
+ new_row = row[:-1]
743
+ z_attr, z_cs, last_text = row[-1]
744
+ last_cols = str_util.calc_width(last_text, 0, len(last_text))
745
+ last_offs, z_col = str_util.calc_text_pos(last_text, 0, len(last_text), last_cols - 1)
746
+ if last_offs == 0:
747
+ z_text = last_text
748
+ del new_row[-1]
749
+ # we need another segment
750
+ y_attr, y_cs, nlast_text = row[-2]
751
+ nlast_cols = str_util.calc_width(nlast_text, 0, len(nlast_text))
752
+ z_col += nlast_cols
753
+ nlast_offs, y_col = str_util.calc_text_pos(nlast_text, 0, len(nlast_text), nlast_cols - 1)
754
+ y_text = nlast_text[nlast_offs:]
755
+ if nlast_offs:
756
+ new_row.append((y_attr, y_cs, nlast_text[:nlast_offs]))
757
+ else:
758
+ z_text = last_text[last_offs:]
759
+ y_attr, y_cs = z_attr, z_cs
760
+ nlast_cols = str_util.calc_width(last_text, 0, last_offs)
761
+ nlast_offs, y_col = str_util.calc_text_pos(last_text, 0, last_offs, nlast_cols - 1)
762
+ y_text = last_text[nlast_offs:last_offs]
763
+ if nlast_offs:
764
+ new_row.append((y_attr, y_cs, last_text[:nlast_offs]))
765
+
766
+ new_row.append((z_attr, z_cs, z_text))
767
+ return new_row, z_col - y_col, (y_attr, y_cs, y_text)
768
+
769
+ def clear(self) -> None:
770
+ """
771
+ Force the screen to be completely repainted on the next
772
+ call to draw_screen().
773
+ """
774
+ self.screen_buf = None
775
+
776
+ def _attrspec_to_escape(self, a: AttrSpec) -> str:
777
+ """
778
+ Convert AttrSpec instance a to an escape sequence for the terminal
779
+
780
+ >>> s = Screen()
781
+ >>> s.set_terminal_properties(colors=256)
782
+ >>> a2e = s._attrspec_to_escape
783
+ >>> a2e(s.AttrSpec('brown', 'dark green'))
784
+ '\\x1b[0;33;42m'
785
+ >>> a2e(s.AttrSpec('#fea,underline', '#d0d'))
786
+ '\\x1b[0;38;5;229;4;48;5;164m'
787
+ """
788
+ if self.term == "fbterm":
789
+ fg = escape.ESC + f"[1;{a.foreground_number:d}}}"
790
+ bg = escape.ESC + f"[2;{a.background_number:d}}}"
791
+ return fg + bg
792
+
793
+ if a.foreground_true:
794
+ fg = f"38;2;{';'.join(str(part) for part in a.get_rgb_values()[0:3])}"
795
+ elif a.foreground_high:
796
+ fg = f"38;5;{a.foreground_number:d}"
797
+ elif a.foreground_basic:
798
+ if a.foreground_number > 7:
799
+ if self.fg_bright_is_bold:
800
+ fg = f"1;{a.foreground_number - 8 + 30:d}"
801
+ else:
802
+ fg = f"{a.foreground_number - 8 + 90:d}"
803
+ else:
804
+ fg = f"{a.foreground_number + 30:d}"
805
+ else:
806
+ fg = "39"
807
+ st = (
808
+ "1;" * a.bold
809
+ + "3;" * a.italics
810
+ + "4;" * a.underline
811
+ + "5;" * a.blink
812
+ + "7;" * a.standout
813
+ + "9;" * a.strikethrough
814
+ )
815
+ if a.background_true:
816
+ bg = f"48;2;{';'.join(str(part) for part in a.get_rgb_values()[3:6])}"
817
+ elif a.background_high:
818
+ bg = f"48;5;{a.background_number:d}"
819
+ elif a.background_basic:
820
+ if a.background_number > 7:
821
+ if self.bg_bright_is_blink:
822
+ bg = f"5;{a.background_number - 8 + 40:d}"
823
+ else:
824
+ # this doesn't work on most terminals
825
+ bg = f"{a.background_number - 8 + 100:d}"
826
+ else:
827
+ bg = f"{a.background_number + 40:d}"
828
+ else:
829
+ bg = "49"
830
+ return f"{escape.ESC}[0;{fg};{st}{bg}m"
831
+
832
+ def set_terminal_properties(
833
+ self,
834
+ colors: Literal[1, 16, 88, 256, 16777216] | None = None,
835
+ bright_is_bold: bool | None = None,
836
+ has_underline: bool | None = None,
837
+ ) -> None:
838
+ """
839
+ colors -- number of colors terminal supports (1, 16, 88, 256, or 2**24)
840
+ or None to leave unchanged
841
+ bright_is_bold -- set to True if this terminal uses the bold
842
+ setting to create bright colors (numbers 8-15), set to False
843
+ if this Terminal can create bright colors without bold or
844
+ None to leave unchanged
845
+ has_underline -- set to True if this terminal can use the
846
+ underline setting, False if it cannot or None to leave
847
+ unchanged
848
+ """
849
+ if colors is None:
850
+ colors = self.colors
851
+ if bright_is_bold is None:
852
+ bright_is_bold = self.fg_bright_is_bold
853
+ if has_underline is None:
854
+ has_underline = self.has_underline
855
+
856
+ if colors == self.colors and bright_is_bold == self.fg_bright_is_bold and has_underline == self.has_underline:
857
+ return
858
+
859
+ self.colors = colors
860
+ self.fg_bright_is_bold = bright_is_bold
861
+ self.has_underline = has_underline
862
+
863
+ self.clear()
864
+ self._pal_escape = {}
865
+ for p, v in self._palette.items():
866
+ self._on_update_palette_entry(p, *v)
867
+
868
+ def reset_default_terminal_palette(self) -> None:
869
+ """
870
+ Attempt to set the terminal palette to default values as taken
871
+ from xterm. Uses number of colors from current
872
+ set_terminal_properties() screen setting.
873
+ """
874
+ if self.colors == 1:
875
+ return
876
+ if self.colors == 2**24:
877
+ colors = 256
878
+ else:
879
+ colors = self.colors
880
+
881
+ def rgb_values(n) -> tuple[int | None, int | None, int | None]:
882
+ if colors == 16:
883
+ aspec = AttrSpec(f"h{n:d}", "", 256)
884
+ else:
885
+ aspec = AttrSpec(f"h{n:d}", "", colors)
886
+ return aspec.get_rgb_values()[:3]
887
+
888
+ entries = [(n, *rgb_values(n)) for n in range(min(colors, 256))]
889
+ self.modify_terminal_palette(entries)
890
+
891
+ def modify_terminal_palette(self, entries: list[tuple[int, int | None, int | None, int | None]]):
892
+ """
893
+ entries - list of (index, red, green, blue) tuples.
894
+
895
+ Attempt to set part of the terminal palette (this does not work
896
+ on all terminals.) The changes are sent as a single escape
897
+ sequence so they should all take effect at the same time.
898
+
899
+ 0 <= index < 256 (some terminals will only have 16 or 88 colors)
900
+ 0 <= red, green, blue < 256
901
+ """
902
+
903
+ if self.term == "fbterm":
904
+ modify = [f"{index:d};{red:d};{green:d};{blue:d}" for index, red, green, blue in entries]
905
+ self.write(f"\x1b[3;{';'.join(modify)}}}")
906
+ else:
907
+ modify = [f"{index:d};rgb:{red:02x}/{green:02x}/{blue:02x}" for index, red, green, blue in entries]
908
+ self.write(f"\x1b]4;{';'.join(modify)}\x1b\\")
909
+ self.flush()
910
+
911
+ # shortcut for creating an AttrSpec with this screen object's
912
+ # number of colors
913
+ def AttrSpec(self, fg, bg) -> AttrSpec:
914
+ return AttrSpec(fg, bg, self.colors)