console-window 2.0.0.3__tar.gz → 2.0.0.5__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.3 → console_window-2.0.0.5}/.github/workflows/ci.yml +21 -7
  2. console_window-2.0.0.5/AUDIT.md +120 -0
  3. {console_window-2.0.0.3 → console_window-2.0.0.5}/CHANGELOG.md +74 -0
  4. {console_window-2.0.0.3 → console_window-2.0.0.5}/PKG-INFO +22 -8
  5. {console_window-2.0.0.3 → console_window-2.0.0.5}/README.md +21 -7
  6. {console_window-2.0.0.3 → console_window-2.0.0.5}/SampleApp.py +64 -72
  7. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/__init__.py +3 -1
  8. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/backend.py +5 -2
  9. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/header.py +36 -0
  10. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/opts.py +6 -1
  11. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/screens.py +19 -4
  12. console_window-2.0.0.5/console_window/signals.py +144 -0
  13. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/spinner.py +18 -12
  14. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/theme.py +98 -22
  15. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/window.py +155 -54
  16. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_header.py +83 -0
  17. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_interactive.py +5 -2
  18. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_overflow.py +31 -0
  19. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_public_api.py +1 -0
  20. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_sample_app.py +2 -2
  21. console_window-2.0.0.5/tests/test_screens.py +62 -0
  22. console_window-2.0.0.5/tests/test_signals.py +166 -0
  23. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_spinner.py +8 -0
  24. console_window-2.0.0.5/tests/test_theme.py +207 -0
  25. console_window-2.0.0.3/AUDIT.md +0 -152
  26. console_window-2.0.0.3/console_window/signals.py +0 -49
  27. console_window-2.0.0.3/tests/test_signals.py +0 -71
  28. console_window-2.0.0.3/tests/test_theme.py +0 -72
  29. {console_window-2.0.0.3 → console_window-2.0.0.5}/.gitignore +0 -0
  30. {console_window-2.0.0.3 → console_window-2.0.0.5}/LICENSE +0 -0
  31. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/context.py +0 -0
  32. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/geometry.py +0 -0
  33. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/keys.py +0 -0
  34. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/textwidth.py +0 -0
  35. {console_window-2.0.0.3 → console_window-2.0.0.5}/console_window/widgets.py +0 -0
  36. {console_window-2.0.0.3 → console_window-2.0.0.5}/pyproject.toml +0 -0
  37. {console_window-2.0.0.3 → console_window-2.0.0.5}/runner +0 -0
  38. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_backend.py +0 -0
  39. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_context.py +0 -0
  40. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_demo_mode.py +0 -0
  41. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_dialogs.py +0 -0
  42. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_flash.py +0 -0
  43. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_geometry.py +0 -0
  44. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_render_retry.py +0 -0
  45. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_textwidth.py +0 -0
  46. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_viewport_pad.py +0 -0
  47. {console_window-2.0.0.3 → console_window-2.0.0.5}/tests/test_widechars.py +0 -0
  48. {console_window-2.0.0.3 → console_window-2.0.0.5}/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,36 @@ 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).
41
83
  - Body and header pads are now viewport-sized blit buffers rather than content
42
84
  stores. `add_body()`, `add_header()` and `draw()` write the model only, and
43
85
  `render()` paints the visible page into pad row 0, so pad memory and the
