console-window 2.0.0.2__tar.gz → 2.0.0.3__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.2 → console_window-2.0.0.3}/CHANGELOG.md +13 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/PKG-INFO +8 -7
- {console_window-2.0.0.2 → console_window-2.0.0.3}/README.md +7 -6
- {console_window-2.0.0.2 → console_window-2.0.0.3}/SampleApp.py +3 -6
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/__init__.py +1 -1
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/backend.py +3 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/opts.py +11 -6
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/window.py +237 -98
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_interactive.py +6 -1
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_overflow.py +42 -12
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_sample_app.py +2 -5
- console_window-2.0.0.3/tests/test_viewport_pad.py +152 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_widechars.py +8 -5
- {console_window-2.0.0.2 → console_window-2.0.0.3}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/.gitignore +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/AUDIT.md +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/LICENSE +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/context.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/geometry.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/header.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/keys.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/screens.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/signals.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/spinner.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/textwidth.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/theme.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/widgets.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/pyproject.toml +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/runner +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_backend.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_context.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_dialogs.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_flash.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_geometry.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_header.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_public_api.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_render_retry.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_signals.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_spinner.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_textwidth.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_theme.py +0 -0
- {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_widgets.py +0 -0
|
@@ -38,6 +38,19 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
38
38
|
smoke-tested in CI (AUDIT.md §3.2).
|
|
39
39
|
|
|
40
40
|
### Changed
|
|
41
|
+
- Body and header pads are now viewport-sized blit buffers rather than content
|
|
42
|
+
stores. `add_body()`, `add_header()` and `draw()` write the model only, and
|
|
43
|
+
`render()` paints the visible page into pad row 0, so pad memory and the
|
|
44
|
+
per-frame `clear()` cost track the terminal instead of the highest row count
|
|
45
|
+
an app ever reached. `opts.body_rows`/`opts.head_rows` are retained for
|
|
46
|
+
compatibility but are now **advisory hints only**: no row is discarded for
|
|
47
|
+
exceeding them. The single ceiling is the new `opts.max_rows` (default
|
|
48
|
+
`32767`, `None` for unlimited), which is what `rows_dropped` and its warning
|
|
49
|
+
report. Apps no longer have to pre-size storage for the largest list they
|
|
50
|
+
might show. `draw()` keeps its content-row coordinates (a row written
|
|
51
|
+
off-screen appears when scrolled to) and records its span so the attribute it
|
|
52
|
+
was given survives the deferred paint. Pick highlighting is applied while
|
|
53
|
+
painting, so it follows the viewport when scrolling without moving the pick.
|
|
41
54
|
- The package version now has a single source of truth: `pyproject.toml`
|
|
42
55
|
declares `dynamic = ["version"]` and flit reads `__version__` from
|
|
43
56
|
`console_window/__init__.py` (AUDIT.md §3.14). The stale manual-build recipe
|
|
@@ -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.3
|
|
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
|
|
@@ -68,8 +68,8 @@ up immediately. The equivalent one-off is `python3 SampleApp.py`.)
|
|
|
68
68
|
|
|
69
69
|
The wrapper handles several advanced terminal interaction patterns automatically:
|
|
70
70
|
|
|
71
|
-
* **
|
|
72
|
-
* **Loud
|
|
71
|
+
* **Viewport-Sized Pads:** Separate `head` and `body` pads give the static header band and the independently scrolling body, but the pads are blit buffers sized to the terminal rather than content stores. `add_body()`/`add_header()`/`draw()` write the model, and `render()` paints the visible page into pad row 0, so pad memory and per-frame `clear()` cost stay constant no matter how many rows an app adds. Row counts need no configuration at all: `opts.body_rows` / `opts.head_rows` are advisory hints only, and `opts.max_rows` (default `32767`, `None` for unlimited) is the single content ceiling.
|
|
72
|
+
* **Loud Content Limits:** A row added past `opts.max_rows` is discarded, but no longer quietly: the first discarded row of each fill episode emits a `UserWarning` naming the namespace and the option to change, and every discarded row is counted in `ConsoleWindow.rows_dropped`. It stays non-fatal — run `python -W error::UserWarning` if you would rather it killed the app.
|
|
73
73
|
* **Seamless Resizing:** Automatically detects terminal size changes (`KEY_RESIZE`) and recalculates layout dimensions, with robust error handling to prevent common `curses.error` crashes during resizing.
|
|
74
74
|
* **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously.
|
|
75
75
|
* When using pick mode, scrolling automatically tracks the highlighted element to keep it visible in `.pick_pos`.
|
|
@@ -125,9 +125,9 @@ demonstrates:
|
|
|
125
125
|
* **Multi-row picking** — `p` sets `pick_size` (1–3) via
|
|
126
126
|
`set_pick_mode(True, size)`.
|
|
127
127
|
* **Large-body stress** — `z` appends 2000 non-pickable filler rows to exercise
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
is
|
|
128
|
+
long bodies and the scroll indicator. No option is needed for them: the pads
|
|
129
|
+
stay viewport-sized, memory and `clear()` cost do not grow with the row count,
|
|
130
|
+
and the default `max_rows` ceiling is far above 2000.
|
|
131
131
|
|
|
132
132
|
### Running it
|
|
133
133
|
|
|
@@ -145,7 +145,8 @@ from console_window import ConsoleWindow, ConsoleWindowOpts
|
|
|
145
145
|
|
|
146
146
|
def main() -> None:
|
|
147
147
|
# ConsoleWindow.__init__ performs the curses setup (no curses.wrapper needed).
|
|
148
|
-
|
|
148
|
+
# No row sizing is required; max_rows is only a safety ceiling.
|
|
149
|
+
win = ConsoleWindow(opts=ConsoleWindowOpts(head_line=True))
|
|
149
150
|
try:
|
|
150
151
|
while True:
|
|
151
152
|
win.clear()
|
|
@@ -49,8 +49,8 @@ up immediately. The equivalent one-off is `python3 SampleApp.py`.)
|
|
|
49
49
|
|
|
50
50
|
The wrapper handles several advanced terminal interaction patterns automatically:
|
|
51
51
|
|
|
52
|
-
* **
|
|
53
|
-
* **Loud
|
|
52
|
+
* **Viewport-Sized Pads:** Separate `head` and `body` pads give the static header band and the independently scrolling body, but the pads are blit buffers sized to the terminal rather than content stores. `add_body()`/`add_header()`/`draw()` write the model, and `render()` paints the visible page into pad row 0, so pad memory and per-frame `clear()` cost stay constant no matter how many rows an app adds. Row counts need no configuration at all: `opts.body_rows` / `opts.head_rows` are advisory hints only, and `opts.max_rows` (default `32767`, `None` for unlimited) is the single content ceiling.
|
|
53
|
+
* **Loud Content Limits:** A row added past `opts.max_rows` is discarded, but no longer quietly: the first discarded row of each fill episode emits a `UserWarning` naming the namespace and the option to change, and every discarded row is counted in `ConsoleWindow.rows_dropped`. It stays non-fatal — run `python -W error::UserWarning` if you would rather it killed the app.
|
|
54
54
|
* **Seamless Resizing:** Automatically detects terminal size changes (`KEY_RESIZE`) and recalculates layout dimensions, with robust error handling to prevent common `curses.error` crashes during resizing.
|
|
55
55
|
* **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously.
|
|
56
56
|
* When using pick mode, scrolling automatically tracks the highlighted element to keep it visible in `.pick_pos`.
|
|
@@ -106,9 +106,9 @@ demonstrates:
|
|
|
106
106
|
* **Multi-row picking** — `p` sets `pick_size` (1–3) via
|
|
107
107
|
`set_pick_mode(True, size)`.
|
|
108
108
|
* **Large-body stress** — `z` appends 2000 non-pickable filler rows to exercise
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
is
|
|
109
|
+
long bodies and the scroll indicator. No option is needed for them: the pads
|
|
110
|
+
stay viewport-sized, memory and `clear()` cost do not grow with the row count,
|
|
111
|
+
and the default `max_rows` ceiling is far above 2000.
|
|
112
112
|
|
|
113
113
|
### Running it
|
|
114
114
|
|
|
@@ -126,7 +126,8 @@ from console_window import ConsoleWindow, ConsoleWindowOpts
|
|
|
126
126
|
|
|
127
127
|
def main() -> None:
|
|
128
128
|
# ConsoleWindow.__init__ performs the curses setup (no curses.wrapper needed).
|
|
129
|
-
|
|
129
|
+
# No row sizing is required; max_rows is only a safety ceiling.
|
|
130
|
+
win = ConsoleWindow(opts=ConsoleWindowOpts(head_line=True))
|
|
130
131
|
try:
|
|
131
132
|
while True:
|
|
132
133
|
win.clear()
|
|
@@ -63,12 +63,9 @@ class DemoApp:
|
|
|
63
63
|
# Configure console window
|
|
64
64
|
win_opts = ConsoleWindowOpts(
|
|
65
65
|
head_line=True,
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# screens actually draw.
|
|
70
|
-
body_rows=4000, # the 'z' stress mode appends 2000 filler rows
|
|
71
|
-
head_rows=8, # the help screen's nav-key block is 5 header rows
|
|
66
|
+
# No row sizing needed: the pads are viewport-sized and rows live in
|
|
67
|
+
# the model, so the 'z' stress mode's 2000 filler rows need no
|
|
68
|
+
# option. max_rows is only a safety ceiling (32767 by default).
|
|
72
69
|
min_cols_rows=(60,10),
|
|
73
70
|
pick_attr=cs.A_REVERSE,
|
|
74
71
|
# Ctrl-C arrives as an ordinary key code (bound to 'quit' below)
|
|
@@ -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.3"
|
|
4
4
|
|
|
5
5
|
from .context import Context
|
|
6
6
|
from .header import HeaderLine, Span
|
|
@@ -8,8 +8,8 @@ from typing import Any, Callable
|
|
|
8
8
|
class ConsoleWindowOpts:
|
|
9
9
|
__slots__ = ['answer_show_redraws', 'body_cols', 'body_rows', 'ctrl_c_terminates',
|
|
10
10
|
'demo_mode', 'dialog_abort', 'dialog_return', 'head_line', 'head_rows',
|
|
11
|
-
'keys', 'min_cols_rows', 'mod_pick', 'pick_attr', 'pick_mode',
|
|
12
|
-
'pick_size', 'relax_handled_keys', 'return_if_pos_change',
|
|
11
|
+
'keys', 'max_rows', 'min_cols_rows', 'mod_pick', 'pick_attr', 'pick_mode',
|
|
12
|
+
'pick_range', 'pick_size', 'relax_handled_keys', 'return_if_pos_change',
|
|
13
13
|
'single_cell_scroll_indicator', 'strip_attrs_in_pick_mode']
|
|
14
14
|
|
|
15
15
|
# Explicit attribute types: assignments below come from **kwargs, so without
|
|
@@ -18,13 +18,14 @@ class ConsoleWindowOpts:
|
|
|
18
18
|
# are checked.
|
|
19
19
|
answer_show_redraws: bool
|
|
20
20
|
body_cols: int
|
|
21
|
-
body_rows: int
|
|
21
|
+
body_rows: int | None # advisory hint only; see max_rows
|
|
22
22
|
ctrl_c_terminates: bool
|
|
23
23
|
demo_mode: bool
|
|
24
24
|
dialog_abort: str | None
|
|
25
25
|
dialog_return: str
|
|
26
26
|
head_line: bool
|
|
27
|
-
head_rows: int
|
|
27
|
+
head_rows: int | None # advisory hint only; see max_rows
|
|
28
|
+
max_rows: int | None
|
|
28
29
|
keys: set | list | None
|
|
29
30
|
min_cols_rows: tuple[int, int]
|
|
30
31
|
mod_pick: Callable[[str], str] | None
|
|
@@ -49,8 +50,12 @@ class ConsoleWindowOpts:
|
|
|
49
50
|
f'ConsoleWindowOpts() got unknown option(s): {", ".join(details)}.')
|
|
50
51
|
|
|
51
52
|
self.head_line = kwargs.get('head_line', True)
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
# Row counts are hints, not limits: the pads are viewport-sized, so an
|
|
54
|
+
# app never has to pre-size storage. max_rows is the only ceiling and
|
|
55
|
+
# applies to both namespaces; pass None to disable it entirely.
|
|
56
|
+
self.head_rows = kwargs.get('head_rows')
|
|
57
|
+
self.body_rows = kwargs.get('body_rows')
|
|
58
|
+
self.max_rows = kwargs.get('max_rows', 32767)
|
|
54
59
|
self.body_cols = kwargs.get('body_cols', 200)
|
|
55
60
|
self.keys = kwargs.get('keys')
|
|
56
61
|
self.pick_mode = kwargs.get('pick_mode', False)
|
|
@@ -10,6 +10,7 @@ import atexit
|
|
|
10
10
|
import curses
|
|
11
11
|
import itertools
|
|
12
12
|
import logging
|
|
13
|
+
import sys
|
|
13
14
|
import time
|
|
14
15
|
import warnings
|
|
15
16
|
import weakref
|
|
@@ -74,6 +75,11 @@ _SINGLETON_MODE: bool | None = None
|
|
|
74
75
|
# window, so creating multiple windows does not stack up duplicate handlers.
|
|
75
76
|
_ATEXIT_REGISTERED = False
|
|
76
77
|
|
|
78
|
+
# Sentinel for "no content-row ceiling" (opts.max_rows is None). It only has to
|
|
79
|
+
# be larger than any row index a process could allocate; it is never passed to
|
|
80
|
+
# curses, because the pads themselves are viewport-sized.
|
|
81
|
+
_NO_ROW_LIMIT = sys.maxsize
|
|
82
|
+
|
|
77
83
|
|
|
78
84
|
def _register_window(win: ConsoleWindow) -> None:
|
|
79
85
|
key = next(_WINDOW_IDS)
|
|
@@ -205,8 +211,8 @@ class ConsoleWindow:
|
|
|
205
211
|
)
|
|
206
212
|
self.opts = ConsoleWindowOpts(
|
|
207
213
|
head_line=head_line if head_line is not None else True,
|
|
208
|
-
head_rows=head_rows
|
|
209
|
-
body_rows=body_rows
|
|
214
|
+
head_rows=head_rows,
|
|
215
|
+
body_rows=body_rows,
|
|
210
216
|
body_cols=body_cols if body_cols is not None else 200,
|
|
211
217
|
keys=keys,
|
|
212
218
|
pick_mode=pick_mode if pick_mode is not None else False,
|
|
@@ -244,38 +250,46 @@ class ConsoleWindow:
|
|
|
244
250
|
# they ran, instead of assuming the class default.
|
|
245
251
|
self._scr_timeout_ms = ConsoleWindow.timeout_ms
|
|
246
252
|
|
|
253
|
+
# Scheme B: the pads are viewport-sized blit buffers, not content
|
|
254
|
+
# stores. `rows` below is the enforced content ceiling (opts.max_rows,
|
|
255
|
+
# or _NO_ROW_LIMIT when that option is None) and `hint_rows` is the
|
|
256
|
+
# advisory opts.head_rows/opts.body_rows value, kept for compatibility
|
|
257
|
+
# and introspection. pad_rows/pad_cols track the actual allocation.
|
|
258
|
+
# _sync_pad_geometry() creates/sizes the pads once the terminal
|
|
259
|
+
# dimensions are known (calc() runs at the end of __init__), so no
|
|
260
|
+
# oversized placeholder is ever allocated.
|
|
261
|
+
row_limit = (self.opts.max_rows if self.opts.max_rows is not None
|
|
262
|
+
else _NO_ROW_LIMIT)
|
|
247
263
|
self.head = SimpleNamespace(
|
|
248
|
-
pad=
|
|
249
|
-
rows=
|
|
264
|
+
pad=None,
|
|
265
|
+
rows=row_limit, # enforced ceiling (NOT the pad height)
|
|
266
|
+
hint_rows=self.opts.head_rows,
|
|
250
267
|
cols=self.opts.body_cols,
|
|
268
|
+
pad_rows=0,
|
|
269
|
+
pad_cols=0,
|
|
251
270
|
row_cnt=0, # no. head rows added
|
|
252
271
|
texts = [],
|
|
253
272
|
text_attrs = [], # run-length encoded: [(attr, count), ...] per line
|
|
254
273
|
contexts = [], # Context object per line (or None)
|
|
274
|
+
drawn = {}, # row -> [(x, text, attr), ...] written by draw()
|
|
255
275
|
view_cnt=0, # no. head rows viewable (NOT in body)
|
|
256
|
-
rows_dropped=0, # rows discarded because the
|
|
276
|
+
rows_dropped=0, # rows discarded because the content cap was reached
|
|
257
277
|
)
|
|
258
278
|
self.body = SimpleNamespace(
|
|
259
|
-
pad =
|
|
260
|
-
rows=
|
|
279
|
+
pad = None,
|
|
280
|
+
rows= row_limit, # enforced ceiling (NOT the pad height)
|
|
281
|
+
hint_rows= self.opts.body_rows,
|
|
261
282
|
cols= self.opts.body_cols,
|
|
283
|
+
pad_rows=0,
|
|
284
|
+
pad_cols=0,
|
|
262
285
|
row_cnt = 0,
|
|
263
286
|
texts = [],
|
|
264
287
|
text_attrs = [], # run-length encoded: [(attr, count), ...] per line
|
|
265
288
|
contexts = [], # Context object per line (or None)
|
|
266
|
-
|
|
289
|
+
drawn = {}, # row -> [(x, text, attr), ...] written by draw()
|
|
290
|
+
rows_dropped = 0, # rows discarded because the content cap was reached
|
|
267
291
|
)
|
|
268
292
|
|
|
269
|
-
# Apply theme background to pads
|
|
270
|
-
if self._backend.has_colors():
|
|
271
|
-
theme = Theme.THEMES.get(Theme.get_current(), Theme.THEMES['default'])
|
|
272
|
-
if 'default_fg' in theme and 'default_bg' in theme:
|
|
273
|
-
default_fg = theme['default_fg']
|
|
274
|
-
default_bg = theme['default_bg']
|
|
275
|
-
if default_fg != -1 or default_bg != -1:
|
|
276
|
-
self.head.pad.bkgd(' ', self._backend.color_pair(Theme.DEFAULT_BG))
|
|
277
|
-
self.body.pad.bkgd(' ', self._backend.color_pair(Theme.DEFAULT_BG))
|
|
278
|
-
|
|
279
293
|
# Register this instance so theme broadcasts and process teardown reach
|
|
280
294
|
# it. Singleton mode also republishes the old ``static_win`` pointer.
|
|
281
295
|
_register_window(self)
|
|
@@ -311,7 +325,7 @@ class ConsoleWindow:
|
|
|
311
325
|
self.passthrough_mode = False # When True, all printable keys pass through
|
|
312
326
|
self.relax_handled_keys = self.opts.relax_handled_keys # When True, pass non-nav keys without checking handled_keys
|
|
313
327
|
self._set_screen_dims()
|
|
314
|
-
self.calc()
|
|
328
|
+
self.calc() # also creates the viewport-sized pads
|
|
315
329
|
|
|
316
330
|
def set_handled_keys(self, keys: object) -> None:
|
|
317
331
|
if hasattr(keys, 'keys'):
|
|
@@ -355,12 +369,44 @@ class ConsoleWindow:
|
|
|
355
369
|
def get_pad_width(self) -> int:
|
|
356
370
|
return min(self.cols-1, self.body_cols)
|
|
357
371
|
|
|
372
|
+
@property
|
|
373
|
+
def max_rows(self) -> int | None:
|
|
374
|
+
"""The enforced per-namespace content ceiling (None = unlimited)."""
|
|
375
|
+
return self.opts.max_rows
|
|
376
|
+
|
|
377
|
+
def _apply_pad_theme(self, pad: CursesSurface) -> None:
|
|
378
|
+
# A freshly created pad has no background, so re-apply the theme colour
|
|
379
|
+
# after every reallocation; otherwise a resize leaves the new pad on the
|
|
380
|
+
# terminal default instead of the configured background.
|
|
381
|
+
if not self._backend.has_colors():
|
|
382
|
+
return
|
|
383
|
+
theme = Theme.THEMES.get(Theme.get_current(), Theme.THEMES['default'])
|
|
384
|
+
default_fg = theme.get('default_fg', -1)
|
|
385
|
+
default_bg = theme.get('default_bg', -1)
|
|
386
|
+
if default_fg != -1 or default_bg != -1:
|
|
387
|
+
pad.bkgd(' ', self._backend.color_pair(Theme.DEFAULT_BG))
|
|
388
|
+
|
|
389
|
+
def _sync_pad_geometry(self) -> None:
|
|
390
|
+
# Viewport-sized pads: height is the terminal height and width is the
|
|
391
|
+
# blittable width (cols-1 with the last column reserved), so neither
|
|
392
|
+
# content length nor opts.body_rows has any effect on pad memory or on
|
|
393
|
+
# pad clear()/erase() cost. Called from calc(), i.e. once per frame,
|
|
394
|
+
# but it only allocates when the terminal dimensions actually changed.
|
|
395
|
+
height = max(1, self.rows)
|
|
396
|
+
width = max(1, self.cols - 1)
|
|
397
|
+
for ns in (self.head, self.body):
|
|
398
|
+
if ns.pad_rows == height and ns.pad_cols == width:
|
|
399
|
+
continue
|
|
400
|
+
ns.pad = self._backend.newpad(height, width)
|
|
401
|
+
ns.pad_rows, ns.pad_cols = height, width
|
|
402
|
+
self._apply_pad_theme(cast(CursesSurface, ns.pad))
|
|
403
|
+
|
|
358
404
|
@property
|
|
359
405
|
def rows_dropped(self) -> int:
|
|
360
|
-
# Rows handed to add_body()/add_header() after that
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
406
|
+
# Rows handed to add_body()/add_header() after that namespace reached
|
|
407
|
+
# opts.max_rows (the only ceiling; None disables it). body_rows and
|
|
408
|
+
# head_rows are advisory hints and never drop anything. This counter,
|
|
409
|
+
# plus the one-shot warning in _add(), keeps a runaway append loop
|
|
364
410
|
# observable without the library deciding to abort the app.
|
|
365
411
|
return self.head.rows_dropped + self.body.rows_dropped
|
|
366
412
|
|
|
@@ -612,6 +658,7 @@ class ConsoleWindow:
|
|
|
612
658
|
|
|
613
659
|
def calc(self) -> bool:
|
|
614
660
|
same = self._set_screen_dims()
|
|
661
|
+
self._sync_pad_geometry()
|
|
615
662
|
self.head.view_cnt = min(self.rows - self.hor_line_cnt, self.head.row_cnt)
|
|
616
663
|
self.scroll_view_size = self.rows - self.head.view_cnt - self.hor_line_cnt
|
|
617
664
|
self.max_scroll_pos = max(self.body.row_cnt - self.scroll_view_size, 0)
|
|
@@ -621,41 +668,47 @@ class ConsoleWindow:
|
|
|
621
668
|
def _add(self, ns: SimpleNamespace, text: str, attr: int | None = None,
|
|
622
669
|
resume: bool = False, context: Context | None = None,
|
|
623
670
|
*, is_body: bool = False) -> None:
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if is_body and self.pick_mode and self.opts.strip_attrs_in_pick_mode:
|
|
630
|
-
attr = curses.A_NORMAL
|
|
631
|
-
|
|
632
|
-
if resume:
|
|
633
|
-
_, x = ns.pad.getyx()
|
|
634
|
-
cols = self.get_pad_width() - x
|
|
635
|
-
if cols > 0:
|
|
636
|
-
actual_text = textwidth.truncate(text, cols)
|
|
637
|
-
ns.pad.addstr(actual_text, attr)
|
|
638
|
-
ns.texts[row] += actual_text
|
|
639
|
-
# Append to existing attrs list (counts are code points)
|
|
640
|
-
if row < len(ns.text_attrs):
|
|
641
|
-
ns.text_attrs[row].append((attr, len(actual_text)))
|
|
642
|
-
# Note: context not updated on resume
|
|
643
|
-
else:
|
|
644
|
-
# Truncate to the single "usable width" (get_pad_width), matching
|
|
645
|
-
# the resume branch, draw() and highlight_picked() — not the raw
|
|
646
|
-
# terminal width, which is one column wider and uncapped by the pad.
|
|
647
|
-
actual_text = textwidth.truncate(text, self.get_pad_width())
|
|
648
|
-
ns.pad.addstr(row, 0, actual_text, attr)
|
|
649
|
-
ns.texts.append(actual_text) # text only history
|
|
650
|
-
ns.text_attrs.append([(attr, len(actual_text))]) # run-length encoded
|
|
651
|
-
ns.contexts.append(context) # Context metadata (or None)
|
|
652
|
-
ns.row_cnt += 1
|
|
653
|
-
else:
|
|
671
|
+
# Model-only: the row is stored here and painted by _paint_body()/
|
|
672
|
+
# _paint_head() on the frames where it is actually visible. `ns.rows`
|
|
673
|
+
# is ops.max_rows (or the no-limit sentinel), not the pad height and not
|
|
674
|
+
# the advisory hint, so nothing is dropped unless a ceiling was set.
|
|
675
|
+
if ns.row_cnt >= ns.rows:
|
|
654
676
|
self._warn_rows_dropped(ns, 'body' if is_body else 'head')
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
if attr is None:
|
|
680
|
+
attr = curses.A_NORMAL
|
|
681
|
+
# Apply stripping logic if needed
|
|
682
|
+
if is_body and self.pick_mode and self.opts.strip_attrs_in_pick_mode:
|
|
683
|
+
attr = curses.A_NORMAL
|
|
684
|
+
|
|
685
|
+
if resume and ns.row_cnt > 0 and ns.texts:
|
|
686
|
+
row = min(ns.row_cnt - 1, len(ns.texts) - 1)
|
|
687
|
+
# The old code read the pad cursor with getyx(); the model already
|
|
688
|
+
# records the row's text, so its display width is the resume column.
|
|
689
|
+
x = textwidth.string_width(ns.texts[row])
|
|
690
|
+
cols = self.get_pad_width() - x
|
|
691
|
+
if cols > 0:
|
|
692
|
+
actual_text = textwidth.truncate(text, cols)
|
|
693
|
+
ns.texts[row] += actual_text
|
|
694
|
+
# Append to existing attrs list (counts are code points)
|
|
695
|
+
if row < len(ns.text_attrs):
|
|
696
|
+
ns.text_attrs[row].append((attr, len(actual_text)))
|
|
697
|
+
# Note: context not updated on resume
|
|
698
|
+
else:
|
|
699
|
+
# Truncate to the single "usable width" (get_pad_width), matching
|
|
700
|
+
# the resume branch, draw() and highlight_picked() — not the raw
|
|
701
|
+
# terminal width, which is one column wider and uncapped by the pad.
|
|
702
|
+
actual_text = textwidth.truncate(text, self.get_pad_width())
|
|
703
|
+
ns.texts.append(actual_text) # text only history
|
|
704
|
+
ns.text_attrs.append([(attr, len(actual_text))]) # run-length encoded
|
|
705
|
+
ns.contexts.append(context) # Context metadata (or None)
|
|
706
|
+
ns.row_cnt += 1
|
|
655
707
|
|
|
656
708
|
def _warn_rows_dropped(self, ns: SimpleNamespace, which: str) -> None:
|
|
657
|
-
# The
|
|
658
|
-
#
|
|
709
|
+
# The content cap is reached, so this row is discarded (as it always
|
|
710
|
+
# was when the pad was the storage). Counted always, warned about once
|
|
711
|
+
# per fill episode -- clear() resets both, so
|
|
659
712
|
# a bulk overflow produces exactly one message instead of one per row --
|
|
660
713
|
# and never fatal: the app keeps running and can inspect
|
|
661
714
|
# ConsoleWindow.rows_dropped. A caller that would rather treat this as
|
|
@@ -665,10 +718,10 @@ class ConsoleWindow:
|
|
|
665
718
|
if ns.rows_dropped != 1:
|
|
666
719
|
return
|
|
667
720
|
warnings.warn(
|
|
668
|
-
f'console-window: the {which}
|
|
669
|
-
f'further {which} rows are being discarded. Raise
|
|
670
|
-
f'
|
|
671
|
-
f'
|
|
721
|
+
f'console-window: the {which} content limit is full ({ns.rows} '
|
|
722
|
+
f'rows), so further {which} rows are being discarded. Raise '
|
|
723
|
+
f'ConsoleWindowOpts(max_rows=...) or pass max_rows=None to remove '
|
|
724
|
+
f'the ceiling entirely.',
|
|
672
725
|
# warn() <- _warn_rows_dropped() <- _add() <- add_body()/add_header()
|
|
673
726
|
# <- caller, so 4 is the application's own line.
|
|
674
727
|
stacklevel=4,
|
|
@@ -748,6 +801,10 @@ class ConsoleWindow:
|
|
|
748
801
|
|
|
749
802
|
def draw(self, y: int, x: int, text: str | bytes, text_attr: int | None = None,
|
|
750
803
|
width: int | None = None, leftpad: bool = False, header: bool = False) -> None:
|
|
804
|
+
# Scheme B keeps y a *content* row, exactly as before; the row is
|
|
805
|
+
# painted at y - scroll_pos by _paint_body() on the frames where it is
|
|
806
|
+
# visible. The attribute has no home in texts[], so the span is also
|
|
807
|
+
# recorded in ns.drawn and replayed over the row's normal attribute runs.
|
|
751
808
|
ns = self.head if header else self.body
|
|
752
809
|
text_attr = text_attr if text_attr else curses.A_NORMAL
|
|
753
810
|
if y < 0 or y >= ns.rows or x < 0 or x >= ns.cols:
|
|
@@ -762,21 +819,16 @@ class ConsoleWindow:
|
|
|
762
819
|
return
|
|
763
820
|
# Pad/truncate to exactly `width` display cells (wide-char safe).
|
|
764
821
|
uni = textwidth.rjust(uni, width) if leftpad else textwidth.ljust(uni, width)
|
|
765
|
-
text = uni.encode('utf-8')
|
|
766
|
-
else:
|
|
767
|
-
text = uni.encode('utf-8')
|
|
768
822
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
# curses errors on drawing the last character on the screen; ignore
|
|
779
|
-
pass
|
|
823
|
+
while y >= len(ns.texts):
|
|
824
|
+
ns.texts.append('')
|
|
825
|
+
ns.text_attrs.append([])
|
|
826
|
+
ns.contexts.append(None)
|
|
827
|
+
existing = ns.texts[y]
|
|
828
|
+
left = textwidth.ljust(textwidth.truncate(existing, x), x)
|
|
829
|
+
right_start = textwidth.cell_to_index(existing, x + textwidth.string_width(uni))
|
|
830
|
+
ns.texts[y] = left + uni + existing[right_start:]
|
|
831
|
+
ns.drawn.setdefault(y, []).append((x, uni, text_attr))
|
|
780
832
|
|
|
781
833
|
|
|
782
834
|
def highlight_picked(self) -> None:
|
|
@@ -805,24 +857,65 @@ class ConsoleWindow:
|
|
|
805
857
|
def _body_attrs(self, pos: int) -> list | None:
|
|
806
858
|
return self.body.text_attrs[pos] if pos < len(self.body.text_attrs) else None
|
|
807
859
|
|
|
860
|
+
def _pad_row(self, pos: int, header: bool = False) -> int | None:
|
|
861
|
+
# Map a content row to the pad row that currently holds it. Returns
|
|
862
|
+
# None when the row is outside the viewport, which is the common case
|
|
863
|
+
# for a long body: only the visible page lives in the pad.
|
|
864
|
+
row = pos if header else pos - self.scroll_pos
|
|
865
|
+
ns = self.head if header else self.body
|
|
866
|
+
if row < 0 or row >= ns.pad_rows:
|
|
867
|
+
return None
|
|
868
|
+
return row
|
|
869
|
+
|
|
870
|
+
def _overlay_spans(self, pad: CursesSurface, pad_row: int, ns: SimpleNamespace,
|
|
871
|
+
pos: int) -> None:
|
|
872
|
+
# Replay the draw() spans for one content row on top of its normal
|
|
873
|
+
# attribute runs. Off-screen rows keep their spans in ns.drawn and are
|
|
874
|
+
# painted when they scroll into view.
|
|
875
|
+
spans = ns.drawn.get(pos)
|
|
876
|
+
if not spans:
|
|
877
|
+
return
|
|
878
|
+
width = self.get_pad_width()
|
|
879
|
+
for x, text, attr in spans:
|
|
880
|
+
if x >= width:
|
|
881
|
+
continue
|
|
882
|
+
pad.addstr(pad_row, x, textwidth.truncate(text, width - x), attr)
|
|
883
|
+
|
|
884
|
+
def _paint_row(self, pad: CursesSurface, pad_row: int, pos: int, *,
|
|
885
|
+
picked: bool) -> None:
|
|
886
|
+
# One content row -> one pad row, with the pick styling applied here
|
|
887
|
+
# rather than incrementally: a scrolled viewport moves rows on screen
|
|
888
|
+
# without changing pick_pos, so highlight state must follow the paint,
|
|
889
|
+
# not the last pick position.
|
|
890
|
+
text = self._body_text(pos)
|
|
891
|
+
if picked and self.mod_pick:
|
|
892
|
+
text = self.mod_pick(text)
|
|
893
|
+
text = textwidth.ljust(text, self.get_pad_width())
|
|
894
|
+
if picked:
|
|
895
|
+
self._draw_line_with_attrs(pad, pad_row, 0, text,
|
|
896
|
+
self._body_attrs(pos),
|
|
897
|
+
extra_attr=self.opts.pick_attr,
|
|
898
|
+
extra_attr_range=self.opts.pick_range)
|
|
899
|
+
else:
|
|
900
|
+
self._draw_line_with_attrs(pad, pad_row, 0, text,
|
|
901
|
+
self._body_attrs(pos), extra_attr=None)
|
|
902
|
+
self._overlay_spans(pad, pad_row, self.body, pos)
|
|
903
|
+
|
|
808
904
|
def _redraw_rows(self, rows: Iterable[int]) -> None:
|
|
809
905
|
for row_idx in rows:
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
906
|
+
pad_row = self._pad_row(row_idx)
|
|
907
|
+
if pad_row is None or row_idx >= len(self.body.texts):
|
|
908
|
+
continue
|
|
909
|
+
self._paint_row(cast(CursesSurface, self.body.pad), pad_row,
|
|
910
|
+
row_idx, picked=False)
|
|
814
911
|
|
|
815
912
|
def _draw_picked_rows(self, rows: Iterable[int]) -> None:
|
|
816
913
|
for row_idx in rows:
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
self._draw_line_with_attrs(self.body.pad, row_idx, 0, text,
|
|
823
|
-
self._body_attrs(row_idx),
|
|
824
|
-
extra_attr=self.opts.pick_attr,
|
|
825
|
-
extra_attr_range=self.opts.pick_range)
|
|
914
|
+
pad_row = self._pad_row(row_idx)
|
|
915
|
+
if pad_row is None or row_idx >= len(self.body.texts):
|
|
916
|
+
continue
|
|
917
|
+
self._paint_row(cast(CursesSurface, self.body.pad), pad_row,
|
|
918
|
+
row_idx, picked=True)
|
|
826
919
|
|
|
827
920
|
def _draw_line_with_attrs(self, pad: CursesSurface, row: int, col_offset: int,
|
|
828
921
|
text: str, attrs: list | None,
|
|
@@ -845,6 +938,13 @@ class ConsoleWindow:
|
|
|
845
938
|
col = self._draw_attr_run(pad, row, col, text, attr, pos, count,
|
|
846
939
|
range_start, range_end, extra_attr)
|
|
847
940
|
pos += count
|
|
941
|
+
if pos < len(text):
|
|
942
|
+
# draw() can replace part of a row without rewriting text_attrs[],
|
|
943
|
+
# so the run list may not cover the whole string. Paint the tail in
|
|
944
|
+
# the normal attribute rather than leaving a hole in the row.
|
|
945
|
+
tail = textwidth.truncate(text[pos:], self.get_pad_width() - col)
|
|
946
|
+
if tail:
|
|
947
|
+
pad.addstr(row, col, tail, curses.A_NORMAL)
|
|
848
948
|
|
|
849
949
|
def _draw_plain_line(self, pad: CursesSurface, row: int, col_offset: int, text: str,
|
|
850
950
|
extra_attr: int | None,
|
|
@@ -1145,23 +1245,59 @@ class ConsoleWindow:
|
|
|
1145
1245
|
top = min(round(ind_pos+width/2), self.cols-1)
|
|
1146
1246
|
return bot, max(top - bot, 1)
|
|
1147
1247
|
|
|
1148
|
-
def
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1248
|
+
def _paint_body(self) -> int:
|
|
1249
|
+
# Paint the visible page into the viewport-sized body pad. Unlike the
|
|
1250
|
+
# old pad-as-storage model, every frame redraws at most one screenful,
|
|
1251
|
+
# so cost tracks the terminal, not the document.
|
|
1252
|
+
view = min(self.scroll_view_size, self.rows - self.body_base)
|
|
1253
|
+
if view <= 0:
|
|
1254
|
+
return 0
|
|
1255
|
+
pad = cast(CursesSurface, self.body.pad)
|
|
1256
|
+
pad.erase()
|
|
1257
|
+
for row in range(view):
|
|
1258
|
+
pos = self.scroll_pos + row
|
|
1259
|
+
if pos < 0 or pos >= self.body.row_cnt or pos >= len(self.body.texts):
|
|
1260
|
+
continue
|
|
1261
|
+
picked = (self.pick_mode
|
|
1262
|
+
and self.pick_pos <= pos < self.pick_pos + self.pick_size)
|
|
1263
|
+
self._paint_row(pad, row, pos, picked=picked)
|
|
1264
|
+
if self.pick_mode:
|
|
1265
|
+
# Bookkeeping only: the paint above is stateless, but the
|
|
1266
|
+
# incremental API (and the debug log) still reports the pick that
|
|
1267
|
+
# was last shown and considers any pending full repaint done.
|
|
1268
|
+
self.last_pick_pos = self.pick_pos
|
|
1269
|
+
self._pick_clear_pending = False
|
|
1270
|
+
return view
|
|
1271
|
+
|
|
1272
|
+
def _paint_head(self) -> int:
|
|
1273
|
+
last_row = min(self.head.view_cnt, self.rows) - 1
|
|
1274
|
+
if last_row < 0:
|
|
1275
|
+
return -1
|
|
1276
|
+
pad = cast(CursesSurface, self.head.pad)
|
|
1277
|
+
pad.erase()
|
|
1278
|
+
width = self.get_pad_width()
|
|
1279
|
+
for row in range(last_row + 1):
|
|
1280
|
+
if row >= len(self.head.texts):
|
|
1281
|
+
continue
|
|
1282
|
+
text = textwidth.ljust(self.head.texts[row], width)
|
|
1283
|
+
attrs = self.head.text_attrs[row] if row < len(self.head.text_attrs) else None
|
|
1284
|
+
self._draw_line_with_attrs(pad, row, 0, text, attrs, extra_attr=None)
|
|
1285
|
+
self._overlay_spans(pad, row, self.head, row)
|
|
1286
|
+
return last_row
|
|
1152
1287
|
|
|
1288
|
+
def _refresh_pads(self, indent: int) -> None:
|
|
1289
|
+
if self._paint_body() > 0:
|
|
1153
1290
|
self.body.pad.noutrefresh(
|
|
1154
|
-
|
|
1291
|
+
0, 0,
|
|
1155
1292
|
self.body_base, indent, self.rows-1, self.cols-1
|
|
1156
1293
|
)
|
|
1157
1294
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
)
|
|
1295
|
+
last_row = self._paint_head()
|
|
1296
|
+
if last_row >= 0:
|
|
1297
|
+
self.head.pad.noutrefresh(
|
|
1298
|
+
0, 0,
|
|
1299
|
+
0, indent, last_row, self.cols-1
|
|
1300
|
+
)
|
|
1165
1301
|
|
|
1166
1302
|
|
|
1167
1303
|
def answer(self, prompt: str = 'Type string [then Enter]', seed: str = '', width: int = 80,
|
|
@@ -1758,12 +1894,15 @@ class ConsoleWindow:
|
|
|
1758
1894
|
|
|
1759
1895
|
def clear(self) -> None:
|
|
1760
1896
|
self.scr.clear()
|
|
1897
|
+
# Viewport-sized pads: clearing costs one screenful, not one high-water
|
|
1898
|
+
# mark, and no shrink/grow decision is needed at a frame boundary.
|
|
1761
1899
|
self.head.pad.clear()
|
|
1762
1900
|
self.body.pad.clear()
|
|
1763
1901
|
self.head.texts, self.body.texts, self.last_pick_pos = [], [], -1
|
|
1764
1902
|
self._pick_clear_pending = False
|
|
1765
1903
|
self.head.text_attrs, self.body.text_attrs = [], []
|
|
1766
1904
|
self.head.contexts, self.body.contexts = [], []
|
|
1905
|
+
self.head.drawn, self.body.drawn = {}, {}
|
|
1767
1906
|
self.head.row_cnt = self.body.row_cnt = 0
|
|
1768
1907
|
self.head.rows_dropped = self.body.rows_dropped = 0
|
|
1769
1908
|
|
|
@@ -368,11 +368,15 @@ def test_draw_writes_a_row_and_tracks_row_cnt(make):
|
|
|
368
368
|
win.draw(2, 1, 'hello')
|
|
369
369
|
assert win.body.row_cnt == 3
|
|
370
370
|
assert win.body.texts[2] == ' hello'
|
|
371
|
+
# Deferred painting: the span is recorded now and replayed by render().
|
|
372
|
+
assert win.body.drawn[2] == [(1, 'hello', curses.A_NORMAL)]
|
|
373
|
+
win.render()
|
|
371
374
|
assert win.body.pad.calls_named('addstr')
|
|
372
375
|
|
|
373
376
|
|
|
374
377
|
def test_draw_out_of_bounds_is_a_no_op(make):
|
|
375
|
-
|
|
378
|
+
# max_rows, not body_rows, is what bounds draw(): body_rows is only a hint.
|
|
379
|
+
win, _backend = make(max_rows=10, body_cols=40)
|
|
376
380
|
for y, x in ((-1, 0), (0, -1), (999, 0), (0, 999)):
|
|
377
381
|
win.draw(y, x, 'x')
|
|
378
382
|
assert win.body.row_cnt == 0
|
|
@@ -383,6 +387,7 @@ def test_draw_header_flag_targets_the_head_pad(make):
|
|
|
383
387
|
win, _backend = make(head_rows=5)
|
|
384
388
|
win.draw(0, 0, 'HDR', header=True)
|
|
385
389
|
assert win.head.row_cnt == 1
|
|
390
|
+
win.render()
|
|
386
391
|
assert win.head.pad.calls_named('addstr')
|
|
387
392
|
|
|
388
393
|
|
|
@@ -5,9 +5,11 @@ from console_window.opts import ConsoleWindowOpts
|
|
|
5
5
|
from console_window.window import ConsoleWindow
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def _make(
|
|
8
|
+
def _make(max_rows=5):
|
|
9
|
+
# max_rows is the only enforced ceiling; body_rows/head_rows are hints and
|
|
10
|
+
# deliberately absent here so these tests pin the enforcement path.
|
|
9
11
|
return ConsoleWindow(
|
|
10
|
-
opts=ConsoleWindowOpts(
|
|
12
|
+
opts=ConsoleWindowOpts(max_rows=max_rows),
|
|
11
13
|
backend=HeadlessBackend(rows=24, cols=80),
|
|
12
14
|
singleton=False,
|
|
13
15
|
)
|
|
@@ -24,7 +26,7 @@ def _record(fn):
|
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def test_add_body_within_capacity_is_silent():
|
|
27
|
-
win = _make(
|
|
29
|
+
win = _make(max_rows=5)
|
|
28
30
|
try:
|
|
29
31
|
caught = _record(lambda: [win.add_body(f'row {i}') for i in range(5)])
|
|
30
32
|
assert [str(w.message) for w in caught] == []
|
|
@@ -34,13 +36,41 @@ def test_add_body_within_capacity_is_silent():
|
|
|
34
36
|
win.stop_curses()
|
|
35
37
|
|
|
36
38
|
|
|
39
|
+
def test_body_rows_is_a_hint_not_a_limit():
|
|
40
|
+
# The whole point of the viewport-sized pads: an app no longer has to
|
|
41
|
+
# pre-size storage, so a small (or absent) body_rows never discards rows.
|
|
42
|
+
win = ConsoleWindow(
|
|
43
|
+
opts=ConsoleWindowOpts(body_rows=5, head_rows=2, max_rows=None),
|
|
44
|
+
backend=HeadlessBackend(rows=24, cols=80),
|
|
45
|
+
singleton=False,
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
caught = _record(lambda: [win.add_body(f'row {i}') for i in range(12)])
|
|
49
|
+
assert [str(w.message) for w in caught] == []
|
|
50
|
+
assert win.rows_dropped == 0
|
|
51
|
+
assert win.body.row_cnt == 12
|
|
52
|
+
assert win.body.texts == [f'row {i}' for i in range(12)]
|
|
53
|
+
assert win.max_rows is None
|
|
54
|
+
finally:
|
|
55
|
+
win.stop_curses()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_max_rows_is_the_only_ceiling_and_can_be_removed():
|
|
59
|
+
assert ConsoleWindowOpts().max_rows == 32767
|
|
60
|
+
assert ConsoleWindowOpts(max_rows=None).max_rows is None
|
|
61
|
+
# The old row options survive as hints; they no longer control the ceiling.
|
|
62
|
+
opts = ConsoleWindowOpts(body_rows=5, head_rows=2)
|
|
63
|
+
assert (opts.body_rows, opts.head_rows) == (5, 2)
|
|
64
|
+
assert opts.max_rows == 32767
|
|
65
|
+
|
|
66
|
+
|
|
37
67
|
def test_overflowing_the_body_pad_counts_rows_and_warns_once():
|
|
38
|
-
win = _make(
|
|
68
|
+
win = _make(max_rows=5)
|
|
39
69
|
try:
|
|
40
70
|
caught = _record(lambda: [win.add_body(f'row {i}') for i in range(12)])
|
|
41
71
|
assert len(caught) == 1
|
|
42
72
|
assert caught[0].category is UserWarning
|
|
43
|
-
assert 'body
|
|
73
|
+
assert 'body content limit is full' in str(caught[0].message)
|
|
44
74
|
# The excess is still discarded -- 7 of the 12 rows -- but no longer in
|
|
45
75
|
# silence.
|
|
46
76
|
assert win.rows_dropped == 7
|
|
@@ -53,7 +83,7 @@ def test_overflowing_the_body_pad_counts_rows_and_warns_once():
|
|
|
53
83
|
|
|
54
84
|
|
|
55
85
|
def test_overflow_is_not_fatal_and_clear_rearms_the_warning():
|
|
56
|
-
win = _make(
|
|
86
|
+
win = _make(max_rows=2)
|
|
57
87
|
try:
|
|
58
88
|
caught = _record(lambda: [win.add_body(f'a{i}') for i in range(9)])
|
|
59
89
|
# No exception: the app limped along, exactly as it did before.
|
|
@@ -72,7 +102,7 @@ def test_overflow_is_not_fatal_and_clear_rearms_the_warning():
|
|
|
72
102
|
|
|
73
103
|
|
|
74
104
|
def test_head_pad_overflow_is_counted_and_summed():
|
|
75
|
-
win = _make(
|
|
105
|
+
win = _make(max_rows=2)
|
|
76
106
|
try:
|
|
77
107
|
def draw():
|
|
78
108
|
for i in range(4):
|
|
@@ -83,8 +113,8 @@ def test_head_pad_overflow_is_counted_and_summed():
|
|
|
83
113
|
caught = _record(draw)
|
|
84
114
|
messages = [str(w.message) for w in caught]
|
|
85
115
|
assert len(messages) == 2
|
|
86
|
-
assert any('head
|
|
87
|
-
assert any('body
|
|
116
|
+
assert any('head content limit is full' in m for m in messages)
|
|
117
|
+
assert any('body content limit is full' in m for m in messages)
|
|
88
118
|
assert win.head.rows_dropped == 2
|
|
89
119
|
assert win.body.rows_dropped == 1
|
|
90
120
|
assert win.rows_dropped == 3
|
|
@@ -93,12 +123,12 @@ def test_head_pad_overflow_is_counted_and_summed():
|
|
|
93
123
|
|
|
94
124
|
|
|
95
125
|
def test_resume_on_a_full_pad_is_counted_too():
|
|
96
|
-
win = _make(
|
|
126
|
+
win = _make(max_rows=1)
|
|
97
127
|
try:
|
|
98
128
|
win.add_body('only')
|
|
99
129
|
caught = _record(lambda: win.add_body('more', resume=True))
|
|
100
130
|
assert len(caught) == 1
|
|
101
|
-
assert 'body
|
|
131
|
+
assert 'body content limit is full' in str(caught[0].message)
|
|
102
132
|
assert win.body.row_cnt == 1
|
|
103
133
|
assert win.rows_dropped == 1
|
|
104
134
|
finally:
|
|
@@ -106,7 +136,7 @@ def test_resume_on_a_full_pad_is_counted_too():
|
|
|
106
136
|
|
|
107
137
|
|
|
108
138
|
def test_the_warning_blames_the_callers_line_not_the_library():
|
|
109
|
-
win = _make(
|
|
139
|
+
win = _make(max_rows=1)
|
|
110
140
|
try:
|
|
111
141
|
caught = _record(lambda: [win.add_body('first'), win.add_body('second')])
|
|
112
142
|
assert caught[0].filename.endswith('test_overflow.py')
|
|
@@ -113,11 +113,8 @@ def test_stress_mode_pads_the_body_within_pad_capacity():
|
|
|
113
113
|
app.opts.stress = True
|
|
114
114
|
app.screens[sample.MAIN_ST].draw_screen()
|
|
115
115
|
assert app.win.body.row_cnt > normal
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
# ...and must not hit it either: the demo's 'z' filler only stays whole
|
|
119
|
-
# because body_rows was raised for it. Any drop is counted (and warned
|
|
120
|
-
# about) rather than passing unnoticed.
|
|
116
|
+
# Nothing is dropped: the pads are viewport-sized, the 2000 filler rows
|
|
117
|
+
# live in the model, and the default max_rows ceiling is far above them.
|
|
121
118
|
assert app.win.rows_dropped == 0
|
|
122
119
|
finally:
|
|
123
120
|
app.win.stop_curses()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Scheme B: body/head pads are viewport-sized blit buffers.
|
|
2
|
+
|
|
3
|
+
These tests pin the properties that make `opts.body_rows` a hint rather than a
|
|
4
|
+
storage limit: pad size tracks the terminal, the visible page is painted into
|
|
5
|
+
pad row 0 on every frame, and `draw()` spans are replayed with their attributes
|
|
6
|
+
even when the row was written long before it scrolled into view.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import curses
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import console_window.window as window_mod
|
|
14
|
+
from console_window.backend import HeadlessBackend
|
|
15
|
+
from console_window.opts import ConsoleWindowOpts
|
|
16
|
+
from console_window.window import ConsoleWindow
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(autouse=True)
|
|
20
|
+
def _headless_curses(monkeypatch):
|
|
21
|
+
# curses.ACS_* are unbound until initscr(); the separator/indicator path
|
|
22
|
+
# needs them even when rendering through HeadlessBackend.
|
|
23
|
+
for name in ('ACS_HLINE', 'ACS_VLINE'):
|
|
24
|
+
if not hasattr(window_mod.curses, name):
|
|
25
|
+
monkeypatch.setattr(window_mod.curses, name, '-', raising=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _window(rows=10, cols=40, **opts):
|
|
29
|
+
backend = HeadlessBackend(rows=rows, cols=cols)
|
|
30
|
+
win = ConsoleWindow(opts=ConsoleWindowOpts(**opts), backend=backend, singleton=False)
|
|
31
|
+
return win, backend
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _addstrs(pad):
|
|
35
|
+
calls = pad.calls_named('addstr')
|
|
36
|
+
out = []
|
|
37
|
+
for _name, args, _kw in calls:
|
|
38
|
+
text = args[2]
|
|
39
|
+
if isinstance(text, (bytes, bytearray)):
|
|
40
|
+
text = text.decode('utf-8')
|
|
41
|
+
out.append((args[0], args[1], text, args[3] if len(args) > 3 else None))
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _row_texts(pad):
|
|
46
|
+
return {y: text for y, x, text, _attr in _addstrs(pad) if x == 0}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_pads_are_viewport_sized_regardless_of_body_rows():
|
|
50
|
+
win, backend = _window(rows=10, cols=40, body_rows=5000)
|
|
51
|
+
try:
|
|
52
|
+
assert len(backend.pads) == 2
|
|
53
|
+
assert win.body.pad_rows == 10
|
|
54
|
+
assert win.body.pad_cols == 39
|
|
55
|
+
assert win.head.pad_rows == 10
|
|
56
|
+
finally:
|
|
57
|
+
win.stop_curses()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_render_paints_the_visible_page_into_pad_row_zero():
|
|
61
|
+
win, _backend = _window(rows=8, cols=40, head_line=False)
|
|
62
|
+
try:
|
|
63
|
+
for i in range(100):
|
|
64
|
+
win.add_body(f'row {i:03d}')
|
|
65
|
+
win.scroll_pos = 50
|
|
66
|
+
win.render()
|
|
67
|
+
|
|
68
|
+
rows = _row_texts(win.body.pad)
|
|
69
|
+
assert 'row 050' in rows[0]
|
|
70
|
+
assert 'row 057' in rows[7]
|
|
71
|
+
assert not any('row 000' in text for text in rows.values())
|
|
72
|
+
finally:
|
|
73
|
+
win.stop_curses()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_draw_span_is_replayed_with_its_attribute():
|
|
77
|
+
win, _backend = _window(rows=6, cols=30, head_line=False)
|
|
78
|
+
try:
|
|
79
|
+
win.draw(1, 2, 'X', curses.A_BOLD)
|
|
80
|
+
win.render()
|
|
81
|
+
|
|
82
|
+
assert any(y == 1 and x == 2 and text == 'X' and attr == curses.A_BOLD
|
|
83
|
+
for y, x, text, attr in _addstrs(win.body.pad))
|
|
84
|
+
finally:
|
|
85
|
+
win.stop_curses()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_draw_on_an_offscreen_row_survives_until_it_is_scrolled_to():
|
|
89
|
+
win, _backend = _window(rows=6, cols=30, head_line=False)
|
|
90
|
+
try:
|
|
91
|
+
for i in range(40):
|
|
92
|
+
win.add_body(f'r{i}')
|
|
93
|
+
win.draw(30, 5, 'ZZ', curses.A_BOLD)
|
|
94
|
+
|
|
95
|
+
win.render()
|
|
96
|
+
assert not any(text == 'ZZ' for _y, _x, text, _a in _addstrs(win.body.pad))
|
|
97
|
+
|
|
98
|
+
win.scroll_pos = 30
|
|
99
|
+
win.render()
|
|
100
|
+
assert any(y == 0 and x == 5 and text == 'ZZ' and attr == curses.A_BOLD
|
|
101
|
+
for y, x, text, attr in _addstrs(win.body.pad))
|
|
102
|
+
finally:
|
|
103
|
+
win.stop_curses()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_resize_reallocates_the_pads_to_the_new_viewport():
|
|
107
|
+
win, backend = _window(rows=10, cols=40)
|
|
108
|
+
try:
|
|
109
|
+
backend.screen._rows = 12
|
|
110
|
+
backend.screen._cols = 30
|
|
111
|
+
win.render()
|
|
112
|
+
|
|
113
|
+
assert win.rows == 12
|
|
114
|
+
assert win.body.pad_rows == 12
|
|
115
|
+
assert win.body.pad_cols == 29
|
|
116
|
+
assert len(backend.pads) == 4 # two original, two replacements
|
|
117
|
+
finally:
|
|
118
|
+
win.stop_curses()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_pick_highlight_follows_the_viewport_without_moving_the_pick():
|
|
122
|
+
# The old pad-as-storage model kept the reverse-video row inside the pad, so
|
|
123
|
+
# scrolling moved it on screen for free. Painting statelessly must apply
|
|
124
|
+
# the pick attribute wherever the picked row currently lands.
|
|
125
|
+
win, _backend = _window(rows=6, cols=30, head_line=False, pick_mode=True)
|
|
126
|
+
try:
|
|
127
|
+
for i in range(40):
|
|
128
|
+
win.add_body(f'row {i}')
|
|
129
|
+
win.pick_pos = 5
|
|
130
|
+
|
|
131
|
+
win.render()
|
|
132
|
+
assert any(text.startswith('row 5') and attr & curses.A_REVERSE
|
|
133
|
+
for y, _x, text, attr in _addstrs(win.body.pad) if y == 5)
|
|
134
|
+
|
|
135
|
+
win.scroll_pos = 2
|
|
136
|
+
win.render()
|
|
137
|
+
assert any(text.startswith('row 5') and attr & curses.A_REVERSE
|
|
138
|
+
for y, _x, text, attr in _addstrs(win.body.pad) if y == 3)
|
|
139
|
+
finally:
|
|
140
|
+
win.stop_curses()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_clear_drops_recorded_draw_spans():
|
|
144
|
+
win, _backend = _window(rows=6, cols=30, head_line=False)
|
|
145
|
+
try:
|
|
146
|
+
win.draw(0, 0, 'X')
|
|
147
|
+
assert win.body.drawn
|
|
148
|
+
win.clear()
|
|
149
|
+
assert win.body.drawn == {}
|
|
150
|
+
assert win.body.row_cnt == 0
|
|
151
|
+
finally:
|
|
152
|
+
win.stop_curses()
|
|
@@ -79,8 +79,10 @@ def make_window(cols=10, body_cols=10):
|
|
|
79
79
|
win.cols = cols
|
|
80
80
|
win.body_cols = body_cols
|
|
81
81
|
for name in ('head', 'body'):
|
|
82
|
+
pad = FakePad()
|
|
82
83
|
setattr(win, name, SimpleNamespace(
|
|
83
|
-
pad=
|
|
84
|
+
pad=pad, rows=50, cols=body_cols, row_cnt=0,
|
|
85
|
+
pad_rows=50, pad_cols=body_cols, drawn={},
|
|
84
86
|
texts=[], text_attrs=[], contexts=[]))
|
|
85
87
|
return win
|
|
86
88
|
|
|
@@ -126,9 +128,10 @@ def test_draw_pads_to_cells():
|
|
|
126
128
|
win = make_window(cols=8, body_cols=8)
|
|
127
129
|
win.draw(0, 0, CJK, width=6)
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
# draw() now records the span; the viewport painter replays it (with the
|
|
132
|
+
# same display-cell padding) on the frame where row 0 is visible.
|
|
133
|
+
x, text, _attr = win.body.drawn[0][-1]
|
|
134
|
+
assert x == 0
|
|
132
135
|
assert tw.string_width(text) == 6
|
|
133
136
|
|
|
134
137
|
|
|
@@ -136,7 +139,7 @@ def test_draw_leftpad_pads_on_the_left():
|
|
|
136
139
|
win = make_window(cols=8, body_cols=8)
|
|
137
140
|
win.draw(0, 0, CJK, width=4, leftpad=True)
|
|
138
141
|
|
|
139
|
-
text = win.body.
|
|
142
|
+
text = win.body.drawn[0][-1][1]
|
|
140
143
|
assert text == ' ' + CJK
|
|
141
144
|
|
|
142
145
|
# --- pick-range highlighting ------------------------------------------------
|
|
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
|