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.
- {console_window-2.0.0 → console_window-2.0.0.2}/CHANGELOG.md +10 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/PKG-INFO +1 -1
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/__init__.py +1 -1
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/context.py +13 -3
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/textwidth.py +77 -11
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_textwidth.py +20 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/.gitignore +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/AUDIT.md +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/LICENSE +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/README.md +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/SampleApp.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/backend.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/geometry.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/header.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/keys.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/opts.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/screens.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/signals.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/spinner.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/theme.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/widgets.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/console_window/window.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/pyproject.toml +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/runner +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_backend.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_context.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_dialogs.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_flash.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_geometry.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_header.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_interactive.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_overflow.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_public_api.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_render_retry.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_sample_app.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_signals.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_spinner.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_theme.py +0 -0
- {console_window-2.0.0 → console_window-2.0.0.2}/tests/test_widechars.py +0 -0
- {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
|
# 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 =
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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[:
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|