console-window 1.4.2__tar.gz → 2.0.0__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 (47) hide show
  1. console_window-2.0.0/.github/workflows/ci.yml +52 -0
  2. console_window-2.0.0/AUDIT.md +152 -0
  3. console_window-2.0.0/CHANGELOG.md +285 -0
  4. console_window-2.0.0/PKG-INFO +245 -0
  5. console_window-2.0.0/README.md +225 -0
  6. console_window-1.4.2/ConsoleWindowDemo.py → console_window-2.0.0/SampleApp.py +84 -111
  7. console_window-2.0.0/console_window/__init__.py +31 -0
  8. console_window-2.0.0/console_window/backend.py +279 -0
  9. console_window-2.0.0/console_window/context.py +37 -0
  10. console_window-2.0.0/console_window/geometry.py +82 -0
  11. console_window-2.0.0/console_window/header.py +126 -0
  12. console_window-2.0.0/console_window/keys.py +18 -0
  13. console_window-2.0.0/console_window/opts.py +78 -0
  14. console_window-2.0.0/console_window/screens.py +234 -0
  15. console_window-2.0.0/console_window/signals.py +49 -0
  16. console_window-2.0.0/console_window/spinner.py +333 -0
  17. console_window-2.0.0/console_window/textwidth.py +170 -0
  18. console_window-2.0.0/console_window/theme.py +231 -0
  19. console_window-2.0.0/console_window/widgets.py +337 -0
  20. console_window-2.0.0/console_window/window.py +1983 -0
  21. console_window-2.0.0/pyproject.toml +85 -0
  22. console_window-2.0.0/runner +13 -0
  23. console_window-2.0.0/tests/test_backend.py +315 -0
  24. console_window-2.0.0/tests/test_context.py +45 -0
  25. console_window-2.0.0/tests/test_demo_mode.py +46 -0
  26. console_window-2.0.0/tests/test_dialogs.py +177 -0
  27. console_window-2.0.0/tests/test_flash.py +130 -0
  28. console_window-2.0.0/tests/test_geometry.py +116 -0
  29. console_window-2.0.0/tests/test_header.py +172 -0
  30. console_window-2.0.0/tests/test_interactive.py +663 -0
  31. console_window-2.0.0/tests/test_overflow.py +114 -0
  32. console_window-2.0.0/tests/test_public_api.py +127 -0
  33. console_window-2.0.0/tests/test_render_retry.py +88 -0
  34. console_window-2.0.0/tests/test_sample_app.py +123 -0
  35. console_window-2.0.0/tests/test_signals.py +71 -0
  36. console_window-2.0.0/tests/test_spinner.py +268 -0
  37. console_window-2.0.0/tests/test_textwidth.py +143 -0
  38. console_window-2.0.0/tests/test_theme.py +72 -0
  39. console_window-2.0.0/tests/test_widechars.py +191 -0
  40. console_window-2.0.0/tests/test_widgets.py +184 -0
  41. console_window-1.4.2/PKG-INFO +0 -133
  42. console_window-1.4.2/README.md +0 -117
  43. console_window-1.4.2/console_window/ConsoleWindow.py +0 -3808
  44. console_window-1.4.2/console_window/__init__.py +0 -14
  45. console_window-1.4.2/pyproject.toml +0 -47
  46. {console_window-1.4.2 → console_window-2.0.0}/.gitignore +0 -0
  47. {console_window-1.4.2 → console_window-2.0.0}/LICENSE +0 -0
