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.
Files changed (43) hide show
  1. {console_window-2.0.0.2 → console_window-2.0.0.3}/CHANGELOG.md +13 -0
  2. {console_window-2.0.0.2 → console_window-2.0.0.3}/PKG-INFO +8 -7
  3. {console_window-2.0.0.2 → console_window-2.0.0.3}/README.md +7 -6
  4. {console_window-2.0.0.2 → console_window-2.0.0.3}/SampleApp.py +3 -6
  5. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/__init__.py +1 -1
  6. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/backend.py +3 -0
  7. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/opts.py +11 -6
  8. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/window.py +237 -98
  9. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_interactive.py +6 -1
  10. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_overflow.py +42 -12
  11. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_sample_app.py +2 -5
  12. console_window-2.0.0.3/tests/test_viewport_pad.py +152 -0
  13. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_widechars.py +8 -5
  14. {console_window-2.0.0.2 → console_window-2.0.0.3}/.github/workflows/ci.yml +0 -0
  15. {console_window-2.0.0.2 → console_window-2.0.0.3}/.gitignore +0 -0
  16. {console_window-2.0.0.2 → console_window-2.0.0.3}/AUDIT.md +0 -0
  17. {console_window-2.0.0.2 → console_window-2.0.0.3}/LICENSE +0 -0
  18. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/context.py +0 -0
  19. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/geometry.py +0 -0
  20. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/header.py +0 -0
  21. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/keys.py +0 -0
  22. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/screens.py +0 -0
  23. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/signals.py +0 -0
  24. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/spinner.py +0 -0
  25. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/textwidth.py +0 -0
  26. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/theme.py +0 -0
  27. {console_window-2.0.0.2 → console_window-2.0.0.3}/console_window/widgets.py +0 -0
  28. {console_window-2.0.0.2 → console_window-2.0.0.3}/pyproject.toml +0 -0
  29. {console_window-2.0.0.2 → console_window-2.0.0.3}/runner +0 -0
  30. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_backend.py +0 -0
  31. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_context.py +0 -0
  32. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_demo_mode.py +0 -0
  33. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_dialogs.py +0 -0
  34. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_flash.py +0 -0
  35. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_geometry.py +0 -0
  36. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_header.py +0 -0
  37. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_public_api.py +0 -0
  38. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_render_retry.py +0 -0
  39. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_signals.py +0 -0
  40. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_spinner.py +0 -0
  41. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_textwidth.py +0 -0
  42. {console_window-2.0.0.2 → console_window-2.0.0.3}/tests/test_theme.py +0 -0
  43. {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.2
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
- * **Padded Content Management:** Uses separate `head` and `body` pads, allowing for a static header area while the main content area can be scrolled independently.
72
- * **Loud Pad Limits:** The pads are fixed-size (`opts.head_rows` / `opts.body_rows`). Rows added past capacity are still discarded, but no longer quietly: the first discarded row of each fill episode emits a `UserWarning` naming the pad and the option to raise, 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.
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
- big pads and the scroll indicator. The sample raises `body_rows` to 4000 so
129
- that nothing is discarded: `row_cnt` is capped at the pad size and any excess
130
- is counted in `ConsoleWindow.rows_dropped`.
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
- win = ConsoleWindow(opts=ConsoleWindowOpts(head_line=True, body_rows=200))
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
- * **Padded Content Management:** Uses separate `head` and `body` pads, allowing for a static header area while the main content area can be scrolled independently.
53
- * **Loud Pad Limits:** The pads are fixed-size (`opts.head_rows` / `opts.body_rows`). Rows added past capacity are still discarded, but no longer quietly: the first discarded row of each fill episode emits a `UserWarning` naming the pad and the option to raise, 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.
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
- big pads and the scroll indicator. The sample raises `body_rows` to 4000 so
110
- that nothing is discarded: `row_cnt` is capped at the pad size and any excess
111
- is counted in `ConsoleWindow.rows_dropped`.
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
- win = ConsoleWindow(opts=ConsoleWindowOpts(head_line=True, body_rows=200))
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
- # Pad capacity: add_body()/add_header() discard rows past this (they
67
- # are counted in ConsoleWindow.rows_dropped, and the first one of each
68
- # fill is warned about), so each pad must be big enough for what the
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.2"
3
+ __version__ = "2.0.0.3"
4
4
 
5
5
  from .context import Context
6
6
  from .header import HeaderLine, Span
@@ -26,6 +26,9 @@ class CursesSurface(Surface, Protocol):
26
26
  def clear(self) -> None:
27
27
  ...
28
28
 
29
+ def erase(self) -> None:
30
+ ...
31
+
29
32
  def addstr(self, *args: object) -> None:
30
33
  ...
31
34
 
@@ -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', 'pick_range',
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
- self.head_rows = kwargs.get('head_rows', 50)
53
- self.body_rows = kwargs.get('body_rows', 200)
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 if head_rows is not None else 50,
209
- body_rows=body_rows if body_rows is not None else 200,
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=self._backend.newpad(self.opts.head_rows, self.opts.body_cols),
249
- rows=self.opts.head_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 pad was full (see _add)
276
+ rows_dropped=0, # rows discarded because the content cap was reached
257
277
  )