@@ -73,7 +115,36 @@ This project follows [Semantic Versioning](https://semver.org/).
73
115
  (`tests/test_sample_app.py`) to match. The classes inside are still named
74
116
  `DemoApp`/`DemoScreen`.
75
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
+
76
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.
77
148
  - Navigation keys are responsive again. Two 2.0.0 hot paths made a full
78
149
  redraw of a large list far slower than 1.4.3. `textwidth.truncate()` ran
79
150
  three `unicodedata` lookups per character on every row, and
@@ -169,6 +240,9 @@ This project follows [Semantic Versioning](https://semver.org/).
169
240
  and that both init paths agree (AUDIT.md §3.9).
170
241
 
171
242
  ### Removed
243
+ - `SampleApp.do_key()`, a dead method with no caller: `main_loop` dispatches
244
+ through `handle_key()`, which already forwards spinner keys to
245
+ `spin.do_key()`. No behaviour changed (AUDIT.md §3.1).
172
246
  - `console_window/_selftest.py`, the TTY-only dev self-test. The old
173
247
  `python -m console_window._selftest` entry point is gone; the features it
174
248
  demonstrated — multi-row picking, a prompt-style option, forced
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 2.0.0.3
3
+ Version: 2.0.0.5
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
@@ -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,10 +120,13 @@ 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
@@ -169,6 +176,14 @@ For anything beyond a single list, subclass `Screen` and drive it with
169
176
  `ScreenStack` — that is what the sample app does, and it is the paradigm the
170
177
  library is designed around.
171
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
+
172
187
  ### Singleton vs. per-instance windows
173
188
 
174
189
  `ConsoleWindow` supports two process-wide models, selected by the constructor's
@@ -200,8 +215,7 @@ Deprecated APIs remain supported **indefinitely**. A deprecation is a request to
200
215
  migrate, not a scheduled removal: deprecated functions and parameters keep
201
216
  working, and one is removed only when there is significant value in doing so
202
217
  (for example, the API is actively harmful or blocks a necessary redesign).
203
- Deprecated paths emit a `DeprecationWarning` where practical, and every removal
204
- is recorded in [`CHANGELOG.md`](CHANGELOG.md:1).
218
+ Deprecated paths emit a `DeprecationWarning` where practical.
205
219
 
206
220
  ## License
207
221
  MIT
@@ -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,10 +101,13 @@ 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
@@ -150,6 +157,14 @@ For anything beyond a single list, subclass `Screen` and drive it with
150
157
  `ScreenStack` — that is what the sample app does, and it is the paradigm the
151
158
  library is designed around.
152
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
+
153
168
  ### Singleton vs. per-instance windows
154
169
 
155
170
  `ConsoleWindow` supports two process-wide models, selected by the constructor's
@@ -181,8 +196,7 @@ Deprecated APIs remain supported **indefinitely**. A deprecation is a request to
181
196
  migrate, not a scheduled removal: deprecated functions and parameters keep
182
197
  working, and one is removed only when there is significant value in doing so
183
198
  (for example, the API is actively harmful or blocks a necessary redesign).
184
- Deprecated paths emit a `DeprecationWarning` where practical, and every removal
185
- is recorded in [`CHANGELOG.md`](CHANGELOG.md:1).
199
+ Deprecated paths emit a `DeprecationWarning` where practical.
186
200
 
187
201
  ## License
188
202
  MIT
@@ -8,6 +8,7 @@ import traceback
8
8
  from types import SimpleNamespace
9
9
 
10
10
  from console_window import (
11
+ BasicHelpScreen,
11
12
  ConsoleWindow,
12
13
  ConsoleWindowOpts,
13
14
  Context,
@@ -28,7 +29,7 @@ THEME_ST = 3
28
29
  SCREEN_NAMES = ('MAIN', 'HELP', 'HISTORY', 'THEMES')
29
30
 
30
31
 
31
- class DemoApp:
32
+ class SampleApp:
32
33
  def __init__(self, backend=None, singleton=True):
33
34
  self.win = None
34
35
  self.spin = None
@@ -108,6 +109,7 @@ class DemoApp:
108
109
  genre='action', scope={MAIN_ST})
109
110
  spin.add_key('alert_demo', 'l - alert demo',
110
111
  genre='action', scope={MAIN_ST})
112
+ spin.add_key('show_keys', 'K - show keys (demo mode)', genre='action')
111
113
 
112
114
  # Folded in from the retired dev self-test.
113
115
  # Scoped to MAIN and HELP, like view_mode/sort_by, so the keys keep
@@ -144,8 +146,10 @@ class DemoApp:
144
146
  'home': home,
145
147
  'line': line.strip()
146
148
  })
147
- except Exception:
148
- # Fallback sample data
149
+ except Exception: # pylint: disable=broad-exception-caught
150
+ # The example must run on any host: a malformed/absent passwd file
151
+ # is not an error worth aborting the demo for, so fall back to the
152
+ # fixed sample rows below.
149
153
  self.items = [
150
154
  {'username': 'demo1', 'uid': '1001', 'home': '/home/demo1',
151
155
  'line': 'demo1:x:1001:1001::/home/demo1:/bin/bash'},
@@ -213,13 +217,6 @@ class DemoApp:
213
217
  if self.filter_text in item['username'].lower() or
214
218
  self.filter_text in item['home'].lower()]
215
219
 
216
- def do_key(self, key):
217
- if not key:
218
- return
219
- # handle Inline confirmation / filter
220
- if key in self.spin.keys:
221
- self.spin.do_key(key, self.win)
222
-
223
220
  def main_loop(self):
224
221
  while True:
225
222
  current_screen = self.screens[self.stack.curr.num]
@@ -235,9 +232,11 @@ class DemoApp:
235
232
 
236
233
 
237
234
  class DemoScreen(Screen):
