PyTermGUI 7.7.4__py3-none-any.whl → 7.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. CHANGELOG.md +38 -2
  2. README.md +19 -17
  3. pytermgui/__init__.py +1 -1
  4. pytermgui/animations.py +1 -1
  5. pytermgui/ansi_interface.py +1 -1
  6. pytermgui/colors.py +3 -3
  7. pytermgui/exporters.py +2 -2
  8. pytermgui/file_loaders.py +4 -4
  9. pytermgui/helpers.py +21 -74
  10. pytermgui/input.py +1 -5
  11. pytermgui/inspector.py +1 -1
  12. pytermgui/markup/macros.py +1 -1
  13. pytermgui/markup/parsing.py +11 -5
  14. pytermgui/palettes.py +5 -10
  15. pytermgui/regex.py +6 -2
  16. pytermgui/term.py +50 -19
  17. pytermgui/widgets/base.py +0 -12
  18. pytermgui/widgets/containers.py +47 -4
  19. pytermgui/widgets/input_field.py +13 -6
  20. pytermgui/window_manager/compositor.py +3 -1
  21. pytermgui/window_manager/manager.py +10 -3
  22. pytermgui/window_manager/window.py +3 -1
  23. {pytermgui-7.7.4.dist-info → pytermgui-7.8.1.dist-info}/METADATA +50 -18
  24. pytermgui-7.8.1.dist-info/RECORD +60 -0
  25. examples/README.md +0 -19
  26. pytermgui-7.7.4.dist-info/RECORD +0 -78
  27. tests/__init__.py +0 -0
  28. tests/_exporter_targets.py +0 -1646
  29. tests/colorgrids.py +0 -122
  30. tests/test.json +0 -45
  31. tests/test.yaml +0 -103
  32. tests/test_animations.py +0 -114
  33. tests/test_auto.py +0 -68
  34. tests/test_colors.py +0 -113
  35. tests/test_dump_n_load.py +0 -34
  36. tests/test_exporters.py +0 -1063
  37. tests/test_fancy_repr.py +0 -17
  38. tests/test_helpers.py +0 -53
  39. tests/test_highlight_markup_literal.py +0 -8
  40. tests/test_layouts.py +0 -99
  41. tests/test_parser.py +0 -179
  42. tests/test_regex.py +0 -40
  43. tests/test_styles.py +0 -56
  44. {pytermgui-7.7.4.dist-info → pytermgui-7.8.1.dist-info}/WHEEL +0 -0
  45. {pytermgui-7.7.4.dist-info → pytermgui-7.8.1.dist-info}/entry_points.txt +0 -0
  46. {pytermgui-7.7.4.dist-info → pytermgui-7.8.1.dist-info}/licenses/LICENSE +0 -0
CHANGELOG.md CHANGED
@@ -1,11 +1,45 @@
1
- ## [7.7.3] - 2024-12-29
1
+ ## [7.8.1] - 2026-09-10
2
2
 
3
3
  ### Bugfixes
4
4
 
5
- - Fix getch mapping for Windows keys
5
+ - Fix multiline input navigation and joining lines with Backspace
6
+
7
+ ## [7.8.0] - 2026-08-10
8
+
9
+ ### Additions
10
+
11
+ - Add grapheme-aware wrapping with correct widths for CJK and emoji sequences
12
+ - Preserve ANSI styles and hyperlinks when wrapping text
13
+ - Document terminal compatibility and the project's archived status
14
+
15
+ ### Bugfixes
16
+
17
+ - Fix terminal clearing, synchronized frame recording, and resize handling
18
+ - Fix multiline input cursor placement and nested container scrolling
19
+ - Fix uneven splitter columns and automatically sized scrollable windows
20
+ - Fix ANSI bright magenta, cyan, and white color mappings
21
+ - Fix cursor-home and erase sequences in ANSI exports
22
+ - Avoid busy-waiting for input on Windows
23
+
24
+ ### Maintenance
25
+
26
+ - Modernize linting and packaging configuration
27
+ - Remove obsolete project assets and tooling
6
28
 
