console-window 2.0.0.2__tar.gz → 2.0.0.4__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 (48) hide show
  1. {console_window-2.0.0.2 → console_window-2.0.0.4}/.github/workflows/ci.yml +21 -7
  2. console_window-2.0.0.4/AUDIT.md +120 -0
  3. {console_window-2.0.0.2 → console_window-2.0.0.4}/CHANGELOG.md +84 -0
  4. {console_window-2.0.0.2 → console_window-2.0.0.4}/PKG-INFO +28 -12
  5. {console_window-2.0.0.2 → console_window-2.0.0.4}/README.md +27 -11
  6. {console_window-2.0.0.2 → console_window-2.0.0.4}/SampleApp.py +67 -78
  7. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/__init__.py +3 -1
  8. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/backend.py +8 -2
  9. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/header.py +36 -0
  10. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/opts.py +17 -7
  11. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/screens.py +19 -4
  12. console_window-2.0.0.4/console_window/signals.py +144 -0
  13. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/spinner.py +18 -12
  14. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/theme.py +98 -22
  15. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/window.py +390 -150
  16. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_header.py +83 -0
  17. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_interactive.py +10 -2
  18. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_overflow.py +73 -12
  19. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_public_api.py +1 -0
  20. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_sample_app.py +4 -7
  21. console_window-2.0.0.4/tests/test_screens.py +62 -0
  22. console_window-2.0.0.4/tests/test_signals.py +166 -0
  23. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_spinner.py +8 -0
  24. console_window-2.0.0.4/tests/test_theme.py +207 -0
  25. console_window-2.0.0.4/tests/test_viewport_pad.py +152 -0
  26. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_widechars.py +8 -5
  27. console_window-2.0.0.2/AUDIT.md +0 -152
  28. console_window-2.0.0.2/console_window/signals.py +0 -49
  29. console_window-2.0.0.2/tests/test_signals.py +0 -71
  30. console_window-2.0.0.2/tests/test_theme.py +0 -72
  31. {console_window-2.0.0.2 → console_window-2.0.0.4}/.gitignore +0 -0
  32. {console_window-2.0.0.2 → console_window-2.0.0.4}/LICENSE +0 -0
  33. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/context.py +0 -0
  34. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/geometry.py +0 -0
  35. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/keys.py +0 -0
  36. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/textwidth.py +0 -0
  37. {console_window-2.0.0.2 → console_window-2.0.0.4}/console_window/widgets.py +0 -0
  38. {console_window-2.0.0.2 → console_window-2.0.0.4}/pyproject.toml +0 -0
  39. {console_window-2.0.0.2 → console_window-2.0.0.4}/runner +0 -0
  40. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_backend.py +0 -0
  41. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_context.py +0 -0
  42. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_demo_mode.py +0 -0
  43. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_dialogs.py +0 -0
  44. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_flash.py +0 -0
  45. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_geometry.py +0 -0
  46. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_render_retry.py +0 -0
  47. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_textwidth.py +0 -0
  48. {console_window-2.0.0.2 → console_window-2.0.0.4}/tests/test_widgets.py +0 -0
@@ -19,9 +19,9 @@ jobs:
19
19
  with:
20
20
  python-version: ${{ matrix.python-version }}
21
21
  - name: Install test dependencies
22
- run: python -m pip install --upgrade pip pytest
23
- - name: Run tests (TTY-free)
24
- run: python -m pytest -q
22
+ run: python -m pip install pytest==9.1.1 pytest-cov==7.1.0
23
+ - name: Run tests (TTY-free) with coverage floor
24
+ run: python -m pytest -q --cov=console_window --cov-fail-under=80
25
25
 
26
26
  lint:
27
27
  name: ruff
@@ -31,9 +31,9 @@ jobs:
31
31
  - name: Set up Python
32
32
  uses: actions/setup-python@v5
33
33
  with:
34
- python-version: "3.x"
34
+ python-version: "3.13"
35
35
  - name: Install ruff
36
- run: python -m pip install ruff
36
+ run: python -m pip install ruff==0.17.0
37
37
  - name: Lint
38
38
  run: ruff check .
39
39
 
@@ -45,8 +45,22 @@ jobs:
45
45
  - name: Set up Python
46
46
  uses: actions/setup-python@v5
47
47
  with:
48
- python-version: "3.x"
48
+ python-version: "3.13"
49
49
  - name: Install mypy
50
- run: python -m pip install mypy
50
+ run: python -m pip install mypy==2.4.0
51
51
  - name: Type check
