console-window 2.0.0.4__tar.gz → 2.0.0.6__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.4 → console_window-2.0.0.6}/CHANGELOG.md +24 -0
  2. {console_window-2.0.0.4 → console_window-2.0.0.6}/PKG-INFO +7 -4
  3. {console_window-2.0.0.4 → console_window-2.0.0.6}/README.md +6 -3
  4. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/__init__.py +1 -1
  5. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/backend.py +92 -8
  6. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/theme.py +183 -14
  7. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/window.py +18 -10
  8. console_window-2.0.0.6/tests/test_low_colour.py +221 -0
  9. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_theme.py +15 -4
  10. {console_window-2.0.0.4 → console_window-2.0.0.6}/.github/workflows/ci.yml +0 -0
  11. {console_window-2.0.0.4 → console_window-2.0.0.6}/.gitignore +0 -0
  12. {console_window-2.0.0.4 → console_window-2.0.0.6}/AUDIT.md +0 -0
  13. {console_window-2.0.0.4 → console_window-2.0.0.6}/LICENSE +0 -0
  14. {console_window-2.0.0.4 → console_window-2.0.0.6}/SampleApp.py +0 -0
  15. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/context.py +0 -0
  16. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/geometry.py +0 -0
  17. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/header.py +0 -0
  18. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/keys.py +0 -0
  19. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/opts.py +0 -0
  20. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/screens.py +0 -0
  21. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/signals.py +0 -0
  22. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/spinner.py +0 -0
  23. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/textwidth.py +0 -0
  24. {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/widgets.py +0 -0
  25. {console_window-2.0.0.4 → console_window-2.0.0.6}/pyproject.toml +0 -0
  26. {console_window-2.0.0.4 → console_window-2.0.0.6}/runner +0 -0
  27. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_backend.py +0 -0
  28. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_context.py +0 -0
  29. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_demo_mode.py +0 -0
  30. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_dialogs.py +0 -0
  31. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_flash.py +0 -0
  32. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_geometry.py +0 -0
  33. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_header.py +0 -0
  34. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_interactive.py +0 -0
  35. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_overflow.py +0 -0
  36. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_public_api.py +0 -0
  37. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_render_retry.py +0 -0
  38. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_sample_app.py +0 -0
  39. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_screens.py +0 -0
  40. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_signals.py +0 -0
  41. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_spinner.py +0 -0
  42. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_textwidth.py +0 -0
  43. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_viewport_pad.py +0 -0
  44. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_widechars.py +0 -0
  45. {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_widgets.py +0 -0
@@ -122,6 +122,27 @@ This project follows [Semantic Versioning](https://semver.org/).
122
122
  No in-repo code used it.
123
123
 
124
124
  ### Fixed
125
+ - Starting a window on a terminal with fewer than 256 colours (including tmux's
126
+ stock `TERM=screen`, whose terminfo entry has 8 colours) no longer crashes with
127
+ `ValueError: Color number is greater than COLORS-1`. Theme installation now
128
+ asks the backend for its capabilities through the new
129
+ `RenderBackend.colors()` / `color_pairs()` seam (captured after
130
+ `start_color()`), so `theme.py` and `window.py` never read the `curses`
131
+ globals directly. On 256-colour terminals the theme's literal specs are used
132
+ unchanged; on 16/88/8-colour terminals each index is mapped to the nearest
133
+ entry of the standard xterm palette (base colours 0..15, the 6x6x6 cube and
134
+ the grayscale ramp are modelled), with a contrast guard that moves the
135
+ foreground if mapping would collapse a pair to `fg == bg`. Below 8 colours, or
136
+ when colours are unavailable, installation falls back to attributes instead
137
+ of raising. `use_default_colors()` is now guarded by `except curses.error` and
138
+ its result is remembered, so `-1` becomes a concrete default (fg 7, bg 0) on
139
+ terminals that reject it; pair ids at or past `COLOR_PAIRS` are skipped.
140
+ Startup, `ConsoleWindow.set_theme()` and `Theme.set()` all install through the
141
+ new `Theme.install()`, and `Theme.set()` no longer calls `curses.init_pair()`
142
+ directly, so the built-in theme picker gets the same degradation.
143
+ `HeadlessBackend(colors=...)` now accepts a colour count (plus optional
144
+ `color_pairs=` and `default_colors=`) so the low-colour paths are covered by
145
+ `tests/test_low_colour.py`.
125
146
  - `draw()` now routes rows at or past the `max_rows` content ceiling through the
126
147
  same `rows_dropped` counter and once-per-fill warning as `add_body()` /
127
148
  `add_header()`, with the warning attributed to the caller's line. Previously
@@ -240,6 +261,9 @@ This project follows [Semantic Versioning](https://semver.org/).
240
261
  and that both init paths agree (AUDIT.md §3.9).
241
262
 
242
263
  ### Removed
264
+ - `SampleApp.do_key()`, a dead method with no caller: `main_loop` dispatches
265
+ through `handle_key()`, which already forwards spinner keys to
266
+ `spin.do_key()`. No behaviour changed (AUDIT.md §3.1).
243
267
  - `console_window/_selftest.py`, the TTY-only dev self-test. The old
244
268
  `python -m console_window._selftest` entry point is gone; the features it
245
269
  demonstrated — multi-row picking, a prompt-style option, forced
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 2.0.0.4
3
+ Version: 2.0.0.6
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
@@ -215,8 +219,7 @@ Deprecated APIs remain supported **indefinitely**. A deprecation is a request to
215
219
  migrate, not a scheduled removal: deprecated functions and parameters keep
216
220
  working, and one is removed only when there is significant value in doing so
217
221
  (for example, the API is actively harmful or blocks a necessary redesign).
218
- Deprecated paths emit a `DeprecationWarning` where practical, and every removal
219
- is recorded in [`CHANGELOG.md`](CHANGELOG.md:1).
222
+ Deprecated paths emit a `DeprecationWarning` where practical.
220
223
 
221
224
  ## License
222
225
  MIT
@@ -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
@@ -196,8 +200,7 @@ Deprecated APIs remain supported **indefinitely**. A deprecation is a request to
196
200
  migrate, not a scheduled removal: deprecated functions and parameters keep
197
201
  working, and one is removed only when there is significant value in doing so
198
202
  (for example, the API is actively harmful or blocks a necessary redesign).
199
- Deprecated paths emit a `DeprecationWarning` where practical, and every removal
200
- is recorded in [`CHANGELOG.md`](CHANGELOG.md:1).
203
+ Deprecated paths emit a `DeprecationWarning` where practical.
201
204
 
202
205
  ## License
203
206
  MIT
@@ -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.4"
3
+ __version__ = "2.0.0.6"
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.
@@ -264,13 +341,20 @@ class HeadlessBackend:
264
341
  self.screen.queue_keys(*keys)
265
342
 
266
343
  def has_colors(self) -> bool:
344
+ return self._colors > 0
345
+
346
+ def colors(self) -> int:
267
347
  return self._colors
268
348
 
349
+ def color_pairs(self) -> int:
350
+ return self._color_pairs
351
+
269
352
  def start_color(self) -> None:
270
353
  self.start_color_calls += 1
271
354
 
272
- def use_default_colors(self) -> None:
355
+ def use_default_colors(self) -> bool:
273
356
  self.use_default_colors_calls += 1
357
+ return self._default_colors
274
358
 
275
359
  def init_pair(self, pair: int, fg: int, bg: int) -> None:
276
360
  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
 
@@ -439,9 +439,11 @@ class ConsoleWindow:
439
439
  if not self._backend.has_colors():
440
440
  return
441
441
  theme = Theme.THEMES[Theme.resolve(getattr(self, 'theme', None))]
442
- default_fg = theme.get('default_fg', -1)
443
- default_bg = theme.get('default_bg', -1)
444
- if default_fg != -1 or default_bg != -1:
442
+ if ('default_fg' in theme and 'default_bg' in theme
443
+ and Theme.DEFAULT_BG < self._backend.color_pairs()):
444
+ # Theme.install() has already installed this pair with either the
445
+ # literal values or the low-colour mapping, so bkgd() never sees a
446
+ # stale/out-of-range pair.
445
447
  pad.bkgd(' ', self._backend.color_pair(Theme.DEFAULT_BG))
446
448
 
447
449
  def _sync_pad_geometry(self) -> None:
@@ -578,14 +580,18 @@ class ConsoleWindow:
578
580
 
579
581
  Colour pairs are per-terminal (one table, not one per window), so both
580
582
  startup and ``set_theme()`` call this; the caller decides *whose*
581
- theme wins. Returns the resolved theme name.
583
+ theme wins. The actual installation (including low-colour mapping and
584
+ ``-1`` resolution) lives in :meth:`Theme.install`, which ``Theme.set()``
585
+ uses too, so the two entry points cannot diverge. Returns the resolved
586
+ theme name.
582
587
  """
583
- name = Theme.resolve(theme_name or Theme.get_current())
588
+ name = Theme.install(backend, theme_name)
584
589
  theme = Theme.THEMES[name]
585
- for pair_id, fg, bg in Theme.pair_specs(name):
586
- backend.init_pair(pair_id, fg, bg)
587
- # Can't change pair 0, so the screen background is set directly.
588
- if theme.get('default_fg', -1) != -1 or theme.get('default_bg', -1) != -1:
590
+ # Can't change pair 0, so the screen background is set directly. The
591
+ # pair has just been installed above (with mapped values on low-colour
592
+ # terminals), so this never references a stale or out-of-range pair.
593
+ if ('default_fg' in theme and 'default_bg' in theme
594
+ and Theme.DEFAULT_BG < backend.color_pairs()):
589
595
  scr.bkgd(' ', backend.color_pair(Theme.DEFAULT_BG))
590
596
  return name
591
597
 
@@ -599,8 +605,10 @@ class ConsoleWindow:
599
605
  # Initialize color support with theme. Every colour call goes through
600
606
  # the backend, so a non-curses backend that reports colours can use it.
601
607
  if backend.has_colors():
608
+ # start_color() is where a curses backend captures COLORS and
609
+ # COLOR_PAIRS; Theme.install() then enables default colours (and
610
+ # remembers whether -1 is usable) before resolving each pair.
602
611
  backend.start_color()
603
- backend.use_default_colors() # Allow -1 for default terminal colors
604
612
  # The window's own theme (per-window since AUDIT.md §3.1) or the
605
613
  # process default for a window that has not chosen one yet.
606
614
  name = ConsoleWindow._install_theme(backend, scr, theme_name)
@@ -0,0 +1,221 @@
1
+ """Theme installation on terminals with fewer than 256 colours.
2
+
3
+ The 256-colour themes used to be handed to ``curses.init_pair()`` verbatim, so
4
+ an 8-colour terminal (including tmux's stock ``TERM=screen``) raised
5
+ ``ValueError: Color number is greater than COLORS-1``. These tests pin the
6
+ degradation contract: install the requested theme, resolve every colour to what
7
+ the terminal supports, and never raise.
8
+ """
9
+
10
+ import curses
11
+
12
+ import pytest
13
+
14
+ import console_window.backend as backend_mod
15
+ from console_window.backend import CursesBackend, HeadlessBackend
16
+ from console_window.opts import ConsoleWindowOpts
17
+ from console_window.theme import (
18
+ Theme,
19
+ nearest_palette_index,
20
+ palette_rgb,
21
+ resolve_color_pair,
22
+ )
23
+ from console_window.window import ConsoleWindow
24
+
25
+
26
+ def _window(theme_name, backend):
27
+ opts = ConsoleWindowOpts(theme=theme_name, body_rows=10, head_rows=3)
28
+ return ConsoleWindow(opts=opts, backend=backend, singleton=False)
29
+
30
+
31
+ def _assert_within_capability(backend, colors):
32
+ assert backend.init_pair_calls, 'the theme installed no colour pairs'
33
+ for pair_id, fg, bg in backend.init_pair_calls:
34
+ assert pair_id < backend.color_pairs()
35
+ assert -1 <= fg < colors
36
+ assert -1 <= bg < colors
37
+ # A pair of terminal defaults (-1, -1) is the one legitimate exception:
38
+ # the terminal picks a legible foreground/background itself.
39
+ assert fg != bg or (fg == -1 and bg == -1), (pair_id, fg, bg)
40
+
41
+
42
+ # --- every bundled theme on every supported colour depth --------------------
43
+
44
+ @pytest.mark.parametrize('colors', [8, 16, 88, 256])
45
+ @pytest.mark.parametrize('theme_name', Theme.list_all())
46
+ def test_every_theme_installs_within_the_terminals_capability(theme_name, colors,
47
+ monkeypatch):
48
+ monkeypatch.setattr(Theme, '_current', theme_name)
49
+ backend = HeadlessBackend(colors=colors)
50
+ win = _window(theme_name, backend)
51
+ try:
52
+ _assert_within_capability(backend, colors)
53
+ assert Theme.DEFAULT_BG in {pair for pair, _fg, _bg in backend.init_pair_calls}
54
+ finally:
55
+ win.stop_curses()
56
+
57
+
58
+ @pytest.mark.parametrize('colors', [1, 2, 4, 7])
59
+ def test_below_eight_colours_degrades_to_attributes_without_raising(colors,
60
+ monkeypatch):
61
+ # There is no legible colour mapping for a 1-colour terminal, so nothing is
62
+ # installed and drawing falls back to A_BOLD/A_REVERSE instead of raising.
63
+ monkeypatch.setattr(Theme, '_current', 'nord')
64
+ backend = HeadlessBackend(colors=colors)
65
+ win = _window('nord', backend)
66
+ try:
67
+ for pair_id, fg, bg in backend.init_pair_calls:
68
+ assert pair_id < backend.color_pairs()
69
+ if colors > 1:
70
+ assert 0 <= fg < colors and 0 <= bg < colors
71
+ assert fg != bg
72
+ if colors == 1:
73
+ assert backend.init_pair_calls == []
74
+ finally:
75
+ win.stop_curses()
76
+
77
+
78
+ # --- the picker path (Theme.set) -------------------------------------------
79
+
80
+ def test_theme_set_on_an_eight_colour_window_does_not_raise(monkeypatch):
81
+ monkeypatch.setattr(Theme, '_current', None)
82
+ backend = HeadlessBackend(colors=8)
83
+ win = _window('dark-mono', backend)
84
+ try:
85
+ backend.init_pair_calls.clear()
86
+ assert Theme.set('nord') is True
87
+ assert win.theme == 'nord'
88
+ _assert_within_capability(backend, 8)
89
+ finally:
90
+ win.stop_curses()
91
+
92
+
93
+ def test_window_set_theme_on_an_eight_colour_window_does_not_raise(monkeypatch):
94
+ monkeypatch.setattr(Theme, '_current', None)
95
+ backend = HeadlessBackend(colors=8)
96
+ win = _window('nord', backend)
97
+ try:
98
+ assert win.set_theme('solarized-dark') is True
99
+ assert win.theme == 'solarized-dark'
100
+ _assert_within_capability(backend, 8)
101
+ finally:
102
+ win.stop_curses()
103
+
104
+
105
+ # --- 256 colours stay byte-for-byte identical -------------------------------
106
+
107
+ def test_256_colour_install_uses_the_literal_theme_specs(monkeypatch):
108
+ monkeypatch.setattr(Theme, '_current', 'nord')
109
+ backend = HeadlessBackend(colors=256)
110
+ win = _window('nord', backend)
111
+ try:
112
+ assert backend.init_pair_calls == Theme.pair_specs('nord')
113
+ finally:
114
+ win.stop_curses()
115
+
116
+
117
+ def test_pair_ids_beyond_color_pairs_are_skipped(monkeypatch):
118
+ monkeypatch.setattr(Theme, '_current', 'nord')
119
+ backend = HeadlessBackend(colors=256, color_pairs=8)
120
+ win = _window('nord', backend)
121
+ try:
122
+ assert all(pair < 8 for pair, _fg, _bg in backend.init_pair_calls)
123
+ assert Theme.DEFAULT_BG not in {pair for pair, _fg, _bg
124
+ in backend.init_pair_calls}
125
+ finally:
126
+ win.stop_curses()
127
+
128
+
129
+ # --- -1 handling ------------------------------------------------------------
130
+
131
+ def test_use_default_colors_failure_resolves_minus_one(monkeypatch):
132
+ monkeypatch.setattr(Theme, '_current', 'default')
133
+ backend = HeadlessBackend(colors=8, default_colors=False)
134
+ win = _window('default', backend)
135
+ try:
136
+ pairs = {pair: (fg, bg) for pair, fg, bg in backend.init_pair_calls}
137
+ assert all(-1 not in pair for pair in pairs.values())
138
+ # The theme's (-1, -1) default background becomes concrete elsewhere.
139
+ assert pairs[Theme.DEFAULT_BG] == (7, 0)
140
+ # DANGER is (1, -1) in the 'default' theme.
141
+ assert pairs[Theme.DANGER] == (1, 0)
142
+ finally:
143
+ win.stop_curses()
144
+
145
+
146
+ def test_curses_backend_reports_use_default_colors_failure(monkeypatch):
147
+ def _raise():
148
+ raise curses.error('terminal does not support default colours')
149
+
150
+ monkeypatch.setattr(backend_mod.curses, 'use_default_colors', _raise)
151
+ backend = CursesBackend()
152
+ assert backend.use_default_colors() is False
153
+
154
+
155
+ def test_curses_backend_captures_capabilities_after_start_color(monkeypatch):
156
+ monkeypatch.setattr(backend_mod.curses, 'start_color', lambda: None)
157
+ monkeypatch.setattr(backend_mod.curses, 'COLORS', 8, raising=False)
158
+ monkeypatch.setattr(backend_mod.curses, 'COLOR_PAIRS', 64, raising=False)
159
+ backend = CursesBackend()
160
+ backend.start_color()
161
+ assert backend.colors() == 8
162
+ assert backend.color_pairs() == 64
163
+
164
+
165
+ def test_curses_backend_start_color_failure_degrades(monkeypatch):
166
+ def _raise():
167
+ raise curses.error('no colour support')
168
+
169
+ monkeypatch.setattr(backend_mod.curses, 'start_color', _raise)
170
+ backend = CursesBackend()
171
+ backend.start_color() # must not raise
172
+ assert backend.colors() == 0
173
+
174
+
175
+ # --- the pure palette mapping ----------------------------------------------
176
+
177
+ def test_palette_rgb_uses_the_xterm_model():
178
+ assert palette_rgb(0) == (0, 0, 0)
179
+ assert palette_rgb(16) == (0, 0, 0)
180
+ assert palette_rgb(196) == (255, 0, 0)
181
+ assert palette_rgb(232) == (8, 8, 8)
182
+ assert palette_rgb(255) == (238, 238, 238)
183
+ # Unknown indices never raise.
184
+ assert palette_rgb(-7) == (0, 0, 0)
185
+ assert palette_rgb(9999) == (0, 0, 0)
186
+
187
+
188
+ @pytest.mark.parametrize('index,colors,expected', [
189
+ (232, 16, 0), # near-black gray -> black
190
+ (232, 8, 0),
191
+ (253, 16, 7), # gray level 218 -> normal white (192), not bright white
192
+ (255, 16, 15), # gray level 238 -> bright white
193
+ (253, 8, 7), # ... and normal white is all an 8-colour terminal has
194
+ (196, 16, 9), # pure red -> bright red
195
+ (196, 8, 1), # ... normal red in 8 colours
196
+ (196, 256, 196), # 256 colours: unchanged
197
+ (1, 8, 1), # already available
198
+ ])
199
+ def test_nearest_palette_index_known_conversions(index, colors, expected):
200
+ assert nearest_palette_index(index, colors) == expected
201
+
202
+
203
+ def test_nearest_palette_index_without_colours_returns_minus_one():
204
+ assert nearest_palette_index(196, 0) == -1
205
+
206
+
207
+ def test_resolve_color_pair_breaks_a_collapsed_pair():
208
+ # 232 and 16 both map to black on an 8-colour terminal; the foreground must
209
+ # move so the text stays legible.
210
+ fg, bg = resolve_color_pair(232, 16, 8, True)
211
+ assert bg == 0
212
+ assert fg != bg
213
+ assert 0 <= fg < 8
214
+
215
+
216
+ def test_resolve_color_pair_returns_none_for_a_one_colour_terminal():
217
+ assert resolve_color_pair(1, 0, 1, True) is None
218
+
219
+
220
+ def test_resolve_color_pair_keeps_default_pair_when_supported():
221
+ assert resolve_color_pair(-1, -1, 256, True) == (-1, -1)
@@ -67,11 +67,22 @@ def test_startup_initialises_every_semantic_pair(monkeypatch):
67
67
  win.stop_curses()
68
68
 
69
69
 
70
- def test_theme_set_initialises_the_same_pair_set(colour_recorder, monkeypatch):
70
+ def test_theme_set_initialises_the_same_pair_set(monkeypatch):
71
+ # Theme.set() must go through the live window's backend, not curses
72
+ # directly: the old direct path bypassed every low-colour guard.
71
73
  monkeypatch.setattr(Theme, '_current', None)
72
- assert Theme.set('dark-mono') is True
73
- assert set(Theme.SEMANTIC_PAIRS) <= set(colour_recorder)
74
- assert Theme.DEFAULT_BG in colour_recorder
74
+ backend = HeadlessBackend(colors=True)
75
+ win = ConsoleWindow(opts=ConsoleWindowOpts(body_rows=10, head_rows=3),
76
+ backend=backend, singleton=False)
77
+ try:
78
+ backend.init_pair_calls.clear()
79
+ assert Theme.set('dark-mono') is True
80
+ initialised = {pair for pair, _fg, _bg in backend.init_pair_calls}
81
+ assert set(Theme.SEMANTIC_PAIRS) <= initialised
82
+ assert Theme.DEFAULT_BG in initialised
83
+ assert win.theme == 'dark-mono'
84
+ finally:
85
+ win.stop_curses()
75
86
 
76
87
 
77
88
  # --- the shared picker screen ----------------------------------------------