urwid 4.2.4__tar.gz → 4.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. {urwid-4.2.4/urwid.egg-info → urwid-4.2.5}/PKG-INFO +2 -2
  2. urwid-4.2.5/mypy-requirements.txt +1 -0
  3. urwid-4.2.5/requirements.txt +2 -0
  4. {urwid-4.2.4 → urwid-4.2.5}/urwid/ansi_parser.py +134 -39
  5. {urwid-4.2.4 → urwid-4.2.5}/urwid/canvas.py +163 -30
  6. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/_posix_raw_display.py +77 -62
  7. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/_raw_display_base.py +35 -16
  8. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/_win32.py +8 -1
  9. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/_win32_raw_display.py +52 -25
  10. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/common.py +3 -1
  11. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/escape.py +25 -22
  12. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/html_fragment.py +1 -1
  13. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/web.py +1 -1
  14. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/asyncio_loop.py +6 -1
  15. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/main_loop.py +38 -20
  16. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/select_loop.py +6 -1
  17. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/trio_loop.py +18 -5
  18. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/zmq_loop.py +23 -10
  19. {urwid-4.2.4 → urwid-4.2.5}/urwid/signals.py +15 -2
  20. {urwid-4.2.4 → urwid-4.2.5}/urwid/str_util.py +74 -27
  21. {urwid-4.2.4 → urwid-4.2.5}/urwid/text_layout.py +56 -26
  22. {urwid-4.2.4 → urwid-4.2.5}/urwid/util.py +20 -3
  23. {urwid-4.2.4 → urwid-4.2.5}/urwid/version.py +3 -3
  24. {urwid-4.2.4 → urwid-4.2.5}/urwid/vterm.py +7 -0
  25. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/bar_graph.py +44 -5
  26. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/columns.py +54 -16
  27. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/edit.py +30 -5
  28. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/grid_flow.py +17 -7
  29. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/listbox.py +74 -13
  30. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/monitored_deque.py +108 -33
  31. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/monitored_list.py +43 -29
  32. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/pile.py +58 -19
  33. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/progress_bar.py +20 -3
  34. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/treetools.py +84 -24
  35. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/wimp.py +5 -4
  36. {urwid-4.2.4 → urwid-4.2.5/urwid.egg-info}/PKG-INFO +2 -2
  37. {urwid-4.2.4 → urwid-4.2.5}/urwid.egg-info/requires.txt +1 -1
  38. {urwid-4.2.4 → urwid-4.2.5}/urwid.egg-info/scm_file_list.json +2 -0
  39. urwid-4.2.5/urwid.egg-info/scm_version.json +8 -0
  40. urwid-4.2.4/mypy-requirements.txt +0 -1
  41. urwid-4.2.4/requirements.txt +0 -2
  42. urwid-4.2.4/urwid.egg-info/scm_version.json +0 -8
  43. {urwid-4.2.4 → urwid-4.2.5}/.devcontainer/dev.dockerfile +0 -0
  44. {urwid-4.2.4 → urwid-4.2.5}/.devcontainer/devcontainer.json +0 -0
  45. {urwid-4.2.4 → urwid-4.2.5}/.dockerignore +0 -0
  46. {urwid-4.2.4 → urwid-4.2.5}/.editorconfig +0 -0
  47. {urwid-4.2.4 → urwid-4.2.5}/.github/CODE_OF_CONDUCT.md +0 -0
  48. {urwid-4.2.4 → urwid-4.2.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  49. {urwid-4.2.4 → urwid-4.2.5}/.github/ISSUE_TEMPLATE/docs.md +0 -0
  50. {urwid-4.2.4 → urwid-4.2.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  51. {urwid-4.2.4 → urwid-4.2.5}/.github/ISSUE_TEMPLATE/question.md +0 -0
  52. {urwid-4.2.4 → urwid-4.2.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  53. {urwid-4.2.4 → urwid-4.2.5}/.github/dependabot.yml +0 -0
  54. {urwid-4.2.4 → urwid-4.2.5}/.github/labeler.yml +0 -0
  55. {urwid-4.2.4 → urwid-4.2.5}/.github/release.yml +0 -0
  56. {urwid-4.2.4 → urwid-4.2.5}/.github/workflows/documentation.yml +0 -0
  57. {urwid-4.2.4 → urwid-4.2.5}/.github/workflows/isolated_mypy.yml +0 -0
  58. {urwid-4.2.4 → urwid-4.2.5}/.github/workflows/isolated_pylint.yml +0 -0
  59. {urwid-4.2.4 → urwid-4.2.5}/.github/workflows/labels.yml +0 -0
  60. {urwid-4.2.4 → urwid-4.2.5}/.github/workflows/pythonpackage.yml +0 -0
  61. {urwid-4.2.4 → urwid-4.2.5}/.github/workflows/yamllint.yml +0 -0
  62. {urwid-4.2.4 → urwid-4.2.5}/.gitignore +0 -0
  63. {urwid-4.2.4 → urwid-4.2.5}/.pre-commit-config.yaml +0 -0
  64. {urwid-4.2.4 → urwid-4.2.5}/.yamllint.yml +0 -0
  65. {urwid-4.2.4 → urwid-4.2.5}/COPYING +0 -0
  66. {urwid-4.2.4 → urwid-4.2.5}/MANIFEST.in +0 -0
  67. {urwid-4.2.4 → urwid-4.2.5}/README.rst +0 -0
  68. {urwid-4.2.4 → urwid-4.2.5}/SECURITY.md +0 -0
  69. {urwid-4.2.4 → urwid-4.2.5}/classifiers.txt +0 -0
  70. {urwid-4.2.4 → urwid-4.2.5}/conftest.py +0 -0
  71. {urwid-4.2.4 → urwid-4.2.5}/pylint-requirements.txt +0 -0
  72. {urwid-4.2.4 → urwid-4.2.5}/pyproject.toml +0 -0
  73. {urwid-4.2.4 → urwid-4.2.5}/pytest_requirements.txt +0 -0
  74. {urwid-4.2.4 → urwid-4.2.5}/setup.cfg +0 -0
  75. {urwid-4.2.4 → urwid-4.2.5}/tox.ini +0 -0
  76. {urwid-4.2.4 → urwid-4.2.5}/urwid/__init__.py +0 -0
  77. {urwid-4.2.4 → urwid-4.2.5}/urwid/command_map.py +0 -0
  78. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/__init__.py +0 -0
  79. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/_web.css +0 -0
  80. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/_web.js +0 -0
  81. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/curses.py +0 -0
  82. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/lcd.py +0 -0
  83. {urwid-4.2.4 → urwid-4.2.5}/urwid/display/raw.py +0 -0
  84. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/__init__.py +0 -0
  85. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/abstract_loop.py +0 -0
  86. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/glib_loop.py +0 -0
  87. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/tornado_loop.py +0 -0
  88. {urwid-4.2.4 → urwid-4.2.5}/urwid/event_loop/twisted_loop.py +0 -0
  89. {urwid-4.2.4 → urwid-4.2.5}/urwid/font.py +0 -0
  90. {urwid-4.2.4 → urwid-4.2.5}/urwid/graphics.py +0 -0
  91. {urwid-4.2.4 → urwid-4.2.5}/urwid/numedit.py +0 -0
  92. {urwid-4.2.4 → urwid-4.2.5}/urwid/py.typed +0 -0
  93. {urwid-4.2.4 → urwid-4.2.5}/urwid/split_repr.py +0 -0
  94. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/__init__.py +0 -0
  95. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/ansi_text.py +0 -0
  96. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/attr_map.py +0 -0
  97. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/attr_wrap.py +0 -0
  98. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/big_text.py +0 -0
  99. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/box_adapter.py +0 -0
  100. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/constants.py +0 -0
  101. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/container.py +0 -0
  102. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/deque_walker.py +0 -0
  103. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/divider.py +0 -0
  104. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/filler.py +0 -0
  105. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/frame.py +0 -0
  106. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/line_box.py +0 -0
  107. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/list_walker.py +0 -0
  108. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/overlay.py +0 -0
  109. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/padding.py +0 -0
  110. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/popup.py +0 -0
  111. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/scrollable.py +0 -0
  112. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/solid_fill.py +0 -0
  113. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/text.py +0 -0
  114. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/widget.py +0 -0
  115. {urwid-4.2.4 → urwid-4.2.5}/urwid/widget/widget_decoration.py +0 -0
  116. {urwid-4.2.4 → urwid-4.2.5}/urwid.egg-info/SOURCES.txt +0 -0
  117. {urwid-4.2.4 → urwid-4.2.5}/urwid.egg-info/dependency_links.txt +0 -0
  118. {urwid-4.2.4 → urwid-4.2.5}/urwid.egg-info/not-zip-safe +0 -0
  119. {urwid-4.2.4 → urwid-4.2.5}/urwid.egg-info/top_level.txt +0 -0
  120. {urwid-4.2.4 → urwid-4.2.5}/yamllint-requirements.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urwid
3
- Version: 4.2.4
3
+ Version: 4.2.5
4
4
  Summary: A full-featured console (xterm et al.) user interface library
5
5
  Author-email: Ian Ward <ian@excess.org>
6
6
  License-Expression: LGPL-2.1-only
@@ -38,7 +38,7 @@ Classifier: Typing :: Typed
38
38
  Requires-Python: >=3.9.0
39
39
  Description-Content-Type: text/x-rst
40
40
  License-File: COPYING
41
- Requires-Dist: wcwidth>=0.4
41
+ Requires-Dist: wcwidth!=0.9.0,!=0.9.1,>=0.4
42
42
  Requires-Dist: typing-extensions
43
43
  Provides-Extra: curses
44
44
  Requires-Dist: windows-curses; sys_platform == "win32" and extra == "curses"
@@ -0,0 +1 @@
1
+ mypy==2.4.0
@@ -0,0 +1,2 @@
1
+ wcwidth>=0.4,!=0.9.0,!=0.9.1
2
+ typing-extensions
@@ -89,6 +89,34 @@ _TERMINAL_SETTING_CSI = frozenset("hlrJKsu@LMPXcng")
89
89
  #: Line-end byte pairs that collapse into a single structural split point.
90
90
  _LINE_END_PAIR: dict[str, str] = {"\r": "\n", "\n": "\r"}
91
91
 
92
+ #: The furthest a relative cursor move advances, and the furthest 1-based row or column an absolute move reaches.
93
+ #: The input is untrusted, and a few bytes such as ``CSI 300000000 C`` would otherwise pad the grid with that many
94
+ #: cells. 1024 exceeds the width and height of practical terminals, and caps what one movement sequence adds to the
95
+ #: grid at 1024 cells.
96
+ _MAX_CURSOR_POSITION = 1024
97
+
98
+ #: CSI parameter strings of this length or longer are skipped unparsed. Real sequences are far shorter (a truecolour
99
+ #: foreground and background SGR is under 50 characters). Staying below 640, the lowest limit
100
+ #: :func:`sys.set_int_max_str_digits` accepts, guarantees ``int()`` never rejects a parameter.
101
+ _MAX_CSI_LENGTH = 256
102
+
103
+ #: OSC strings of this length or longer are skipped, and the rest of the string up to its terminator, or up to an ESC
104
+ #: starting a new sequence, is discarded unbuffered, so an unterminated OSC costs neither memory nor quadratic string
105
+ #: growth.
106
+ _MAX_OSC_LENGTH = 4096
107
+
108
+ #: The most entries a ``skipped`` list holds, covering one 64 KiB raw display read of 2-byte sequences.
109
+ _MAX_SKIPPED = 65536
110
+
111
+ #: Padding cells a parser may insert regardless of input length, one full 1024 x 1024 screen.
112
+ _PADDING_BUDGET = _MAX_CURSOR_POSITION * _MAX_CURSOR_POSITION
113
+
114
+ #: Padding cells each fed character adds to the budget, as many as a tab writes.
115
+ _PADDING_PER_CHARACTER = 8
116
+
117
+ #: Padding cells an empty row inserted by a vertical move costs, about what the row allocates.
118
+ _PADDING_PER_ROW = 8
119
+
92
120
 
93
121
  @dataclasses.dataclass(frozen=True)
94
122
  class SkippedOp:
@@ -98,7 +126,8 @@ class SkippedOp:
98
126
  full two-dimensional terminal canvas.
99
127
 
100
128
  :ivar kind: one of ``"vertical-move"``, ``"terminal-setting"`` or ``"unknown"``.
101
- :ivar raw: the raw sequence text as encountered (including its ``ESC``/``CSI`` framing).
129
+ :ivar raw: the raw sequence text as encountered (including its ``ESC``/``CSI`` framing), truncated for a sequence
130
+ skipped for its length.
102
131
  :ivar reason: a human-readable explanation, used verbatim in the log message emitted alongside this entry.
103
132
  """
104
133
 
@@ -125,8 +154,8 @@ class ParsedLine:
125
154
  :ivar bel: the number of BEL (``\a``) characters seen on this line (``0`` if none were seen).
126
155
  :ivar title: the last OSC window-title string seen on this line, or ``None`` if none was seen.
127
156
  :ivar leds: the last keyboard-LED state requested on this line (``CSI n q``), or ``None`` if none was seen.
128
- :ivar skipped: every :class:`SkippedOp` stripped while parsing this line, in encounter order, for programmatic
129
- inspection independent of the log.
157
+ :ivar skipped: the :class:`SkippedOp` entries stripped while parsing this line, in encounter order, for
158
+ programmatic inspection independent of the log. It holds at most the first 65536 of them.
130
159
  """
131
160
 
132
161
  text: str
@@ -168,8 +197,8 @@ class AnsiParser:
168
197
  (``"progress: 10%\rprogress: 20%"``) resolve correctly onto a single row, mirroring real terminal behaviour.
169
198
  Vertical cursor movement genuinely repositions the cursor within the grid instead of being stripped. The side
170
199
  channels are aggregated over the *entire* input rather than reset per row: ``bel`` is the total BEL count,
171
- ``title``/``leds`` are the last value seen anywhere, and ``skipped`` lists every :class:`SkippedOp` in
172
- encounter order.
200
+ ``title``/``leds`` are the last value seen anywhere, and ``skipped`` lists the :class:`SkippedOp`
201
+ entries in encounter order.
173
202
 
174
203
  Deliberately out of scope in both modes (stripped and logged as :class:`SkippedOp` with
175
204
  ``kind="terminal-setting"`` or ``kind="unknown"``): erase (``CSI J``/``K``), scroll regions, save/restore
@@ -177,6 +206,12 @@ class AnsiParser:
177
206
  (``ESC D``/``M``/``E``) -- a genuine two-dimensional buffer would technically allow implementing erase and
178
207
  save/restore cursor, but that is a candidate follow-up, kept out of scope here.
179
208
 
209
+ The padding inserted to reach a moved cursor -- blank cells before a written character and empty rows below the
210
+ last one -- is limited to ``1024 * 1024 + 8 * n`` cells over the parser's lifetime, ``n`` being the number of
211
+ characters fed so far, and an empty row counts as 8 cells. Once the limit is reached, a character is written at the
212
+ end of its row instead, a vertical move past the last row stays on the last row, and each such cut is recorded as
213
+ a :class:`SkippedOp` with ``kind="unknown"``.
214
+
180
215
  :meth:`finalize` returns a uniform ``tuple[ParsedLine, ...]`` in both modes: in ``one_line=True`` mode, every
181
216
  line completed since construction (queue plus a final flush of whatever remains, possibly empty or
182
217
  unterminated); in ``one_line=False`` mode, always exactly one :class:`ParsedLine` -- built from the whole grid,
@@ -210,6 +245,10 @@ class AnsiParser:
210
245
  self._row = 0
211
246
  self._col = 0
212
247
 
248
+ # padding cells still allowed, and the last cursor move a cut padding is recorded against
249
+ self._padding_budget = _PADDING_BUDGET
250
+ self._last_move = ""
251
+
213
252
  # side channels -- reset per completed line in one_line mode (see
214
253
  # _complete_line()), aggregated over the entire input otherwise
215
254
  self._bel_count = 0
@@ -220,7 +259,8 @@ class AnsiParser:
220
259
  # completed-lines queue, used only in one_line mode
221
260
  self._completed: list[ParsedLine] = []
222
261
 
223
- # escape-framing state: 0 none / 1 CSI / 2 OSC / 3 two-char intermediate
262
+ # escape-framing state: 0 none / 1 CSI / 2 OSC / 3 two-char intermediate /
263
+ # 4 the discarded remainder of an over-long OSC string
224
264
  self._within_escape = False
225
265
  self._parsestate = 0
226
266
  self._escbuf = ""
@@ -240,6 +280,7 @@ class AnsiParser:
240
280
  """
241
281
  if not text:
242
282
  return
283
+ self._padding_budget += _PADDING_PER_CHARACTER * len(text)
243
284
 
244
285
  if self._pending_line_end is not None:
245
286
  pending = self._pending_line_end
@@ -366,6 +407,20 @@ class AnsiParser:
366
407
  while len(self._rows) <= row:
367
408
  self._rows.append([])
368
409
 
410
+ def _move_to_row(self, row: int, raw: str) -> None:
411
+ """Move the cursor to ``row``, inserting the missing empty rows up to it within the padding budget.
412
+
413
+ Without enough budget the cursor moves to the last existing row instead, and ``raw`` is recorded as skipped.
414
+ """
415
+ cost = (row + 1 - len(self._rows)) * _PADDING_PER_ROW
416
+ if cost > self._padding_budget:
417
+ self._skip("unknown", raw, "padding limit reached, cursor kept on the last row")
418
+ row = min(row, len(self._rows) - 1)
419
+ elif cost > 0:
420
+ self._padding_budget -= cost
421
+ self._ensure_row(row)
422
+ self._row = row
423
+
369
424
  def _newline(self) -> None:
370
425
  self._row += 1
371
426
  self._ensure_row(self._row)
@@ -374,7 +429,7 @@ class AnsiParser:
374
429
  # -- internal: char-by-char state machine ---------------------------
375
430
 
376
431
  def _step(self, ch: str) -> None:
377
- if self._parsestate == 2:
432
+ if self._parsestate in {2, 4}:
378
433
  self._handle_osc_char(ch)
379
434
  return
380
435
  if ch == BEL:
@@ -413,8 +468,13 @@ class AnsiParser:
413
468
  elif self._col == len(row):
414
469
  row.append((ch, attr))
415
470
  else:
416
- while len(row) < self._col:
417
- row.append((" ", None))
471
+ gap = self._col - len(row)
472
+ if gap > self._padding_budget:
473
+ self._skip("unknown", self._last_move, "padding limit reached, written at the end of the row")
474
+ self._col = len(row)
475
+ else:
476
+ self._padding_budget -= gap
477
+ row.extend([(" ", None)] * gap)
418
478
  row.append((ch, attr))
419
479
  self._col += 1
420
480
 
@@ -426,17 +486,22 @@ class AnsiParser:
426
486
  self._escbuf = ""
427
487
 
428
488
  def _skip(self, kind: str, raw: str, reason: str) -> None:
429
- self._skipped.append(SkippedOp(kind=kind, raw=raw, reason=reason))
430
- message = f"{reason}: {raw!r}"
431
- if kind == "unknown":
432
- LOGGER.warning(message)
433
- else:
434
- LOGGER.info(message)
489
+ """Record a stripped sequence and log it at debug level.
490
+
491
+ Entries past the first :data:`_MAX_SKIPPED` are not recorded.
492
+ Untrusted input can repeat a sequence without limit.
493
+ A warning would reach stderr, over the UI, through Python's last-resort handler.
494
+ """
495
+ if len(self._skipped) < _MAX_SKIPPED:
496
+ self._skipped.append(SkippedOp(kind=kind, raw=raw, reason=reason))
497
+ LOGGER.debug("%s: %r", reason, raw)
435
498
 
436
499
  def _parse_escape(self, ch: str) -> None:
437
500
  if self._parsestate == 1: # within CSI
438
501
  if ch in "0123456789;" or (not self._escbuf and ch == "?"):
439
- self._escbuf += ch
502
+ # past the limit the parameters are dropped; _dispatch_csi skips the sequence by its length
503
+ if len(self._escbuf) < _MAX_CSI_LENGTH:
504
+ self._escbuf += ch
440
505
  return
441
506
  self._dispatch_csi(ch, self._escbuf)
442
507
  self._leave_escape()
@@ -469,23 +534,29 @@ class AnsiParser:
469
534
  self._leave_escape()
470
535
 
471
536
  def _dispatch_csi(self, final: str, escbuf: str) -> None:
537
+ raw = f"{ESC}[{escbuf}{final}"
538
+ if len(escbuf) >= _MAX_CSI_LENGTH:
539
+ self._skip("unknown", raw, f"CSI parameters of {_MAX_CSI_LENGTH} characters or more, truncated")
540
+ return
541
+
472
542
  qmark = escbuf.startswith("?")
473
543
  body = escbuf[1:] if qmark else escbuf
474
544
  params: list[int | None] = [int(p) if p else None for p in body.split(";")] if body else []
475
- raw = f"{ESC}[{escbuf}{final}"
476
545
 
477
546
  if final == "m":
478
547
  ints = [p if p is not None else 0 for p in params] or [0]
479
548
  self._attrspec = sgi_params_to_attrspec(ints, self._attrspec)
480
549
  elif final == "C":
481
550
  n = params[0] if params and params[0] else 1
482
- self._col += n
551
+ self._col += min(n, _MAX_CURSOR_POSITION)
552
+ self._last_move = raw
483
553
  elif final == "D":
484
554
  n = params[0] if params and params[0] else 1
485
555
  self._col = max(0, self._col - n)
486
556
  elif final == "G":
487
557
  n = params[0] if params and params[0] else 1
488
- self._col = max(0, n - 1)
558
+ self._col = min(n, _MAX_CURSOR_POSITION) - 1
559
+ self._last_move = raw
489
560
  elif final == "q":
490
561
  mode = params[0] if params and params[0] else 0
491
562
  self._leds = led_state(mode)
@@ -503,6 +574,7 @@ class AnsiParser:
503
574
  # reinterpreted
504
575
  self._skip("vertical-move", raw, f"CSI {final!r} vertical cursor movement is meaningless for a single line")
505
576
  return
577
+ self._last_move = raw
506
578
 
507
579
  # these genuinely reposition the cursor within the grid
508
580
  if final == "A":
@@ -510,12 +582,10 @@ class AnsiParser:
510
582
  self._row = max(0, self._row - n)
511
583
  elif final == "B":
512
584
  n = params[0] if params and params[0] else 1
513
- self._row += n
514
- self._ensure_row(self._row)
585
+ self._move_to_row(self._row + min(n, _MAX_CURSOR_POSITION), raw)
515
586
  elif final == "E":
516
587
  n = params[0] if params and params[0] else 1
517
- self._row += n
518
- self._ensure_row(self._row)
588
+ self._move_to_row(self._row + min(n, _MAX_CURSOR_POSITION), raw)
519
589
  self._col = 0
520
590
  elif final == "F":
521
591
  n = params[0] if params and params[0] else 1
@@ -523,14 +593,12 @@ class AnsiParser:
523
593
  self._col = 0
524
594
  elif final == "d":
525
595
  n = params[0] if params and params[0] else 1
526
- self._row = max(0, n - 1)
527
- self._ensure_row(self._row)
596
+ self._move_to_row(min(n, _MAX_CURSOR_POSITION) - 1, raw)
528
597
  elif final in ("H", "f"):
529
598
  r = params[0] if params and params[0] else 1
530
599
  c = params[1] if len(params) > 1 and params[1] else 1
531
- self._row = max(0, r - 1)
532
- self._ensure_row(self._row)
533
- self._col = max(0, c - 1)
600
+ self._move_to_row(min(r, _MAX_CURSOR_POSITION) - 1, raw)
601
+ self._col = min(c, _MAX_CURSOR_POSITION) - 1
534
602
 
535
603
  def _dispatch_noncsi(self, ch: str, mod: str) -> None:
536
604
  raw = f"{ESC}{mod}{ch}"
@@ -558,12 +626,35 @@ class AnsiParser:
558
626
  self._skip("unknown", raw, f"unrecognised escape sequence {raw!r}")
559
627
 
560
628
  def _handle_osc_char(self, ch: str) -> None:
629
+ if self._escbuf[-1:] == ESC and ch != "\\":
630
+ # ECMA-48 allows no ESC inside a command string other than in ST, so any other ESC ends the string
631
+ # unapplied and starts a new escape sequence
632
+ if self._parsestate == 2:
633
+ self._skip("unknown", f"{ESC}]{self._escbuf[:-1]}", "unterminated OSC string")
634
+ self._leave_escape()
635
+ self._within_escape = True
636
+ self._step(ch)
637
+ return
638
+ if self._parsestate == 4:
639
+ # only the last character is kept, which is enough to recognise either terminator
640
+ if ch == BEL or (self._escbuf == ESC and ch == "\\"):
641
+ self._leave_escape()
642
+ else:
643
+ self._escbuf = ch
644
+ return
561
645
  if ch == BEL:
562
646
  self._finish_osc(self._escbuf)
563
647
  return
564
648
  if self._escbuf[-1:] == ESC and ch == "\\":
565
649
  self._finish_osc(self._escbuf[:-1])
566
650
  return
651
+ if len(self._escbuf) >= _MAX_OSC_LENGTH:
652
+ self._skip(
653
+ "unknown", f"{ESC}]{self._escbuf}", f"OSC string of {_MAX_OSC_LENGTH} characters or more, truncated"
654
+ )
655
+ self._parsestate = 4
656
+ self._escbuf = ch
657
+ return
567
658
  if self._escbuf.startswith("P") and len(self._escbuf) == 8:
568
659
  self._finish_osc_skip("OSC palette set")
569
660
  return
@@ -687,6 +778,8 @@ def sgi_params_to_attrspec(params: Sequence[int], previous: AttrSpec | None) ->
687
778
  ``vterm.TermCanvas.sgi_to_attrspec``. SGR 10/11/12 (which toggle vterm's charset/display-control state rather
688
779
  than colour/attributes) are deliberately ignored here -- a caller that also needs that side effect applies it
689
780
  itself; this function never raises for an unrecognised numeric code, it simply has no effect on the result.
781
+ An extended colour (``38``/``48``) whose index or component is outside ``0..255`` is consumed and ignored the same
782
+ way.
690
783
 
691
784
  :param params: the SGR numeric parameters to apply, in encounter order.
692
785
  :param previous: the :class:`~urwid.AttrSpec` in effect before ``params`` is applied, or ``None`` if none is
@@ -739,19 +832,21 @@ def sgi_params_to_attrspec(params: Sequence[int], previous: AttrSpec | None) ->
739
832
  elif attr in (38, 48):
740
833
  if idx + 2 < len(params) and params[idx + 1] == 5:
741
834
  color = params[idx + 2]
742
- colors = max(256, colors)
743
- if attr == 38:
744
- fg = color
745
- else:
746
- bg = color
835
+ if 0 <= color <= 255:
836
+ colors = max(256, colors)
837
+ if attr == 38:
838
+ fg = color
839
+ else:
840
+ bg = color
747
841
  idx += 2
748
842
  elif idx + 4 < len(params) and params[idx + 1] == 2:
749
- color = (params[idx + 2] << 16) + (params[idx + 3] << 8) + params[idx + 4]
750
- colors = 16777216 # 2 ** 24
751
- if attr == 38:
752
- fg = color
753
- else:
754
- bg = color
843
+ if all(0 <= component <= 255 for component in params[idx + 2 : idx + 5]):
844
+ color = (params[idx + 2] << 16) + (params[idx + 3] << 8) + params[idx + 4]
845
+ colors = 16777216 # 2 ** 24
846
+ if attr == 38:
847
+ fg = color
848
+ else:
849
+ bg = color
755
850
  idx += 4
756
851
  elif attr == 39:
757
852
  fg = None