console-window 2.0.0__tar.gz → 2.0.0.2__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 (42) hide show
  1. {console_window-2.0.0 → console_window-2.0.0.2}/CHANGELOG.md +10 -0
  2. {console_window-2.0.0 → console_window-2.0.0.2}/PKG-INFO +1 -1
  3. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/__init__.py +1 -1
  4. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/context.py +13 -3
  5. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/textwidth.py +77 -11
  6. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_textwidth.py +20 -0
  7. {console_window-2.0.0 → console_window-2.0.0.2}/.github/workflows/ci.yml +0 -0
  8. {console_window-2.0.0 → console_window-2.0.0.2}/.gitignore +0 -0
  9. {console_window-2.0.0 → console_window-2.0.0.2}/AUDIT.md +0 -0
  10. {console_window-2.0.0 → console_window-2.0.0.2}/LICENSE +0 -0
  11. {console_window-2.0.0 → console_window-2.0.0.2}/README.md +0 -0
  12. {console_window-2.0.0 → console_window-2.0.0.2}/SampleApp.py +0 -0
  13. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/backend.py +0 -0
  14. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/geometry.py +0 -0
  15. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/header.py +0 -0
  16. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/keys.py +0 -0
  17. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/opts.py +0 -0
  18. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/screens.py +0 -0
  19. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/signals.py +0 -0
  20. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/spinner.py +0 -0
  21. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/theme.py +0 -0
  22. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/widgets.py +0 -0
  23. {console_window-2.0.0 → console_window-2.0.0.2}/console_window/window.py +0 -0
  24. {console_window-2.0.0 → console_window-2.0.0.2}/pyproject.toml +0 -0
  25. {console_window-2.0.0 → console_window-2.0.0.2}/runner +0 -0
  26. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_backend.py +0 -0
  27. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_context.py +0 -0
  28. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_demo_mode.py +0 -0
  29. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_dialogs.py +0 -0
  30. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_flash.py +0 -0
  31. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_geometry.py +0 -0
  32. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_header.py +0 -0
  33. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_interactive.py +0 -0
  34. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_overflow.py +0 -0
  35. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_public_api.py +0 -0
  36. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_render_retry.py +0 -0
  37. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_sample_app.py +0 -0
  38. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_signals.py +0 -0
  39. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_spinner.py +0 -0
  40. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_theme.py +0 -0
  41. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_widechars.py +0 -0
  42. {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_widgets.py +0 -0
@@ -61,6 +61,16 @@ This project follows [Semantic Versioning](https://semver.org/).
61
61
  `DemoApp`/`DemoScreen`.
62
62
 
63
63
  ### Fixed
64
+ - Navigation keys are responsive again. Two 2.0.0 hot paths made a full
65
+ redraw of a large list far slower than 1.4.3. `textwidth.truncate()` ran
66
+ three `unicodedata` lookups per character on every row, and
67
+ `Context.__init__()` ran `difflib.get_close_matches()` for every custom
68
+ keyword on every row (a 9,000-row screen constructs 9,000 Contexts per
69
+ frame). Printable ASCII now takes a direct one-cell path, mixed text skips
70
+ whole ASCII runs and visits only the glyphs that differ, `truncate()` uses a
71
+ frame-sized cache, non-ASCII glyph widths are cached, and the Context typo
72
+ suggestion is cached per attribute name. On vappman's 9,035-row screen,
73
+ `draw_screen()` fell from ~225 ms to ~74 ms (1.4.3 measured ~58 ms).
64
74
  - Hygiene debt from AUDIT.md §3.14: removed blank lines inside the
65
75
  `index_at_cell()`/`answer()` signatures and the commented-out assignment in
66
76
  `fix_positions()`; replaced `render()`'s `print()`/`except Exception` failure
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 2.0.0
3
+ Version: 2.0.0.2
4
4
  Summary: A robust curses wrapper for creating Text User Interfaces (TUIs).
5
5
  Author-email: Joe Defen <joedef@duck.com>
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,6 @@
1
1
  # Single source of truth for the package version: pyproject.toml declares
2
2
  # ``dynamic = ["version"]``, so flit reads this value instead of duplicating it.
3
- __version__ = "2.0.0"
3
+ __version__ = "2.0.0.2"
4
4
 
5
5
  from .context import Context
6
6
  from .header import HeaderLine, Span
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import difflib
4
4
  import warnings
5
+ from functools import lru_cache
5
6
  from typing import TYPE_CHECKING, ClassVar
6
7
 
7
8
  if TYPE_CHECKING: # pragma: no cover - typing only
@@ -27,11 +28,20 @@ class Context:
27
28
  # known attribute (e.g. 'abutt' for 'abut'): such a name would otherwise
28
29
  # be attached silently and never read by the engine.
29
30
  for key, value in kwargs.items():
30
- near = difflib.get_close_matches(key, self.KNOWN_ATTRS, n=1, cutoff=0.8)
31
- if near:
31
+ near = _near_miss(key, self.KNOWN_ATTRS)
32
+ if near is not None:
32
33
  warnings.warn(
33
34
  f'Context: unknown attribute {key!r} closely matches '
34
- f'{near[0]!r} (did you mean {near[0]!r}?)',
35
+ f'{near!r} (did you mean {near!r}?)',
35
36
  stacklevel=2,
36
37
  )
37
38
  setattr(self, key, value)
39
+
40
+
41
+ @lru_cache(maxsize=256)
42
+ def _near_miss(key: str, known_attrs: frozenset[str]) -> str | None:
43
+ # Advisory typo detection only depends on the attribute name, not its value.
44
+ # Apps construct one Context per row, so without this cache difflib scans
45
+ # the same custom names thousands of times per redraw (info, item, ...).
46
+ matches = difflib.get_close_matches(key, known_attrs, n=1, cutoff=0.8)
47
+ return matches[0] if matches else None
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  import unicodedata
5
+ from functools import lru_cache
4
6
 
5
7
  __all__ = [
6
8
  'cell_to_index',
@@ -19,10 +21,27 @@ __all__ = [
19
21
  # characters (ZWJ, variation selectors, BOM, …) and control characters.
20
22
  _ZERO_WIDTH_CATEGORIES = frozenset({'Mn', 'Me', 'Cf', 'Cc'})
21
23
 
24
+ # Any character that is not printable ASCII needs per-character measurement.
25
+ # Everything else is exactly one cell wide, so measurement can skip whole
26
+ # ASCII runs in C and only visit the glyphs that actually differ.
27
+ _SPECIAL_CHAR = re.compile(r'[^\x20-\x7e]')
22
28
 
23
- def char_width(ch: str) -> int:
24
- if not ch:
25
- return 0
29
+
30
+ def _is_plain_ascii(text: str) -> bool:
31
+ # Fast path for the overwhelmingly common terminal case. Printable ASCII
32
+ # occupies exactly one cell per code point, so the per-character
33
+ # unicodedata scan in char_width() can be skipped entirely. Control
34
+ # characters are excluded because this module treats them as zero-width.
35
+ # The checks themselves are C-level string scans, and keep add_body() on
36
+ # the same cost profile as the pre-2.0 plain slicing it replaced.
37
+ return text.isascii() and text.isprintable()
38
+
39
+
40
+ @lru_cache(maxsize=4096)
41
+ def _non_ascii_char_width(ch: str) -> int:
42
+ # Only reached for non-printable-ASCII characters. A terminal UI reuses a
43
+ # small alphabet (box drawing, check marks, CJK, emoji), so caching turns
44
+ # the three unicodedata lookups into one dict hit after the first sighting.
26
45
  if unicodedata.combining(ch):
27
46
  return 0
28
47
  if unicodedata.category(ch) in _ZERO_WIDTH_CATEGORIES:
@@ -32,19 +51,55 @@ def char_width(ch: str) -> int:
32
51
  return 1
33
52
 
34
53
 
35
- def string_width(text: str) -> int:
36
- return sum(char_width(ch) for ch in text)
54
+ def char_width(ch: str) -> int:
55
+ if not ch:
56
+ return 0
57
+ # Printable ASCII is one cell. This branch is what keeps mixed text (the
58
+ # common case: ASCII rows with a ✔/─/◆ glyph) from paying unicodedata
59
+ # costs for every ordinary character.
60
+ if ' ' <= ch <= '~':
61
+ return 1
62
+ return _non_ascii_char_width(ch)
37
63
 
38
64
 
65
+ def string_width(text: str) -> int:
66
+ if _is_plain_ascii(text):
67
+ return len(text)
68
+ # Count printable ASCII as one cell, then adjust only the glyphs that
69
+ # differ (wide, combining, ZWJ, control). This keeps mixed rows cheap
70
+ # without needing a whole-string cache large enough for every body row.
71
+ width = len(text)
72
+ for match in _SPECIAL_CHAR.finditer(text):
73
+ width += char_width(match.group()) - 1
74
+ return width
75
+
76
+
77
+ # A redraw usually re-measures the same rows. The cache must hold a whole
78
+ # frame, not just a terminal's worth: large package/app lists routinely exceed
79
+ # a few thousand rows, and a too-small cache evicts every entry before the next
80
+ # frame and then pays both the scan and the eviction cost. The ASCII-run scan
81
+ # below still handles lists larger than this cap efficiently.
82
+ @lru_cache(maxsize=32768)
39
83
  def truncate(text: str, max_width: int) -> str:
40
84
  if max_width <= 0:
41
85
  return ''
86
+ if _is_plain_ascii(text):
87
+ return text if len(text) <= max_width else text[:max_width]
42
88
  total = 0
43
- for i, ch in enumerate(text):
44
- w = char_width(ch)
89
+ ascii_start = 0
90
+ for match in _SPECIAL_CHAR.finditer(text):
91
+ run = match.start() - ascii_start
92
+ if total + run > max_width:
93
+ return text[:ascii_start + max_width - total]
94
+ total += run
95
+ w = char_width(match.group())
45
96
  if total + w > max_width:
46
- return text[:i]
97
+ return text[:match.start()]
47
98
  total += w
99
+ ascii_start = match.end()
100
+ run = len(text) - ascii_start
101
+ if total + run > max_width:
102
+ return text[:ascii_start + max_width - total]
48
103
  return text
49
104
 
50
105
 
@@ -61,12 +116,23 @@ def rjust(text: str, width: int) -> str:
61
116
  def cell_to_index(text: str, col: int) -> int:
62
117
  if col <= 0:
63
118
  return 0
119
+ if _is_plain_ascii(text):
120
+ return min(col, len(text))
64
121
  total = 0
65
- for i, ch in enumerate(text):
66
- w = char_width(ch)
122
+ ascii_start = 0
123
+ for match in _SPECIAL_CHAR.finditer(text):
124
+ run = match.start() - ascii_start
125
+ if total + run > col:
126
+ return ascii_start + col - total
127
+ total += run
128
+ w = char_width(match.group())
67
129
  if total + w > col:
68
- return i
130
+ return match.start()
69
131
  total += w
132
+ ascii_start = match.end()
133
+ run = len(text) - ascii_start
134
+ if total + run > col:
135
+ return ascii_start + col - total
70
136
  return len(text)
71
137
 
72
138
 
@@ -41,6 +41,26 @@ def test_truncate_narrow():
41
41
  assert tw.truncate('abc', 10) == 'abc'
42
42
 
43
43
 
44
+ def test_ascii_fast_path_keeps_control_chars_zero_width():
45
+ # Printable ASCII takes the fast path; control characters must still fall
46
+ # back to the unicodedata rules and occupy no cells.
47
+ assert tw.string_width('a\tb') == 2
48
+ assert tw.truncate('a\tb', 10) == 'a\tb'
49
+ assert tw.cell_to_index('a\tb', 1) == 2
50
+
51
+
52
+ def test_ascii_fast_path_skips_unicodedata(monkeypatch):
53
+ def unexpected(ch):
54
+ raise AssertionError(f'unicodedata called for ASCII {ch!r}')
55
+
56
+ monkeypatch.setattr(tw.unicodedata, 'combining', unexpected)
57
+ monkeypatch.setattr(tw.unicodedata, 'category', unexpected)
58
+ monkeypatch.setattr(tw.unicodedata, 'east_asian_width', unexpected)
59
+ tw.truncate.cache_clear()
60
+ assert tw.string_width('plain ascii') == 11
61
+ assert tw.truncate('plain ascii', 5) == 'plain'
62
+
63
+
44
64
  def test_truncate_never_splits_wide_char():
45
65
  # 3 cells can hold one wide (2) char but not two (4).
46
66
  assert tw.truncate(CJK * 2, 3) == CJK
File without changes
File without changes