console-window 2.2.0__tar.gz → 3.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 (30) hide show
  1. console_window-3.0.0/PKG-INFO +392 -0
  2. console_window-3.0.0/README.md +360 -0
  3. {console_window-2.2.0 → console_window-3.0.0}/console_window/__init__.py +4 -2
  4. console_window-3.0.0/console_window/_appearance.py +152 -0
  5. {console_window-2.2.0 → console_window-3.0.0}/console_window/backend.py +9 -9
  6. {console_window-2.2.0 → console_window-3.0.0}/console_window/cli.py +45 -40
  7. {console_window-2.2.0 → console_window-3.0.0}/console_window/context.py +14 -0
  8. console_window-3.0.0/console_window/fonts.py +141 -0
  9. console_window-3.0.0/console_window/glyphs.py +1438 -0
  10. {console_window-2.2.0 → console_window-3.0.0}/console_window/header.py +62 -0
  11. console_window-3.0.0/console_window/help.py +68 -0
  12. {console_window-2.2.0 → console_window-3.0.0}/console_window/opts.py +58 -6
  13. console_window-3.0.0/console_window/screens.py +768 -0
  14. console_window-3.0.0/console_window/spinner.py +856 -0
  15. {console_window-2.2.0 → console_window-3.0.0}/console_window/theme.py +395 -77
  16. {console_window-2.2.0 → console_window-3.0.0}/console_window/widgets.py +123 -6
  17. {console_window-2.2.0 → console_window-3.0.0}/console_window/window.py +906 -404
  18. {console_window-2.2.0 → console_window-3.0.0}/pyproject.toml +6 -5
  19. console_window-2.2.0/PKG-INFO +0 -518
  20. console_window-2.2.0/README.md +0 -486
  21. console_window-2.2.0/console_window/appearance.py +0 -135
  22. console_window-2.2.0/console_window/glyphs.py +0 -555
  23. console_window-2.2.0/console_window/help.py +0 -66
  24. console_window-2.2.0/console_window/screens.py +0 -353
  25. console_window-2.2.0/console_window/spinner.py +0 -350
  26. {console_window-2.2.0 → console_window-3.0.0}/LICENSE +0 -0
  27. {console_window-2.2.0 → console_window-3.0.0}/console_window/geometry.py +0 -0
  28. {console_window-2.2.0 → console_window-3.0.0}/console_window/keys.py +0 -0
  29. {console_window-2.2.0 → console_window-3.0.0}/console_window/signals.py +0 -0
  30. {console_window-2.2.0 → console_window-3.0.0}/console_window/textwidth.py +0 -0