238
- app: DemoApp
235
+ app: SampleApp
239
236
 
240
- def __init__(self, name, app):
237
+ def __init__(self, name, app): # pylint: disable=unused-argument
238
+ # ``name`` is part of the screen protocol (ThemeScreen passes 'THEMES'),
239
+ # but Screen identifies a screen by its stack number, not a label.
241
240
  super().__init__(app)
242
241
  self.app = app
243
242
 
@@ -262,6 +261,7 @@ class DemoScreen(Screen):
262
261
  sys.exit(0)
263
262
 
264
263
  class MainScreen(DemoScreen):
264
+ header_cnt = 0
265
265
  def __init__(self, app):
266
266
  super().__init__('MAIN', app)
267
267
 
@@ -337,20 +337,39 @@ class MainScreen(DemoScreen):
337
337
  else:
338
338
  line.text('/')
339
339
 
340
- # Show spinner keys
340
+ header_test = self.header_cnt % 3
341
+ self.header_cnt += 1
342
+
343
+ # Show spinner keys.
344
+ #
345
+ # Every key below is spelled with bracket_key(), because the third
346
+ # strategy round-trips the line through str() and the legacy string
347
+ # scanner, which only knows two KEY spellings: '[x]' (exactly one
348
+ # character between brackets) and 'UPPERCASE:' (one or more capitals
349
+ # followed by a colon). An unbracketed key('l') or key('?') keeps its
350
+ # KEY role in the token paths, but str(line) is just text: 'l:alrtDemo'
351
+ # and '?:help' fail both legacy rules, so the re-parse would render the
352
+ # same characters with no styling. Bracket-spelling them makes all
353
+ # three strategies produce the same runs. (key('SPC') is fine: it is
354
+ # the 'UPPERCASE:' form.)
341
355
  line.text(' ').bracket_key('v').text(f'iew={app.opts.view_mode}')
342
356
  line.text(' ').bracket_key('s').text(f'ort={app.opts.sort_by}')
343
357
  line.text(' ').bracket_key('a').text('nsDemo')
344
- line.text(' ').key('l').text(':alrtDemo')
358
+ line.text(' ').bracket_key('l').text(':alrtDemo')
345
359
  line.text(' ').key('SPC').text(':process')
346
- line.text(' ').bracket_key('p').text(f'ick={app.opts.pick_size}')
360
+ # line.text(' ').bracket_key('p').text(f'ick={app.opts.pick_size}')
347
361
  line.text(' ').bracket_key('n').text(f'ame={app.opts.name or "-"}')
348
- line.text(' ').key('r').text('edraw')
349
- line.text(' ').key('z').text(':stress')
350
- line.text(' ').bracket_key('t').text('hemes')
351
- line.text(' ').bracket_key('h').text('ist')
352
- line.text(' ').key('?').text(':help')
353
- app.win.add_header_line(line, mode='Underline')
362
+ # line.text(' ').key('r').text('edraw')
363
+ # line.text(' ').key('z').text(':stress')
364
+ # line.text(' ').bracket_key('t').text('hemes')
365
+ # line.text(' ').bracket_key('h').text('ist')
366
+ line.text(' ').bracket_key('?').text(':help')
367
+ if header_test == 0: # tokens, emitted verbatim (brackets are literal)
368
+ app.win.add_header_line(line, mode='Underline')
369
+ elif header_test == 1: # tokens, brackets treated as markup only
370
+ app.win.add_fancy_header(line, mode='Underline')
371
+ else: # str(line) re-parsed by the legacy scanner (lossy by design)
372
+ app.win.add_fancy_header(str(line), mode='Underline')
354
373
 
355
374
  # Column headers
356
375
  view_mode = app.opts.view_mode
@@ -370,7 +389,10 @@ class MainScreen(DemoScreen):
370
389
 
371
390
  def clear_filter_ACTION(self):
372
391
  app = self.app
373
- app.filter_bar._text = ''
392
+ # IncrementalSearchBar exposes only a read-only ``text`` property, so
393
+ # the example resets the buffer directly rather than growing the widget
394
+ # API for a single demo action.
395
+ app.filter_bar._text = '' # pylint: disable=protected-access
374
396
  app.filter_text = ''
375
397
  app.win.pick_pos = 0
376
398
 
@@ -425,20 +447,24 @@ class MainScreen(DemoScreen):
425
447
  win = app.win
426
448
  app.stack.push(THEME_ST, win.pick_pos)
427
449
 
450
+ def show_keys_ACTION(self):
451
+ """ Show last key for demo"""
452
+ self.app.win.set_demo_mode(enabled=None) # toggle it
453
+
428
454
 
