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.
- {console_window-2.0.0.1 → console_window-2.0.0.2}/CHANGELOG.md +10 -7
- {console_window-2.0.0.1 → console_window-2.0.0.2}/PKG-INFO +1 -1
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/__init__.py +1 -1
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/context.py +13 -3
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/textwidth.py +45 -14
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_textwidth.py +0 -1
- {console_window-2.0.0.1 → console_window-2.0.0.2}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/.gitignore +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/AUDIT.md +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/LICENSE +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/README.md +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/SampleApp.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/backend.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/geometry.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/header.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/keys.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/opts.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/screens.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/signals.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/spinner.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/theme.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/widgets.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/console_window/window.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/pyproject.toml +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/runner +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_backend.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_context.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_dialogs.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_flash.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_geometry.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_header.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_interactive.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_overflow.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_public_api.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_render_retry.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_sample_app.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_signals.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_spinner.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_theme.py +0 -0
- {console_window-2.0.0.1 → console_window-2.0.0.2}/tests/test_widechars.py +0 -0
- {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
|
|
65
|
-
|
|
66
|
-
lookups per character on every
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
# 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,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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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[:
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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'
|
|
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
|