@@ -0,0 +1,392 @@
1
+ Metadata-Version: 2.4
2
+ Name: console-window
3
+ Version: 3.0.0
4
+ Summary: A robust curses wrapper for creating Text User Interfaces (TUIs).
5
+ Author-email: Joe Defen <joedef@duck.com>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Topic :: Terminals
13
+ Classifier: Topic :: Software Development :: User Interfaces
14
+ License-File: LICENSE
15
+ Requires-Dist: pytest >= 7.0 ; extra == "dev"
16
+ Requires-Dist: pytest-cov >= 4.0 ; extra == "dev"
17
+ Requires-Dist: ruff >= 0.9 ; extra == "dev"
18
+ Requires-Dist: build >= 1.2 ; extra == "dev"
19
+ Requires-Dist: twine >= 7 ; extra == "dev"
20
+ Requires-Dist: packaging >= 26 ; extra == "dev"
21
+ Requires-Dist: tomli >= 2.0 ; extra == "dev" and ( python_version < "3.11")
22
+ Requires-Dist: pytest >= 7.0 ; extra == "test"
23
+ Requires-Dist: pytest-cov >= 4.0 ; extra == "test"
24
+ Requires-Dist: tomli >= 2.0 ; extra == "test" and ( python_version < "3.11")
25
+ Project-URL: Changelog, https://github.com/joedefen/console-window/blob/main/CHANGELOG.md
26
+ Project-URL: Homepage, https://github.com/joedefen/console-window
27
+ Project-URL: Issues, https://github.com/joedefen/console-window/issues
28
+ Project-URL: Repository, https://github.com/joedefen/console-window
29
+ Provides-Extra: dev
30
+ Provides-Extra: test
31
+
32
+
33
+ # console-window
34
+
35
+ `console-window` is a **stdlib-only, single-dependency-free curses framework** that is *not* a general widget toolkit. It is best understood as a **keyboard-driven, full-screen "pager + selection engine + screen-stack router"** built on raw curses **pads**. The genuinely novel ideas are:
36
+
37
+ - a separate **head pad / body pad** split with a static header band;
38
+ - a **scroll position** and an independent **pick (cursor) position** that stay synchronized;
39
+ - a **`Context` metadata object per line** with an `abut` concept (keep N lines around the picked row visible — perfect for expandable/transient detail rows);
40
+ - **`OptionSpinner` scoped key bindings** with last-writer-wins **scope subtraction** per screen.
41
+
42
+
43
+ The package abstracts away the complexities of managing curses pads, dynamic screen resizing, custom scroll/pick synchronization, and keyboard handling. Its strengths include:
44
+
45
+ 1. **Zero runtime dependencies.** The only requirements are the standard-library `curses` and `curses.textpad` modules — no third-party dependency tree and no associated supply-chain risk.
46
+ 2. **Efficient rendering.** Uses accumulated refreshes on the screen and pads with a single terminal update per frame, rather than flicker-prone full refreshes. A redraw path can recover the display from terminal corruption.
47
+ 3. **Two-axis scroll/pick model.** The scroll position and the pick (cursor) position are tracked independently and reconciled each frame. Most simple pagers conflate these; separating them is what enables kept-visible selection with context rows above and below.
48
+ 4. **Per-line metadata plus an `abut` concept.** Because context is attached to each line rather than parsed from text, and transient child rows auto-abut to their parent, expandable/transient detail rows stay attached to the row that produced them. "After trumps before" is a thoughtful touch.
49
+ 5. **Non-pickable rows are first-class.** Decorative separator and header lines are skipped by navigation and page movement, so they do not produce "dead" cursor stops; scrolling still reaches them, including a trailing block of them past the last pickable row.
50
+ 6. **Scoped, self-subtracting key bindings.** A later, narrower binding (e.g. `ESC` to clear a filter on one screen only) subtracts that screen from an earlier global binding. This is a real router, not a flat key map, and it actively rejects collisions with navigation keys.
51
+ 7. **Convention-based dispatch.** Actions map to matching handler methods on screen classes, keeping them free of central `if key == ...` towers.
52
+ 8. **Resize resilience.** Rendering retries during terminal resizes, dialogs drain resize events, and the window enforces a minimum size instead of crashing. The minimum starts at a small floor and is raised once, after the first screen is drawn, to fit that screen's header plus a few body rows.
53
+ 9. **Inline confirmation is unusually complete.** Tiered matching (exact, then case-insensitive, then abbreviation), an auto-calculated minimum abbreviation length, and six modes including identity matching and free text.
54
+ 10. **Real, dogfooded apps.** The API is validated by several downstream projects (listed below), not by theory.
55
+ 11. **Headless, testable core.** The layout math and a headless render backend let the whole engine be exercised without a TTY.
56
+ 12. **Display-cell correctness.** A small standard-library width engine accounts for wide CJK/emoji and zero-width combining marks, so layout and highlighting stay correct across the body, header, dialogs and the demos that exercise wide and combining text.
57
+
58
+
59
+ ## Installation
60
+
61
+ Install the package via `pip`:
62
+
63
+ ```bash
64
+ pip install console-window
65
+ ```
66
+
67
+ ## Development
68
+
69
+ Working on `console-window` itself — running the lettered demos, the demo
70
+ curriculum, the test gate and the release gate — is covered in
71
+ [`tests/README-DEV.md`](tests/README-DEV.md). None of it is needed to use the
72
+ installed package.
73
+
74
+ ## Core Features (The "Secret Sauce")
75
+
76
+ The wrapper handles several advanced terminal interaction patterns automatically:
77
+
78
+ * **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.
79
+ * **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.
80
+ * **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.
81
+ * **Graceful Color Degradation:** Themes are written against the 256-color xterm palette, but installation asks the backend how many colors the terminal actually exposes. A 256-color terminal gets the theme's literal specs; 16-, 88- and 8-color terminals get the nearest available palette entry, with a contrast guard so a pair never collapses to `fg == bg`; below 8 colors (or with no color support) the theme falls back to attributes. `-1` is only emitted when `use_default_colors()` succeeds, and `COLOR_PAIRS` is respected. Stock tmux (`TERM=screen`, 8 colors) therefore works instead of raising `ValueError: Color number is greater than COLORS-1`.
82
+ * **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously.
83
+ * When using pick mode, scrolling automatically tracks the highlighted element to keep it visible in `.pick_pos`.
84
+ * To use that value, you must separately be able to associate `.pick_pos` to its value by, say, keeping an array of values for every added line to the `body`.
85
+ * **Intuitive Navigation:** Provides built-in handling for common navigation keys (`j`/`k`, `UP`/`DOWN`, Page Up/Down, `H`/`M`/`L` for home/middle/last viewable line, etc.).
86
+ * **Dynamic Options Management (`OptionSpinner`):** The companion class simplifies creation of application settings, allowing users to **cycle values** or input strings using single keypresses (e.g., a theme name, or a validated free-text range).
87
+ * **Blocking Popups:** Includes fully implemented, blocking pop-up methods for user input (`.answer()`) and alerts (`.alert()`), handling the necessary temporary screen takeover and cursor management.
88
+
89
+ ## Usage
90
+
91
+ ### The shape of every app
92
+
93
+ A `Screen` owns its content and its pick/scroll position; the window owns the terminal, the pads and key routing. The loop is four calls, and rebuilding the whole page every pass is the sanctioned style:
94
+
95
+ ```python
96
+ from types import SimpleNamespace
97
+
98
+ from console_window import ConsoleWindow, ConsoleWindowOpts, Screen
99
+
100
+
101
+ class Pager(Screen):
102
+ def draw_screen(self) -> None:
103
+ self.add_header('hello - q or ESC to quit')
104
+ for i in range(100):
105
+ self.add_body(f'row {i}')
106
+
107
+
108
+ def main() -> None:
109
+ # ConsoleWindow.__init__ performs the curses setup (no curses.wrapper needed).
110
+ # No row sizing is required; max_rows is only a safety ceiling.
111
+ win = ConsoleWindow(opts=ConsoleWindowOpts(head_line=True))
112
+ app = SimpleNamespace(win=win)
113
+ screen = Pager(app)
114
+ try:
115
+ while True:
116
+ screen.clear() # content reset for the new pass
117
+ screen.draw_screen() # add_header()/add_body() only
118
+ win.render() # pads repaint, doupdate() diffs
119
+ key = win.prompt(seconds=0.5) # key code, or None on timeout
120
+ if key in (ord('q'), 27):
121
+ break
122
+ finally:
123
+ win.stop_curses()
124
+
125
+
126
+ if __name__ == '__main__':
127
+ main()
128
+ ```
129
+
130
+ `clear()` is also what hands the screen to the window, which is why it comes
131
+ first. The pick/scroll position, pick mode and free-scroll latch live on the
132
+ screen and survive each pass, so a rebuild never loses the user's place. For
133
+ more than one screen, subclass `Screen` and drive the screens with
134
+ `ScreenStack` — activation is a pointer swap, so returning to a screen restores
135
+ its position with no copying in the application.
136
+
137
+ ### Inline editing and key forwarding
138
+
139
+ `InlineConfirmation` is a curses-free line editor. `start(..., initial='x')` seeds the buffer with the caret at the end; `LEFT`/`RIGHT`, Home/End (and Ctrl-A/Ctrl-E), Backspace and Delete edit at the caret. The `get_display_string(prompt='…')` method returns the prompt plus the buffer and caret glyph, so wording and layout stay in the application, while `cursor_pos` (string index) and `cursor_cell` (terminal column, measured with `textwidth`) are available to callers that need them.
140
+
141
+ Passthrough widgets receive printable ASCII, navigation keys, Backspace, Delete, Home/End, ESC and the keys in `handled_keys` while `passthrough_mode` is `True`. For any additional application key, use the reversible context manager rather than mutating `handled_keys` by hand:
142
+ ```python
143
+ win.passthrough_mode = True
144
+ try:
145
+ with win.temp_handled_keys(27):
146
+ key = win.prompt(seconds=0.5)
147
+ finally:
148
+ win.passthrough_mode = False
149
+ ```
150
+
151
+ Only keys the context actually added are removed on exit, so a pre-existing binding is left in place and nested uses are safe.
152
+
153
+ ### Transient status on the separator row
154
+
155
+ `status()` declares a one-line message that is painted on the separator row — the row that also owns the scroll indicator — so the listing stays visible. Unlike `flash()`, which clears the whole screen, `status()` never takes over the display:
156
+
157
+ ```python
158
+ win.status('Backup tag failed', level='error', timeout=5.0)
159
+ ```
160
+
161
+ The message appears on the next `render()`/`prompt()`; `status()` never renders by itself. Levels `'info'`, `'warn'` and `'error'` select an attribute from `ConsoleWindow.STATUS_LEVELS` (`info` dim, `warn` bold, `error` bold+reverse); an unknown level falls back to `'info'` instead of raising, and `attr=` overrides the level for that message only. `timeout=None` keeps the message until it is replaced or cleared; any number expires it lazily at draw time. `status('')` and `clear_status()` remove it, and `current_status` returns `(text, attr)`.
162
+
163
+ The exact rendering rule is: the text starts at column 0 of the separator row and is truncated to leave **one blank cell between it and the scroll indicator**. If the window currently has no separator row, the text is still stored and `status()` returns `False` (the message is deferred, not lost); a later resize reveals it. With `head_line=False` there is no separator row at all, so `status()` also warns once per episode instead of painting over the first body row.
164
+
165
+ ### In-place prompts
166
+
167
+ `PromptLine` is the window-independent state machine: text, an editable buffer, an optional hint and completion callbacks. It never runs the callbacks itself; `take()` returns `(answer, on_confirm, on_cancel)` after cancelling the widget. The window wraps it with a lifecycle that keeps key routing and passthrough consistent:
168
+
169
+ ```python
170
+ win.begin_prompt('Overwrite the tag?', mode='y',
171
+ on_confirm=lambda answer: apply(answer))
172
+ while win.prompt_active:
173
+ key = win.prompt(seconds=0.5)
174
+ if win.handle_prompt_key(key) == 'continue':
175
+ continue # swallow every key while a prompt is live
176
+ break
177
+ win.render()
178
+ ```
179
+
180
+ While `prompt_active` is true, route **every** key through `handle_prompt_key()` and swallow a `'continue'` result, so no action key can fire underneath a live prompt. `begin_prompt()` turns `passthrough_mode` on and `end_prompt()` restores its previous value; completion callbacks run *after* the prompt is gone and passthrough is restored, so a callback may safely begin the next prompt (the "re-edit, then ENTER or ESC" re-ask path).
181
+
182
+ The display formats are fixed so applications do not have to reproduce them:
183
+
184
+ * single-key modes `Y`/`y`: `f'{prompt} [Press "{mode}" or ESC]'`, e.g. `Really quit? [Press "y" or ESC]`;
185
+ * every other mode: `f'{prompt}: {buffer}{caret}{rest}'`, e.g. `Value: 5│`.
186
+
187
+ `get_hint_string(width)` returns the hint (truncated when a width is given), and `get_choice_lines()` returns one row per choice, marking the currently resolved one: `➤ alpha` versus ` beta`. Drawing is the application's choice: `Screen.add_prompt_header()` puts a screen-wide question in the header band, while `Screen.add_prompt_lines()` appends a bold prompt row and a dim hint row as `Context(genre='TRANSIENT', pickable=False)` from the application's own row loop. The window never learns which item the prompt belongs to.
188
+
189
+ ### Read-only text screens
190
+
191
+ `BasicTextScreen` is the small `Screen` for preformatted, scrollable text. It resolves `app.win` at call time, disables pick mode, adds each header before the body, and adds each line with `add_body()` and no `Context` (so normal navigation scrolls it). Content is normalized by `set_text()`, which is safe before or after the first draw; the loop still calls `screen.clear()` before `draw_screen()`:
192
+
193
+ ```python
194
+ screen = BasicTextScreen(app, head='COMPARE',
195
+ lines=[('first difference', curses.A_BOLD),
196
+ 'second difference'])
197
+ screen.set_text(lines=['replacement'])
198
+ ```
199
+
200
+ `lines` items are `str` or `(str, attr)`; `head` may be a `str`, a `HeaderLine`, or an iterable of either. There is no pick mode, no action handler and no spinner: ESC/back uses the default `Screen.escape_ACTION` (pop), which the application may override.
201
+
202
+ ### Taking navigation keys for the application
203
+
204
+ Navigation keys are reserved by default, but an app may take specific ones for itself without monkeypatching the module global. Declare them on the window's options; the mapping value is the label the help screen shows:
205
+
206
+ ```python
207
+ import curses
208
+ from console_window import ConsoleWindowOpts, OptionSpinner
209
+
210
+ opts = ConsoleWindowOpts(
211
+ taken_nav_keys={
212
+ curses.KEY_LEFT: 'prev page',
213
+ curses.KEY_RIGHT: 'next page',
214
+ },
215
+ )
216
+ win = ConsoleWindow(opts=opts)
217
+ spin = OptionSpinner(stack=stack, nav_keys=opts.nav_keys) # accept them in add_key()
218
+ spin.add_key('prev', '⬅ - previous', genre='action', keys=[curses.KEY_LEFT])
219
+ ```
220
+
221
+ A taken key is withdrawn from navigation dispatch for that window only: it no longer scrolls, it is no longer advertised as navigation, and it takes the normal `handled_keys`/`relax_handled_keys` path on return. Text editing is unchanged — dialogs and widgets still use LEFT/RIGHT/HOME/END for the caret. Because ownership lives in `ConsoleWindowOpts`, two windows in one process can have different bindings; `NAVIGATION_KEYS` itself is never mutated. Taking every vertical key is allowed (and logged as "reduced navigation"), since the application is then responsible for its own scrolling.
222
+
223
+ `ScreenStack.perform_actions()` is the normal router: it walks the spinner options, honors their scopes, and calls the matching `<attr>_ACTION` method. For apps that dispatch an action themselves, `ScreenStack.act_in(action, screens=None)` is the low-level single-flag variant: it clears that flag from `stack.obj` and returns whether it was set and the current screen matched. `ScreenStack.is_curr()` answers the screen test on its own. A missing flag reads as `False`, so an options object need not pre-seed every action.
224
+
225
+ ### Help body, built-in actions and the spinner
226
+
227
+ A spinner registers two actions before the application registers anything: `quit` (`q`, `^C`) and `escape` (`ESC`, `^J`, `ENTER`). They are printed once in the `NavKeys:` block, never in the option body. `Screen` supplies the default handlers — `quit_ACTION()` exits and `escape_ACTION()` pops one screen (a no-op at home) — and an app overrides either method as usual. The key sets are constructor arguments:
228
+
229
+ ```python
230
+ spin = OptionSpinner(stack=stack, quit_keys=(ord('q'), ord('Q'), 0x3))
231
+ ```
232
+
233
+ A `ScreenStack` owns at most one spinner: the spinner injects itself as `stack.spinner`, and the stack assigns each screen its `num` and `stack`, so a help screen finds the spinner without scanning the application. Constructing a second spinner on the same stack raises `ValueError`.
234
+
235
+ The help body describes the screen the help was pushed from. It lists that screen's options -- global ones and those scoped to it -- under a `Keys for '<screen>':` heading. Its `cycle` and `prompt` options keep working while the help is shown, so a value can be changed without backing out; actions are listed for reference but do nothing until you return to their screen. The built-in `quit` and `escape` stay in the `NavKeys:` block. Nothing pushed (a stackless spinner, or a help screen that is the entry screen) means the complete option list under an `All keys:` heading, and when nothing applies the heading is omitted. `OptionSpinner.help_subject(screen)` names the described screen and `options_for_screen(screen)` is the scope test the body applies to it. A registration that would take a built-in key must name its screens explicitly (`scope=...`); an implicit registration raises, so a global option can no longer silently unbind `q`/`^C`/`ESC`.
236
+
237
+ The body is laid out for the width the window has when it draws, so a narrow terminal shows all of it instead of clipping the right edge. The value column is measured from the rows that screen actually lists -- an action's description sets no column, because nothing follows it -- and a row that would overflow wraps: values break between entries and continue under the column, a single value too long for one row is ellipsized, and comments wrap under the same column (or two cells in from the option, when the column leaves too little room for prose). The heading wraps the same way. When the column itself leaves fewer than eight cells to wrap values in, the layout degrades in two steps: the four-cell dot gap first, then a flat layout with the description on its own row and its values beneath it, indented two cells. A resize re-flows on the next draw.
238
+
239
+ Any `extra_help_paragraphs()` come last: `BasicHelpScreen` draws an `---------- Instructions ----------` separator and then each paragraph in the normal attribute, introduced by a ` ◆ ` bullet with the wrapped rows indented three cells. The built-in Fonts section follows that block; set `BasicHelpScreen.font_section = False` on a subclass to leave it out of one screen.
240
+
241
+ ### Singleton vs. per-instance windows
242
+
243
+ `ConsoleWindow` supports two process-wide models, selected by the constructor's `singleton` argument:
244
+
245
+ * **`singleton=True` (default)** — the historical process-wide model. Every window draws through one shared screen, `ConsoleWindow.static_scr` and `ConsoleWindow.static_win` are populated, and a second window reuses the first window's screen and backend. `stop_curses()` here *suspends* the shared screen and `start_curses()` resumes it (the pre-2.0 pair, kept working); `ConsoleWindow.stop_all()` is the permanent teardown and the `atexit` hook.
246
+ * **`singleton=False`** — per-instance windows. Each window owns its screen, pads and teardown; `win.stop_curses()` stops only that window for good, `ConsoleWindow.stop_all()` stops every live window, and the unbound `ConsoleWindow.stop_curses()` is an error.
247
+
248
+ ### Pausing to run another program
249
+
250
+ To shell out (an editor, a package manager, `efibootmgr`, a confirmation prompt) and then come back, suspend instead of stopping:
251
+
252
+ ```python
253
+ win.suspend_curses() # endwin(): the terminal is yours again
254
+ try:
255
+ ... prints, input(), subprocess.run(...) ...
256
+ finally:
257
+ win.resume_curses() # reattaches a screen; the next render() repaints
258
+ ```
259
+
260
+ `endwin()` restores the terminal without destroying the curses SCREEN, so the pads, the content model and the pick state all survive; resume refreshes the screen handle, backend, dimensions, pad backgrounds and redraw bookkeeping. In singleton mode `suspend_curses()` suspends every window that shares the screen, and `resume_curses()` reattaches all of them. The legacy pair works the same way there: `ConsoleWindow.stop_curses(); ...; ConsoleWindow.start_curses()`. The pre-2.0 instance hook `win._start_curses()` is also supported: it reattaches that window (resuming a suspended screen) and returns the live screen, so the old `win.stop_curses(); ...; win._start_curses()` sequence keeps working. Only `stop_all()` is a permanent teardown.
261
+
262
+ The first successful construction latches the mode for the whole process. Mixing the two values raises `AssertionError` rather than silently changing what teardown means. Use `singleton=False` in tests and in applications that create independent windows (for example, one real window plus a headless one).
263
+
264
+ ### Symbols on terminals that cannot draw them
265
+
266
+ The UI is built around non-ASCII symbols (`│`, `➤`, `█`, `⬅`, `➡`, `·`, box drawing). A terminal that cannot encode them does not fail loudly: it paints a box, or nothing, and the row quietly loses its meaning. `console-window` therefore keeps every symbol in one registry, in two spellings, and degrades by itself:
267
+
268
+ * **No code change.** A window detects the terminal when it is constructed — a non-UTF-8 output encoding or `TERM=dumb` selects the ASCII spellings — and text handed to `add_header()` / `add_body()` / `draw()` / `status()` / `flash()` is transliterated before it is measured, so widths and the run-length attribute counts stay correct.
269
+ * **Only registered symbols are rewritten.** `Grüße` stays `Grüße`: the substitution table contains the catalog's characters, never arbitrary text, so letters, digits and unregistered code points pass through byte for byte. A registered typographic mark does follow the mode, which is the point of it — an em dash inside file content becomes `--` on an ASCII terminal rather than a box. The eight spellings this library used to draw (`⮞`, `←`, `→`, `↑`, `↓`, `○`, `□`, `■`) are upgraded to their replacements (`➤`, `⬅`, `➡`, `⬆`, `⬇`, `◯`, `◻`, `◼`) in **both** modes, so text written against an older release renders from the font that has it — inside content as well. Registering an old code point yourself keeps it. Set `CONSOLE_WINDOW_GLYPH_AUDIT=1` to log each decorative code point you use but
270
+ did not register, with the exact `register()` call to add.
271
+
272
+ * **A catalog to choose from.** The registry carries about eighty named symbols, grouped as UI marks, frames, arrows, triangles, filled/hollow shape pairs, blocks and bars, marks, math, currency and typography. Every one is a single cell wide and is covered by the default monospace font on Debian, Ubuntu and Fedora (DejaVu Sans Mono), so it is safe to use without a patched font: no emoji, no icon fonts. `Glyphs.catalog()` returns the set in the active spelling and `Glyphs.catalog_rows(width)` packs it into display rows; the theme picker page prints all of them, so that page doubles as the font check. `Glyphs.catalog_entries()` adds the name and family (`Glyphs.group_of('infinity')` is `'math'`) for a table or a grouped listing, and `Glyphs.lookup()` returns the whole `Glyph`. A character has exactly one fallback even when two names draw it (`cursor_block` and `swatch` are the same block).
273
+ * **Two-cell marks, on purpose.** Some rows need a mark that is *seen*: a status column, a prompt badge. `Glyphs.marks()` is a small approved family (39 emoji) whose contract is width, not beauty: every entry is a single East-Asian-Width `W` code point, so it measures two cells in this library and in the terminal, and appending a variation selector cannot change that. `Glyphs.mark('ok')` returns the emoji, or its two-cell ASCII fallback after `--ascii`, so switching modes never moves a column; a name outside the family raises `KeyError`. `Glyphs.find_issues(text)` reports the four ways a mark is unsafe (VS16-promoted, zero-width joiner, East-Asian Ambiguous, unapproved) and says what to use instead, which is the quickest development-time check for a prompt or a table. The one-cell catalog above still promises no emoji; this is a separate family.
274
+ * **The font bill for both families, checked on the help page.** The one-cell catalog is covered by the stock monospace face: all 80 names (78 distinct characters) are in DejaVu Sans Mono, each at exactly the font's cell advance, so "no patched font, no icon fonts" is measured, not hoped for. The two-cell family needs one colour emoji font — Noto Color Emoji, Apple Color Emoji or Segoe UI Emoji — from mid-2019 or later: 🧲 is Emoji 11 (2018) and 🟢/🟡 are Emoji 12 (2019), everything else dates from 2016 or earlier. No single font covers both (DejaVu Sans Mono has none of the marks; Noto Color Emoji has 13 of the 78 glyphs), which is why the fallback is one switch rather than per-app font code. Every help page now draws the whole catalog and every mark with the unconditional advice to install those two families; `ConsoleWindowOpts(help_fonts='once' | 'always' | 'never')` decides how often (default `'once'`, the first help page the window shows), and a symbol registered by the app is checked against its family at registration time and named on the page if the font lacks it. The measurements are pinned by the test suite.
275
+ * **App vocabulary.** Register what only your app draws:
276
+
277
+ ```python
278
+ Glyphs.register_many({'tick': ('✓', 'OK')}, probe=('tick',))
279
+ ```
280
+
281
+ or pass it with the window: `ConsoleWindowOpts(glyphs={'tick': ('✓', 'OK')}, glyphs_probe=('tick',))`. Once registered, the glyph both degrades with `Glyphs.get('tick')` and appears in the font check via `Glyphs.probe_text()`.
282
+ * **The switches, with no argparse boilerplate.** The bundled helper adds them:
283
+
284
+ ```python
285
+ parser = cli.add_display_arguments(argparse.ArgumentParser())
286
+ opts = parser.parse_args()
287
+ cli.apply_display_arguments(opts, prog='myapp')
288
+ ```
289
+
290
+ `--ascii` forces the fallback (`--ascii=no` forces the full set) and `--glyphs {auto,ascii,unicode}` is its symmetric spelling; `--glyphs` wins if both are given. `--theme` seeds the first window's color theme, and `--theme mono` draws the semantic roles with bold/reverse attributes instead of color. A non-empty `NO_COLOR` selects `mono` when no explicit theme is given. Apps without argparse can use `CONSOLE_WINDOW_GLYPHS=auto|ascii|unicode`, which outranks detection.
291
+ * **The mode is process-wide**, like the color-pair table, because there is one terminal. `ConsoleWindowOpts(ascii=...)` seeds it and `win.ascii_mode` reads it; a window constructed later never reverts an explicit choice.
292
+ * **Help screens can explain it for you.** Return `help.theme_paragraph()` (and any prose of your own) from `Screen.extra_help_paragraphs()`; `BasicHelpScreen` adds the separator, the bullet and the wrapping. The symbol check is no longer part of that block: the first help page in a run gets the built-in Fonts section, later pages do not mention it, and `ConsoleWindowOpts(help_fonts=...)` can keep it on every page or turn it off.
293
+
294
+ Themes work the same way: `Theme.create_picker_screen(base, cycle_key=None, store=None, note=None, captions=None, include_defaults=True)` discovers the key you bound to `spin_theme` for its hint line, remembers the choice through any object with `load()`/`save()`, and `Theme.initial(cli_theme, store)` returns the theme a new process should start on. The library never opens a file. `Theme.list_all()` includes `mono`, so the picker cycles it like any other theme; its swatches follow the same switch, so a picker opened on `mono` previews the monochrome hierarchy rather than a previous theme's colors.
295
+
296
+ `captions` renames or re-describes the semantic roles the picker shows, which is useful when the application's vocabulary differs from the library's. The mapping is `role -> (label, usage)`, where a role can be a `Theme` constant or a stable name such as `'danger'`; `None` or `''` keeps the library text. With the default `include_defaults=True`, overrides replace captions in place and the library rows remain; with `include_defaults=False` the mapping is the whole list, in mapping order. Unknown or duplicate roles raise `ValueError` when the factory runs.
297
+
298
+ ```python
299
+ ThemeScreen = Theme.create_picker_screen(
300
+ DemoScreen,
301
+ cycle_key='t',
302
+ captions={
303
+ 'danger': ('FAILED', 'Boot or initramfs failed'),
304
+ 'warning': ('DEGRADED', ''), # keep the library usage text
305
+ },
306
+ )
307
+ ```
308
+
309
+ Override `swatch_attr()` on a subclass to style swatches differently, `swatch_rows()` for dynamic captions (translation, for example), and `theme_label()` only if an application adds its own pseudo-theme.
310
+
311
+ The appearance layer is shared too. `Appearance.semantic_attr(role)` returns the installed color pair for a semantic role, or the matching attribute from `Appearance.MONO_ROLES` when color is off or the terminal reports no color support; a lookup that fails before `initscr()` degrades to plain text, so a redraw is never aborted by a color. `Appearance.resolve_appearance(cli_theme=None, saved_theme='', environ=None)` returns the theme to start with: an explicit `--theme` first, then the `NO_COLOR` convention (`mono`), then the remembered choice. Pass the result to `Theme.set_active()`, which records it and synchronizes `Appearance.colors_enabled()` / `Appearance.set_colors_enabled()`; applications select `mono` rather than flipping the switch themselves. Apps keep their preference file and any additional pseudo-themes; the library gives them the policy, not the storage.
312
+
313
+ ## Versioning and Deprecation
314
+
315
+ `console-window` follows semantic versioning: patch releases fix behavior without changing the public API, minor releases add functionality, and major releases may make incompatible changes.
316
+
317
+ Deprecated APIs remain supported **indefinitely**. A deprecation is a request to migrate, not a scheduled removal: deprecated functions and parameters keep working, and one is removed only when there is significant value in doing so (for example, the API is actively harmful or blocks a necessary redesign). Deprecated paths emit a `DeprecationWarning` where practical.
318
+
319
+ ---
320
+
321
+ ### 3.0.0 Breakages
322
+
323
+ `3.0.0` is the first release after the 2.x line, so every deliberate break from 2.x is collected here, each with its replacement. Later 3.x releases are intended to remain upward compatible with this one.
324
+
325
+ **The Screen owns content.** Content and the pick/scroll state moved from `ConsoleWindow` onto `Screen`, which removes one of the two drawing models:
326
+
327
+ - **`win.add_header()`, `win.add_body()`, `win.add_fancy_header()`, `win.add_header_line()`, `win.add_prompt_header()` and `win.add_prompt_lines()` are gone.** Call them on the current `Screen` instead (`self.add_body(...)` inside `draw_screen()`). The window keeps `head`/`body` as read-only views.
328
+ - **`win.draw(y, x, text, ...)` is gone**, with `highlight_picked()` and the deferred `drawn` span store. There is one drawing model: append rows with `add_*()`; a row's column placement is composed by the application with `textwidth.string_width`/`ljust`/`truncate`.
329
+ - **Content and pick/scroll state are per screen.** `win.pick_pos`, `win.scroll_pos`, `win.pick_mode`, `win.free_scroll` and `win.pick_range` read and write the *current* screen; `pick_range` is no longer stored in `ConsoleWindowOpts`, so two screens can hold different ranges. `last_pick_pos` and `_pick_clear_pending` are gone with the incremental painter.
330
+ - **The loop is `screen.clear(); screen.draw_screen(); win.render(); win.prompt()`.** `Screen.clear()` is what makes the screen current, so it must be the first call. `ConsoleWindow.clear()` still works and resets the current screen's content, but it no longer wipes the physical screen (the pads repaint themselves every frame).
331
+ - **`head_line=False` now has no side effects.** It no longer paints a rule, a scroll indicator or a status line onto the body's first row, and in scroll mode the pick tracks the top of the viewport (`pick_pos == scroll_pos`).
332
+
333
+ **Appearance and themes.** There are renames for consistency and some simplifications:
334
+
335
+ - **`appearance` is now `Appearance`.** This matches the rest of the package root (`Theme`, `Glyphs`, `Context`, …).
336
+ - **`--no-color` is gone.** Select the real `mono` theme instead (`--theme mono`), or rely on the `NO_COLOR` environment convention. `cli.resolve_no_color()` and the `no_color=` / `saved_colors=` parameters of `Appearance.resolve_appearance()` were removed with it.
337
+ - **`Appearance.resolve_appearance()` returns one theme name**, not `(theme, colors)`. Apply it with `Theme.set_active()`; `mono` is the color-off state.
338
+ - **`dark-mono` / `light-mono` are now `dark` / `light`.** The old names are still accepted as aliases by `--theme`, remembered preferences and `Theme.resolve()`, but `Theme.list_all()` lists only the canonical names.
339
+ - **`mono` is a theme.** It installs only the terminal-default background and uses `Appearance.MONO_ROLES` for the semantic roles. It is terminal-global like any other theme install, because curses owns one color-pair table per terminal.
340
+
341
+ **The built-in symbols are the heavier, font-safe forms.** The library moved the symbols it draws itself to the heavier variant wherever one exists in the default monospace font:
342
+
343
+ - **The resolved-choice marker is `➤` (U+27A4), not `⮞` (U+2B9E).** U+2B9E is absent from DejaVu Sans Mono — the default on Debian, Ubuntu and Fedora — so the marker was drawn by whichever fallback font had it, which can be wider than one cell. `Glyphs.get('choice')`, `get_choice_lines()` and `InlineConfirmation` follow the new form; the ASCII fallback is still `>`. An application that hardcoded `⮞` should use `➤`, or register `⮞` itself to keep the old look.
344
+ - **The arrows are the black forms.** `⬅`/`➡` replace `←`/`→` (including the help legend's labels for taken keys) and `⬆`/`⬇` replace `↑`/`↓`. The light code points are no longer registered, so text containing them is not transliterated; `CONSOLE_WINDOW_GLYPH_AUDIT=1` reports them.
345
+ - **The hollow shapes are the heavier pair.** `◯` replaces `○`, and `◻`/`◼` replace `□`/`■`. The hollow triangles `△▽◁▷` keep their light form: Unicode has no heavy outline triangle.
346
+ - **Old spellings are upgraded in both modes.** `⮞`, `←`, `→`, `↑`, `↓`, `○`, `□` and `■` are rewritten to `➤`, `⬅`, `➡`, `⬆`, `⬇`, `◯`, `◻` and `◼` before the text is measured, in Unicode mode as well as ASCII. An unmodified application therefore picks up the font-safe shapes; registering one of the old code points (`register('choice', '⮞', '>')`) keeps it instead.
347
+
348
+ **The font check is built into the first help page.** `BasicHelpScreen` has a Fonts section that shows the whole one-cell catalog, every approved mark and the two font families to install when the terminal does not draw them. The rows are fixed-width blocks: five ten-cell blocks plus five `|` markers per row, with partial blocks padded with `.`, so a box, a blank or a shifted column breaks the alignment visibly. The symbols and the marks each start on their own row and fill out the last row of their section, so the two groups stay visibly separate. The section appears on the first help page of a run by default and later help pages do not mention it. Choose how often it appears with `ConsoleWindowOpts(help_fonts='once' | 'always' | 'never')` (a bool is accepted), or override it per screen with `BasicHelpScreen.font_section`.
349
+
350
+ ---
351
+
352
+ ## License
353
+ MIT
354
+
355
+ ## Comparable and Competing Projects
356
+
357
+ `console-window` is a niche tool. **For almost everyone, one of the projects below is the better choice** — reach for `console-window` specifically when you want a dependency-free, keyboard-driven list/detail viewer in the "pager meets selection engine" style and you are comfortable building your own widgets on top of it.
358
+
359
+ | Framework | Model | Widgets | Dependencies | Async/threads | Wide chars | Testing | Learning curve | Best for |
360
+ |---|---|---|---|---|---|---|---|---|
361
+ | **console-window** | head/body **pads** + scroll/pick + screen stack | you build views from your own classes; options, search bar, inline confirmation, dialogs | **none (stdlib)** | sync polling | yes (stdlib cell width) | `pytest`, TTY-free, render via a fake backend | low | keyboard list/detail viewers, "less-like" browsers |
362
+ | **urwid** | canvas + widget tree, event loop | rich (ListBox, Tree, Edit, Overlay, Columns/Pile) | `wcwidth` | event loops | yes (via wcwidth) | good (examples, docs) | medium | general full-screen apps |
363
+ | **Textual** | DOM + CSS, async, reactive | very rich; mouse, animations, web target | `rich`, `markdown-it`, `typing-extensions`, … | asyncio-first | yes | strong (pytest harness) | medium–high | modern, styled, ambitious TUIs |
364
+ | **prompt_toolkit** | input-centric full-screen | editing, completion, dialogs; limited layout widgets | `wcwidth` | yes | yes | strong | medium | REPLs, shells, rich input |
365
+ | **npyscreen** | forms/fields | form widgets (menus, multisets) | stdlib curses | no | partial | sparse | low | classic form-style admin TUIs |
366
+ | **py_cui** | widget grid | menus, scroll, forms | stdlib curses | no | partial | some | low | quick dashboards |
367
+ | **asciimatics** | screens + effects | widgets + animation engine | `pyfiglet`, `pillow` | no | partial | some | low–medium | animated/demo screens |
368
+ | **blessed** | raw terminfo control | none (primitives) | `jinxed` (Windows) | no | partial | some | low | custom low-level drawing |
369
+ | **Rich** | text/print styling (+Live/Layout) | none (not a TUI toolkit) | `markdown-it-py`, `pygments` | partial | yes | strong | low | pretty output/logs, not navigation |
370
+
371
+ **Where `console-window` wins:** zero dependencies; a tiny conceptual surface; excellent selection/paging semantics (`abut`, pick vs. scroll, decorative-row skipping); a scoped key router; efficient pad blitting; and a model already proven across several real apps.
372
+
373
+ **Where it loses to the big two (urwid / Textual):** no widget library, no mouse, no CSS/layout language, no async, no documentation site, single-window only. Textual in particular offers a testing framework and a modern developer experience a solo project cannot match — at the cost of a dependency tree and a very different paradigm.
374
+
375
+ **Closest functional peers:** `npyscreen` (form/list admin TUIs) and `py_cui` (dashboard grids) — both stdlib-curses, both simpler and less maintained than Textual/urwid. `console-window` is differentiated by its pager-centric selection model, which those two lack.
376
+
377
+ ## Projects Using console-window
378
+ For more extensive examples, you can look at some projects using `console-window`:
379
+
380
+ * [efibootdude](https://github.com/joedefen/efibootdude)
381
+ * simple wrapper for `efibootmgr`
382
+ * one of the smaller projects
383
+ * demonstrates a context sensitive available keys lines (i.e., first line) where the actions depend on current line.
384
+ * [dwipe](https://github.com/joedefen/dwipe)
385
+ * disk cleaner with parallel jobs and stamps wiped partitions
386
+ * [pmemstat](https://github.com/joedefen/pmemstat)
387
+ * system/process memory monitor with rollups and hooks for zRAM
388
+ * [memfo](https://github.com/joedefen/memfo)
389
+ * viewer of /proc/meminfo that shows historical values
390
+ * [vappman](https://github.com/joedefen/vappman)
391
+ * convenince wrapper to mostly hide CLI of [AppMan](https://github.com/ivan-hc/AppMan), a life-cycle manager for 2000+ AppImages
392
+