52
52
  run: python -m mypy
53
+
54
+ pylint:
55
+ name: pylint
56
+ runs-on: ubuntu-latest
57
+ steps:
58
+ - uses: actions/checkout@v4
59
+ - name: Set up Python
60
+ uses: actions/setup-python@v5
61
+ with:
62
+ python-version: "3.13"
63
+ - name: Install pylint
64
+ run: python -m pip install pylint==3.3.4
65
+ - name: Lint
66
+ run: python -m pylint console_window SampleApp.py runner --disable=C,R
@@ -0,0 +1,120 @@
1
+ # `console-window` — Code & Design Audit
2
+
3
+ **Audited:** 2026-10-22 (working tree on `4f48396`).
4
+ **Scope:** the `console_window` package, its test suite, `SampleApp.py`, `runner`, packaging and CI. Competitive/ecosystem analysis is deliberately excluded (it is covered in [`README.md`](README.md:1)).
5
+ **Method:** full source read of all 14 package modules plus the example and tests; `ruff`, `pylint`, `mypy`, `pytest` and `coverage` run locally; API reachability established by repo-wide call-site search, not by inference.
6
+
7
+ | Metric | Value |
8
+ | --- | --- |
9
+ | Package size | 4,512 lines / 2,607 statements across 14 modules; stdlib-only, POSIX curses |
10
+ | Example | [`SampleApp.py`](SampleApp.py:1) 520 lines; [`runner`](runner:1) 13 lines |
11
+ | Tests | 302 tests, ~3,670 lines in 21 files — **all pass** (1.4 s) |
12
+ | Coverage (`--source=console_window`) | **90%** overall; engine [`window.py`](console_window/window.py:1) **89%**, [`spinner.py`](console_window/spinner.py:1) **92%**, [`widgets.py`](console_window/widgets.py:1) **86%**, [`screens.py`](console_window/screens.py:1) **83%** |
13
+ | `ruff check .` | clean (`E,F,W,I,B,C4,ANN,RUF,C901`) |
14
+ | `pylint` | **10.00/10** with `C,R` disabled (0 warnings) |
15
+ | Type checking | **mypy** configured for `console_window` and run in CI; clean (14 source files) |
16
+ | CI gates ([`.github/workflows/ci.yml`](.github/workflows/ci.yml:1)) | tests on 3.10-3.13 with `--cov-fail-under=80`; `ruff`, `mypy` and `pylint --disable=C,R` jobs; every tool pinned (`pytest 9.1.1`, `pytest-cov 7.1.0`, `ruff 0.17.0`, `mypy 2.4.0`, `pylint 3.3.4`) |
17
+ | Complexity (ruff `C901`, threshold 12) | worst is [`answer()`](console_window/window.py:1384) at **12**; next are [`parse_fancy_header()`](console_window/header.py:113) and [`perform_actions()`](console_window/screens.py:197) at **11** |
18
+
19
+ Section numbers in older [`CHANGELOG.md`](CHANGELOG.md:1) entries and in source comments refer to earlier revisions of this document.
20
+
21
+ ## 1. Executive summary
22
+
23
+ The library is small, dependency-free, and unusually disciplined for a single-author project: a real module split, a headless render backend, 302 fast TTY-free tests, a green linter and type checker, changelog and release metadata, and a worked example that is itself smoke-tested.
24
+
25
+ There is no data-loss or security defect, and the interactive core is well covered. What is left is a small body of public surface with no consumer outside the test suite; the dialog retry policy is documented as an accepted limitation and every lint finding now states its reason. Highest-risk findings, in order:
26
+
27
+ 1. **Some public API has no consumer.** `ConsoleWindow.set_pick_range()`, `ConsoleWindow.draw()` and the `OptionSpinner.add()` tombstone are reached only from tests (§3.1).
28
+ 2. **The dialog retry policy is class-wide and discards input.** `answer()` spends the shared `render_retry_seconds` budget, and resize recovery drains every queued key. Both are now explicit, documented limitations rather than silent surprises (§3.2).
29
+
30
+ ## 2. Architecture as implemented
31
+
32
+ | Module | Lines | Stmts | Cover | Role |
33
+ | --- | --- | --- | --- | --- |
34
+ | [`window.py`](console_window/window.py:1) | 2193 | 1271 | 89% | The engine: two pads + screen, add/draw/render/prompt/answer/alert/flash, navigation |
35
+ | [`widgets.py`](console_window/widgets.py:1) | 337 | 231 | 86% | `InlineConfirmation`, `IncrementalSearchBar` (pure, no curses drawing) |
36
+ | [`spinner.py`](console_window/spinner.py:1) | 339 | 215 | 92% | `OptionSpinner`: key → option registry, genre inference, scope subtraction, help body |
37
+ | [`screens.py`](console_window/screens.py:1) | 248 | 161 | 83% | `Screen` base, `ScreenStack` router, `HOME_ST`, `BasicHelpScreen` |
38
+ | [`theme.py`](console_window/theme.py:1) | 303 | 100 | 98% | 7 palettes, `set()`/`set_active()`, picker-screen factory |
39
+ | [`textwidth.py`](console_window/textwidth.py:1) | 236 | 167 | 95% | Display-cell arithmetic: width, truncate, justify, wrap, wrap positions |
40
+ | [`header.py`](console_window/header.py:1) | 162 | 110 | 97% | `Span`/`HeaderLine` token API + legacy `[x]`/`KEY:` string parser |
41
+ | [`backend.py`](console_window/backend.py:1) | 282 | 128 | 88% | `Surface`/`RenderBackend` protocols, `CursesBackend`, `HeadlessBackend` |
42
+ | [`geometry.py`](console_window/geometry.py:1) | 82 | 61 | 100% | Pure abut/indicator/pickable-row math |
43
+ | [`opts.py`](console_window/opts.py:1) | 88 | 63 | 100% | `ConsoleWindowOpts` (`__slots__` + kwargs) |
44
+ | [`context.py`](console_window/context.py:1) | 47 | 22 | 100% | Per-row `Context` (`genre`/`pickable`/`abut` + open kwargs) |
45
+ | [`signals.py`](console_window/signals.py:1) | 144 | 63 | 87% | SIGINT policy: captured host handler, per-window setup/teardown, reset |
46
+ | [`keys.py`](console_window/keys.py:1) | 18 | 3 | 100% | `NAVIGATION_KEYS` |
47
+ | [`__init__.py`](console_window/__init__.py:1) | 33 | 12 | 100% | Re-exports + `__all__` |
48
+
49
+ **Frame cycle.** `main_loop` → `screen.draw_screen()` (`clear()` + `add_header*`/`add_body` into the two pads) → `win.render()` → `fix_positions()` (clamp/scroll/pick, returns `indent`) → gutter/separator/indicator → `pnoutrefresh` both pads → `doupdate()` → `win.prompt()` (key read + built-in navigation) → `handle_key()` → `stack.perform_actions(spin)` (convention dispatch of `<attr>_ACTION`) → `clear()`.
50
+
51
+ **Two data models, one window.** Layout/scroll/pick live in `SimpleNamespace`s (`self.head`, `self.body`) with parallel lists (`texts`, `text_attrs` run-length encoded, `contexts`, `row_cnt`); `Row Context` carries per-row semantics. `Screen` subclasses add routing and `*_ACTION` dispatch. `OptionSpinner` is the key/option registry, optionally scope-aware per screen with automatic scope subtraction ([`spinner.py:134`](console_window/spinner.py:134)).
52
+
53
+ **The seam covers the drawing, input and colour paths.** `RenderBackend` abstracts `start`/`newpad`/`doupdate`/`curs_set`/`update_lines_cols`/`is_term_resized`/`has_colors`/`start_color`/`use_default_colors`/`init_pair`/`color_pair`/`stop`, and the dialogs, flash, resize checks and colour init all go through it.
54
+
55
+ **Two window models over that seam.** `ConsoleWindow(singleton=True)` (the default) restores the historical process-wide screen: every instance shares one screen/backend, `static_scr`/`static_win` are populated, and `ConsoleWindow.stop_curses()` tears the process screen down. `singleton=False` keeps the per-instance model: each window owns its screen and teardown, and `stop_all()` is the multi-window hook. The first construction latches the mode process-wide; mixing values asserts.
56
+
57
+ **Current shape.** Docstrings are reserved for public entry points whose call shape needs spelling out, while module- and algorithm-level explanation lives in comments; `_selftest.py` is folded into `SampleApp.py`; the pad-overflow contract is counted, warned about once per fill episode, and enforced in this project's suite; and the package version is single-sourced through `dynamic = ["version"]`.
58
+
59
+ ## 3. Findings
60
+
61
+ ### 3.1 🟡 Unreachable public surface
62
+
63
+ No consumer outside the test suite for:
64
+
65
+ | Symbol | Where | Note |
66
+ | --- | --- | --- |
67
+ | `ConsoleWindow.set_pick_range()` | [`window.py:609`](console_window/window.py:609) | The `pick_range` feature's only public entry point; called only by [`tests/test_interactive.py`](tests/test_interactive.py:434) |
68
+ | `OptionSpinner.add()` | [`spinner.py:163`](console_window/spinner.py:163) | Retired tombstone that raises `NotImplementedError` pointing at `add_key()`; only [`tests/test_spinner.py`](tests/test_spinner.py:259) calls it, to assert the raise |
69
+ | `ConsoleWindow.draw()` | [`window.py:863`](console_window/window.py:863) | Named as a model-writing entry point in [`README.md`](README.md:52), but no library or example code calls it; only tests do ([`tests/test_interactive.py`](tests/test_interactive.py:368), [`tests/test_overflow.py`](tests/test_overflow.py:152), [`tests/test_viewport_pad.py`](tests/test_viewport_pad.py:79), [`tests/test_widechars.py`](tests/test_widechars.py:129)) |
70
+
71
+ `OptionSpinner.add()` keeps its old signature so pre-2.0 call sites fail at the call rather than at import, and its `NotImplementedError` names the replacement call. It is otherwise inert, so the only thing keeping it alive is its own regression test.
72
+
73
+ `draw()` is the inverse case: the README presents it as part of the row-model API while every actual caller is a test, so the documentation promises a path the project never demonstrates.
74
+
75
+ The former fourth row of this table, `SampleApp.do_key()`, has been **removed**: it had no caller at all because `main_loop` dispatches through `handle_key()`, which already forwards spinner keys itself.
76
+
77
+ **Fix:** delete what no consumer needs, or promote it deliberately: give each survivor an exercising caller (the example or a documented snippet) alongside its test. For `OptionSpinner.add()`, either set a removal release or keep it explicitly documented as deprecated.
78
+
79
+ ### 3.2 ✅ `answer()` residual: documented limitations
80
+
81
+ [`answer()`](console_window/window.py:1384) has one unambiguous meaning per outcome (`str` = submitted, `None` = user abort, `TerminalTooSmallError` = environment), and its recovery loop is bounded. Two narrow characteristics remain, and both are now stated where a maintainer will meet them rather than fixed:
82
+
83
+ * **The retry budget is class-wide.** `answer()` spends the shared `render_retry_seconds` budget ([`window.py:1421`](console_window/window.py:1421), declared at [`window.py:160`](console_window/window.py:160)). The budget is read as `ConsoleWindow.render_retry_seconds`, so it applies to `render()` and `answer()` alike and must be tuned on the class; an instance assignment has no effect. This is accepted because the knob already exists, no consumer has asked for a different dialog budget, and a second option would add public surface for a ~2 s recovery policy. The attribute comment in [`window.py`](console_window/window.py:153) spells this out.
84
+ * **Recovery discards queued keys.** [`_recover_from_resize()`](console_window/window.py:1750) drains **every** queued key, not only `KEY_RESIZE` ([`window.py:1767`](console_window/window.py:1767)). This is accepted because curses exposes no push-back API: preserving keys would mean replaying raw codes read after a failed redraw, where duplicates and partial escape sequences are indistinguishable from real input. The loss is bounded by `render_retry_seconds` (keys typed after recovery are read normally), and both the method docstring and [`answer()`](console_window/window.py:1384)'s docstring state the loss explicitly.
85
+
86
+ **Resolution:** documented as accepted limitations; no behaviour change. Revisit only if an app needs an independent dialog budget or faithful key replay, which would justify the extra API and input-buffer plumbing.
87
+
88
+ ### 3.3 ✅ Lint findings now carry rationale
89
+
90
+ `pylint` is **10.00/10** with `C,R` disabled. Each former warning now either states why it is deliberate or has been removed:
91
+
92
+ * **protected access** — the registry helpers explain that the module owns `_registry_key` ([`window.py:97`](console_window/window.py:97), [`window.py:105`](console_window/window.py:105)); the `stop_curses` descriptor explains why it reaches the private implementations ([`window.py:147`](console_window/window.py:147), [`window.py:149`](console_window/window.py:149)); `Theme.set()` owns the internal default-colour step ([`theme.py:204`](console_window/theme.py:204)); the sample resets `filter_bar._text` because `IncrementalSearchBar` exposes only a read-only `text` property ([`SampleApp.py:402`](SampleApp.py:402)).
93
+ * **unused arguments** — `_paint_alert()`'s dead `row9` parameter was removed (both call sites updated). The headless backend's protocol arguments ([`backend.py:255`](console_window/backend.py:255), [`backend.py:283`](console_window/backend.py:283)) and `DemoScreen.__init__`'s protocol `name` ([`SampleApp.py:244`](SampleApp.py:244)) are kept for signature compatibility and say so inline.
94
+ * **not-callable** — [`screens.py:68`](console_window/screens.py:68) carries a `disable-next` noting that pylint cannot see through `getattr` + `callable()`.
95
+ * **broad exceptions** — [`SampleApp.py:149`](SampleApp.py:149) explains the sample-data fallback; [`SampleApp.py:518`](SampleApp.py:518) explains the top-level demo guard that leaves the terminal usable.
96
+
97
+ **Resolution:** resolved; no suppression is unexplained, and `pylint --disable=C,R` is clean.
98
+
99
+ ## 4. Prioritized recommendations
100
+
101
+ **P1 — surface**
102
+
103
+ 1. Delete or promote the unreachable API and the `draw()` documentation mismatch (§3.1). (`SampleApp.do_key()` has already been deleted.)
104
+
105
+ **P2 — hygiene**
106
+
107
+ The §3.2 dialog residuals are closed as documented limitations and the §3.3 lint findings are annotated; reopen either only against a concrete consumer requirement.
108
+
109
+ ## 5. Reproduction
110
+
111
+ ```bash
112
+ python3 -m ruff check .
113
+ python3 -m pytest -q
114
+ python3 -m pytest -q --cov=console_window --cov-fail-under=80 # the CI coverage gate
115
+ python3 -m coverage run --source=console_window -m pytest -q && python3 -m coverage report -m
116
+ python3 -m pylint console_window SampleApp.py runner --disable=C,R
117
+ python3 -m mypy
118
+ python3 -m ruff check --select C901 console_window SampleApp.py # uses the ratchet in pyproject.toml
119
+ python3 -W error::UserWarning -c "import SampleApp" # exit path for the pad-overflow policy
120
+ ```
@@ -6,6 +6,18 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
  ## [Unreleased]
