console-window 2.0.0.5__tar.gz → 2.0.0.7__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 (45) hide show
  1. {console_window-2.0.0.5 → console_window-2.0.0.7}/CHANGELOG.md +34 -0
  2. {console_window-2.0.0.5 → console_window-2.0.0.7}/PKG-INFO +32 -7
  3. {console_window-2.0.0.5 → console_window-2.0.0.7}/README.md +31 -6
  4. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/__init__.py +1 -1
  5. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/backend.py +95 -8
  6. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/theme.py +183 -14
  7. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/window.py +176 -21
  8. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_backend.py +72 -2
  9. console_window-2.0.0.7/tests/test_low_colour.py +221 -0
  10. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_theme.py +15 -4
  11. {console_window-2.0.0.5 → console_window-2.0.0.7}/.github/workflows/ci.yml +0 -0
  12. {console_window-2.0.0.5 → console_window-2.0.0.7}/.gitignore +0 -0
  13. {console_window-2.0.0.5 → console_window-2.0.0.7}/AUDIT.md +0 -0
  14. {console_window-2.0.0.5 → console_window-2.0.0.7}/LICENSE +0 -0
  15. {console_window-2.0.0.5 → console_window-2.0.0.7}/SampleApp.py +0 -0
  16. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/context.py +0 -0
  17. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/geometry.py +0 -0
  18. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/header.py +0 -0
  19. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/keys.py +0 -0
  20. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/opts.py +0 -0
  21. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/screens.py +0 -0
  22. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/signals.py +0 -0
  23. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/spinner.py +0 -0
  24. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/textwidth.py +0 -0
  25. {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/widgets.py +0 -0
  26. {console_window-2.0.0.5 → console_window-2.0.0.7}/pyproject.toml +0 -0
  27. {console_window-2.0.0.5 → console_window-2.0.0.7}/runner +0 -0
  28. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_context.py +0 -0
  29. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_demo_mode.py +0 -0
  30. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_dialogs.py +0 -0
  31. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_flash.py +0 -0
  32. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_geometry.py +0 -0
  33. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_header.py +0 -0
  34. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_interactive.py +0 -0
  35. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_overflow.py +0 -0
  36. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_public_api.py +0 -0
  37. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_render_retry.py +0 -0
  38. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_sample_app.py +0 -0
  39. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_screens.py +0 -0
  40. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_signals.py +0 -0
  41. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_spinner.py +0 -0
  42. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_textwidth.py +0 -0
  43. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_viewport_pad.py +0 -0
  44. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_widechars.py +0 -0
  45. {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_widgets.py +0 -0
@@ -6,6 +6,19 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
  ## [Unreleased]
7
7
 
8
8
  ### Added
9
+ - `suspend_curses()`/`resume_curses()` on `ConsoleWindow`: leave curses mode
10
+ with `endwin()` while keeping the window (pads, content model, pick state)
11
+ alive, then reattach a screen and repaint. This is the supported way for an
12
+ app to shell out to a command or an editor and come back. The pads survive
13
+ because `endwin()` restores the terminal without destroying the curses
14
+ SCREEN; `resume_curses()` refreshes the screen handle, backend, dimensions,
15
+ pad backgrounds and redraw bookkeeping.
16
+ - Singleton-mode compatibility for the pre-2.0 start/stop pair:
17
+ `stop_curses()` now *suspends* the shared screen and `start_curses()`
18
+ resumes it, so `stop_curses(); ...; start_curses()` works again (this is what
19
+ efibootdude's write/reboot paths needed). `ConsoleWindow.stop_all()` remains
20
+ the permanent teardown and is the atexit hook; per-instance windows keep
21
+ `stop_curses()` as a one-way teardown and use suspend/resume explicitly.
9
22
  - Per-window themes. `ConsoleWindow.theme` records the theme a window is on,
10
23
  `ConsoleWindowOpts(theme=...)` seeds it, and `ConsoleWindow.set_theme()`
11
24
  switches it: colours are reinstalled through the window's own backend (curses
@@ -122,6 +135,27 @@ This project follows [Semantic Versioning](https://semver.org/).
122
135
  No in-repo code used it.
123
136
 
124
137
  ### Fixed
138
+ - Starting a window on a terminal with fewer than 256 colours (including tmux's
139
+ stock `TERM=screen`, whose terminfo entry has 8 colours) no longer crashes with
140
+ `ValueError: Color number is greater than COLORS-1`. Theme installation now
141
+ asks the backend for its capabilities through the new
142
+ `RenderBackend.colors()` / `color_pairs()` seam (captured after
143
+ `start_color()`), so `theme.py` and `window.py` never read the `curses`
144
+ globals directly. On 256-colour terminals the theme's literal specs are used
145
+ unchanged; on 16/88/8-colour terminals each index is mapped to the nearest
146
+ entry of the standard xterm palette (base colours 0..15, the 6x6x6 cube and
147
+ the grayscale ramp are modelled), with a contrast guard that moves the
148
+ foreground if mapping would collapse a pair to `fg == bg`. Below 8 colours, or
149
+ when colours are unavailable, installation falls back to attributes instead
150
+ of raising. `use_default_colors()` is now guarded by `except curses.error` and
151
+ its result is remembered, so `-1` becomes a concrete default (fg 7, bg 0) on
152
+ terminals that reject it; pair ids at or past `COLOR_PAIRS` are skipped.
153
+ Startup, `ConsoleWindow.set_theme()` and `Theme.set()` all install through the
154
+ new `Theme.install()`, and `Theme.set()` no longer calls `curses.init_pair()`
155
+ directly, so the built-in theme picker gets the same degradation.
156
+ `HeadlessBackend(colors=...)` now accepts a colour count (plus optional
157
+ `color_pairs=` and `default_colors=`) so the low-colour paths are covered by
158
+ `tests/test_low_colour.py`.
125
159
  - `draw()` now routes rows at or past the `max_rows` content ceiling through the
126
160
  same `rows_dropped` counter and once-per-fill warning as `add_body()` /
127
161
  `add_header()`, with the warning attributed to the caller's line. Previously
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 2.0.0.5
3
+ Version: 2.0.0.7
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
@@ -71,6 +71,7 @@ The wrapper handles several advanced terminal interaction patterns automatically
71
71
  * **Viewport-Sized Pads:** Separate `head` and `body` pads give the static header band and the independently scrolling body, but the pads are blit buffers sized to the terminal rather than content stores. `add_body()`/`add_header()`/`draw()` write the model, and `render()` paints the visible page into pad row 0, so pad memory and per-frame `clear()` cost stay constant no matter how many rows an app adds. Row counts need no configuration at all: `opts.body_rows` / `opts.head_rows` are advisory hints only, and `opts.max_rows` (default `32767`, `None` for unlimited) is the single content ceiling.
72
72
  * **Loud Content Limits:** A row added past `opts.max_rows` is discarded, but no longer quietly: the first discarded row of each fill episode emits a `UserWarning` naming the namespace and the option to change, and every discarded row is counted in `ConsoleWindow.rows_dropped`. It stays non-fatal — run `python -W error::UserWarning` if you would rather it killed the app.
73
73
  * **Seamless Resizing:** Automatically detects terminal size changes (`KEY_RESIZE`) and recalculates layout dimensions, with robust error handling to prevent common `curses.error` crashes during resizing.
74
+ * **Graceful Colour Degradation:** Themes are written against the 256-colour xterm palette, but installation asks the backend how many colours the terminal actually exposes. A 256-colour terminal gets the theme's literal specs; 16-, 88- and 8-colour terminals get the nearest available palette entry, with a contrast guard so a pair never collapses to `fg == bg`; below 8 colours (or with no colour 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 colours) therefore works instead of raising `ValueError: Color number is greater than COLORS-1`.
74
75
  * **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously.
75
76
  * When using pick mode, scrolling automatically tracks the highlighted element to keep it visible in `.pick_pos`.
76
77
  * 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`.
@@ -110,7 +111,10 @@ demonstrates:
110
111
  per-window state: `ConsoleWindowOpts(theme=...)` seeds `window.theme`,
111
112
  `window.set_theme()` switches it (and re-initialises the terminal's colour
112
113
  pairs, which curses keeps per terminal rather than per window), and
113
- `Theme.set()` remains the process-wide switch.
114
+ `Theme.set()` remains the process-wide switch. Theme names are semantic:
115
+ asking for `nord` on an 8-colour terminal installs the closest supported
116
+ representation rather than raising, and startup, `set_theme()` and
117
+ `Theme.set()` all share `Theme.install()`.
114
118
  * **Dialogs** — `a` calls `answer()` for text input, `l` calls `alert()`, and `n`
115
119
  drives `answer()` from an `OptionSpinner` option (`add_key(..., prompt=...)`).
116
120
  `answer()` returns `None` only when the user aborts; if the terminal cannot
@@ -191,15 +195,36 @@ as `False`, so an options object need not pre-seed every action.
191
195
 
192
196
  * **`singleton=True` (default)** — the historical process-wide model. Every
193
197
  window draws through one shared screen, `ConsoleWindow.static_scr` and
194
- `ConsoleWindow.static_win` are populated, and the unbound
195
- `ConsoleWindow.stop_curses()` tears that screen down. Calling
196
- `win.stop_curses()` on any singleton window does the same. A second window
197
- reuses the first window's screen and backend.
198
+ `ConsoleWindow.static_win` are populated, and a second window reuses the
199
+ first window's screen and backend. `stop_curses()` here *suspends* the shared
200
+ screen and `start_curses()` resumes it (the pre-2.0 pair, kept working);
201
+ `ConsoleWindow.stop_all()` is the permanent teardown and the `atexit` hook.
198
202
  * **`singleton=False`** — per-instance windows. Each window owns its screen,
199
- pads and teardown; `win.stop_curses()` stops only that window,
203
+ pads and teardown; `win.stop_curses()` stops only that window for good,
200
204
  `ConsoleWindow.stop_all()` stops every live window, and the unbound
201
205
  `ConsoleWindow.stop_curses()` is an error.
202
206
 
207
+ ### Pausing to run another program
208
+
209
+ To shell out (an editor, a package manager, `efibootmgr`, a confirmation
210
+ prompt) and then come back, suspend instead of stopping:
211
+
212
+ ```python
213
+ win.suspend_curses() # endwin(): the terminal is yours again
214
+ try:
215
+ ... prints, input(), subprocess.run(...) ...
216
+ finally:
217
+ win.resume_curses() # reattaches a screen; the next render() repaints
218
+ ```
219
+
220
+ `endwin()` restores the terminal without destroying the curses SCREEN, so the
221
+ pads, the content model and the pick state all survive; resume refreshes the
222
+ screen handle, backend, dimensions, pad backgrounds and redraw bookkeeping.
223
+ In singleton mode `suspend_curses()` suspends every window that shares the
224
+ screen, and `resume_curses()` reattaches all of them. The legacy pair works the
225
+ same way there: `ConsoleWindow.stop_curses(); ...; ConsoleWindow.start_curses()`.
226
+ Only `stop_all()` is a permanent teardown.
227
+
203
228
  The first successful construction latches the mode for the whole process.
204
229
  Mixing the two values raises `AssertionError` rather than silently changing what
205
230
  teardown means. Use `singleton=False` in tests and in applications that create
@@ -52,6 +52,7 @@ The wrapper handles several advanced terminal interaction patterns automatically
52
52
  * **Viewport-Sized Pads:** Separate `head` and `body` pads give the static header band and the independently scrolling body, but the pads are blit buffers sized to the terminal rather than content stores. `add_body()`/`add_header()`/`draw()` write the model, and `render()` paints the visible page into pad row 0, so pad memory and per-frame `clear()` cost stay constant no matter how many rows an app adds. Row counts need no configuration at all: `opts.body_rows` / `opts.head_rows` are advisory hints only, and `opts.max_rows` (default `32767`, `None` for unlimited) is the single content ceiling.
53
53
  * **Loud Content Limits:** A row added past `opts.max_rows` is discarded, but no longer quietly: the first discarded row of each fill episode emits a `UserWarning` naming the namespace and the option to change, and every discarded row is counted in `ConsoleWindow.rows_dropped`. It stays non-fatal — run `python -W error::UserWarning` if you would rather it killed the app.
54
54
  * **Seamless Resizing:** Automatically detects terminal size changes (`KEY_RESIZE`) and recalculates layout dimensions, with robust error handling to prevent common `curses.error` crashes during resizing.
55
+ * **Graceful Colour Degradation:** Themes are written against the 256-colour xterm palette, but installation asks the backend how many colours the terminal actually exposes. A 256-colour terminal gets the theme's literal specs; 16-, 88- and 8-colour terminals get the nearest available palette entry, with a contrast guard so a pair never collapses to `fg == bg`; below 8 colours (or with no colour 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 colours) therefore works instead of raising `ValueError: Color number is greater than COLORS-1`.
55
56
  * **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously.
56
57
  * When using pick mode, scrolling automatically tracks the highlighted element to keep it visible in `.pick_pos`.
57
58
  * 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`.
@@ -91,7 +92,10 @@ demonstrates:
91
92
  per-window state: `ConsoleWindowOpts(theme=...)` seeds `window.theme`,
92
93
  `window.set_theme()` switches it (and re-initialises the terminal's colour
93
94
  pairs, which curses keeps per terminal rather than per window), and
94
- `Theme.set()` remains the process-wide switch.
95
+ `Theme.set()` remains the process-wide switch. Theme names are semantic:
96
+ asking for `nord` on an 8-colour terminal installs the closest supported
97
+ representation rather than raising, and startup, `set_theme()` and
98
+ `Theme.set()` all share `Theme.install()`.
95
99
  * **Dialogs** — `a` calls `answer()` for text input, `l` calls `alert()`, and `n`
96
100
  drives `answer()` from an `OptionSpinner` option (`add_key(..., prompt=...)`).
97
101
  `answer()` returns `None` only when the user aborts; if the terminal cannot
@@ -172,15 +176,36 @@ as `False`, so an options object need not pre-seed every action.
172
176
 
173
177
  * **`singleton=True` (default)** — the historical process-wide model. Every
174
178
  window draws through one shared screen, `ConsoleWindow.static_scr` and
175
- `ConsoleWindow.static_win` are populated, and the unbound
176
- `ConsoleWindow.stop_curses()` tears that screen down. Calling
177
- `win.stop_curses()` on any singleton window does the same. A second window
178
- reuses the first window's screen and backend.
179
+ `ConsoleWindow.static_win` are populated, and a second window reuses the
180
+ first window's screen and backend. `stop_curses()` here *suspends* the shared
181
+ screen and `start_curses()` resumes it (the pre-2.0 pair, kept working);
182
+ `ConsoleWindow.stop_all()` is the permanent teardown and the `atexit` hook.
179
183
  * **`singleton=False`** — per-instance windows. Each window owns its screen,
180
- pads and teardown; `win.stop_curses()` stops only that window,
184
+ pads and teardown; `win.stop_curses()` stops only that window for good,
181
185
  `ConsoleWindow.stop_all()` stops every live window, and the unbound
182
186
  `ConsoleWindow.stop_curses()` is an error.
183
187
 
188
+ ### Pausing to run another program
189
+
190
+ To shell out (an editor, a package manager, `efibootmgr`, a confirmation
191
+ prompt) and then come back, suspend instead of stopping:
192
+
193
+ ```python
194
+ win.suspend_curses() # endwin(): the terminal is yours again
195
+ try:
196
+ ... prints, input(), subprocess.run(...) ...
197
+ finally:
198
+ win.resume_curses() # reattaches a screen; the next render() repaints
199
+ ```
200
+
201
+ `endwin()` restores the terminal without destroying the curses SCREEN, so the
202
+ pads, the content model and the pick state all survive; resume refreshes the
203
+ screen handle, backend, dimensions, pad backgrounds and redraw bookkeeping.
204
+ In singleton mode `suspend_curses()` suspends every window that shares the
205
+ screen, and `resume_curses()` reattaches all of them. The legacy pair works the
206
+ same way there: `ConsoleWindow.stop_curses(); ...; ConsoleWindow.start_curses()`.
207
+ Only `stop_all()` is a permanent teardown.
208
+
184
209
  The first successful construction latches the mode for the whole process.
185
210
  Mixing the two values raises `AssertionError` rather than silently changing what
186
211
  teardown means. Use `singleton=False` in tests and in applications that create
@@ -1,6 +1,6 @@
1
1
  # Single source of truth for the package version: pyproject.toml declares
2
2
  # ``dynamic = ["version"]``, so flit reads this value instead of duplicating it.
3
- __version__ = "2.0.0.5"
3
+ __version__ = "2.0.0.7"
4
4
 
5
5
  from .context import Context
6
6
  from .header import HeaderLine, Span
@@ -91,11 +91,17 @@ class RenderBackend(Protocol):
91
91
  def has_colors(self) -> bool:
92
92
  ...
93
93
 
94
+ def colors(self) -> int:
95
+ """Number of colours the terminal supports (0 when unknown/none)."""
96
+
97
+ def color_pairs(self) -> int:
98
+ """Size of the terminal's colour-pair table."""
99
+
94
100
  def start_color(self) -> None:
95
101
  ...
96
102
 
97
- def use_default_colors(self) -> None:
98
- ...
103
+ def use_default_colors(self) -> bool:
104
+ """Enable ``-1`` as "terminal default"; return whether it worked."""
99
105
 
100
106
  def init_pair(self, pair: int, fg: int, bg: int) -> None:
101
107
  ...
@@ -108,6 +114,19 @@ class RenderBackend(Protocol):
108
114
 
109
115
 
110
116
  class CursesBackend:
117
+ # Curses exposes COLORS/COLOR_PAIRS only after start_color(), so the values
118
+ # are captured there. Before that, colours()/color_pairs() fall back to the
119
+ # module globals (valid once initscr() has run) and finally to a conservative
120
+ # default so a caller never sees a bogus zero-capability terminal merely
121
+ # because it constructed a second backend for an already-running curses
122
+ # session.
123
+ _FALLBACK_COLOR_PAIRS = 64 # the ncurses floor for a colour terminal
124
+
125
+ def __init__(self) -> None:
126
+ self._colors = 0
127
+ self._color_pairs = 0
128
+ self._default_colors = False
129
+
111
130
  def start(self, timeout_ms: int) -> Surface:
112
131
  scr = curses.initscr()
113
132
  curses.set_escdelay(25) # Reduce ESC key delay from 1000ms to 25ms
@@ -140,11 +159,55 @@ class CursesBackend:
140
159
  except curses.error: # curses not initialised yet
141
160
  return False
142
161
 
162
+ def colors(self) -> int:
163
+ if self._colors > 0:
164
+ return self._colors
165
+ try:
166
+ return max(int(getattr(curses, 'COLORS', 0)), 0)
167
+ except (AttributeError, curses.error, TypeError, ValueError):
168
+ return 0
169
+
170
+ def color_pairs(self) -> int:
171
+ if self._color_pairs > 0:
172
+ return self._color_pairs
173
+ try:
174
+ pairs = max(int(getattr(curses, 'COLOR_PAIRS', 0)), 0)
175
+ except (AttributeError, curses.error, TypeError, ValueError):
176
+ pairs = 0
177
+ if pairs > 0:
178
+ return pairs
179
+ # A colour-capable terminal always has at least the ncurses minimum;
180
+ # reporting 0 here would make a theme install silently do nothing.
181
+ return self._FALLBACK_COLOR_PAIRS if self.colors() > 0 else 0
182
+
143
183
  def start_color(self) -> None:
144
- curses.start_color()
184
+ try:
185
+ curses.start_color()
186
+ except curses.error:
187
+ # No colour support after all: degrade to monochrome instead of
188
+ # letting the caller crash during startup.
189
+ self._colors = 0
190
+ self._color_pairs = 0
191
+ return
192
+ try:
193
+ self._colors = max(int(getattr(curses, 'COLORS', 0)), 0)
194
+ except (AttributeError, curses.error, TypeError, ValueError):
195
+ self._colors = 0
196
+ try:
197
+ self._color_pairs = max(int(getattr(curses, 'COLOR_PAIRS', 0)), 0)
198
+ except (AttributeError, curses.error, TypeError, ValueError):
199
+ self._color_pairs = 0
145
200
 
146
- def use_default_colors(self) -> None:
147
- curses.use_default_colors()
201
+ def use_default_colors(self) -> bool:
202
+ try:
203
+ curses.use_default_colors()
204
+ except curses.error:
205
+ # Some terminals reject -1; the theme resolver then substitutes
206
+ # concrete defaults rather than passing -1 to init_pair().
207
+ self._default_colors = False
208
+ else:
209
+ self._default_colors = True
210
+ return self._default_colors
148
211
 
149
212
  def init_pair(self, pair: int, fg: int, bg: int) -> None:
150
213
  curses.init_pair(pair, fg, bg)
@@ -216,13 +279,27 @@ class _NoopSurface:
216
279
 
217
280
 
218
281
  class HeadlessBackend:
219
- def __init__(self, rows: int = 24, cols: int = 80, colors: bool = False) -> None:
282
+ def __init__(self, rows: int = 24, cols: int = 80,
283
+ colors: bool | int = False,
284
+ color_pairs: int | None = None,
285
+ default_colors: bool = True) -> None:
220
286
  self.rows = rows
221
287
  self.cols = cols
222
288
  self.screen = _NoopSurface(rows, cols)
223
289
  self.pads: list[_NoopSurface] = []
224
290
  self.stopped = False
225
- self._colors = colors
291
+ # ``colors`` used to be a bare bool. It still accepts one (True means a
292
+ # 256-colour terminal, False means none) but now also accepts the exact
293
+ # count, so the low-colour degradation paths are unit-testable without a
294
+ # real terminal.
295
+ if isinstance(colors, bool):
296
+ self._colors = 256 if colors else 0
297
+ else:
298
+ self._colors = max(int(colors), 0)
299
+ if color_pairs is None:
300
+ color_pairs = 64 if 0 < self._colors <= 16 else 256
301
+ self._color_pairs = max(int(color_pairs), 0)
302
+ self._default_colors = bool(default_colors)
226
303
  # Process-global curses calls the window used to make directly are
227
304
  # recorded here instead, so the dialog/render paths are backend-driven
228
305
  # and observable headlessly.
@@ -236,6 +313,9 @@ class HeadlessBackend:
236
313
 
237
314
  def start(self, timeout_ms: int) -> Surface:
238
315
  self.screen.timeout(timeout_ms)
316
+ # A backend that has been (re)started is live again; resume paths rely
317
+ # on this, so a suspend/resume cycle does not leave ``stopped`` latched.
318
+ self.stopped = False
239
319
  return self.screen
240
320
 
241
321
  def newpad(self, rows: int, cols: int) -> Surface:
@@ -264,13 +344,20 @@ class HeadlessBackend:
264
344
  self.screen.queue_keys(*keys)
265
345
 
266
346
  def has_colors(self) -> bool:
347
+ return self._colors > 0
348
+
349
+ def colors(self) -> int:
267
350
  return self._colors
268
351
 
352
+ def color_pairs(self) -> int:
353
+ return self._color_pairs
354
+
269
355
  def start_color(self) -> None:
270
356
  self.start_color_calls += 1
271
357
 
272
- def use_default_colors(self) -> None:
358
+ def use_default_colors(self) -> bool:
273
359
  self.use_default_colors_calls += 1
360
+ return self._default_colors
274
361
 
275
362
  def init_pair(self, pair: int, fg: int, bg: int) -> None:
276
363
  self.init_pair_calls.append((pair, fg, bg))
@@ -3,6 +3,138 @@ from __future__ import annotations
3
3
  import curses
4
4
  from typing import ClassVar
5
5
 
6
+ from .backend import CursesBackend, RenderBackend
7
+
8
+ # --- palette model -----------------------------------------------------------
9
+ # Standard xterm palette model. 0..15 are the ANSI/base colours, 16..231 the
10
+ # 6x6x6 colour cube with the levels below, and 232..255 the grayscale ramp
11
+ # ``8 + 10 * n``. The RGB values are the conventional xterm ones; exact
12
+ # fidelity is not required because the result only picks the closest entry a
13
+ # low-colour terminal can actually display.
14
+ _ANSI_RGB: tuple[tuple[int, int, int], ...] = (
15
+ (0, 0, 0), # 0 black
16
+ (128, 0, 0), # 1 red
17
+ (0, 128, 0), # 2 green
18
+ (128, 128, 0), # 3 yellow
19
+ (0, 0, 128), # 4 blue
20
+ (128, 0, 128), # 5 magenta
21
+ (0, 128, 128), # 6 cyan
22
+ (192, 192, 192), # 7 white
23
+ (128, 128, 128), # 8 bright black
24
+ (255, 0, 0), # 9 bright red
25
+ (0, 255, 0), # 10 bright green
26
+ (255, 255, 0), # 11 bright yellow
27
+ (0, 0, 255), # 12 bright blue
28
+ (255, 0, 255), # 13 bright magenta
29
+ (0, 255, 255), # 14 bright cyan
30
+ (255, 255, 255), # 15 bright white
31
+ )
32
+ _CUBE_LEVELS: tuple[int, ...] = (0, 95, 135, 175, 215, 255)
33
+
34
+
35
+ def palette_rgb(index: int) -> tuple[int, int, int]:
36
+ """Return the conventional xterm RGB value for palette *index*.
37
+
38
+ Unknown/negative indices (``-1`` is "terminal default", resolved before
39
+ this function is called) fall back to black so callers never raise.
40
+ """
41
+ if 0 <= index < 16:
42
+ return _ANSI_RGB[index]
43
+ if 16 <= index <= 231:
44
+ offset = index - 16
45
+ return (
46
+ _CUBE_LEVELS[offset // 36],
47
+ _CUBE_LEVELS[(offset // 6) % 6],
48
+ _CUBE_LEVELS[offset % 6],
49
+ )
50
+ if 232 <= index <= 255:
51
+ level = 8 + 10 * (index - 232)
52
+ return (level, level, level)
53
+ return (0, 0, 0)
54
+
55
+
56
+ def _rgb_distance(left: tuple[int, int, int],
57
+ right: tuple[int, int, int]) -> int:
58
+ return sum((a - b) ** 2 for a, b in zip(left, right, strict=False))
59
+
60
+
61
+ def nearest_palette_index(index: int, colors: int) -> int:
62
+ """Map 256-colour *index* to the nearest entry a *colors*-terminal has.
63
+
64
+ Entries 0..15 are the base palette, so the search is restricted to
65
+ ``0 .. min(colors, 16) - 1``. A 256-colour terminal gets *index* back
66
+ unchanged (exact theme specs, no visual regression). Returns ``-1`` when
67
+ the terminal has no usable colours at all.
68
+ """
69
+ if colors >= 256:
70
+ return index
71
+ limit = max(0, min(int(colors), 16))
72
+ if limit <= 0:
73
+ return -1
74
+ if 0 <= index < limit:
75
+ return index
76
+ target = palette_rgb(index)
77
+ best = 0
78
+ best_distance = _rgb_distance(target, palette_rgb(0))
79
+ for candidate in range(1, limit):
80
+ distance = _rgb_distance(target, palette_rgb(candidate))
81
+ if distance < best_distance:
82
+ best = candidate
83
+ best_distance = distance
84
+ return best
85
+
86
+
87
+ def _contrast_partner(color: int, colors: int) -> int | None:
88
+ """Return the palette entry furthest from *color*, or None if impossible."""
89
+ limit = max(0, min(int(colors), 16))
90
+ if limit <= 1:
91
+ return None
92
+ base = palette_rgb(color)
93
+ best: int | None = None
94
+ best_distance = -1
95
+ for candidate in range(limit):
96
+ if candidate == color:
97
+ continue
98
+ distance = _rgb_distance(base, palette_rgb(candidate))
99
+ if distance > best_distance:
100
+ best = candidate
101
+ best_distance = distance
102
+ return best
103
+
104
+
105
+ def resolve_color_pair(fg: int, bg: int, colors: int,
106
+ default_colors: bool) -> tuple[int, int] | None:
107
+ """Resolve a theme's ``(fg, bg)`` to values *colors* can accept.
108
+
109
+ ``-1`` is passed through only when :meth:`RenderBackend.use_default_colors`
110
+ succeeded; otherwise it is replaced with concrete defaults (fg 7, bg 0).
111
+ On terminals with fewer than 256 colours each index is mapped to the
112
+ nearest available palette entry. If the mapping would collapse the pair to
113
+ ``fg == bg`` the foreground is moved to the most contrasting entry. Returns
114
+ ``None`` when no legible pair exists (e.g. a 1-colour terminal), in which
115
+ case the caller falls back to attributes and installs nothing.
116
+ """
117
+ if colors <= 0:
118
+ return None
119
+ if fg == -1 and not default_colors:
120
+ fg = 7
121
+ if bg == -1 and not default_colors:
122
+ bg = 0
123
+ if colors < 256:
124
+ if fg != -1:
125
+ fg = nearest_palette_index(fg, colors)
126
+ if bg != -1:
127
+ bg = nearest_palette_index(bg, colors)
128
+ # A pair of terminal defaults is left alone: the terminal guarantees a
129
+ # legible default combination, and preserving (-1, -1) keeps the 256-colour
130
+ # path byte-for-byte identical to the theme spec.
131
+ if fg != -1 and bg != -1 and fg == bg:
132
+ partner = _contrast_partner(bg, colors)
133
+ if partner is None:
134
+ return None
135
+ fg = partner
136
+ return fg, bg
137
+
6
138
 
7
139
  class Theme:
8
140
  # Semantic color-pair IDs. The pairs themselves are (re)initialized per theme
@@ -159,8 +291,8 @@ class Theme:
159
291
  ``DEFAULT_BG`` comes first (the screen and pad background), then the
160
292
  semantic roles in ``SEMANTIC_PAIRS`` order. Startup
161
293
  (``ConsoleWindow._init_screen``), :meth:`set` and
162
- ``ConsoleWindow.set_theme`` all install colours from this one list, so
163
- the paths cannot drift apart.
294
+ ``ConsoleWindow.set_theme`` all install colours through :meth:`install`
295
+ from this one list, so the paths cannot drift apart.
164
296
  """
165
297
  theme = cls.THEMES[cls.resolve(theme_name)]
166
298
  specs: list[tuple[int, int, int]] = []
@@ -176,23 +308,59 @@ class Theme:
176
308
  def list_all(cls) -> list[str]:
177
309
  return list(cls.THEMES.keys())
178
310
 
311
+ @classmethod
312
+ def install(cls, backend: RenderBackend,
313
+ theme_name: str | None = None) -> str:
314
+ """Install *theme_name*'s colours through *backend*, degrading safely.
315
+
316
+ This is the single installation path used by startup
317
+ (``ConsoleWindow._init_screen``), ``ConsoleWindow.set_theme()`` and
318
+ :meth:`set`. It consults the backend's colour capability instead of the
319
+ ``curses`` globals: on a 256-colour terminal the theme's literal specs
320
+ are used unchanged, while fewer colours are mapped to the nearest
321
+ available palette entry and ``-1`` is only emitted when the terminal
322
+ accepted ``use_default_colors()``. Pair ids beyond ``COLOR_PAIRS`` and
323
+ pairs with no legible mapping are skipped rather than raising. Returns
324
+ the resolved theme name.
325
+ """
326
+ name = cls.resolve(theme_name if theme_name is not None
327
+ else cls.get_current())
328
+ colors = int(backend.colors())
329
+ max_pairs = int(backend.color_pairs())
330
+ default_colors = bool(backend.use_default_colors()) if colors > 0 else False
331
+ for pair_id, fg, bg in cls.pair_specs(name):
332
+ if pair_id >= max_pairs:
333
+ continue
334
+ pair = resolve_color_pair(fg, bg, colors, default_colors)
335
+ if pair is None:
336
+ continue
337
+ backend.init_pair(pair_id, pair[0], pair[1])
338
+ return name
339
+
179
340
  @classmethod
180
341
  def set(cls, theme_name: str) -> bool:
181
342
  if theme_name not in cls.THEMES:
182
343
  return False
183
344
 
184
- if not curses.has_colors():
185
- return False
186
-
187
- cls._current = theme_name
188
- theme = cls.THEMES[theme_name]
189
-
190
345
  # Late import to avoid a circular import at module load time.
191
346
  from .window import ConsoleWindow
192
347
 
193
- # Reinitialize every pair this theme defines (DEFAULT_BG first).
194
- for pair_id, fg, bg in cls.pair_specs(theme_name):
195
- curses.init_pair(pair_id, fg, bg)
348
+ # Colour pairs are terminal-global, so a live window's backend is the
349
+ # authoritative seam. Without one, fall back to curses itself, but go
350
+ # through CursesBackend so this method never calls curses.init_pair()
351
+ # directly (that path used to bypass every low-colour guard).
352
+ win = ConsoleWindow.primary_window()
353
+ backend = getattr(win, '_backend', None) if win is not None else None
354
+ if backend is None:
355
+ backend = CursesBackend()
356
+ backend.start_color()
357
+ if not backend.has_colors():
358
+ return False
359
+
360
+ cls._current = theme_name
361
+ name = cls.install(backend, theme_name)
362
+ cls._current = name
363
+ theme = cls.THEMES[name]
196
364
 
197
365
  # Set the screen/pad background too, even when it is (-1, -1) — i.e.
198
366
  # "back to the terminal default" — and broadcast it to *every* live
@@ -200,14 +368,15 @@ class Theme:
200
368
  # Theme.set() is the process-wide entry point, so it owns this internal
201
369
  # step; exposing a public hook would only invite apps to bypass the
202
370
  # pair-table installation above.
203
- if 'default_fg' in theme and 'default_bg' in theme:
371
+ if ('default_fg' in theme and 'default_bg' in theme
372
+ and cls.DEFAULT_BG < backend.color_pairs()):
204
373
  ConsoleWindow._apply_default_colors( # pylint: disable=protected-access
205
- curses.color_pair(cls.DEFAULT_BG))
374
+ backend.color_pair(cls.DEFAULT_BG))
206
375
 
207
376
  # A process-wide switch leaves every window reporting the same theme,
208
377
  # so per-window state cannot contradict the pair table just installed.
209
378
  for win in ConsoleWindow.active_windows():
210
- win.theme = theme_name
379
+ win.theme = name
211
380
 
212
381
  return True
213
382