258
278
  self.body = SimpleNamespace(
259
- pad = self._backend.newpad(self.opts.body_rows, self.opts.body_cols),
260
- rows= self.opts.body_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
- rows_dropped = 0, # rows discarded because the pad was full (see _add)
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 pad had filled up.
361
- # The pads are fixed-size (opts.body_rows/head_rows), so a caller that
362
- # adds more rows than that used to lose the excess in silence; this
363
- # counter, plus the one-shot warning in _add(), is how that is now
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
- if ns.row_cnt < ns.rows:
625
- row = max(ns.row_cnt - (1 if resume else 0), 0)
626
- if attr is None:
627
- attr = curses.A_NORMAL
628
- # Apply stripping logic if needed
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 pad is full, so this row is discarded (as it always was). Counted
658
- # always, warned about once per fill episode -- clear() resets both, so
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} pad is full ({ns.rows} rows), so '
669
- f'further {which} rows are being discarded. Raise the '
670
- f'{which}_rows option (ConsoleWindowOpts({which}_rows=...)) or add '
671
- f'fewer rows per frame.',
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
- try:
770
- while y >= len(ns.texts):
771
- ns.texts.append('')
772
- existing = ns.texts[y]
773
- left = textwidth.ljust(textwidth.truncate(existing, x), x)
774
- right_start = textwidth.cell_to_index(existing, x + textwidth.string_width(uni))
775
- ns.texts[y] = left + uni + existing[right_start:]
776
- ns.pad.addstr(y, x, text, text_attr)
777
- except curses.error:
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
- if row_idx < len(self.body.texts):
811
- text = textwidth.ljust(self._body_text(row_idx), self.get_pad_width())
812
- self._draw_line_with_attrs(self.body.pad, row_idx, 0, text,
813
- self._body_attrs(row_idx), extra_attr=None)
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
- if row_idx < len(self.body.texts):
818
- text = self._body_text(row_idx)
819
- if self.mod_pick:
820
- text = self.mod_pick(text)
821
- text = textwidth.ljust(text, self.get_pad_width())
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 _refresh_pads(self, indent: int) -> None:
1149
- if self.body_base < self.rows:
1150
- if self.pick_mode:
1151
- self.highlight_picked()
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
- self.scroll_pos, 0,
1291
+ 0, 0,
1155
1292
  self.body_base, indent, self.rows-1, self.cols-1
1156
1293
  )
1157
1294
 
1158
- if self.rows > 0:
1159
- last_row = min(self.head.view_cnt, self.rows)-1
1160
- if last_row >= 0:
1161
- self.head.pad.noutrefresh(
1162
- 0, 0,
1163
- 0, indent, last_row, self.cols-1
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
- win, _backend = make(body_rows=10, body_cols=40)
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(head_rows=3, body_rows=5):
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(head_rows=head_rows, body_rows=body_rows),
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(body_rows=5)
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(body_rows=5)
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 pad is full' in str(caught[0].message)
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(body_rows=2)
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(head_rows=2, body_rows=2)
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 pad is full' in m for m in messages)
87
- assert any('body pad is full' in m for m in messages)
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(body_rows=1)
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 pad is full' in str(caught[0].message)
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(body_rows=1)
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
- # add_body() drops rows past the pad capacity, so it must not exceed it...
117
- assert app.win.body.row_cnt <= app.win.body.rows
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=FakePad(), rows=50, cols=body_cols, row_cnt=0,
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
- y, x, data, _attr = win.body.pad.calls[-1]
130
- text = data.decode('utf-8')
131
- assert (y, x) == (0, 0)
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.pad.calls[-1][2].decode('utf-8')
142
+ text = win.body.drawn[0][-1][1]
140
143
  assert text == ' ' + CJK
141
144
 
142
145
  # --- pick-range highlighting ------------------------------------------------