console-window 2.0.0.6__tar.gz → 2.0.0.8__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.6 → console_window-2.0.0.8}/CHANGELOG.md +23 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/PKG-INFO +30 -6
- {console_window-2.0.0.6 → console_window-2.0.0.8}/README.md +29 -5
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/__init__.py +1 -1
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/backend.py +3 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/window.py +199 -14
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_backend.py +150 -2
- {console_window-2.0.0.6 → console_window-2.0.0.8}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/.gitignore +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/AUDIT.md +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/LICENSE +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/SampleApp.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/context.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/geometry.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/header.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/keys.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/opts.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/screens.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/signals.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/spinner.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/textwidth.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/theme.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/widgets.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/pyproject.toml +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/runner +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_context.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_dialogs.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_flash.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_geometry.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_header.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_interactive.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_low_colour.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_overflow.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_public_api.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_render_retry.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_sample_app.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_screens.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_signals.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_spinner.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_textwidth.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_theme.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_viewport_pad.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_widechars.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.8}/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,16 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
122
135
|
No in-repo code used it.
|
|
123
136
|
|
|
124
137
|
### Fixed
|
|
138
|
+
- `win._start_curses()` once again *reattaches* the window. The 1.4.x
|
|
139
|
+
compatibility hook is still called in the wild (psistat's "dump event log"
|
|
140
|
+
path uses `self.window._start_curses()` to return from shell mode), but the
|
|
141
|
+
2.0 implementation only created and returned a screen, leaving `win.scr` as
|
|
142
|
+
`None`; the next `clear()`/`render()` then raised
|
|
143
|
+
`AttributeError: 'NoneType' object has no attribute 'clear'`. It now resumes
|
|
144
|
+
a suspended window through `resume_curses()` (singleton mode reattaches every
|
|
145
|
+
window sharing the screen), so the pads and the content model survive, and
|
|
146
|
+
`clear()` while suspended resets the content model without dereferencing the
|
|
147
|
+
missing screen.
|
|
125
148
|
- Starting a window on a terminal with fewer than 256 colours (including tmux's
|
|
126
149
|
stock `TERM=screen`, whose terminfo entry has 8 colours) no longer crashes with
|
|
127
150
|
`ValueError: Color number is greater than COLORS-1`. Theme installation now
|
|
@@ -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.8
|
|
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
|
|
@@ -195,15 +195,39 @@ as `False`, so an options object need not pre-seed every action.
|
|
|
195
195
|
|
|
196
196
|
* **`singleton=True` (default)** — the historical process-wide model. Every
|
|
197
197
|
window draws through one shared screen, `ConsoleWindow.static_scr` and
|
|
198
|
-
`ConsoleWindow.static_win` are populated, and the
|
|
199
|
-
`
|
|
200
|
-
`
|
|
201
|
-
|
|
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.
|
|
202
202
|
* **`singleton=False`** — per-instance windows. Each window owns its screen,
|
|
203
|
-
pads and teardown; `win.stop_curses()` stops only that window,
|
|
203
|
+
pads and teardown; `win.stop_curses()` stops only that window for good,
|
|
204
204
|
`ConsoleWindow.stop_all()` stops every live window, and the unbound
|
|
205
205
|
`ConsoleWindow.stop_curses()` is an error.
|
|
206
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
|
+
The pre-2.0 instance hook `win._start_curses()` is also supported: it reattaches
|
|
227
|
+
that window (resuming a suspended screen) and returns the live screen, so the
|
|
228
|
+
old `win.stop_curses(); ...; win._start_curses()` sequence keeps working.
|
|
229
|
+
Only `stop_all()` is a permanent teardown.
|
|
230
|
+
|
|
207
231
|
The first successful construction latches the mode for the whole process.
|
|
208
232
|
Mixing the two values raises `AssertionError` rather than silently changing what
|
|
209
233
|
teardown means. Use `singleton=False` in tests and in applications that create
|
|
@@ -176,15 +176,39 @@ as `False`, so an options object need not pre-seed every action.
|
|
|
176
176
|
|
|
177
177
|
* **`singleton=True` (default)** — the historical process-wide model. Every
|
|
178
178
|
window draws through one shared screen, `ConsoleWindow.static_scr` and
|
|
179
|
-
`ConsoleWindow.static_win` are populated, and the
|
|
180
|
-
`
|
|
181
|
-
`
|
|
182
|
-
|
|
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.
|
|
183
183
|
* **`singleton=False`** — per-instance windows. Each window owns its screen,
|
|
184
|
-
pads and teardown; `win.stop_curses()` stops only that window,
|
|
184
|
+
pads and teardown; `win.stop_curses()` stops only that window for good,
|
|
185
185
|
`ConsoleWindow.stop_all()` stops every live window, and the unbound
|
|
186
186
|
`ConsoleWindow.stop_curses()` is an error.
|
|
187
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
|
+
The pre-2.0 instance hook `win._start_curses()` is also supported: it reattaches
|
|
208
|
+
that window (resuming a suspended screen) and returns the live screen, so the
|
|
209
|
+
old `win.stop_curses(); ...; win._start_curses()` sequence keeps working.
|
|
210
|
+
Only `stop_all()` is a permanent teardown.
|
|
211
|
+
|
|
188
212
|
The first successful construction latches the mode for the whole process.
|
|
189
213
|
Mixing the two values raises `AssertionError` rather than silently changing what
|
|
190
214
|
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.8"
|
|
4
4
|
|
|
5
5
|
from .context import Context
|
|
6
6
|
from .header import HeaderLine, Span
|
|
@@ -313,6 +313,9 @@ class HeadlessBackend:
|
|
|
313
313
|
|
|
314
314
|
def start(self, timeout_ms: int) -> Surface:
|
|
315
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
|
|
316
319
|
return self.screen
|
|
317
320
|
|
|
318
321
|
def newpad(self, rows: int, cols: int) -> Surface:
|
|
@@ -74,6 +74,14 @@ _PASSTHROUGH_EDITING_KEYS = frozenset({
|
|
|
74
74
|
_WINDOWS: weakref.WeakValueDictionary = weakref.WeakValueDictionary()
|
|
75
75
|
_WINDOW_IDS = itertools.count()
|
|
76
76
|
|
|
77
|
+
# Windows that have left curses mode (endwin) but are still resumable. A
|
|
78
|
+
# strong reference is deliberate: the live registry is weak, and a suspended
|
|
79
|
+
# window must survive until resume_curses()/start_curses() reattaches it. The
|
|
80
|
+
# screen itself is not kept here: in singleton mode the screen's owner stays
|
|
81
|
+
# reachable through ``static_win``/``_static_backend`` (``static_scr`` is None
|
|
82
|
+
# while suspended), and a per-instance window keeps its own backend handle.
|
|
83
|
+
_SUSPENDED: list[ConsoleWindow] = []
|
|
84
|
+
|
|
77
85
|
# None until the first ConsoleWindow is constructed. The value is process-wide
|
|
78
86
|
# and deliberately never reset: ConsoleWindow(singleton=...) must agree on every
|
|
79
87
|
# construction or the assertion in __init__ fires.
|
|
@@ -136,6 +144,14 @@ class _StopCurses:
|
|
|
136
144
|
``ConsoleWindow.stop_curses()``; the per-instance model needs the bound
|
|
137
145
|
window. A descriptor distinguishes the two call shapes, which a plain
|
|
138
146
|
method with ``self=None`` cannot do without lying to type checkers.
|
|
147
|
+
|
|
148
|
+
Semantics depend on the window model:
|
|
149
|
+
|
|
150
|
+
* singleton (the pre-2.0 model): ``stop_curses()`` *suspends* the shared
|
|
151
|
+
screen and ``start_curses()`` resumes it, which keeps the historical
|
|
152
|
+
stop/start pair working. Use ``stop_all()`` for a permanent teardown.
|
|
153
|
+
* per-instance: ``stop_curses()`` is a permanent teardown; use
|
|
154
|
+
``suspend_curses()``/``resume_curses()`` to pause and continue.
|
|
139
155
|
"""
|
|
140
156
|
|
|
141
157
|
def __get__(self, obj: ConsoleWindow | None,
|
|
@@ -261,6 +277,11 @@ class ConsoleWindow:
|
|
|
261
277
|
# drawing API is described by CursesSurface, which is only a cast target
|
|
262
278
|
# (curses.window and HeadlessBackend's no-op surface both satisfy it at
|
|
263
279
|
# runtime). This keeps addstr/border/insstr/... type-checked.
|
|
280
|
+
if self.singleton and _SUSPENDED:
|
|
281
|
+
# A suspended shared screen must be resumed before another window
|
|
282
|
+
# can join it; otherwise the new window would draw into a terminal
|
|
283
|
+
# that is still in shell mode.
|
|
284
|
+
ConsoleWindow._resume_singleton()
|
|
264
285
|
if self.singleton and ConsoleWindow.static_scr is not None:
|
|
265
286
|
# Singleton mode: every instance draws through the process screen
|
|
266
287
|
# and the backend that owns it, even if a different backend was
|
|
@@ -270,7 +291,7 @@ class ConsoleWindow:
|
|
|
270
291
|
self._backend = ConsoleWindow._static_backend or self._backend
|
|
271
292
|
self.scr = ConsoleWindow.static_scr
|
|
272
293
|
else:
|
|
273
|
-
self.scr = self.
|
|
294
|
+
self.scr = self._new_screen()
|
|
274
295
|
if self.singleton:
|
|
275
296
|
ConsoleWindow.static_scr = self.scr
|
|
276
297
|
ConsoleWindow._static_backend = self._backend
|
|
@@ -345,6 +366,10 @@ class ConsoleWindow:
|
|
|
345
366
|
# created but unreachable (the view snapped back to the last pickable
|
|
346
367
|
# row every frame).
|
|
347
368
|
self.free_scroll = False
|
|
369
|
+
# True between suspend_curses() and resume_curses()/start_curses(): the
|
|
370
|
+
# terminal was handed back (endwin) but the pads and content model stay
|
|
371
|
+
# valid, so the window can be reattached to a screen later.
|
|
372
|
+
self.suspended = False
|
|
348
373
|
self.rows, self.cols = 0, 0
|
|
349
374
|
self.body_cols, self.body_rows = self.opts.body_cols, self.opts.body_rows
|
|
350
375
|
self.scroll_view_size = 0 # no. viewable lines of the body
|
|
@@ -556,13 +581,112 @@ class ConsoleWindow:
|
|
|
556
581
|
|
|
557
582
|
return True
|
|
558
583
|
|
|
559
|
-
def
|
|
584
|
+
def _new_screen(self) -> CursesSurface:
|
|
585
|
+
"""Create and initialise a brand-new screen for this window."""
|
|
560
586
|
return self._init_screen(self._backend, self.theme)
|
|
561
587
|
|
|
588
|
+
def _start_curses(self) -> CursesSurface:
|
|
589
|
+
"""Re-enter curses for this window and return the live screen.
|
|
590
|
+
|
|
591
|
+
Pre-2.0 exposed ``_start_curses()`` as the "come back after
|
|
592
|
+
``stop_curses()``" call, and applications (psistat among them) kept
|
|
593
|
+
calling it on the instance. It therefore has to *reattach* the
|
|
594
|
+
window, not merely create a screen: the old implementation returned a
|
|
595
|
+
fresh screen without storing it, so ``self.scr`` stayed ``None`` and
|
|
596
|
+
the first ``clear()``/``render()`` after returning from shell mode
|
|
597
|
+
raised ``AttributeError: 'NoneType' object has no attribute 'clear'``.
|
|
598
|
+
|
|
599
|
+
A suspended window resumes through :meth:`resume_curses` (in singleton
|
|
600
|
+
mode that reattaches every window sharing the screen), so the pads and
|
|
601
|
+
the content model survive. ``start_curses()`` remains the class-level
|
|
602
|
+
entry point for both models.
|
|
603
|
+
"""
|
|
604
|
+
if self.suspended:
|
|
605
|
+
scr = self.resume_curses()
|
|
606
|
+
if scr is not None:
|
|
607
|
+
return scr
|
|
608
|
+
if self.scr is not None:
|
|
609
|
+
return self.scr
|
|
610
|
+
# Not suspended but screen-less: restart a screen and attach this
|
|
611
|
+
# window to it. Singleton mode owns a process-wide screen, so reuse the
|
|
612
|
+
# class-level starter; per-instance windows create their own.
|
|
613
|
+
scr = ConsoleWindow.start_curses() if self.singleton else self._new_screen()
|
|
614
|
+
if self.scr is None: # start_curses() may have reattached it already
|
|
615
|
+
backend = (ConsoleWindow._static_backend if self.singleton else None) \
|
|
616
|
+
or self._backend
|
|
617
|
+
self._attach_screen(scr, backend)
|
|
618
|
+
return scr
|
|
619
|
+
|
|
620
|
+
def _attach_screen(self, scr: CursesSurface, backend: RenderBackend) -> None:
|
|
621
|
+
"""Point this window at a (re)started screen and force a full repaint.
|
|
622
|
+
|
|
623
|
+
Used by resume_curses()/start_curses() after a suspend. The content
|
|
624
|
+
model and pads survive ``endwin()``; only the screen handle, the
|
|
625
|
+
backend that owns it, the dimensions, the pad backgrounds and the
|
|
626
|
+
redraw bookkeeping have to be refreshed.
|
|
627
|
+
"""
|
|
628
|
+
self._backend = backend
|
|
629
|
+
self.scr = scr
|
|
630
|
+
self.suspended = False
|
|
631
|
+
_register_window(self)
|
|
632
|
+
self.calc() # dimensions + pad reallocation through the new backend
|
|
633
|
+
self._set_scr_timeout(self._scr_timeout_ms)
|
|
634
|
+
for ns in (self.head, self.body):
|
|
635
|
+
self._apply_pad_theme(cast(CursesSurface, ns.pad))
|
|
636
|
+
self._mark_pads_for_redraw()
|
|
637
|
+
self._pick_clear_pending = True
|
|
638
|
+
|
|
639
|
+
def suspend_curses(self) -> None:
|
|
640
|
+
"""Leave curses mode without destroying this window.
|
|
641
|
+
|
|
642
|
+
``endwin()`` restores the terminal but keeps the curses SCREEN (and
|
|
643
|
+
therefore the pads) alive, so the next :meth:`resume_curses` can
|
|
644
|
+
reattach a screen and the next ``render()`` repaints. In singleton
|
|
645
|
+
mode all windows share one screen, so this suspends them together.
|
|
646
|
+
Use :meth:`stop_curses` when the window is finished for good.
|
|
647
|
+
"""
|
|
648
|
+
if getattr(self, 'singleton', False):
|
|
649
|
+
ConsoleWindow._suspend_singleton()
|
|
650
|
+
return
|
|
651
|
+
scr = getattr(self, 'scr', None)
|
|
652
|
+
if scr is None or self.suspended:
|
|
653
|
+
return
|
|
654
|
+
try:
|
|
655
|
+
backend = getattr(self, '_backend', None) or CursesBackend()
|
|
656
|
+
backend.stop(scr)
|
|
657
|
+
finally:
|
|
658
|
+
self.scr = cast(CursesSurface, None)
|
|
659
|
+
self.suspended = True
|
|
660
|
+
_unregister_window(self)
|
|
661
|
+
signals.teardown_ctrl_c()
|
|
662
|
+
|
|
663
|
+
def resume_curses(self) -> CursesSurface | None:
|
|
664
|
+
"""Re-enter curses after :meth:`suspend_curses`.
|
|
665
|
+
|
|
666
|
+
Returns the (new) screen, or None when the window was not suspended.
|
|
667
|
+
The next ``render()`` repaints from the preserved content model.
|
|
668
|
+
"""
|
|
669
|
+
if getattr(self, 'singleton', False):
|
|
670
|
+
return ConsoleWindow._resume_singleton()
|
|
671
|
+
if not self.suspended:
|
|
672
|
+
return self.scr
|
|
673
|
+
backend = getattr(self, '_backend', None) or CursesBackend()
|
|
674
|
+
scr = self._init_screen(backend, self.theme)
|
|
675
|
+
self._attach_screen(scr, backend)
|
|
676
|
+
return scr
|
|
677
|
+
|
|
562
678
|
@classmethod
|
|
563
679
|
def start_curses(cls, backend: RenderBackend | None = None) -> CursesSurface:
|
|
564
680
|
# Legacy class-level entry point: in singleton mode it shares the one
|
|
565
|
-
# process screen rather than starting a second terminal session.
|
|
681
|
+
# process screen rather than starting a second terminal session. When
|
|
682
|
+
# windows were suspended by stop_curses()/suspend_curses(), this is the
|
|
683
|
+
# matching resume: every suspended window is reattached to the screen
|
|
684
|
+
# (the backend argument is ignored then -- the screen belongs to the
|
|
685
|
+
# backend that created it).
|
|
686
|
+
if _SINGLETON_MODE and _SUSPENDED:
|
|
687
|
+
scr = cls._resume_singleton()
|
|
688
|
+
if scr is not None:
|
|
689
|
+
return scr
|
|
566
690
|
if _SINGLETON_MODE and cls.static_scr is not None:
|
|
567
691
|
return cls.static_scr
|
|
568
692
|
screen_backend = backend if backend is not None else CursesBackend()
|
|
@@ -573,6 +697,45 @@ class ConsoleWindow:
|
|
|
573
697
|
cls._static_backend = screen_backend
|
|
574
698
|
return scr
|
|
575
699
|
|
|
700
|
+
@classmethod
|
|
701
|
+
def _suspend_singleton(cls) -> None:
|
|
702
|
+
"""Leave the shared screen, keeping every window resumable."""
|
|
703
|
+
scr = cls.static_scr
|
|
704
|
+
if scr is None:
|
|
705
|
+
return
|
|
706
|
+
backend = cls._static_backend or CursesBackend()
|
|
707
|
+
windows = list(_WINDOWS.values())
|
|
708
|
+
try:
|
|
709
|
+
backend.stop(scr)
|
|
710
|
+
finally:
|
|
711
|
+
cls.static_scr = None
|
|
712
|
+
for win in windows:
|
|
713
|
+
win.scr = cast(CursesSurface, None)
|
|
714
|
+
win.suspended = True
|
|
715
|
+
_unregister_window(win)
|
|
716
|
+
signals.teardown_ctrl_c()
|
|
717
|
+
if win not in _SUSPENDED:
|
|
718
|
+
_SUSPENDED.append(win)
|
|
719
|
+
# static_win is deliberately kept: it is the anchor that
|
|
720
|
+
# start_curses()/resume_curses() uses to restore the theme and is
|
|
721
|
+
# replaced by the next singleton window construction.
|
|
722
|
+
|
|
723
|
+
@classmethod
|
|
724
|
+
def _resume_singleton(cls) -> CursesSurface | None:
|
|
725
|
+
"""Re-enter the shared screen and reattach every suspended window."""
|
|
726
|
+
if not _SUSPENDED:
|
|
727
|
+
return cls.static_scr
|
|
728
|
+
backend = cls._static_backend or CursesBackend()
|
|
729
|
+
anchor = cls.static_win
|
|
730
|
+
scr = cls._init_screen(backend, anchor.theme if anchor is not None else None)
|
|
731
|
+
cls.static_scr = scr
|
|
732
|
+
cls._static_backend = backend
|
|
733
|
+
windows = list(_SUSPENDED)
|
|
734
|
+
del _SUSPENDED[:]
|
|
735
|
+
for win in windows:
|
|
736
|
+
win._attach_screen(scr, backend) # pylint: disable=protected-access
|
|
737
|
+
return scr
|
|
738
|
+
|
|
576
739
|
@staticmethod
|
|
577
740
|
def _install_theme(backend: RenderBackend, scr: CursesSurface,
|
|
578
741
|
theme_name: str | None) -> str:
|
|
@@ -634,25 +797,31 @@ class ConsoleWindow:
|
|
|
634
797
|
self._pick_clear_pending = True
|
|
635
798
|
|
|
636
799
|
def _stop_instance(self) -> None:
|
|
637
|
-
"""Stop this window (and, in singleton mode, the shared screen)."""
|
|
800
|
+
"""Stop this window (and, in singleton mode, suspend the shared screen)."""
|
|
638
801
|
if getattr(self, 'singleton', False):
|
|
639
|
-
|
|
802
|
+
# Backward compatibility: in singleton mode the legacy
|
|
803
|
+
# stop_curses()/start_curses() pair is a suspend/resume pair, so
|
|
804
|
+
# stop_curses() leaves the screen resumable. stop_all() is the
|
|
805
|
+
# permanent teardown.
|
|
806
|
+
ConsoleWindow._suspend_singleton()
|
|
640
807
|
return
|
|
641
808
|
|
|
642
809
|
scr = getattr(self, 'scr', None)
|
|
643
810
|
if scr is None:
|
|
811
|
+
self.suspended = False
|
|
644
812
|
return
|
|
645
813
|
try:
|
|
646
814
|
backend = getattr(self, '_backend', None) or CursesBackend()
|
|
647
815
|
backend.stop(scr)
|
|
648
816
|
finally:
|
|
649
817
|
self.scr = cast(CursesSurface, None)
|
|
818
|
+
self.suspended = False
|
|
650
819
|
_unregister_window(self)
|
|
651
820
|
signals.teardown_ctrl_c()
|
|
652
821
|
|
|
653
822
|
@classmethod
|
|
654
823
|
def _stop_legacy(cls) -> None:
|
|
655
|
-
"""The pre-2.0 ``ConsoleWindow.stop_curses()`` call."""
|
|
824
|
+
"""The pre-2.0 ``ConsoleWindow.stop_curses()`` call (suspends in singleton mode)."""
|
|
656
825
|
assert _SINGLETON_MODE is not False, (
|
|
657
826
|
'ConsoleWindow.stop_curses() without an instance is only valid '
|
|
658
827
|
'in singleton mode; use win.stop_curses() or ConsoleWindow.stop_all()')
|
|
@@ -662,24 +831,31 @@ class ConsoleWindow:
|
|
|
662
831
|
|
|
663
832
|
@classmethod
|
|
664
833
|
def _stop_singleton(cls) -> None:
|
|
665
|
-
"""Tear down the shared screen and
|
|
834
|
+
"""Tear down the shared screen and forget every window (including suspended)."""
|
|
666
835
|
scr = ConsoleWindow.static_scr
|
|
667
|
-
if scr is None:
|
|
668
|
-
return
|
|
669
|
-
backend = ConsoleWindow._static_backend or CursesBackend()
|
|
670
836
|
windows = list(_WINDOWS.values())
|
|
837
|
+
suspended = list(_SUSPENDED)
|
|
838
|
+
del _SUSPENDED[:]
|
|
671
839
|
ConsoleWindow.static_scr = None
|
|
672
840
|
ConsoleWindow.static_win = None
|
|
841
|
+
backend = ConsoleWindow._static_backend
|
|
673
842
|
ConsoleWindow._static_backend = None
|
|
674
|
-
for win in windows:
|
|
843
|
+
for win in windows + suspended:
|
|
675
844
|
win.scr = cast(CursesSurface, None)
|
|
845
|
+
win.suspended = False
|
|
846
|
+
if scr is None:
|
|
847
|
+
# Already in shell mode (all windows suspended); nothing to end,
|
|
848
|
+
# but the suspended windows still have to be forgotten.
|
|
849
|
+
for win in windows + suspended:
|
|
850
|
+
_unregister_window(win)
|
|
851
|
+
return
|
|
676
852
|
try:
|
|
677
|
-
backend.stop(scr)
|
|
853
|
+
(backend or CursesBackend()).stop(scr)
|
|
678
854
|
finally:
|
|
679
855
|
# One teardown per window, matching the per-window setup in
|
|
680
856
|
# _init_screen: the SIGINT handler is only restored once the last
|
|
681
857
|
# live window is gone (AUDIT.md §3.1).
|
|
682
|
-
for win in windows:
|
|
858
|
+
for win in windows + suspended:
|
|
683
859
|
_unregister_window(win)
|
|
684
860
|
signals.teardown_ctrl_c()
|
|
685
861
|
|
|
@@ -704,6 +880,10 @@ class ConsoleWindow:
|
|
|
704
880
|
|
|
705
881
|
@classmethod
|
|
706
882
|
def stop_all(cls) -> None:
|
|
883
|
+
"""Permanently tear down every window (the atexit hook)."""
|
|
884
|
+
if _SINGLETON_MODE:
|
|
885
|
+
cls._stop_singleton()
|
|
886
|
+
return
|
|
707
887
|
for win in cls.active_windows():
|
|
708
888
|
win.stop_curses()
|
|
709
889
|
|
|
@@ -2002,7 +2182,12 @@ class ConsoleWindow:
|
|
|
2002
2182
|
pad.refresh(scroll_top, 0, row0 + 1, col0 + 1, row0 + visible_rows, col9 - 1)
|
|
2003
2183
|
|
|
2004
2184
|
def clear(self) -> None:
|
|
2005
|
-
|
|
2185
|
+
# A suspended window has no screen handle until resume_curses()/
|
|
2186
|
+
# start_curses(); the content model still has to be reset so the next
|
|
2187
|
+
# repaint is complete. Calling clear() while suspended used to raise
|
|
2188
|
+
# AttributeError on self.scr.
|
|
2189
|
+
if self.scr is not None:
|
|
2190
|
+
self.scr.clear()
|
|
2006
2191
|
# Viewport-sized pads: clearing costs one screenful, not one high-water
|
|
2007
2192
|
# mark, and no shrink/grow decision is needed at a frame boundary.
|
|
2008
2193
|
self.head.pad.clear()
|
|
@@ -259,7 +259,7 @@ def test_theme_broadcast_reaches_all_windows():
|
|
|
259
259
|
second.stop_curses()
|
|
260
260
|
|
|
261
261
|
|
|
262
|
-
def
|
|
262
|
+
def test_singleton_mode_shares_the_process_screen():
|
|
263
263
|
# Singleton mode is process-wide, so prove it in a fresh interpreter rather
|
|
264
264
|
# than perturb the per-instance mode this test session has latched.
|
|
265
265
|
code = '''
|
|
@@ -276,7 +276,7 @@ assert ConsoleWindow.static_scr is first.scr
|
|
|
276
276
|
assert second.scr is first.scr
|
|
277
277
|
assert ConsoleWindow.static_win is second
|
|
278
278
|
assert ConsoleWindow.primary_window() is second
|
|
279
|
-
ConsoleWindow.
|
|
279
|
+
ConsoleWindow.stop_all() # permanent teardown
|
|
280
280
|
assert first.scr is None and second.scr is None
|
|
281
281
|
assert ConsoleWindow.static_scr is None and ConsoleWindow.static_win is None
|
|
282
282
|
assert first_backend.stopped is True
|
|
@@ -285,6 +285,154 @@ assert ConsoleWindow.active_windows() == []
|
|
|
285
285
|
subprocess.run([sys.executable, '-c', code], check=True)
|
|
286
286
|
|
|
287
287
|
|
|
288
|
+
def test_singleton_stop_curses_suspends_and_start_curses_resumes():
|
|
289
|
+
# The pre-2.0 stop_curses()/start_curses() pair must keep working: in
|
|
290
|
+
# singleton mode it is a suspend/resume pair, not a one-way teardown.
|
|
291
|
+
code = '''
|
|
292
|
+
from console_window.backend import HeadlessBackend
|
|
293
|
+
from console_window.opts import ConsoleWindowOpts
|
|
294
|
+
from console_window.window import ConsoleWindow
|
|
295
|
+
|
|
296
|
+
first_backend = HeadlessBackend()
|
|
297
|
+
first = ConsoleWindow(opts=ConsoleWindowOpts(body_rows=3, head_rows=2),
|
|
298
|
+
backend=first_backend, singleton=True)
|
|
299
|
+
second = ConsoleWindow(opts=ConsoleWindowOpts(body_rows=3, head_rows=2),
|
|
300
|
+
backend=HeadlessBackend())
|
|
301
|
+
first.add_body('kept across the suspend')
|
|
302
|
+
old_screen = first.scr
|
|
303
|
+
|
|
304
|
+
ConsoleWindow.stop_curses() # the pre-2.0 unbound call: suspend
|
|
305
|
+
assert first_backend.stopped is True
|
|
306
|
+
assert first.scr is None and second.scr is None
|
|
307
|
+
assert first.suspended and second.suspended
|
|
308
|
+
assert ConsoleWindow.static_scr is None
|
|
309
|
+
assert ConsoleWindow.active_windows() == []
|
|
310
|
+
# static_win is kept as the resume anchor.
|
|
311
|
+
assert ConsoleWindow.static_win is second
|
|
312
|
+
|
|
313
|
+
scr = ConsoleWindow.start_curses() # matching resume
|
|
314
|
+
assert scr is not None
|
|
315
|
+
assert ConsoleWindow.static_scr is scr
|
|
316
|
+
assert first.scr is scr and second.scr is scr
|
|
317
|
+
assert not first.suspended and not second.suspended
|
|
318
|
+
assert first_backend.stopped is False
|
|
319
|
+
assert ConsoleWindow.active_windows() == [first, second]
|
|
320
|
+
# The content model and pads survived endwin().
|
|
321
|
+
assert first.body.texts == ['kept across the suspend']
|
|
322
|
+
assert first.body.pad is not None
|
|
323
|
+
|
|
324
|
+
ConsoleWindow.stop_all() # permanent teardown
|
|
325
|
+
assert first.scr is None and second.scr is None
|
|
326
|
+
assert ConsoleWindow.static_scr is None and ConsoleWindow.static_win is None
|
|
327
|
+
assert ConsoleWindow.active_windows() == []
|
|
328
|
+
'''
|
|
329
|
+
subprocess.run([sys.executable, '-c', code], check=True)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_per_instance_suspend_and_resume_curses():
|
|
333
|
+
backend = HeadlessBackend()
|
|
334
|
+
win = _make(backend)
|
|
335
|
+
try:
|
|
336
|
+
win.add_body('kept across the suspend')
|
|
337
|
+
win.suspend_curses()
|
|
338
|
+
assert win.suspended is True
|
|
339
|
+
assert win.scr is None
|
|
340
|
+
assert backend.stopped is True
|
|
341
|
+
assert win not in ConsoleWindow.active_windows()
|
|
342
|
+
|
|
343
|
+
scr = win.resume_curses()
|
|
344
|
+
assert scr is not None
|
|
345
|
+
assert win.scr is scr
|
|
346
|
+
assert win.suspended is False
|
|
347
|
+
assert backend.stopped is False
|
|
348
|
+
assert win in ConsoleWindow.active_windows()
|
|
349
|
+
assert win.body.texts == ['kept across the suspend']
|
|
350
|
+
# A second resume is a no-op that returns the live screen.
|
|
351
|
+
assert win.resume_curses() is scr
|
|
352
|
+
finally:
|
|
353
|
+
win.stop_curses()
|
|
354
|
+
assert win.scr is None
|
|
355
|
+
assert win not in ConsoleWindow.active_windows()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_clear_while_suspended_only_resets_the_content_model():
|
|
359
|
+
# A suspended window has no screen, but clear() must still work: the next
|
|
360
|
+
# repaint after resume comes from the content model, not a stale screen.
|
|
361
|
+
backend = HeadlessBackend()
|
|
362
|
+
win = _make(backend)
|
|
363
|
+
try:
|
|
364
|
+
win.add_body('old row')
|
|
365
|
+
win.suspend_curses()
|
|
366
|
+
win.clear() # regression: raised AttributeError on self.scr
|
|
367
|
+
assert win.body.texts == []
|
|
368
|
+
assert win.scr is None
|
|
369
|
+
win.add_body('new row')
|
|
370
|
+
scr = win._start_curses()
|
|
371
|
+
assert win.scr is scr
|
|
372
|
+
assert win.body.texts == ['new row']
|
|
373
|
+
win.clear() # the live screen is cleared again
|
|
374
|
+
assert scr.calls_named('clear')
|
|
375
|
+
finally:
|
|
376
|
+
win.stop_curses()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_legacy_instance_start_curses_reattaches_after_suspend():
|
|
380
|
+
# psistat's dump/return path calls self.window._start_curses() (the 1.4.x
|
|
381
|
+
# hook that was kept as a compatibility method). It must leave the window
|
|
382
|
+
# usable, not create a screen and leave self.scr as None.
|
|
383
|
+
backend = HeadlessBackend()
|
|
384
|
+
win = _make(backend)
|
|
385
|
+
try:
|
|
386
|
+
win.add_body('kept across the dump')
|
|
387
|
+
win.suspend_curses()
|
|
388
|
+
assert win.scr is None and win.suspended
|
|
389
|
+
|
|
390
|
+
scr = win._start_curses()
|
|
391
|
+
assert scr is not None
|
|
392
|
+
assert win.scr is scr
|
|
393
|
+
assert win.suspended is False
|
|
394
|
+
assert win in ConsoleWindow.active_windows()
|
|
395
|
+
assert win.body.texts == ['kept across the dump']
|
|
396
|
+
win.clear() # the call that crashed psistat after returning
|
|
397
|
+
assert scr.calls_named('clear')
|
|
398
|
+
win.add_body('after clear')
|
|
399
|
+
finally:
|
|
400
|
+
win.stop_curses()
|
|
401
|
+
assert win.scr is None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def test_singleton_legacy_instance_start_curses_resumes_shared_screen():
|
|
405
|
+
# Same compatibility hook, but in the pre-2.0 singleton model, where
|
|
406
|
+
# stop_curses() suspends the shared screen rather than tearing it down.
|
|
407
|
+
code = '''
|
|
408
|
+
from console_window.backend import HeadlessBackend
|
|
409
|
+
from console_window.opts import ConsoleWindowOpts
|
|
410
|
+
from console_window.window import ConsoleWindow
|
|
411
|
+
|
|
412
|
+
backend = HeadlessBackend()
|
|
413
|
+
win = ConsoleWindow(opts=ConsoleWindowOpts(body_rows=3, head_rows=2),
|
|
414
|
+
backend=backend, singleton=True)
|
|
415
|
+
win.add_body('kept across the dump')
|
|
416
|
+
win.stop_curses() # pre-2.0 call: suspend
|
|
417
|
+
assert win.scr is None and win.suspended
|
|
418
|
+
|
|
419
|
+
scr = win._start_curses() # psistat's "come back" call
|
|
420
|
+
assert scr is not None
|
|
421
|
+
assert win.scr is scr
|
|
422
|
+
assert ConsoleWindow.static_scr is scr
|
|
423
|
+
assert win.suspended is False
|
|
424
|
+
assert win in ConsoleWindow.active_windows()
|
|
425
|
+
assert win.body.texts == ['kept across the dump']
|
|
426
|
+
win.clear()
|
|
427
|
+
assert scr.calls_named('clear')
|
|
428
|
+
win.add_body('after clear')
|
|
429
|
+
|
|
430
|
+
ConsoleWindow.stop_all() # permanent teardown
|
|
431
|
+
assert win.scr is None and ConsoleWindow.static_scr is None
|
|
432
|
+
'''
|
|
433
|
+
subprocess.run([sys.executable, '-c', code], check=True)
|
|
434
|
+
|
|
435
|
+
|
|
288
436
|
def test_mixing_singleton_modes_asserts():
|
|
289
437
|
code = '''
|
|
290
438
|
from console_window.backend import HeadlessBackend
|
|
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
|
|
File without changes
|
|
File without changes
|