7
29
  <!-- HATCH README END -->
8
30
 
31
+ ## [7.7.4] - 2025-03-31
32
+
33
+ ### Bugfixes
34
+
35
+ - Fix rapid input to \_GetchLinux
36
+
37
+ ## [7.7.3] - 2024-12-29
38
+
39
+ ### Bugfixes
40
+
41
+ - Fix getch mapping for Windows keys
42
+
9
43
  ## [7.7.2] - 2024-08-31
10
44
 
11
45
  ### Additions
@@ -416,6 +450,8 @@
416
450
 
417
451
  <!-- HATCH URI DEFINITIONS START -->
418
452
 
453
+ [7.8.0]: https://github.com/bczsalba/pytermgui/compare/v7.7.4...v7.8.0
454
+ [7.7.4]: https://github.com/bczsalba/pytermgui/compare/v7.7.3...v7.7.4
419
455
  [7.7.3]: https://github.com/bczsalba/pytermgui/compare/v7.7.2...v7.7.3
420
456
  [7.7.2]: https://github.com/bczsalba/pytermgui/compare/v7.7.1...v7.7.2
421
457
  [7.7.1]: https://github.com/bczsalba/pytermgui/compare/v7.7.0...v7.7.1
README.md CHANGED
@@ -1,5 +1,3 @@
1
- <!--![title](https://github.com/bczsalba/pytermgui/raw/master/assets/title.png)-->
2
-
3
1
  ![title](https://github.com/bczsalba/pytermgui/raw/master/assets/readme/screenshot.png)
4
2
 
5
3
  > Python TUI framework with mouse support, modular widget system, customizable and rapid terminal markup language and more!
@@ -12,26 +10,20 @@ pip3 install pytermgui
12
10
  <a href="https://pypi.org/project/pytermgui">
13
11
  <img alt="PyPi project" src="https://img.shields.io/pypi/v/pytermgui?color=brightgreen">
14
12
  </a>
15
- <a href="https://github.com/bczsalba/pytermgui/blob/master/utils/create_badge.py">
16
- <img alt="Code quality" src="https://raw.githubusercontent.com/bczsalba/pytermgui/master/assets/badges/quality.svg">
17
- </a>
18
- <a href="http://ptg.bczsalba.com/">
19
- <img src="https://img.shields.io/badge/documentation-up%20to%20date-brightgreen">
13
+ <a href="https://ptg.bczsalba.com/">
14
+ <img src="https://img.shields.io/badge/documentation-gray">
20
15
  </a>
21
16
  <a href="https://github.com/bczsalba/pytermgui/actions/workflows/pytest.yml">
22
17
  <img src="https://github.com/bczsalba/pytermgui/actions/workflows/pytest.yml/badge.svg">
23
18
  </a>
24
19
  </p>
25
- <p align=center>
26
- <a href="https://discord.gg/g4bqMvpG4U">
27
- <img src="https://img.shields.io/discord/999374285686706367?label=join%20our%20discord">
28
- </a>
29
20
  </p>
30
21
 
31
- ## Notice
22
+ <hr>
32
23
 
33
- A much better, more complete version of PTG's core ideas now exists over at [Shade 40](https://github.com/shade40). While PTG is not yet fully obsolete,
34
- those libraries will be the primary focus of development going forward.
24
+ ## Archival notice
25
+
26
+ PyTermGUI is no longer in development. The latest release will remain available, alongside its documentation. The MIT license allows for free modification of the library, as long as you include credit to its original author. Thank you for everything.
35
27
 
36
28
  <hr>
37
29
 
@@ -64,6 +56,16 @@ Additionally, there are a couple of neat tools to make your general Python devel
64
56
  - A pretty printer for both the REPL and IPython
65
57
  - A way to create SVG and HTML screenshots of your terminal
66
58
 
59
+ ### Terminal compatibility
60
+
61
+ PyTermGUI relies on ANSI virtual-terminal sequences for rendering, cursor reports,
62
+ keyboard input, and mouse input. On Windows, support depends on the terminal emulator's
63
+ ANSI compatibility. [Windows Terminal](https://github.com/microsoft/terminal) supports
64
+ the expected cursor and mouse protocols, while the legacy Windows Console Host
65
+ (`conhost.exe`) may provide reduced functionality. This does not depend on whether
66
+ PowerShell or Command Prompt is running inside the emulator. Mouse support also depends
67
+ on the emulator forwarding the requested events.
68
+
67
69
  <!-- HATCH README END -->
68
70
 
69
71
  ## Examples
@@ -181,9 +183,9 @@ We use algorithms based on human vision to convert and downgrade colors when the
181
183
 
182
184
  ## Questions? See the docs!
183
185
 
184
- Pretty much every single name in the library, private or public, has an insightful dockstring attached to it, and we are accumulating a growing amount of walkthrough-based documentations articles. See 'em all on the [doc website](https://ptg.bczsalba.com)!
186
+ Pretty much every name in the library, private or public, has an insightful docstring attached to it, alongside walkthrough-based documentation articles. See them all on the [documentation website](https://ptg.bczsalba.com)!
185
187
 
186
188
 
187
- ## Contributions, issues et al.
189
+ ## Project status
188
190
 
189
- If you have any problems using the library, feel free to open up a discussion or raise an issue ticket. If you would prefer to hack on the library yourself, see the [contribution guidelines](https://github.com/bczsalba/pytermgui/blob/master/CONTRIBUTING.md). Pull requests are encouraged, but make sure you aren't trying to fix an issue that others are already working on, for your own sake. :slightly_smiling_face:
191
+ PyTermGUI has reached its final release and is no longer under active development. The repository and its documentation are retained as an archive for existing users.
pytermgui/__init__.py CHANGED
@@ -40,7 +40,7 @@ if "-m" in sys.argv: # pragma: no cover
40
40
 
41
41
  warnings.filterwarnings("ignore")
42
42
 
43
- __version__ = "7.7.4"
43
+ __version__ = "7.8.1"
44
44
 
45
45
 
46
46
  def auto(data: Any, **widget_args: Any) -> Optional[Widget | list[Splitter]]:
pytermgui/animations.py CHANGED
@@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, Any, Callable
23
23
  if TYPE_CHECKING:
24
24
  from .widgets import Widget
25
25
  else:
26
- Widget = Any
26
+ Widget = Any # pylint: disable=invalid-name
27
27
 
28
28
  __all__ = ["Animator", "FloatAnimation", "AttrAnimation", "animator", "is_animated"]
29
29
 
@@ -116,7 +116,7 @@ def clear(what: str = "screen") -> None:
116
116
  commands = {
117
117
  "eos": "\x1b[0J",
118
118
  "bos": "\x1b[1J",
119
- "screen": "\x1b[2J",
119
+ "screen": "\x1b[H\x1b[2J",
120
120
  "eol": "\x1b[0K",
121
121
  "bol": "\x1b[1K",
122
122
  "line": "\x1b[2K",
pytermgui/colors.py CHANGED
@@ -68,9 +68,9 @@ XTERM_NAMED_COLORS = {
68
68
  10: "ansi-bright-green",
69
69
  11: "ansi-bright-yellow",
70
70
  12: "ansi-bright-blue",
71
- 14: "ansi-bright-magenta",
72
- 15: "ansi-bright-cyan",
73
- 16: "ansi-bright-white",
71
+ 13: "ansi-bright-magenta",
72
+ 14: "ansi-bright-cyan",
73
+ 15: "ansi-bright-white",
74
74
  }
75
75
 
76
76
  NAMED_COLORS = {
pytermgui/exporters.py CHANGED
@@ -273,7 +273,7 @@ def token_to_css(token: Token, invert: bool = False) -> str:
273
273
 
274
274
  # We take this many arguments for future proofing and customization, not much we can
275
275
  # do about it.
276
- def to_html( # pylint: disable=too-many-arguments, too-many-locals
276
+ def to_html( # pylint: disable=too-many-arguments, too-many-locals, R0917
277
277
  obj: Widget | StyledText | str,
278
278
  prefix: str | None = None,
279
279
  inline_styles: bool = False,
@@ -441,7 +441,7 @@ def _make_tag(tagname: str, content: str = "", **attrs) -> str:
441
441
 
442
442
  # This is a bit of a beast of a function, but it does the job and IMO reducing it
443
443
  # into parts would just make our lives more complicated.
444
- def to_svg( # pylint: disable=too-many-locals, too-many-arguments, too-many-statements, R0912
444
+ def to_svg( # pylint: disable=too-many-locals, too-many-arguments, too-many-statements, R0912, R0917
445
445
  obj: Widget | StyledText | str,
446
446
  prefix: str | None = None,
447
447
  chrome: bool = True,
pytermgui/file_loaders.py CHANGED
@@ -131,14 +131,14 @@ from . import widgets as widgets_m
131
131
  from .markup import tim
132
132
  from .serialization import Serializer
133
133
 
134
- YAML_ERROR = None
134
+ _yaml_error = None
135
135
 
136
136
  try:
137
137
  import yaml
138
138
  except ImportError as import_error:
139
139
  # yaml is explicitly checked to be None later
140
140
  yaml = None # type: ignore
141
- YAML_ERROR = import_error
141
+ _yaml_error = import_error
142
142
 
143
143
 
144
144
  __all__ = ["WidgetNamespace", "FileLoader", "YamlLoader", "JsonLoader"]
@@ -412,10 +412,10 @@ class YamlLoader(FileLoader):
412
412
  def __init__(self, serializer: Serializer | None = None) -> None:
413
413
  """Initialize object, check for installation of PyYAML."""
414
414
 
415
- if YAML_ERROR is not None:
415
+ if _yaml_error is not None:
416
416
  raise RuntimeError(
417
417
  "YAML implementation module not found. Please install `PyYAML` to use `YamlLoader`."
418
- ) from YAML_ERROR
418
+ ) from _yaml_error
419
419
 
420
420
  super().__init__()
421
421
 
pytermgui/helpers.py CHANGED
@@ -4,8 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Iterator
6
6
 
7
- from .markup import tokenize_ansi
8
- from .markup.parsing import LINK_TEMPLATE, PARSERS
7
+ from wcwidth import wrap as wcwidth_wrap
8
+
9
9
  from .regex import real_length
10
10
 
11
11
  __all__ = [
@@ -13,17 +13,14 @@ __all__ = [
13
13
  ]
14
14
 
15
15
 
16
- def break_line( # pylint: disable=too-many-branches
16
+ def break_line(
17
17
  line: str, limit: int, non_first_limit: int | None = None, fill: str | None = None
18
18
  ) -> Iterator[str]:
19
19
  """Breaks a line into a `list[str]` with maximum `limit` length per line.
20
20
 
21
- It keeps ongoing ANSI sequences between lines, and inserts a reset sequence
22
- at the end of each style-containing line.
23
-
24
- At the moment it splits strings exactly on the limit, and not on word
25
- boundaries. That functionality would be preferred, so it will end up being
26
- implemented at some point.
21
+ Uses wcwidth.wrap() for proper word-boundary breaking, grapheme cluster
22
+ handling, and wide character support. ANSI sequences are preserved and
23
+ propagated across line breaks.
27
24
 
28
25
  Args:
29
26
  line: The line to split. May or may not contain ANSI sequences.
@@ -31,83 +28,33 @@ def break_line( # pylint: disable=too-many-branches
31
28
  non-printing sequences.
32
29
  non_first_limit: The limit after the first line. If not given, defaults
33
30
  to `limit`.
31
+ fill: Optional character to pad lines to the limit width.
34
32
  """
35
33
 
36
34
  if line in ["", "\x1b[0m"]:
37
35
  yield ""
38
36
  return
39
37
 
40
- def _pad_and_link(line: str, link: str | None) -> str:
41
- count = limit - real_length(line)
42
-
43
- if link is not None:
44
- line = LINK_TEMPLATE.format(uri=link, label=line)
45
-
38
+ def _pad_line(text: str, width: int) -> str:
46
39
  if fill is None:
47
- return line
48
-
49
- line += count * fill
50
-
51
- return line
40
+ return text
52
41
 
53
- used = 0
54
- current = ""
55
- sequences = ""
42
+ count = width - real_length(text)
43
+ if count > 0:
44
+ return text + count * fill
45
+ return text
56
46
 
57
47
  if non_first_limit is None:
58
48
  non_first_limit = limit
59
49
 
60
- parsers = PARSERS
61
- link = None
62
-
63
- for token in tokenize_ansi(line):
64
- if token.is_plain():
65
- for char in token.value:
66
- if char == "\n" or used >= limit:
67
- if sequences != "":
68
- current += "\x1b[0m"
69
-
70
- yield _pad_and_link(current, link)
71
- link = None
72
-
73
- current = sequences
74
- used = 0
75
-
76
- limit = non_first_limit
77
-
78
- if char != "\n":
79
- current += char
80
- used += 1
81
-
82
- # If the link wasn't yielded along with its token, remove and add it
83
- # to current manually.
84
- if link is not None:
85
- current = current[: -len(token.value)]
86
- current += LINK_TEMPLATE.format(uri=link, label=token.value)
87
- link = None
88
-
89
- continue
90
-
91
- if token.value == "/":
92
- sequences = "\x1b[0m"
93
-
94
- if len(current) > 0:
95
- current += sequences
96
-
50
+ for segment in line.split("\n"):
51
+ if not segment:
52
+ yield _pad_line("", limit)
53
+ limit = non_first_limit
97
54
  continue
98
55
 
99
- if token.is_hyperlink():
100
- link = token.value
101
- continue
102
-
103
- sequence = parsers[type(token)](token, {}, lambda: line) # type: ignore
104
- sequences += sequence
105
- current += sequence
106
-
107
- if current == "":
108
- return
109
-
110
- if sequences != "" and not current.endswith("\x1b[0m"):
111
- current += "\x1b[0m"
56
+ wrapped = wcwidth_wrap(segment, limit)
112
57
 
113
- yield _pad_and_link(current, link)
58
+ for wrapped_line in wrapped:
59
+ yield _pad_line(wrapped_line, limit)
60
+ limit = non_first_limit
pytermgui/input.py CHANGED
@@ -5,10 +5,6 @@ Credits:
5
5
 
6
6
  - Original getch implementation: [Danny Yoo](https://code.activestate.com/recipes/134892)
7
7
  - Modern additions & idea: [kcsaff](https://github.com/kcsaff/getkey)
8
-
9
- Note that the original link seems to no longer be active, but an archive can be found
10
- on [GitHub](https://github.com/ActiveState/code/tree/master/recipes/Python/
11
- 134892_getchlike_unbuffered_character_reading_stdboth).
12
8
  """
13
9
 
14
10
  # pylint doesn't see the C source
@@ -134,7 +130,7 @@ class _GetchUnix:
134
130
 
135
131
  descriptor = sys.stdin.fileno()
136
132
  old_settings = termios.tcgetattr(descriptor)
137
- tty.setcbreak(descriptor)
133
+ tty.setcbreak(descriptor, termios.TCSANOW)
138
134
 
139
135
  try:
140
136
  yield self._read(1)
pytermgui/inspector.py CHANGED
@@ -175,7 +175,7 @@ def inspect(target: object, **inspector_args: Any) -> Inspector:
175
175
  class Inspector(Container):
176
176
  """A widget to inspect any Python object."""
177
177
 
178
- def __init__( # pylint: disable=too-many-arguments
178
+ def __init__( # pylint: disable=too-many-arguments, R0917
179
179
  self,
180
180
  target: object = None,
181
181
  show_private: bool = False,
@@ -9,7 +9,7 @@ from .parsing import parse
9
9
 
10
10
  DEFAULT_MACROS = {}
11
11
 
12
- MarkupLanguage = Any
12
+ MarkupLanguage = Any # pylint: disable=invalid-name
13
13
 
14
14
 
15
15
  MacroTemplate = TypeVar("MacroTemplate")
@@ -211,6 +211,11 @@ def tokenize_ansi( # pylint: disable=too-many-locals, too-many-branches, too-ma
211
211
  if cursor < start:
212
212
  yield PlainToken(text[cursor:start])
213
213
 
214
+ if matchobj.groups()[4] is not None:
215
+ cursor = end
216
+ yield ClearToken("/~")
217
+ continue
218
+
214
219
  if link_osc != (None, None):
215
220
  cursor = end
216
221
  uri, label = link_osc
@@ -225,6 +230,9 @@ def tokenize_ansi( # pylint: disable=too-many-locals, too-many-branches, too-ma
225
230
 
226
231
  cursor = end
227
232
 
233
+ if full.endswith(("J", "K")):
234
+ continue
235
+
228
236
  code = ""
229
237
 
230
238
  # Position
@@ -233,9 +241,7 @@ def tokenize_ansi( # pylint: disable=too-many-locals, too-many-branches, too-ma
233
241
  if posmatch is not None:
234
242
  ypos, xpos = posmatch.groups()
235
243
  if not ypos and not xpos:
236
- raise ValueError(
237
- f"Cannot parse cursor when no position is supplied. Match: {posmatch!r}"
238
- )
244
+ ypos = xpos = "1"
239
245
 
240
246
  yield CursorToken(content, int(ypos) or None, int(xpos) or None)
241
247
  continue
@@ -481,7 +487,7 @@ def optimize_tokens(tokens: list[Token]) -> Iterator[Token]:
481
487
  applied.append(tkn)
482
488
  yield tkn
483
489
 
484
- def _remove_redundant_color(token: Token) -> None:
490
+ def _remove_redundant_color(token: Token, new: Color) -> None:
485
491
  """Removes non-functional colors.
486
492
 
487
493
  These happen in the following ways:
@@ -513,7 +519,7 @@ def optimize_tokens(tokens: list[Token]) -> Iterator[Token]:
513
519
  if Token.is_color(token):
514
520
  new = token.color
515
521
 
516
- _remove_redundant_color(token)
522
+ _remove_redundant_color(token, new)
517
523
 
518
524
  if not any(token.markup == applied.markup for applied in current_tag_group):
519
525
  current_tag_group.append(token)
pytermgui/palettes.py CHANGED
@@ -241,23 +241,18 @@ class Palette:
241
241
  for shadenumber in range(-SHADE_COUNT, SHADE_COUNT + 1):
242
242
  if shadenumber > 0:
243
243
  shadeindex = f"+{shadenumber}"
244
- blend_color = white
245
- blend_multiplier = 1
244
+ blended = color.blend(
245
+ white, SHADE_INCREMENT * shadenumber
246
+ )
246
247
 
247
248
  elif shadenumber == 0:
248
249
  shadeindex = ""
249
-
250
- else:
251
- shadeindex = str(shadenumber)
252
- blend_color = black
253
- blend_multiplier = -1
254
-
255
- if shadenumber == 0:
256
250
  blended = color
257
251
 
258
252
  else:
253
+ shadeindex = str(shadenumber)
259
254
  blended = color.blend(
260
- blend_color, blend_multiplier * SHADE_INCREMENT * shadenumber
255
+ black, -SHADE_INCREMENT * shadenumber
261
256
  )
262
257
 
263
258
  data[f"{name}{shadeindex}"] = blended
pytermgui/regex.py CHANGED
@@ -7,8 +7,12 @@ from typing import Match
7
7
  from wcwidth import wcswidth
8
8
 
9
9
  RE_LINK = re.compile(r"(?:\x1b\]8;;([^\\]*)\x1b\\([^\\]*?)\x1b\]8;;\x1b\\)")
10
- RE_ANSI_NEW = re.compile(rf"(\x1b\[(.*?)[mH])|{RE_LINK.pattern}|(\x1b_G(.*?)\x1b\\)")
11
- RE_ANSI = re.compile(r"(?:\x1b\[(.*?)[mH])|(?:\x1b\](.*?)\x1b\\)|(?:\x1b_G(.*?)\x1b\\)")
10
+ RE_ANSI_NEW = re.compile(
11
+ rf"(\x1b\[(.*?)[mHJK])|{RE_LINK.pattern}|(\x1b\]8;;\x1b\\)|(\x1b_G(.*?)\x1b\\)"
12
+ )
13
+ RE_ANSI = re.compile(
14
+ r"(?:\x1b\[(.*?)[mHJK])|(?:\x1b\](.*?)\x1b\\)|(?:\x1b_G(.*?)\x1b\\)"
15
+ )
12
16
  RE_MACRO = re.compile(r"(![a-z0-9_\-]+)(?:\(([\w\/\.?\-=:]+)\))?")
13
17
  RE_MARKUP = re.compile(r"((\\*)\[([^\[\]]*)\])")
14
18
  RE_POSITION = re.compile(r"\x1b\[(\d*?)(?:;(\d*))?H")
pytermgui/term.py CHANGED
@@ -8,6 +8,7 @@ import errno
8
8
  import os
9
9
  import signal
10
10
  import sys
11
+ import threading
11
12
  import time
12
13
  from contextlib import contextmanager
13
14
  from datetime import datetime
@@ -126,7 +127,7 @@ class Recorder:
126
127
  with open(filename, "w", encoding="utf-8") as file:
127
128
  file.write(self.export_html(prefix=prefix, inline_styles=inline_styles))
128
129
 
129
- def save_svg( # pylint: disable=too-many-arguments
130
+ def save_svg( # pylint: disable=too-many-arguments, R0917
130
131
  self,
131
132
  filename: str | None = None,
132
133
  prefix: str | None = None,
@@ -263,6 +264,9 @@ class Terminal: # pylint: disable=too-many-instance-attributes
263
264
 
264
265
  self._listeners: dict[int, list[Callable[..., Any]]] = {}
265
266
 
267
+ # Async-signal-safe resize mechanism
268
+ self._resize_pending = threading.Event()
269
+
266
270
  if hasattr(signal, "SIGWINCH"):
267
271
  signal.signal(signal.SIGWINCH, self._update_size)
268
272
  else:
@@ -278,16 +282,16 @@ class Terminal: # pylint: disable=too-many-instance-attributes
278
282
  ["" for _ in range(self.width)] for y in range(self.height)
279
283
  ]
280
284
 
281
- def _window_terminal_resize(self):
285
+ def _window_terminal_resize(self) -> None:
282
286
  from time import sleep # pylint: disable=import-outside-toplevel
283
287
 
284
288
  _previous = get_terminal_size()
285
289
  while True:
286
290
  _next = get_terminal_size()
287
291
  if _previous != _next:
288
- self._update_size()
292
+ self._resize_pending.set()
289
293
  _previous = _next
290
- sleep(0.001)
294
+ sleep(0.01)
291
295
 
292
296
  def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
293
297
  """Returns a cool looking repr."""
@@ -348,17 +352,34 @@ class Terminal: # pylint: disable=too-many-instance-attributes
348
352
  return (size[0], size[1])
349
353
 
350
354
  def _update_size(self, *_: Any) -> None:
351
- """Resize terminal when SIGWINCH occurs, and call listeners."""
355
+ """Signal handler for SIGWINCH - ONLY sets flag (async-signal-safe)."""
352
356
 
353
- if hasattr(self, "resolution"):
354
- del self.resolution
357
+ self._resize_pending.set()
355
358
 
356
- self.size = self._get_size()
359
+ def process_pending_resize(self) -> bool:
360
+ """Process pending resize event if one is queued.
361
+
362
+ Call this periodically from the main event loop.
363
+
364
+ :returns: True if a resize was processed.
365
+ """
366
+ if not self._resize_pending.is_set():
367
+ return False
368
+
369
+ self._resize_pending.clear()
370
+
371
+ # Check __dict__ directly to avoid triggering the cached_property getter,
372
+ # which uses signals and can only run in the main thread
373
+ if "resolution" in self.__dict__:
374
+ del self.__dict__["resolution"]
357
375
 
376
+ self.size = self._get_size()
358
377
  self._call_listener(self.RESIZE, self.size)
359
378
 
360
379
  # Wipe the screen in case anything got messed up
361
- self.write("\x1b[2J")
380
+ self.write("\x1b[H\x1b[2J")
381
+
382
+ return True
362
383
 
363
384
  @property
364
385
  def width(self) -> int:
@@ -454,15 +475,21 @@ class Terminal: # pylint: disable=too-many-instance-attributes
454
475
  buffer = StringIO()
455
476
 
456
477
  try:
457
- with self.no_record():
458
- self.write("\x1b[?2026h")
478
+ # Write directly to stream to avoid write()'s auto-clear behavior
479
+ self._stream.write("\x1b[?2026h")
459
480
  yield buffer
460
481
 
461
482
  finally:
462
- self.write(buffer.getvalue())
463
- with self.no_record():
464
- self.write("\x1b[?2026l")
465
- self.flush()
483
+ content = buffer.getvalue()
484
+ self._stream.write(content)
485
+
486
+ # Frame contents bypass write() so they are sent atomically to the output
487
+ # stream. Forward the visual content to an active recorder explicitly.
488
+ if self._recorder is not None:
489
+ self._recorder.write(content)
490
+
491
+ self._stream.write("\x1b[?2026l")
492
+ self._stream.flush()
466
493
 
467
494
  @staticmethod
468
495
  def isatty() -> bool:
@@ -532,6 +559,7 @@ class Terminal: # pylint: disable=too-many-instance-attributes
532
559
 
533
560
  return sliced
534
561
 
562
+ # Truncate pending buffer on clear (may help on Windows)
535
563
  if "\x1b[2J" in data:
536
564
  self.clear_stream()
537
565
 
@@ -546,8 +574,7 @@ class Terminal: # pylint: disable=too-many-instance-attributes
546
574
 
547
575
  maximum = self.width - xpos
548
576
 
549
- if xpos < self.origin[0]:
550
- xpos = self.origin[0]
577
+ xpos = max(xpos, self.origin[0])
551
578
 
552
579
  sliced = _slice(data, maximum) if len(data) > maximum else data
553
580
 
@@ -565,7 +592,11 @@ class Terminal: # pylint: disable=too-many-instance-attributes
565
592
  self._stream.flush()
566
593
 
567
594
  def clear_stream(self) -> None:
568
- """Clears (truncates) the terminal's stream."""
595
+ """Clears the terminal screen.
596
+
597
+ Attempts to truncate any buffered stream data (may work on Windows),
598
+ then moves cursor to home position and clears the entire screen.
599
+ """
569
600
 
570
601
  try:
571
602
  self._stream.truncate(0)
@@ -574,7 +605,7 @@ class Terminal: # pylint: disable=too-many-instance-attributes
574
605
  if error.errno != errno.EINVAL and os.name != "nt":
575
606
  raise
576
607
 
577
- self._stream.write("\x1b[2J")
608
+ self._stream.write("\x1b[H\x1b[2J")
578
609
 
579
610
  def print(
580
611
  self,