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.
- {console_window-2.0.0.5 → console_window-2.0.0.7}/CHANGELOG.md +34 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/PKG-INFO +32 -7
- {console_window-2.0.0.5 → console_window-2.0.0.7}/README.md +31 -6
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/__init__.py +1 -1
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/backend.py +95 -8
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/theme.py +183 -14
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/window.py +176 -21
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_backend.py +72 -2
- console_window-2.0.0.7/tests/test_low_colour.py +221 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_theme.py +15 -4
- {console_window-2.0.0.5 → console_window-2.0.0.7}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/.gitignore +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/AUDIT.md +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/LICENSE +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/SampleApp.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/context.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/geometry.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/header.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/keys.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/opts.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/screens.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/signals.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/spinner.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/textwidth.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/console_window/widgets.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/pyproject.toml +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/runner +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_context.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_dialogs.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_flash.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_geometry.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_header.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_interactive.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_overflow.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_public_api.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_render_retry.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_sample_app.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_screens.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_signals.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_spinner.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_textwidth.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_viewport_pad.py +0 -0
- {console_window-2.0.0.5 → console_window-2.0.0.7}/tests/test_widechars.py +0 -0
- {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.
|
|
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
|
|
195
|
-
`
|
|
196
|
-
`
|
|
197
|
-
|
|
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
|
|
176
|
-
`
|
|
177
|
-
`
|
|
178
|
-
|
|
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.
|
|
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) ->
|
|
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.
|
|
@@ -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) ->
|
|
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
|
|
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
|
|