@@ -0,0 +1,52 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ name: tests (Python ${{ matrix.python-version }})
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
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
25
+
26
+ lint:
27
+ name: ruff
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+ - name: Set up Python
32
+ uses: actions/setup-python@v5
33
+ with:
34
+ python-version: "3.x"
35
+ - name: Install ruff
36
+ run: python -m pip install ruff
37
+ - name: Lint
38
+ run: ruff check .
39
+
40
+ types:
41
+ name: mypy
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ - name: Set up Python
46
+ uses: actions/setup-python@v5
47
+ with:
48
+ python-version: "3.x"
49
+ - name: Install mypy
50
+ run: python -m pip install mypy
51
+ - name: Type check
52
+ run: python -m mypy
@@ -0,0 +1,152 @@
1
+ # `console-window` — Code & Design Audit
2
+
3
+ **Audited:** 2026-10-15 (`368304d`); **refreshed:** 2026-10-18 (`15125a8`) after removing every finding that has since been resolved. Section references inside older `CHANGELOG.md` entries point at the pre-refresh numbering.
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 | 3,988 lines / 2,391 statements across 14 modules; stdlib-only, POSIX curses |
10
+ | Example | [`SampleApp.py`](SampleApp.py:1) 532 lines; [`runner`](runner:1) 13 lines |
11
+ | Tests | 268 tests, ~3,045 lines in 18 files — **all pass** (1.5 s) |
12
+ | Coverage (`--source=console_window`) | **87%** overall; engine [`window.py`](console_window/window.py:1) **88%**, [`theme.py`](console_window/theme.py:1) **69%**, [`screens.py`](console_window/screens.py:1) **67%** |
13
+ | `ruff check .` | clean (`E,F,W,I,B,C4,ANN,RUF,C901`) |
14
+ | `pylint` | 9.92/10 with `C,R` disabled |
15
+ | Type checking | **mypy** configured for `console_window` and run in CI; clean (14 source files) |
16
+ | Complexity (ruff `C901`, threshold 12) | `prompt()` decomposed from 43 to **5**; worst remaining is [`answer()`](console_window/window.py:1167) at **12** |
17
+
18
+ ## 1. Executive summary
19
+
20
+ The library is small, dependency-free, and unusually disciplined for a single-author project: a real module split, a headless render backend, 268 fast TTY-free tests, a green linter and type checker, changelog and release metadata, and a worked example that is itself smoke-tested. Resolved findings from the 2026-10-15 audit have been removed from this document; [`CHANGELOG.md`](CHANGELOG.md:1) records them.
21
+
22
+ The remaining risks are narrower than the ones in the original audit. There is no data-loss or security defect, and the interactive core is no longer an untested hole. What is left is one drawing path outside the overflow contract, a body of unreachable or dead-but-broken public surface, residual process-global state, and CI that cannot reproduce its own inputs. Highest-risk findings, in order:
23
+
24
+ 1. **[`draw()`](console_window/window.py:749) bypasses the pad-overflow policy.** It inflates `row_cnt` without a capacity check and never records row context or attributes, so phantom rows, silently pickable rows and attribute loss can follow (§3.2).
25
+ 2. **A meaningful amount of public API is unreachable and one piece is dead-but-broken.** `Theme.create_picker_screen()`, `ScreenStack.act_in()`, `ScreenStack.is_curr()`, `ConsoleWindow.set_pick_range()`, `BasicHelpScreen` and `OptionSpinner.add()` have no consumer outside the test suite; `add()` additionally discards the `comments` field it exists to populate (§3.3).
26
+ 3. **Process-global state is still managed asymmetrically.** The active theme is a class global, and the Ctrl-C policy deliberately latches terminating Ctrl-C for the process lifetime without restoring a host application's prior SIGINT handler (§3.1).
27
+ 4. **CI is green but shallow.** Tool versions are unpinned, and there is no coverage or `pylint` gate, so the current 87% coverage and 9.92/10 score are not protected (§3.4).
28
+
29
+ None of these is a data-loss or security defect, and the project's own bar remains high. The work left is concentrated in the list above rather than spread across the package.
30
+
31
+ ## 2. Architecture as implemented
32
+
33
+ | Module | Lines | Stmts | Cover | Role |
34
+ | --- | --- | --- | --- | --- |
35
+ | [`window.py`](console_window/window.py:1) | 1983 | 1185 | 88% | The engine: two pads + screen, add/draw/render/prompt/answer/alert/flash, navigation |
36
+ | [`widgets.py`](console_window/widgets.py:1) | 337 | 231 | 86% | `InlineConfirmation`, `IncrementalSearchBar` (pure, no curses drawing) |
37
+ | [`spinner.py`](console_window/spinner.py:1) | 333 | 225 | 87% | `OptionSpinner`: key → option registry, genre inference, scope subtraction, help body |
38
+ | [`screens.py`](console_window/screens.py:1) | 234 | 159 | 67% | `Screen` base, `ScreenStack` router, `HOME_ST`, `BasicHelpScreen` |
39
+ | [`theme.py`](console_window/theme.py:1) | 231 | 71 | 69% | 7 palettes, `set()`/`set_active()`, picker-screen factory |
40
+ | [`textwidth.py`](console_window/textwidth.py:1) | 170 | 129 | 95% | Display-cell arithmetic: width, truncate, justify, wrap, wrap positions |
41
+ | [`header.py`](console_window/header.py:1) | 126 | 88 | 99% | `Span`/`HeaderLine` token API + legacy `[x]`/`KEY:` string parser |
42
+ | [`backend.py`](console_window/backend.py:1) | 279 | 128 | 88% | `Surface`/`RenderBackend` protocols, `CursesBackend`, `HeadlessBackend` |
43
+ | [`geometry.py`](console_window/geometry.py:1) | 82 | 61 | 100% | Pure abut/indicator/pickable-row math |
44
+ | [`opts.py`](console_window/opts.py:1) | 78 | 59 | 100% | `ConsoleWindowOpts` (`__slots__` + kwargs) |
45
+ | [`context.py`](console_window/context.py:1) | 37 | 17 | 100% | Per-row `Context` (`genre`/`pickable`/`abut` + open kwargs) |
46
+ | [`signals.py`](console_window/signals.py:1) | 49 | 24 | 96% | SIGINT policy globals |
47
+ | [`keys.py`](console_window/keys.py:1) | 18 | 3 | 100% | `NAVIGATION_KEYS` |
48
+ | [`__init__.py`](console_window/__init__.py:1) | 31 | 11 | 100% | Re-exports + `__all__` |
49
+
50
+ **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()`.
51
+
52
+ **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)).
53
+
54
+ **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.
55
+
56
+ **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.
57
+
58
+ **Current shape.** Docstrings have been replaced by comments, `_selftest.py` is folded into `SampleApp.py`, the pad-overflow contract is counted + warned + enforced in this project's suite, and the package version is single-sourced through `dynamic = ["version"]`.
59
+
60
+ ## 3. Findings
61
+
62
+ ### 3.1 🟡 Process-global theme, and a one-way Ctrl-C latch
63
+
64
+ `Theme._current` is a class attribute ([`theme.py:36`](console_window/theme.py:36)) — documented, but it means two windows in one process cannot differ. The Ctrl-C policy is now safe against the interleaving that used to leave a custom handler installed, because terminating Ctrl-C is a one-way process-lifetime latch ([`signals.py:16`](console_window/signals.py:16)): once [`setup_ctrl_c()`](console_window/signals.py:31) sees a terminating request it installs Python's default handler and [`teardown_ctrl_c()`](console_window/signals.py:45) subsequently no-ops.
65
+
66
+ **Limits:** the active theme remains global, and the latch deliberately restores `signal.default_int_handler` rather than a handler a host application may have installed before the first window. There is no public reset. An embedding application that manages SIGINT itself can therefore lose its handler for the rest of the process.
67
+
68
+ **Fix:** make the theme per-window (or document one-theme-per-process explicitly), and either capture/restore the previous SIGINT handler or expose a deliberate reset.
69
+
70
+ ### 3.2 🟡 `draw()` bypasses the pad-overflow policy
71
+
72
+ Overflow through `_add()` is counted (`ns.rows_dropped`, exposed as [`ConsoleWindow.rows_dropped`](console_window/window.py:359)), warned about once per fill episode with a caller-facing `stacklevel`, reset by [`clear()`](console_window/window.py:1759), non-fatal by design, and enforced in this project's own suite via `filterwarnings = ["error::UserWarning"]` ([`pyproject.toml:49`](pyproject.toml:49)).
73
+
74
+ **The gap:** [`draw()`](console_window/window.py:749) writes a raw row and inflates `ns.row_cnt = max(ns.row_cnt, y+1)` ([`window.py:755`](console_window/window.py:755)) **without any capacity check**, so it can put `row_cnt` past the pad's real row count — after which `calc()` computes `max_scroll_pos` from phantom rows, `highlight_picked()` walks rows that do not exist, and `rows_dropped` reports nothing. `draw()` also never appends to `contexts`/`text_attrs`, so drawn rows fall through to the default-pickable branch in [`_is_pickable()`](console_window/window.py:367) and lose their attributes on the next full redraw.
75
+
76
+ **Fix:** clamp in `draw()` (or route it through `_add`) and let the same counter warn.
77
+
78
+ ### 3.3 🟡 Unreachable public surface (and dead code in the example)
79
+
80
+ No consumer outside the test suite for:
81
+
82
+ | Symbol | Where | Note |
83
+ | --- | --- | --- |
84
+ | `Theme.create_picker_screen()` | [`theme.py:187`](console_window/theme.py:187) | Untested; duplicates the sample app's theme screen; the factory reaches into `app.opts.theme` |
85
+ | `ScreenStack.act_in()` | [`screens.py:181`](console_window/screens.py:181) | Duplicates what `perform_actions()` already does; `getattr` raises on a missing flag |
86
+ | `ScreenStack.is_curr()` | [`screens.py:168`](console_window/screens.py:168) | Only reachable from `act_in()` |
87
+ | `ConsoleWindow.set_pick_range()` | [`window.py:518`](console_window/window.py:518) | The `pick_range` feature's only public entry point; called only by [`tests/test_interactive.py`](tests/test_interactive.py:1) |
88
+ | `BasicHelpScreen` | [`screens.py:227`](console_window/screens.py:227) | Exported in `__all__`, unused; the sample app writes its own help screen |
89
+ | `OptionSpinner.add()` | [`spinner.py:163`](console_window/spinner.py:163) | Dead **and** broken (see below) |
90
+ | `draw()` | [`window.py:749`](console_window/window.py:749) | Used only by [`tests/test_interactive.py`](tests/test_interactive.py:365) and [`tests/test_widechars.py`](tests/test_widechars.py:1) |
91
+ | `add_fancy_header()` string path | [`window.py:685`](console_window/window.py:685) | The sample uses `add_header_line()`; only tests exercise the string path |
92
+ | `demo_mode` / `set_demo_mode()` | [`window.py:325`](console_window/window.py:325) | Only tests toggle it; internal prompt/render code reads `opts.demo_mode` |
93
+
94
+ `OptionSpinner.add()` truncates `ns.vals` and *then* slices it with the pre-truncation index ([`spinner.py:170`](console_window/spinner.py:170)), so `ns.comments` is always empty — the parameter it exists for is silently dropped. Dead code that cannot be right is worse than no code.
95
+
96
+ The same is true of two example methods: `DemoApp.do_key()` ([`SampleApp.py:219`](SampleApp.py:219)) is never called (`main_loop` calls [`handle_key()`](SampleApp.py:184), which calls `spin.do_key` directly), and `ThemeScreen.theme_screen()` ([`SampleApp.py:512`](SampleApp.py:512)) is never called and, unlike the live [`theme_screen_ACTION()`](SampleApp.py:426) path and `Theme.create_picker_screen`'s `spin_theme_ACTION`, does not update `app.opts.theme`.
97
+
98
+ **Fix:** delete what no consumer needs (these are pre-2.0 leftovers), or promote it deliberately: give each survivor a test and a README mention, and fix `OptionSpinner.add()` if it is retained.
99
+
100
+ ### 3.4 🟡 CI is green but shallow, and its inputs are unpinned
101
+
102
+ [`.github/workflows/ci.yml`](.github/workflows/ci.yml:1) runs `pytest -q` on 3.10-3.13, `ruff check .` and `mypy`, installing `pytest`, `ruff` and `mypy` **unpinned** ([`ci.yml:22`](.github/workflows/ci.yml:22), [`ci.yml:36`](.github/workflows/ci.yml:36), [`ci.yml:50`](.github/workflows/ci.yml:50)) — a new tool release can break the build with no code change, and the same build cannot reproduce yesterday's result. There is no coverage gate and no `pylint` (so the 87% coverage and 9.92/10 figures in this audit are not protected by CI).
103
+
104
+ ### 3.5 🟢 `answer()` residual: class-wide retry budget and key draining
105
+
106
+ [`answer()`](console_window/window.py:1167) now has one unambiguous meaning per outcome (`str` = submitted, `None` = user abort, `TerminalTooSmallError` = environment), and its recovery loop is bounded. Two narrow issues remain:
107
+
108
+ * the budget is the class-wide `render_retry_seconds` ([`window.py:1193`](console_window/window.py:1193)) rather than a dialog-specific option;
109
+ * [`_recover_from_resize()`](console_window/window.py:1522) still drains and **discards** every queued key ([`window.py:1528`](console_window/window.py:1528)).
110
+
111
+ **Fix:** make the dialog retry budget configurable and preserve (or explicitly document the loss of) keys typed during recovery.
112
+
113
+ ### 3.6 🟢 Lint findings lack rationale
114
+
115
+ `pylint` remains in the high 9s only because its warnings are few, not because they are annotated. Current examples: protected-member access ([`window.py:78`](console_window/window.py:78), [`window.py:84`](console_window/window.py:84), [`theme.py:171`](console_window/theme.py:171)), unused arguments ([`window.py:1742`](console_window/window.py:1742), [`backend.py:252`](console_window/backend.py:252), [`backend.py:278`](console_window/backend.py:278)), global statements and unused handler arguments in [`signals.py:19`](console_window/signals.py:19), a not-callable method ([`screens.py:67`](console_window/screens.py:67)), and broad exceptions in the example ([`SampleApp.py:150`](SampleApp.py:150), [`SampleApp.py:523`](SampleApp.py:523)). Each is plausibly deliberate, but none says why.
116
+
117
+ **Fix:** add a short rationale comment or a scoped `# pylint: disable=...` at each site, so a future reader can distinguish intent from accident.
118
+
119
+ ## 4. Prioritized recommendations
120
+
121
+ **P1 — robustness and surface**
122
+
123
+ 1. Clamp `draw()` to the pad and route its overflow through the existing counter (§3.2).
124
+ 2. Delete or promote the unreachable API (§3.3), fix or remove `OptionSpinner.add()`, and delete `DemoApp.do_key()` and `ThemeScreen.theme_screen()`.
125
+ 3. Pin CI tool versions and add a coverage floor (even a low one, e.g. `--cov-fail-under=80`) so the suite cannot silently shrink (§3.4).
126
+
127
+ **P2 — honesty and hygiene**
128
+
129
+ 4. Make the active theme per-window (or document one-theme-per-process), and capture/restore the host SIGINT handler or expose a reset (§3.1).
130
+ 5. Make the dialog retry budget configurable and stop discarding keys during resize recovery (§3.5).
131
+ 6. Add rationale comments or scoped disables for the remaining `pylint` findings (§3.6).
132
+
133
+ ## 5. What is genuinely good (protect it)
134
+
135
+ * **The test suite is fast and genuinely TTY-free** (268 tests in 1.5 s) and includes headless smoke tests for the example ([`tests/test_sample_app.py`](tests/test_sample_app.py:1)), the interactive core ([`tests/test_interactive.py`](tests/test_interactive.py:1)) and the overflow contract ([`tests/test_overflow.py`](tests/test_overflow.py:1)).
136
+ * **The overflow contract for `_add()` is a model of restraint** — counted, warned once, non-fatal for users, fatal in the project's own suite ([`pyproject.toml:49`](pyproject.toml:49)), and it paid for itself in the same commit by finding the sample app's clipped help screen.
137
+ * **The `prompt()` loop is decomposed and guarded** — the one 160-line, complexity-43 function is now a 12-line intake loop over small helpers (worst `prompt`-family helper: 4), and `C901` at a ratcheted threshold of 12 prevents a new monolith ([`pyproject.toml:62`](pyproject.toml:62)).
138
+ * **`textwidth` and `geometry` are pure and near-fully covered** (95%/100%) — the right place for the fiddly math.
139
+ * **`Context`'s near-miss warning** ([`context.py:30`](console_window/context.py:30)) and **`OptionSpinner`'s conflict detection** ([`spinner.py:127`](console_window/spinner.py:127), [`spinner.py:142`](console_window/spinner.py:142)) turn silent misconfiguration into loud `ValueError`/warning at the exact point of the mistake.
140
+ * **Packaging is correct**: flit module config, static metadata, `__all__` matching the re-exports, and a `runner` that executes the example as `__main__` without an installed package.
141
+
142
+ ## 6. Reproduction
143
+
144
+ ```bash
145
+ python3 -m ruff check .
146
+ python3 -m pytest -q
147
+ python3 -m coverage run --source=console_window -m pytest -q && python3 -m coverage report -m
148
+ python3 -m pylint console_window SampleApp.py runner --disable=C,R
149
+ python3 -m mypy
150
+ python3 -m ruff check --select C901 console_window SampleApp.py # uses the ratchet in pyproject.toml
151
+ python3 -W error::UserWarning -c "import SampleApp" # exit path for the pad-overflow policy
152
+ ```
@@ -0,0 +1,285 @@
1
+ # Changelog
2
+
3
+ All notable changes to `console-window` are documented here.
4
+ This project follows [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ### Added
9
+ - `mypy` type checking for the library: `[tool.mypy]` in `pyproject.toml`
10
+ checks `console_window` (`files`, `ignore_missing_imports`,
11
+ `warn_unused_ignores`, `disallow_untyped_defs = false`) and a new `types` job
12
+ in CI runs it on every push/PR. Ruff's `ANN` rules were already enforcing
13
+ annotation *presence*; this enforces that the annotations are true
14
+ (AUDIT.md §3.4).
15
+ - `[project.urls]` in `pyproject.toml` (Homepage / Repository / Issues /
16
+ Changelog) so published package metadata carries project links.
17
+ - `ConsoleWindow(..., singleton=True)` restores the historical process-wide
18
+ window model for applications that relied on it: `static_scr`/`static_win`
19
+ are populated, every window shares one process screen and backend, and the
20
+ unbound `ConsoleWindow.stop_curses()` tears it down. The first successful
21
+ construction latches the mode; constructing another window with a different
22
+ `singleton` value raises `AssertionError`. Pass `singleton=False` for
23
+ independent per-instance screens and teardown (the test suite does).
24
+ - `ConsoleWindow.rows_dropped`, counting the rows `add_body()`/`add_header()`
25
+ discard once that pad has reached `opts.body_rows` / `opts.head_rows`. The
26
+ first discarded row of each fill episode also emits a `UserWarning` naming the
27
+ pad and the option to raise. Overflow previously left no trace, so an
28
+ under-sized pad showed up only as missing content. The behaviour is
29
+ deliberately non-fatal: the excess is still discarded and the app keeps
30
+ running, `-W error::UserWarning` turns it into a hard failure for anyone who
31
+ wants that, and `clear()` re-arms the warning for the next fill.
32
+ - A headless harness for the interactive core: `HeadlessBackend.queue_keys()`
33
+ feeds ordered key codes to the screen's `getch()`, and the new
34
+ `tests/test_interactive.py` (43 tests) drives `prompt()` and its navigation,
35
+ `render()`/`draw()`, the `answer()` dialog and the resize-recovery path
36
+ without a TTY. Coverage of `window.py` rises from 55% to 84% (missing
37
+ statements 444 → 161), so the half of the engine users actually feel is now
38
+ smoke-tested in CI (AUDIT.md §3.2).
39
+
40
+ ### Changed
41
+ - The package version now has a single source of truth: `pyproject.toml`
42
+ declares `dynamic = ["version"]` and flit reads `__version__` from
43
+ `console_window/__init__.py` (AUDIT.md §3.14). The stale manual-build recipe
44
+ at the top of `pyproject.toml` is gone.
45
+ - `OptionSpinner.get_value()` is now a pure read: its `coerce` parameter is
46
+ gone and initialisation moved to the explicit `OptionSpinner.normalize(attr)`,
47
+ which `_register()` calls. Apps that read option values through
48
+ `get_value()` are unaffected because `add_key()` already normalizes the value
49
+ during registration; code that passed `coerce=True` directly should call
50
+ `normalize()` instead (AUDIT.md §3.12).
51
+ - Raised the minimum supported Python to **3.10** (was 3.8): `requires-python =
52
+ ">=3.10"`, the Ruff `target-version` is now `py310`, and the CI matrix drops
53
+ the 3.8/3.9 legs. The 3.8 floor was never real — [`backend.py`](console_window/backend.py:35)
54
+ calls `curses.set_escdelay()`, which is new in 3.9, so every
55
+ `ConsoleWindow()` raised `AttributeError` on 3.8. 3.10 is a deliberate floor
56
+ rather than the bare 3.9 that `set_escdelay` would have permitted.
57
+ - Renamed `ConsoleWindowDemo.py` to `SampleApp.py`: the old name collided with
58
+ the library's own `set_demo_mode()` feature. Updated [`runner`](runner:1), the
59
+ README, the ruff per-file-ignores and the headless smoke tests
60
+ (`tests/test_sample_app.py`) to match. The classes inside are still named
61
+ `DemoApp`/`DemoScreen`.
62
+
63
+ ### Fixed
64
+ - Hygiene debt from AUDIT.md §3.14: removed blank lines inside the
65
+ `index_at_cell()`/`answer()` signatures and the commented-out assignment in
66
+ `fix_positions()`; replaced `render()`'s `print()`/`except Exception` failure
67
+ path with `logging` and `curses.error`; made `_BACKSPACE_KEYS` a single
68
+ module constant; moved `ScreenStack`'s `SimpleNamespace` import to module
69
+ level; stored navigation help as `NAV_KEY_LINES` so indentation cannot alter
70
+ it; bounded `_NoopSurface.calls` to `MAX_CALLS`; and restored the hardware
71
+ cursor in `CursesBackend.stop()`.
72
+ - The window no longer uses identity or sentinel tricks to decide what it is
73
+ drawing. `_add()` takes an explicit `is_body` keyword from
74
+ `add_header()`/`add_body()` instead of comparing `id(ns)` with `id(self.body)`,
75
+ and `_warn_rows_dropped()` receives the pad name. The undocumented
76
+ `last_pick_pos == -2` "clear every row" sentinel is replaced by the one-shot
77
+ `_pick_clear_pending` flag, which `set_pick_mode()`/`set_pick_range()` set and
78
+ `highlight_picked()` consumes; `last_pick_pos` is now only a row index or `-1`.
79
+ A range change still repaints the picked row when its position is unchanged
80
+ (AUDIT.md §3.13).
81
+ - The help screen no longer relies on `assert` to guard option rendering.
82
+ `OptionSpinner._write_option_body()` now renders a dim `<unset: 'attr'>`
83
+ placeholder when an application has deleted or cleared a registered option,
84
+ so the row shows the same thing under `python -O` instead of falling through
85
+ to `None` (AUDIT.md §3.12).
86
+ - `ConsoleWindowOpts` now rejects unknown option names with a `TypeError`
87
+ instead of silently ignoring them. A typo such as
88
+ `ConsoleWindowOpts(body_row=5000)` used to leave `body_rows` at its default
89
+ and surface only later as discarded pad rows; construction now fails, and a
90
+ `difflib` suggestion points at the intended name (`body_rows`). Multiple
91
+ unknown keys are all reported (AUDIT.md §3.11).
92
+ - `answer()` no longer loops forever on `curses.error`, and it no longer uses
93
+ `None` for both "user aborted" and "terminal too small". The recovery loop is
94
+ bounded by `render_retry_seconds` and then re-raises; both too-small paths
95
+ raise `TerminalTooSmallError` (ESC on the mid-dialog too-small notice still
96
+ returns `None`, since that is a real abort). The dialog timeout is now
97
+ restored in a `finally`, so it is also restored when `answer()` raises.
98
+ - Annotations that `mypy` exposed: `fix_positions()` is declared `-> int`
99
+ (it returns `indent` and the caller uses it), `_draw_line_with_attrs()` and
100
+ `_draw_plain_line()` accept `attrs: list | None`, `pick_range` is typed as
101
+ `list[int] | tuple[int, int] | None`, and `draw()` accepts `str | bytes` to
102
+ match its decode path. `ConsoleWindowOpts` attributes now have explicit types
103
+ instead of being `object` values from `**kwargs`, and the full screen/pad API
104
+ is described by the new `CursesSurface` protocol rather than `object`.
105
+ - Ctrl-C termination is now a process-lifetime latch. The first `ConsoleWindow`
106
+ created with `ctrl_c_terminates=True` sets SIGINT to the default handler and
107
+ no later window can install the key-delivery handler, even after that window
108
+ stops. Previously `teardown_ctrl_c()` decided from whichever policy happened to
109
+ be current, so window A (`False`, handler installed) → window B (`True`) →
110
+ stop A left A's handler installed and Ctrl-C could no longer terminate. The
111
+ latch is deliberately one-way: `ctrl_c_terminates=False` applies only while no
112
+ terminating window has existed in the process (AUDIT.md §3.6).
113
+ - Aborting a `prompt`-genre spinner option (ESC in the `answer()` dialog) no
114
+ longer wipes the stored value. `OptionSpinner.do_key()` wrote whatever
115
+ `answer()` returned straight onto the attribute, and an aborted dialog returns
116
+ `None`, so cancelling blanked the setting and the next read coerced it back to
117
+ `''`. The prompt branch now treats `None` as "no change" and returns without
118
+ touching the attribute (AUDIT.md §3.7).
119
+ - `ConsoleWindow.alert()` now actually scrolls. It used to build a pad sized to
120
+ the whole message but always refresh it at offset 0, so content taller than the
121
+ box was silently unreachable despite the "scrollable if needed" comment. The
122
+ content is now reachable with `j`/`k`, the arrow keys, PageUp/PageDown (and
123
+ Ctrl-B/Ctrl-F/Space) and Home/End, and the box's left border shows `▲`/`▼`
124
+ markers when content extends past the fold. The `_height`/`_width` parameters,
125
+ previously accepted and ignored, now cap the (centred) box; passing `None`
126
+ keeps the previous full-box, 1-cell-margin geometry.
127
+ - `alert()` no longer reaches around the backend seam: it builds its content pad
128
+ through `RenderBackend.newpad()` and drives its own key loop rather than calling
129
+ module-level `curses.newpad()`/`curses.newwin()`/`Textbox`. It therefore runs
130
+ under `HeadlessBackend` and is covered by the new `alert()` tests in
131
+ `tests/test_interactive.py`; the `Textbox` monkeypatch in `tests/test_dialogs.py`
132
+ is gone, along with the now-unused `_textbox_edit_on_enter()` helper (AUDIT.md
133
+ §3.3).
134
+ - `answer()`, `flash()` and the shared render/dialog helpers no longer call the
135
+ process-global `curses.doupdate()`/`curs_set()`/`update_lines_cols()`/
136
+ `is_term_resized()` directly. Four matching methods were added to
137
+ `RenderBackend`, implemented by `CursesBackend` (delegating to curses) and
138
+ `HeadlessBackend` (recording no-ops), so the dialogs are backend-driven end to
139
+ end and the `curs_set`/`doupdate` monkeypatches are gone from `tests/test_dialogs.py`
140
+ (AUDIT.md §3.3).
141
+ - `ConsoleWindow` startup and `Theme.set()` now (re)initialise the same semantic
142
+ colour pairs from the single `Theme.SEMANTIC_PAIRS` constant. Startup previously
143
+ omitted `OLD_SUCCESS`, so it rendered on an uninitialised pair (effectively pair
144
+ 0) until the user changed theme. The magic pair id `8` is now the named
145
+ `Theme.DEFAULT_BG`. `tests/test_theme.py` pins the shared list, the historic id
146
+ and that both init paths agree (AUDIT.md §3.9).
147
+
148
+ ### Removed
149
+ - `console_window/_selftest.py`, the TTY-only dev self-test. The old
150
+ `python -m console_window._selftest` entry point is gone; the features it
151
+ demonstrated — multi-row picking, a prompt-style option, forced
152
+ `render(redraw=True)`, `Ctrl-C` as an application key, large-body stress and
153
+ `ConsoleWindow.primary_window()` recovery — were folded into `SampleApp.py`,
154
+ which is now covered by headless smoke tests (`tests/test_sample_app.py`).
155
+ - All docstrings from the package, the test suite, the demo and the `runner`
156
+ script (305 in total, across 29 files). Nothing consumed them via `__doc__`,
157
+ `inspect.getdoc()` or `help()` (verified by grep), so runtime behaviour and the
158
+ public API are unchanged; statement order, bodies and the blank-line layout
159
+ around definitions were preserved.
160
+ - The `DWIPE_THEME` environment variable. `Theme.set()` no longer exports it and
161
+ the startup path reads `Theme.get_current()` instead of `os.environ`, making
162
+ `Theme` state the single source of truth. The library no longer namespaces its
163
+ config under a downstream app's name (`dwipe` never read the variable) and no
164
+ longer leaks the theme value into child processes via the inherited
165
+ environment.
166
+ - `console_window/ConsoleWindow.py`, the compatibility facade. Its filename
167
+ shadowed the `ConsoleWindow` class and violated PEP 8 (ruff `N999`). The
168
+ public API is unchanged for package-root imports (`from console_window import
169
+ ...`), which is the documented style; only the legacy
170
+ `from console_window.ConsoleWindow import ...` path is gone. The interactive
171
+ self-test moved to `python -m console_window._selftest`.
172
+
173
+ ### Fixed
174
+ - Scrolling could not reach non-pickable `DECOR` rows that sit past the last
175
+ pickable row (or before the first one). In pick mode the frame's
176
+ pick-visibility clamp in [`_adjust_scroll_for_abut()`](console_window/window.py:862)
177
+ reset `scroll_pos` back to `pick_pos` every frame, so the SampleApp's `z`
178
+ "stress" mode created 2000 filler rows that were unreachable — the view showed
179
+ the last pickable row plus one screen of filler and the scroll indicator sat
180
+ near the start. Movement is now allowed to scroll past the pick (a new
181
+ `free_scroll` state) while the pick can find no pickable row in the requested
182
+ direction; the clamp is skipped in that state and restored as soon as a real
183
+ pick move resynchronises the view. The blocked-move scroll amount is now the
184
+ *requested* delta rather than a single row, so page keys and `HOME`/`END`
185
+ traverse a `DECOR` block too, and the scroll indicator follows the viewport
186
+ while free-scrolling instead of the parked pick.
187
+ - `SampleApp.py` asked for `head_rows=4`, but the help screen's nav-key block is
188
+ five header rows, so the last line was being clipped in silence. Raised to 8,
189
+ and the pad-sizing comment now records what each pad has to hold. The bug was
190
+ found by the overflow signal above, which this project's own suite now
191
+ enforces (`filterwarnings = ["error::UserWarning"]` in `pyproject.toml`) so
192
+ that neither the sample app nor the library's own helpers can quietly regress
193
+ that way again.
194
+
195
+ ## [2.0.1]
196
+
197
+ Internal overhaul — modularization and tests. The public API is unchanged and
198
+ fully backward compatible (`from console_window import ...` and
199
+ `from console_window.ConsoleWindow import ...` both continue to work).
200
+
201
+ ### Added
202
+ - Unit-test suite (`tests/`, TTY-free) covering scroll/pick geometry,
203
+ `IncrementalSearchBar` / `InlineConfirmation`, `OptionSpinner` scope
204
+ subtraction, public-API compatibility, the `answer()`/`alert()` dialog fixes,
205
+ `set_demo_mode()` toggle/state handling, `render()`'s retry cap, and the
206
+ non-destructive `flash()` behaviour (`tests/test_flash.py`).
207
+ - `ConsoleWindow.flash_repaint` (repaint the underlying screen when `flash()`
208
+ ends) and `ConsoleWindow.flash_poll_ms` (flash key-poll interval).
209
+ - `console_window.textwidth` — a stdlib-only (`unicodedata`-based) display-cell
210
+ width toolkit (`char_width`, `string_width`, `truncate`, `ljust`/`rjust`,
211
+ `cell_to_index`, `wrap_ranges`/`wrap_position`/`index_at_cell`, `wrap`),
212
+ plus `tests/test_textwidth.py` and `tests/test_widechars.py`.
213
+ - `console_window.geometry` — pure scroll/pick math extracted for testing:
214
+ `cook_abut`, `compute_abut`, `calc_indicator`, `scroll_indicator_row`,
215
+ `scroll_indicator_col`, `count_pickable_rows`, `find_pickable`.
216
+ - `console_window.backend` — a `RenderBackend` abstraction with `CursesBackend`
217
+ (real terminal) and `HeadlessBackend` (in-memory), plus `tests/test_backend.py`.
218
+ - `console_window.__version__`.
219
+ - Explicit `__all__` in the package root and compatibility module.
220
+ - `[tool.pytest.ini_options]` in `pyproject.toml`.
221
+ - GitHub Actions CI (`.github/workflows/ci.yml`) running the TTY-free suite on
222
+ Python 3.8–3.13, plus a Ruff lint job backed by a `[tool.ruff]` config in
223
+ `pyproject.toml`.
224
+ - Type annotations across the public API (every package module now uses
225
+ `from __future__ import annotations`), enforced for the package by Ruff's
226
+ `ANN` rules.
227
+
228
+ ### Changed
229
+ - Split the 3,815-line `console_window/ConsoleWindow.py` into focused modules:
230
+ `window.py`, `theme.py`, `context.py`, `opts.py`, `widgets.py`, `spinner.py`,
231
+ `screens.py`, `keys.py`, `signals.py`, `geometry.py`; `ConsoleWindow.py` is now
232
+ a thin compatibility facade.
233
+ - Theme state unified into `Theme._current` (via `Theme.set_active()`); removed
234
+ the split between `_current` and `CURRENT_THEME` that made
235
+ `Theme.get_current()` return `None` after startup.
236
+ - SIGINT handling moved to an explicit `console_window.signals` policy API;
237
+ removed the action-at-a-distance rebinding of module-level functions.
238
+ - `set_demo_mode()` now tests the resulting state rather than the argument.
239
+ - Removed the process-wide `ConsoleWindow.static_scr`/`static_win` singletons:
240
+ live windows are tracked in a weak-reference registry, teardown is per-instance
241
+ (`stop_curses()`), `stop_all()` is the single `atexit` hook, and `Theme.set()`
242
+ broadcasts to every live window via `_apply_default_colors()`. Multiple windows
243
+ may now coexist, and one may be constructed without a terminal by passing
244
+ `backend=HeadlessBackend()` to the constructor.
245
+ - Every package module opts into PEP 563 (`from __future__ import annotations`)
246
+ so the modern `X | None` annotations remain valid on Python 3.8.
247
+ - `add_fancy_header()` regex logic extracted into named helpers.
248
+
249
+ ### Fixed
250
+ - `answer()` now records the screen read timeout in effect before the dialog and
251
+ restores that value on every exit path (submit, TAB, ESC, ESC-ESC, too-small
252
+ abort) instead of resetting to the class default `ConsoleWindow.timeout_ms`.
253
+ - `alert()` recomputes `self.rows`/`self.cols` from `getmaxyx()` before drawing,
254
+ so a resize immediately before the call no longer causes a misdraw.
255
+ - `flash()` is now non-destructive and interruptible: it polls for a key
256
+ (`flash_poll_ms`) instead of `time.sleep`ing, so it can be dismissed early —
257
+ and any key pressed is preserved in `pending_keys` for the next `prompt()`
258
+ rather than being swallowed. A resize while the message is up redraws it, and
259
+ on exit the underlying screen is repainted from the pads (`flash_repaint`)
260
+ instead of being left blank; the pre-flash read timeout is restored on every
261
+ exit path.
262
+ - Width arithmetic is now **display-cell aware** (wide CJK/emoji = 2 cells,
263
+ combining marks/ZWJ = 0) throughout the render pipeline: `_add`, `draw`,
264
+ `highlight_picked`/`_draw_line_with_attrs` (highlight columns map to
265
+ code-point indices), `add_fancy_header`, `flash`, `alert`, and the `answer()`
266
+ display/editor (cell-accurate wrapping and cursor placement) — fixing
267
+ misalignment for the wide characters the demo already uses.
268
+ - `render()` now bounds its transient-error retry loop by wall-clock time
269
+ (`ConsoleWindow.render_retry_seconds`, default 2 s) instead of a fixed 128
270
+ iterations, so a reproducible `curses.error` aborts quickly with the
271
+ diagnostic dump rather than freezing the UI for ~20 s.
272
+ - Demo: the settings spinner keys (`v` view mode, `s` sort, `t` theme) now also
273
+ work while the HELP screen is open — they are scoped to include `HELP_ST`.
274
+ (Scoping logic itself was unchanged by the refactor; this was a demo-scope
275
+ issue.)
276
+ - `ScreenStack.__init__(screen_objects=...)` annotated as `dict | None` (fixes the
277
+ implicit-`Optional` finding, Ruff RUF013); removed redundant `int(round(...))`
278
+ casts (RUF046) and trailing whitespace in `widgets.py` (W291); dropped an
279
+ unused local in `tests/test_spinner.py` (F841).
280
+ - Removed retired `put_body()` references (docstring + `__main__` self-test now
281
+ use `add_body()`).
282
+ - `Development Status` classifier aligned with the release; version bumped to
283
+ `2.0.1`.
284
+
285
+ [2.0.1]: https://github.com/joedefen/console-window