429
- class HelpScreen(DemoScreen):
455
+ class HelpScreen(DemoScreen, BasicHelpScreen):
430
456
  def __init__(self, app):
431
457
  super().__init__('HELP', app)
432
458
 
433
- def draw_screen(self):
434
- app = self.app
435
- app.win.set_pick_mode(False)
459
+ # def draw_screen(self):
460
+ # app = self.app
461
+ # app.win.set_pick_mode(False)
436
462
 
437
- spinner = self.get_spinner()
438
- if spinner:
439
- # Use built-in help display
440
- spinner.show_help_nav_keys(app.win)
441
- spinner.show_help_body(app.win)
463
+ # spinner = self.get_spinner()
464
+ # if spinner:
465
+ # # Use built-in help display
466
+ # spinner.show_help_nav_keys(app.win)
467
+ # spinner.show_help_body(app.win)
442
468
 
443
469
 
444
470
  class HistoryScreen(DemoScreen):
@@ -472,53 +498,19 @@ class HistoryScreen(DemoScreen):
472
498
  'SPACE on an item to process it.')
473
499
 
474
500
 
475
- class ThemeScreen(DemoScreen):
501
+ class ThemeScreen(Theme.create_picker_screen(DemoScreen)):
502
+ """THEMES screen supplied by Theme.create_picker_screen()."""
503
+
476
504
  def __init__(self, app):
477
505
  super().__init__('THEMES', app)
478
506
 
479
- def draw_screen(self):
480
- app = self.app
481
- win = app.win
482
- win.set_pick_mode(False)
483
- if app.opts.theme != Theme.get_current():
484
- Theme.set(app.opts.theme)
485
-
486
- theme = app.opts.theme
487
- win.add_header(f'COLOR THEMES: [t]heme={theme}',
488
- attr=cs.A_BOLD)
489
- win.add_header(' ESC:return', attr=cs.A_DIM, resume=True)
490
-
491
- win.add_body('Color samples:')
492
- win.add_body('')
493
-
494
- # Color samples
495
- color_samples = [
496
- (Theme.DANGER, 'DANGER', 'Destructive operations'),
497
- (Theme.SUCCESS, 'SUCCESS', 'Completed operations'),
498
- (Theme.WARNING, 'WARNING', 'Warnings and cautions'),
499
- (Theme.INFO, 'INFO', 'Informational messages'),
500
- (Theme.EMPHASIS, 'EMPHASIS', 'Emphasized text'),
501
- (Theme.ERROR, 'ERROR', 'Error messages'),
502
- (Theme.PROGRESS, 'PROGRESS', 'Progress indicators'),
503
- ]
504
-
505
- for color_id, label, description in color_samples:
506
- line = f' {label:<12} ████████ {description}'
507
- win.add_body(line, attr=cs.color_pair(color_id))
508
-
509
- def theme_screen(self):
510
- themes = Theme.list_all()
511
- current = Theme.get_current()
512
- idx = themes.index(current) if current in themes else -1
513
- next_theme = themes[(idx + 1) % len(themes)]
514
- Theme.set(next_theme)
515
-
516
507
  def main(backend=None, singleton=True):
517
508
  try:
518
- app = DemoApp(backend=backend, singleton=singleton)
509
+ app = SampleApp(backend=backend, singleton=singleton)
519
510
  app.main_loop()
520
- except Exception as bummer:
521
- # Leave the terminal usable no matter how we failed.
511
+ except Exception as bummer: # pylint: disable=broad-exception-caught
512
+ # Top-level demo guard: no matter how the app failed, leave the terminal
513
+ # usable and show the error instead of dying with a broken screen.
522
514
  win = ConsoleWindow.primary_window()
523
515
  if win:
524
516
  win.stop_curses()
@@ -1,12 +1,13 @@
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"
3
+ __version__ = "2.0.0.5"
4
4
 
5
5
  from .context import Context
6
6
  from .header import HeaderLine, Span
7
7
  from .keys import NAVIGATION_KEYS
8
8
  from .opts import ConsoleWindowOpts
9
9
  from .screens import HOME_ST, BasicHelpScreen, Screen, ScreenStack
10
+ from .signals import reset_ctrl_c
10
11
  from .spinner import OptionSpinner
11
12
  from .theme import Theme
12
13
  from .widgets import IncrementalSearchBar, InlineConfirmation
@@ -28,4 +29,5 @@ __all__ = [
28
29
  'Span',
29
30
  'TerminalTooSmallError',
30
31
  'Theme',
32
+ 'reset_ctrl_c',
31
33
  ]