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.
- {console_window-2.0.0.4 → console_window-2.0.0.6}/CHANGELOG.md +24 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/PKG-INFO +7 -4
- {console_window-2.0.0.4 → console_window-2.0.0.6}/README.md +6 -3
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/__init__.py +1 -1
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/backend.py +92 -8
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/theme.py +183 -14
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/window.py +18 -10
- console_window-2.0.0.6/tests/test_low_colour.py +221 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_theme.py +15 -4
- {console_window-2.0.0.4 → console_window-2.0.0.6}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/.gitignore +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/AUDIT.md +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/LICENSE +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/SampleApp.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/context.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/geometry.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/header.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/keys.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/opts.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/screens.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/signals.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/spinner.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/textwidth.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/console_window/widgets.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/pyproject.toml +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/runner +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_backend.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_context.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_dialogs.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_flash.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_geometry.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_header.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_interactive.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_overflow.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_public_api.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_render_retry.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_sample_app.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_screens.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_signals.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_spinner.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_textwidth.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_viewport_pad.py +0 -0
- {console_window-2.0.0.4 → console_window-2.0.0.6}/tests/test_widechars.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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) ->
|
|
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
|
-
|
|
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) ->
|
|
147
|
-
|
|
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,
|
|
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
|
-
|
|
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) ->
|
|
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
|
|
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
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
443
|
-
|
|
444
|
-
|
|
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.
|
|
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.
|
|
588
|
+
name = Theme.install(backend, theme_name)
|
|
584
589
|
theme = Theme.THEMES[name]
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
#
|
|
588
|
-
if
|
|
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(
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 ----------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|