7
7
 
8
8
  ### Added
9
+ - Per-window themes. `ConsoleWindow.theme` records the theme a window is on,
10
+ `ConsoleWindowOpts(theme=...)` seeds it, and `ConsoleWindow.set_theme()`
11
+ switches it: colours are reinstalled through the window's own backend (curses
12
+ keeps one pair table per terminal, so installing a palette still affects the
13
+ shared screen) and that window's pads are repainted. `Theme.set()` remains the
14
+ process-wide switch and now relabels every live window. Startup, `Theme.set()`
15
+ and `set_theme()` install colours from the single `Theme.pair_specs()` list,
16
+ so the three paths cannot drift apart (AUDIT.md §3.1).
17
+ - `reset_ctrl_c()` (also exported from the package root) restores the SIGINT
18
+ handler that was installed before the first window and clears the Ctrl-C
19
+ policy latch, for embedding applications that manage SIGINT themselves
20
+ (AUDIT.md §3.1).
9
21
  - `mypy` type checking for the library: `[tool.mypy]` in `pyproject.toml`
10
22
  checks `console_window` (`files`, `ignore_missing_imports`,
11
23
  `warn_unused_ignores`, `disallow_untyped_defs = false`) and a new `types` job
@@ -38,6 +50,49 @@ This project follows [Semantic Versioning](https://semver.org/).
38
50
  smoke-tested in CI (AUDIT.md §3.2).
39
51
 
40
52
  ### Changed
53
+ - Every `pylint` warning now states why it is deliberate (AUDIT.md §3.3), taking
54
+ `pylint --disable=C,R` from 9.94/10 to **10.00/10**. Protected-access and
55
+ protocol-signature findings carry scoped disables with an adjacent reason,
56
+ `_paint_alert()`'s genuinely unused `row9` parameter was removed, and the
57
+ `getattr` + `callable()` call site uses `disable-next`. No runtime behaviour
58
+ changed.
59
+ - The example's application class is now `SampleApp` (was `DemoApp`), matching
60
+ the module filename and the package name. The screen base class keeps its
61
+ `DemoScreen` name; only the app class and its construction site changed.
62
+ - `answer()`'s recovery policy is now documented rather than implicit
63
+ (AUDIT.md §3.2): the retry loop deliberately shares the class-level
64
+ `render_retry_seconds`/`render_retry_interval` budget with `render()`, and
65
+ `_recover_from_resize()` deliberately discards every queued key. The budget
66
+ is read on the class, so tuning it on one instance has no effect, and keys
67
+ typed during the bounded recovery window are lost because curses offers no
68
+ push-back API. Both points are stated on the attributes, on `answer()` and on
69
+ the recovery method; no behaviour changed.
70
+ - The SIGINT policy captures the handler that was installed before the library
71
+ first took the signal over instead of assuming `signal.default_int_handler`,
72
+ and restores it when the last live window stops rather than on the first
73
+ teardown, so one of several windows stopping cannot disarm the others.
74
+ Stopping every window ends the latch, so a later window can make Ctrl-C an
75
+ application key again; the latch still prevents a non-terminating window from
76
+ swallowing Ctrl-C while a terminating one is live (AUDIT.md §3.1).
77
+ - The sample app's `THEMES` screen is now generated by
78
+ `Theme.create_picker_screen()` instead of duplicating the factory's header and
79
+ colour swatches. The generated screen applies the host's `opts.theme` before
80
+ drawing (so an ordinary `OptionSpinner` cycle binding keeps working) and
81
+ `spin_theme_ACTION()` starts from that option when present. The factory now has
82
+ direct tests in `tests/test_theme.py` (AUDIT.md §3.3).
83
+ - Body and header pads are now viewport-sized blit buffers rather than content
84
+ stores. `add_body()`, `add_header()` and `draw()` write the model only, and
85
+ `render()` paints the visible page into pad row 0, so pad memory and the
86
+ per-frame `clear()` cost track the terminal instead of the highest row count
87
+ an app ever reached. `opts.body_rows`/`opts.head_rows` are retained for
88
+ compatibility but are now **advisory hints only**: no row is discarded for
89
+ exceeding them. The single ceiling is the new `opts.max_rows` (default
90
+ `32767`, `None` for unlimited), which is what `rows_dropped` and its warning
91
+ report. Apps no longer have to pre-size storage for the largest list they
92
+ might show. `draw()` keeps its content-row coordinates (a row written
93
+ off-screen appears when scrolled to) and records its span so the attribute it
94
+ was given survives the deferred paint. Pick highlighting is applied while
95
+ painting, so it follows the viewport when scrolling without moving the pick.
41
96
  - The package version now has a single source of truth: `pyproject.toml`
42
97
  declares `dynamic = ["version"]` and flit reads `__version__` from
43
98
  `console_window/__init__.py` (AUDIT.md §3.14). The stale manual-build recipe
@@ -60,7 +115,36 @@ This project follows [Semantic Versioning](https://semver.org/).
60
115
  (`tests/test_sample_app.py`) to match. The classes inside are still named
61
116
  `DemoApp`/`DemoScreen`.
62
117
 
118
+ - `OptionSpinner.add()` is now a retired tombstone. Its comment handling sliced
119
+ the truncated value list with the pre-truncation index, so `comments` was
120
+ always empty; rather than register a half-configured option it raises
121
+ `NotImplementedError` naming `add_key()` and showing the replacement call.
122
+ No in-repo code used it.
123
+
63
124
  ### Fixed
125
+ - `draw()` now routes rows at or past the `max_rows` content ceiling through the
126
+ same `rows_dropped` counter and once-per-fill warning as `add_body()` /
127
+ `add_header()`, with the warning attributed to the caller's line. Previously
128
+ an over-ceiling `draw()` returned silently while the rest of the overflow
129
+ contract reported nothing. The stale half of the audit finding (phantom rows,
130
+ missing contexts, lost attributes) had already been fixed by the
131
+ viewport-sized-pad model and is now documented in AUDIT.md §3.2.
132
+ - `ScreenStack.act_in()` no longer raises `AttributeError` when the requested
133
+ action flag is absent; it returns `False` and leaves the options object
134
+ untouched. Its one-shot, current-screen semantics are now covered by
135
+ `tests/test_screens.py` and documented in the README (AUDIT.md §3.3).
136
+ - `BasicHelpScreen.draw_screen()` resolves the window from `self.app.win` at
137
+ draw time instead of using the `app.win` value cached by `Screen.__init__`.
138
+ Applications that build their `Screen` objects before their `ConsoleWindow`
139
+ (including `SampleApp`) then get a working help screen instead of
140
+ `AttributeError: 'NoneType' object has no attribute 'set_pick_mode'` when
141
+ mixing `BasicHelpScreen` into an existing screen class.
142
+ - `add_fancy_header()` now treats `HeaderLine.bracket_key()` delimiters as
143
+ markup in every mode except `'Off'`: the brackets are removed and the
144
+ enclosed span keeps its `KEY` styling. Previously a `HeaderLine` was emitted
145
+ verbatim, so it rendered identically to `add_header_line()`. The explicit
146
+ path still shows literal brackets, and declared span roles are preserved
147
+ rather than re-parsed from the rendered text.
64
148
  - Navigation keys are responsive again. Two 2.0.0 hot paths made a full
65
149
  redraw of a large list far slower than 1.4.3. `textwidth.truncate()` ran
66
150
  three `unicodedata` lookups per character on every row, and
@@ -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.4
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`.
@@ -106,7 +106,11 @@ demonstrates:
106
106
  * **`InlineConfirmation`** — `SPACE` asks you to confirm with `y` before
107
107
  "processing" the picked row.
108
108
  * **Themes** — a `THEMES` screen of colour samples backed by
109
- `Theme.list_all()` / `get_current()` / `set()`.
109
+ `Theme.list_all()` / `get_current()` / `set()`. The active theme is now
110
+ per-window state: `ConsoleWindowOpts(theme=...)` seeds `window.theme`,
111
+ `window.set_theme()` switches it (and re-initialises the terminal's colour
112
+ pairs, which curses keeps per terminal rather than per window), and
113
+ `Theme.set()` remains the process-wide switch.
110
114
  * **Dialogs** — `a` calls `answer()` for text input, `l` calls `alert()`, and `n`
111
115
  drives `answer()` from an `OptionSpinner` option (`add_key(..., prompt=...)`).
112
116
  `answer()` returns `None` only when the user aborts; if the terminal cannot
@@ -116,18 +120,21 @@ demonstrates:
116
120
  * **Token-based headers** — `HeaderLine` (`text`, `bracket_key`, `key`, `extend`)
117
121
  rendered with `add_header_line()`, rather than string markup.
118
122
  * **`Ctrl-C` as an application key** — `ctrl_c_terminates=False` and
119
- `keys={0x3, ord('q')}` on the quit action. This works only while no window in
120
- the process has requested terminating Ctrl-C: once any window uses the default
121
- (`ctrl_c_terminates=True`), terminating Ctrl-C is latched for the process
122
- lifetime and cannot be undone.
123
+ `keys={0x3, ord('q')}` on the quit action. This works only while no live
124
+ window in the process has requested terminating Ctrl-C: once any window uses
125
+ the default (`ctrl_c_terminates=True`), terminating Ctrl-C is latched for as
126
+ long as a window depends on it. The handler that was installed before the
127
+ first window is captured and restored when the last window stops, and
128
+ `reset_ctrl_c()` does the same deliberately for an embedding application that
129
+ manages SIGINT itself.
123
130
  * **Full-repaint recovery** — `r` toggles `render(redraw=True)`, the path that
124
131
  repaints after terminal corruption.
125
132
  * **Multi-row picking** — `p` sets `pick_size` (1–3) via
126
133
  `set_pick_mode(True, size)`.
127
134
  * **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`.
135
+ long bodies and the scroll indicator. No option is needed for them: the pads
136
+ stay viewport-sized, memory and `clear()` cost do not grow with the row count,
137
+ and the default `max_rows` ceiling is far above 2000.
131
138
 
132
139
  ### Running it
133
140
 
@@ -145,7 +152,8 @@ from console_window import ConsoleWindow, ConsoleWindowOpts
145
152
 
146
153
  def main() -> None:
147
154
  # ConsoleWindow.__init__ performs the curses setup (no curses.wrapper needed).
148
- win = ConsoleWindow(opts=ConsoleWindowOpts(head_line=True, body_rows=200))
155
+ # No row sizing is required; max_rows is only a safety ceiling.
156
+ win = ConsoleWindow(opts=ConsoleWindowOpts(head_line=True))
149
157
  try:
150
158
  while True:
151
159
  win.clear()
@@ -168,6 +176,14 @@ For anything beyond a single list, subclass `Screen` and drive it with
168
176
  `ScreenStack` — that is what the sample app does, and it is the paradigm the
169
177
  library is designed around.
170
178
 
179
+ `ScreenStack.perform_actions()` is the normal router: it walks the spinner
180
+ options, honours their scopes, and calls the matching `<attr>_ACTION` method.
181
+ For apps that dispatch an action themselves, `ScreenStack.act_in(action,
182
+ screens=None)` is the low-level single-flag variant: it clears that flag from
183
+ `stack.obj` and returns whether it was set and the current screen matched.
184
+ `ScreenStack.is_curr()` answers the screen test on its own. A missing flag reads
185
+ as `False`, so an options object need not pre-seed every action.
186
+
171
187
  ### Singleton vs. per-instance windows
172
188
 
173
189
  `ConsoleWindow` supports two process-wide models, selected by the constructor's
@@ -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`.
@@ -87,7 +87,11 @@ demonstrates:
87
87
  * **`InlineConfirmation`** — `SPACE` asks you to confirm with `y` before
88
88
  "processing" the picked row.
89
89
  * **Themes** — a `THEMES` screen of colour samples backed by
90
- `Theme.list_all()` / `get_current()` / `set()`.
90
+ `Theme.list_all()` / `get_current()` / `set()`. The active theme is now
91
+ per-window state: `ConsoleWindowOpts(theme=...)` seeds `window.theme`,
92
+ `window.set_theme()` switches it (and re-initialises the terminal's colour
93
+ pairs, which curses keeps per terminal rather than per window), and
94
+ `Theme.set()` remains the process-wide switch.
91
95
  * **Dialogs** — `a` calls `answer()` for text input, `l` calls `alert()`, and `n`
92
96
  drives `answer()` from an `OptionSpinner` option (`add_key(..., prompt=...)`).
93
97
  `answer()` returns `None` only when the user aborts; if the terminal cannot
@@ -97,18 +101,21 @@ demonstrates:
97
101
  * **Token-based headers** — `HeaderLine` (`text`, `bracket_key`, `key`, `extend`)
98
102
  rendered with `add_header_line()`, rather than string markup.
99
103
  * **`Ctrl-C` as an application key** — `ctrl_c_terminates=False` and
100
- `keys={0x3, ord('q')}` on the quit action. This works only while no window in
101
- the process has requested terminating Ctrl-C: once any window uses the default
102
- (`ctrl_c_terminates=True`), terminating Ctrl-C is latched for the process
103
- lifetime and cannot be undone.
104
+ `keys={0x3, ord('q')}` on the quit action. This works only while no live
105
+ window in the process has requested terminating Ctrl-C: once any window uses
106
+ the default (`ctrl_c_terminates=True`), terminating Ctrl-C is latched for as
107
+ long as a window depends on it. The handler that was installed before the
108
+ first window is captured and restored when the last window stops, and
109
+ `reset_ctrl_c()` does the same deliberately for an embedding application that
110
+ manages SIGINT itself.
104
111
  * **Full-repaint recovery** — `r` toggles `render(redraw=True)`, the path that
105
112
  repaints after terminal corruption.
106
113
  * **Multi-row picking** — `p` sets `pick_size` (1–3) via
107
114
  `set_pick_mode(True, size)`.
108
115
  * **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`.
116
+ long bodies and the scroll indicator. No option is needed for them: the pads
117
+ stay viewport-sized, memory and `clear()` cost do not grow with the row count,
118
+ and the default `max_rows` ceiling is far above 2000.
112
119
 
113
120
  ### Running it
114
121
 
@@ -126,7 +133,8 @@ from console_window import ConsoleWindow, ConsoleWindowOpts
126
133
 
127
134
  def main() -> None:
128
135
  # ConsoleWindow.__init__ performs the curses setup (no curses.wrapper needed).
129
- win = ConsoleWindow(opts=ConsoleWindowOpts(head_line=True, body_rows=200))
136
+ # No row sizing is required; max_rows is only a safety ceiling.
137
+ win = ConsoleWindow(opts=ConsoleWindowOpts(head_line=True))
130
138
  try:
131
139
  while True:
132
140
  win.clear()
@@ -149,6 +157,14 @@ For anything beyond a single list, subclass `Screen` and drive it with
149
157
  `ScreenStack` — that is what the sample app does, and it is the paradigm the
150
158
  library is designed around.
151
159
 
160
+ `ScreenStack.perform_actions()` is the normal router: it walks the spinner
161
+ options, honours their scopes, and calls the matching `<attr>_ACTION` method.
162
+ For apps that dispatch an action themselves, `ScreenStack.act_in(action,
163
+ screens=None)` is the low-level single-flag variant: it clears that flag from
164
+ `stack.obj` and returns whether it was set and the current screen matched.
165
+ `ScreenStack.is_curr()` answers the screen test on its own. A missing flag reads
166
+ as `False`, so an options object need not pre-seed every action.
167
+
152
168
  ### Singleton vs. per-instance windows
153
169
 
154
170
  `ConsoleWindow` supports two process-wide models, selected by the constructor's