console-window 2.0.0__tar.gz → 2.0.0.1__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.1}/CHANGELOG.md +7 -0
  2. {console_window-2.0.0 → console_window-2.0.0.1}/PKG-INFO +1 -1
  3. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/__init__.py +1 -1
  4. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/textwidth.py +38 -3
  5. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_textwidth.py +21 -0
  6. {console_window-2.0.0 → console_window-2.0.0.1}/.github/workflows/ci.yml +0 -0
  7. {console_window-2.0.0 → console_window-2.0.0.1}/.gitignore +0 -0
  8. {console_window-2.0.0 → console_window-2.0.0.1}/AUDIT.md +0 -0
  9. {console_window-2.0.0 → console_window-2.0.0.1}/LICENSE +0 -0
  10. {console_window-2.0.0 → console_window-2.0.0.1}/README.md +0 -0
  11. {console_window-2.0.0 → console_window-2.0.0.1}/SampleApp.py +0 -0
  12. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/backend.py +0 -0
  13. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/context.py +0 -0
  14. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/geometry.py +0 -0
  15. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/header.py +0 -0
  16. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/keys.py +0 -0
  17. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/opts.py +0 -0
  18. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/screens.py +0 -0
  19. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/signals.py +0 -0
  20. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/spinner.py +0 -0
  21. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/theme.py +0 -0
  22. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/widgets.py +0 -0
  23. {console_window-2.0.0 → console_window-2.0.0.1}/console_window/window.py +0 -0
  24. {console_window-2.0.0 → console_window-2.0.0.1}/pyproject.toml +0 -0
  25. {console_window-2.0.0 → console_window-2.0.0.1}/runner +0 -0
  26. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_backend.py +0 -0
  27. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_context.py +0 -0
  28. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_demo_mode.py +0 -0
  29. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_dialogs.py +0 -0
  30. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_flash.py +0 -0
  31. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_geometry.py +0 -0
  32. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_header.py +0 -0
  33. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_interactive.py +0 -0
  34. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_overflow.py +0 -0
  35. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_public_api.py +0 -0
  36. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_render_retry.py +0 -0
  37. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_sample_app.py +0 -0
  38. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_signals.py +0 -0
  39. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_spinner.py +0 -0
  40. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_theme.py +0 -0
  41. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_widechars.py +0 -0
  42. {console_window-2.0.0 → console_window-2.0.0.1}/tests/test_widgets.py +0 -0
@@ -61,6 +61,13 @@ 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
71
  - Hygiene debt from AUDIT.md §3.14: removed blank lines inside the
65
72
  `index_at_cell()`/`answer()` signatures and the commented-out assignment in
66
73
  `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.1
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.1"
4
4
 
5
5
  from .context import Context
6
6
  from .header import HeaderLine, Span
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import unicodedata
4
+ from functools import lru_cache
4
5
 
5
6
  __all__ = [
6
7
  'cell_to_index',
@@ -20,9 +21,21 @@ __all__ = [
20
21
  _ZERO_WIDTH_CATEGORIES = frozenset({'Mn', 'Me', 'Cf', 'Cc'})
21
22
 
22
23
 
23
- def char_width(ch: str) -> int:
24
- if not ch:
25
- return 0
24
+ def _is_plain_ascii(text: str) -> bool:
25
+ # Fast path for the overwhelmingly common terminal case. Printable ASCII
26
+ # occupies exactly one cell per code point, so the per-character
27
+ # unicodedata scan in char_width() can be skipped entirely. Control
28
+ # characters are excluded because this module treats them as zero-width.
29
+ # The checks themselves are C-level string scans, and keep add_body() on
30
+ # the same cost profile as the pre-2.0 plain slicing it replaced.
31
+ return text.isascii() and text.isprintable()
32
+
33
+
34
+ @lru_cache(maxsize=4096)
35
+ def _non_ascii_char_width(ch: str) -> int:
36
+ # Only reached for non-printable-ASCII characters. A terminal UI reuses a
37
+ # small alphabet (box drawing, check marks, CJK, emoji), so caching turns
38
+ # the three unicodedata lookups into one dict hit after the first sighting.
26
39
  if unicodedata.combining(ch):
27
40
  return 0
28
41
  if unicodedata.category(ch) in _ZERO_WIDTH_CATEGORIES:
@@ -32,13 +45,33 @@ def char_width(ch: str) -> int:
32
45
  return 1
33
46
 
34
47
 
48
+ def char_width(ch: str) -> int:
49
+ if not ch:
50
+ return 0
51
+ # Printable ASCII is one cell. This branch is what keeps mixed text (the
52
+ # common case: ASCII rows with a ✔/─/◆ glyph) from paying unicodedata
53
+ # costs for every ordinary character.
54
+ if ' ' <= ch <= '~':
55
+ return 1
56
+ return _non_ascii_char_width(ch)
57
+
58
+
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)
35
63
  def string_width(text: str) -> int:
64
+ if _is_plain_ascii(text):
65
+ return len(text)
36
66
  return sum(char_width(ch) for ch in text)
37
67
 
38
68
 
69
+ @lru_cache(maxsize=4096)
39
70
  def truncate(text: str, max_width: int) -> str:
40
71
  if max_width <= 0:
41
72
  return ''
73
+ if _is_plain_ascii(text):
74
+ return text if len(text) <= max_width else text[:max_width]
42
75
  total = 0
43
76
  for i, ch in enumerate(text):
44
77
  w = char_width(ch)
@@ -61,6 +94,8 @@ def rjust(text: str, width: int) -> str:
61
94
  def cell_to_index(text: str, col: int) -> int:
62
95
  if col <= 0:
63
96
  return 0
97
+ if _is_plain_ascii(text):
98
+ return min(col, len(text))
64
99
  total = 0
65
100
  for i, ch in enumerate(text):
66
101
  w = char_width(ch)
@@ -41,6 +41,27 @@ 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.string_width.cache_clear()
60
+ tw.truncate.cache_clear()
61
+ assert tw.string_width('plain ascii') == 11
62
+ assert tw.truncate('plain ascii', 5) == 'plain'
63
+
64
+
44
65
  def test_truncate_never_splits_wide_char():
45
66
  # 3 cells can hold one wide (2) char but not two (4).
46
67
  assert tw.truncate(CJK * 2, 3) == CJK
File without changes
File without changes