console-window 2.0.0.1__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.1 → console_window-2.0.0.2}/CHANGELOG.md +10 -7
  2. {console_window-2.0.0.1 → console_window-2.0.0.2}/PKG-INFO +1 -1
  3. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/__init__.py +1 -1
  4. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/context.py +13 -3
  5. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/textwidth.py +45 -14
  6. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_textwidth.py +0 -1
  7. {console_window-2.0.0.1 → console_window-2.0.0.2}/.github/workflows/ci.yml +0 -0
  8. {console_window-2.0.0.1 → console_window-2.0.0.2}/.gitignore +0 -0
  9. {console_window-2.0.0.1 → console_window-2.0.0.2}/AUDIT.md +0 -0
  10. {console_window-2.0.0.1 → console_window-2.0.0.2}/LICENSE +0 -0
  11. {console_window-2.0.0.1 → console_window-2.0.0.2}/README.md +0 -0
  12. {console_window-2.0.0.1 → console_window-2.0.0.2}/SampleApp.py +0 -0
  13. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/backend.py +0 -0
  14. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/geometry.py +0 -0
  15. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/header.py +0 -0
  16. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/keys.py +0 -0
  17. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/opts.py +0 -0
  18. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/screens.py +0 -0
  19. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/signals.py +0 -0
  20. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/spinner.py +0 -0
  21. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/theme.py +0 -0
  22. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/widgets.py +0 -0
  23. {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/window.py +0 -0
  24. {console_window-2.0.0.1 → console_window-2.0.0.2}/pyproject.toml +0 -0
  25. {console_window-2.0.0.1 → console_window-2.0.0.2}/runner +0 -0
  26. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_backend.py +0 -0
  27. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_context.py +0 -0
  28. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_demo_mode.py +0 -0
  29. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_dialogs.py +0 -0
  30. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_flash.py +0 -0
  31. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_geometry.py +0 -0
  32. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_header.py +0 -0
  33. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_interactive.py +0 -0
  34. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_overflow.py +0 -0
  35. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_public_api.py +0 -0
  36. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_render_retry.py +0 -0
  37. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_sample_app.py +0 -0
  38. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_signals.py +0 -0
  39. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_spinner.py +0 -0
  40. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_theme.py +0 -0
  41. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_widechars.py +0 -0
  42. {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_widgets.py +0 -0
@@ -61,13 +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. 2.0.0 made every body row pass through
65
- wide-character-aware `textwidth.truncate()`, which ran three `unicodedata`
66
- lookups per character on every full redraw; a 2,000-row screen spent ~39 ms
67
- of a ~41 ms frame there. Printable ASCII now takes a direct one-cell path,
68
- repeated `string_width()`/`truncate()` calls are memoised (a TUI redraws the
69
- same rows on each keypress), and non-ASCII glyph widths are cached. The same
70
- headless redraw is now ~4 ms, including mixed `✔`/`─`/`◆` rows.
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).
71
74
  - Hygiene debt from AUDIT.md §3.14: removed blank lines inside the
72
75
  `index_at_cell()`/`answer()` signatures and the commented-out assignment in
73
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.1
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.1"
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,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  import unicodedata
4
5
  from functools import lru_cache
5
6
 
@@ -20,6 +21,11 @@ __all__ = [
20
21
  # characters (ZWJ, variation selectors, BOM, …) and control characters.
21
22
  _ZERO_WIDTH_CATEGORIES = frozenset({'Mn', 'Me', 'Cf', 'Cc'})
22
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]')
28
+
23
29
 
24
30
  def _is_plain_ascii(text: str) -> bool:
25
31
  # Fast path for the overwhelmingly common terminal case. Printable ASCII
@@ -56,28 +62,44 @@ def char_width(ch: str) -> int:
56
62
  return _non_ascii_char_width(ch)
57
63
 
58
64
 
59
- # A TUI redraws the same body rows on every keypress, so these pure functions
60
- # otherwise repeat the same unicode scan for every navigation key. Bounded
61
- # caches turn that into a dict hit while keeping memory predictable.
62
- @lru_cache(maxsize=4096)
63
65
  def string_width(text: str) -> int:
64
66
  if _is_plain_ascii(text):
65
67
  return len(text)
66
- return sum(char_width(ch) for ch in text)
67
-
68
-
69
- @lru_cache(maxsize=4096)
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)
70
83
  def truncate(text: str, max_width: int) -> str:
71
84
  if max_width <= 0:
72
85
  return ''
73
86
  if _is_plain_ascii(text):
74
87
  return text if len(text) <= max_width else text[:max_width]
75
88
  total = 0
76
- for i, ch in enumerate(text):
77
- 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())
78
96
  if total + w > max_width:
79
- return text[:i]
97
+ return text[:match.start()]
80
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]
81
103
  return text
82
104
 
83
105
 
@@ -97,11 +119,20 @@ def cell_to_index(text: str, col: int) -> int:
97
119
  if _is_plain_ascii(text):
98
120
  return min(col, len(text))
99
121
  total = 0
100
- for i, ch in enumerate(text):
101
- 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())
102
129
  if total + w > col:
103
- return i
130
+ return match.start()
104
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
105
136
  return len(text)
106
137
 
107
138
 
@@ -56,7 +56,6 @@ def test_ascii_fast_path_skips_unicodedata(monkeypatch):
56
56
  monkeypatch.setattr(tw.unicodedata, 'combining', unexpected)
57
57
  monkeypatch.setattr(tw.unicodedata, 'category', unexpected)
58
58
  monkeypatch.setattr(tw.unicodedata, 'east_asian_width', unexpected)
59
- tw.string_width.cache_clear()
60
59
  tw.truncate.cache_clear()
61
60
  assert tw.string_width('plain ascii') == 11
62
61
  assert tw.truncate('plain ascii', 5) == 'plain'