console-window 2.0.0.6__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.6 → console_window-2.0.0.7}/CHANGELOG.md +13 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/PKG-INFO +27 -6
- {console_window-2.0.0.6 → console_window-2.0.0.7}/README.md +26 -5
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/__init__.py +1 -1
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/backend.py +3 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/window.py +158 -11
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_backend.py +72 -2
- {console_window-2.0.0.6 → console_window-2.0.0.7}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/.gitignore +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/AUDIT.md +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/LICENSE +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/SampleApp.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/context.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/geometry.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/header.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/keys.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/opts.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/screens.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/signals.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/spinner.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/textwidth.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/theme.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/widgets.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/pyproject.toml +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/runner +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_context.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_dialogs.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_flash.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_geometry.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_header.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_interactive.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_low_colour.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_overflow.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_public_api.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_render_retry.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_sample_app.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_screens.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_signals.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_spinner.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_textwidth.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_theme.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_viewport_pad.py +0 -0
- {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_widechars.py +0 -0
- {console_window-2.0.0.6 → 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
|
|
@@ -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
|
|
@@ -195,15 +195,36 @@ 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
|
+
Only `stop_all()` is a permanent teardown.
|
|
227
|
+
|
|
207
228
|
The first successful construction latches the mode for the whole process.
|
|
208
229
|
Mixing the two values raises `AssertionError` rather than silently changing what
|
|
209
230
|
teardown means. Use `singleton=False` in tests and in applications that create
|
|
@@ -176,15 +176,36 @@ 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
|
+
Only `stop_all()` is a permanent teardown.
|
|
208
|
+
|
|
188
209
|
The first successful construction latches the mode for the whole process.
|
|
189
210
|
Mixing the two values raises `AssertionError` rather than silently changing what
|
|
190
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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -559,10 +584,76 @@ class ConsoleWindow:
|
|
|
559
584
|
def _start_curses(self) -> CursesSurface:
|
|
560
585
|
return self._init_screen(self._backend, self.theme)
|
|
561
586
|
|
|
587
|
+
def _attach_screen(self, scr: CursesSurface, backend: RenderBackend) -> None:
|
|
588
|
+
"""Point this window at a (re)started screen and force a full repaint.
|
|
589
|
+
|
|
590
|
+
Used by resume_curses()/start_curses() after a suspend. The content
|
|
591
|
+
model and pads survive ``endwin()``; only the screen handle, the
|
|
592
|
+
backend that owns it, the dimensions, the pad backgrounds and the
|
|
593
|
+
redraw bookkeeping have to be refreshed.
|
|
594
|
+
"""
|
|
595
|
+
self._backend = backend
|
|
596
|
+
self.scr = scr
|
|
597
|
+
self.suspended = False
|
|
598
|
+
_register_window(self)
|
|
599
|
+
self.calc() # dimensions + pad reallocation through the new backend
|
|
600
|
+
self._set_scr_timeout(self._scr_timeout_ms)
|
|
601
|
+
for ns in (self.head, self.body):
|
|
602
|
+
self._apply_pad_theme(cast(CursesSurface, ns.pad))
|
|
603
|
+
self._mark_pads_for_redraw()
|
|
604
|
+
self._pick_clear_pending = True
|
|
605
|
+
|
|
606
|
+
def suspend_curses(self) -> None:
|
|
607
|
+
"""Leave curses mode without destroying this window.
|
|
608
|
+
|
|
609
|
+
``endwin()`` restores the terminal but keeps the curses SCREEN (and
|
|
610
|
+
therefore the pads) alive, so the next :meth:`resume_curses` can
|
|
611
|
+
reattach a screen and the next ``render()`` repaints. In singleton
|
|
612
|
+
mode all windows share one screen, so this suspends them together.
|
|
613
|
+
Use :meth:`stop_curses` when the window is finished for good.
|
|
614
|
+
"""
|
|
615
|
+
if getattr(self, 'singleton', False):
|
|
616
|
+
ConsoleWindow._suspend_singleton()
|
|
617
|
+
return
|
|
618
|
+
scr = getattr(self, 'scr', None)
|
|
619
|
+
if scr is None or self.suspended:
|
|
620
|
+
return
|
|
621
|
+
try:
|
|
622
|
+
backend = getattr(self, '_backend', None) or CursesBackend()
|
|
623
|
+
backend.stop(scr)
|
|
624
|
+
finally:
|
|
625
|
+
self.scr = cast(CursesSurface, None)
|
|
626
|
+
self.suspended = True
|
|
627
|
+
_unregister_window(self)
|
|
628
|
+
signals.teardown_ctrl_c()
|
|
629
|
+
|
|
630
|
+
def resume_curses(self) -> CursesSurface | None:
|
|
631
|
+
"""Re-enter curses after :meth:`suspend_curses`.
|
|
632
|
+
|
|
633
|
+
Returns the (new) screen, or None when the window was not suspended.
|
|
634
|
+
The next ``render()`` repaints from the preserved content model.
|
|
635
|
+
"""
|
|
636
|
+
if getattr(self, 'singleton', False):
|
|
637
|
+
return ConsoleWindow._resume_singleton()
|
|
638
|
+
if not self.suspended:
|
|
639
|
+
return self.scr
|
|
640
|
+
backend = getattr(self, '_backend', None) or CursesBackend()
|
|
641
|
+
scr = self._init_screen(backend, self.theme)
|
|
642
|
+
self._attach_screen(scr, backend)
|
|
643
|
+
return scr
|
|
644
|
+
|
|
562
645
|
@classmethod
|
|
563
646
|
def start_curses(cls, backend: RenderBackend | None = None) -> CursesSurface:
|
|
564
647
|
# Legacy class-level entry point: in singleton mode it shares the one
|
|
565
|
-
# process screen rather than starting a second terminal session.
|
|
648
|
+
# process screen rather than starting a second terminal session. When
|
|
649
|
+
# windows were suspended by stop_curses()/suspend_curses(), this is the
|
|
650
|
+
# matching resume: every suspended window is reattached to the screen
|
|
651
|
+
# (the backend argument is ignored then -- the screen belongs to the
|
|
652
|
+
# backend that created it).
|
|
653
|
+
if _SINGLETON_MODE and _SUSPENDED:
|
|
654
|
+
scr = cls._resume_singleton()
|
|
655
|
+
if scr is not None:
|
|
656
|
+
return scr
|
|
566
657
|
if _SINGLETON_MODE and cls.static_scr is not None:
|
|
567
658
|
return cls.static_scr
|
|
568
659
|
screen_backend = backend if backend is not None else CursesBackend()
|
|
@@ -573,6 +664,45 @@ class ConsoleWindow:
|
|
|
573
664
|
cls._static_backend = screen_backend
|
|
574
665
|
return scr
|
|
575
666
|
|
|
667
|
+
@classmethod
|
|
668
|
+
def _suspend_singleton(cls) -> None:
|
|
669
|
+
"""Leave the shared screen, keeping every window resumable."""
|
|
670
|
+
scr = cls.static_scr
|
|
671
|
+
if scr is None:
|
|
672
|
+
return
|
|
673
|
+
backend = cls._static_backend or CursesBackend()
|
|
674
|
+
windows = list(_WINDOWS.values())
|
|
675
|
+
try:
|
|
676
|
+
backend.stop(scr)
|
|
677
|
+
finally:
|
|
678
|
+
cls.static_scr = None
|
|
679
|
+
for win in windows:
|
|
680
|
+
win.scr = cast(CursesSurface, None)
|
|
681
|
+
win.suspended = True
|
|
682
|
+
_unregister_window(win)
|
|
683
|
+
signals.teardown_ctrl_c()
|
|
684
|
+
if win not in _SUSPENDED:
|
|
685
|
+
_SUSPENDED.append(win)
|
|
686
|
+
# static_win is deliberately kept: it is the anchor that
|
|
687
|
+
# start_curses()/resume_curses() uses to restore the theme and is
|
|
688
|
+
# replaced by the next singleton window construction.
|
|
689
|
+
|
|
690
|
+
@classmethod
|
|
691
|
+
def _resume_singleton(cls) -> CursesSurface | None:
|
|
692
|
+
"""Re-enter the shared screen and reattach every suspended window."""
|
|
693
|
+
if not _SUSPENDED:
|
|
694
|
+
return cls.static_scr
|
|
695
|
+
backend = cls._static_backend or CursesBackend()
|
|
696
|
+
anchor = cls.static_win
|
|
697
|
+
scr = cls._init_screen(backend, anchor.theme if anchor is not None else None)
|
|
698
|
+
cls.static_scr = scr
|
|
699
|
+
cls._static_backend = backend
|
|
700
|
+
windows = list(_SUSPENDED)
|
|
701
|
+
del _SUSPENDED[:]
|
|
702
|
+
for win in windows:
|
|
703
|
+
win._attach_screen(scr, backend) # pylint: disable=protected-access
|
|
704
|
+
return scr
|
|
705
|
+
|
|
576
706
|
@staticmethod
|
|
577
707
|
def _install_theme(backend: RenderBackend, scr: CursesSurface,
|
|
578
708
|
theme_name: str | None) -> str:
|
|
@@ -634,25 +764,31 @@ class ConsoleWindow:
|
|
|
634
764
|
self._pick_clear_pending = True
|
|
635
765
|
|
|
636
766
|
def _stop_instance(self) -> None:
|
|
637
|
-
"""Stop this window (and, in singleton mode, the shared screen)."""
|
|
767
|
+
"""Stop this window (and, in singleton mode, suspend the shared screen)."""
|
|
638
768
|
if getattr(self, 'singleton', False):
|
|
639
|
-
|
|
769
|
+
# Backward compatibility: in singleton mode the legacy
|
|
770
|
+
# stop_curses()/start_curses() pair is a suspend/resume pair, so
|
|
771
|
+
# stop_curses() leaves the screen resumable. stop_all() is the
|
|
772
|
+
# permanent teardown.
|
|
773
|
+
ConsoleWindow._suspend_singleton()
|
|
640
774
|
return
|
|
641
775
|
|
|
642
776
|
scr = getattr(self, 'scr', None)
|
|
643
777
|
if scr is None:
|
|
778
|
+
self.suspended = False
|
|
644
779
|
return
|
|
645
780
|
try:
|
|
646
781
|
backend = getattr(self, '_backend', None) or CursesBackend()
|
|
647
782
|
backend.stop(scr)
|
|
648
783
|
finally:
|
|
649
784
|
self.scr = cast(CursesSurface, None)
|
|
785
|
+
self.suspended = False
|
|
650
786
|
_unregister_window(self)
|
|
651
787
|
signals.teardown_ctrl_c()
|
|
652
788
|
|
|
653
789
|
@classmethod
|
|
654
790
|
def _stop_legacy(cls) -> None:
|
|
655
|
-
"""The pre-2.0 ``ConsoleWindow.stop_curses()`` call."""
|
|
791
|
+
"""The pre-2.0 ``ConsoleWindow.stop_curses()`` call (suspends in singleton mode)."""
|
|
656
792
|
assert _SINGLETON_MODE is not False, (
|
|
657
793
|
'ConsoleWindow.stop_curses() without an instance is only valid '
|
|
658
794
|
'in singleton mode; use win.stop_curses() or ConsoleWindow.stop_all()')
|
|
@@ -662,24 +798,31 @@ class ConsoleWindow:
|
|
|
662
798
|
|
|
663
799
|
@classmethod
|
|
664
800
|
def _stop_singleton(cls) -> None:
|
|
665
|
-
"""Tear down the shared screen and
|
|
801
|
+
"""Tear down the shared screen and forget every window (including suspended)."""
|
|
666
802
|
scr = ConsoleWindow.static_scr
|
|
667
|
-
if scr is None:
|
|
668
|
-
return
|
|
669
|
-
backend = ConsoleWindow._static_backend or CursesBackend()
|
|
670
803
|
windows = list(_WINDOWS.values())
|
|
804
|
+
suspended = list(_SUSPENDED)
|
|
805
|
+
del _SUSPENDED[:]
|
|
671
806
|
ConsoleWindow.static_scr = None
|
|
672
807
|
ConsoleWindow.static_win = None
|
|
808
|
+
backend = ConsoleWindow._static_backend
|
|
673
809
|
ConsoleWindow._static_backend = None
|
|
674
|
-
for win in windows:
|
|
810
|
+
for win in windows + suspended:
|
|
675
811
|
win.scr = cast(CursesSurface, None)
|
|
812
|
+
win.suspended = False
|
|
813
|
+
if scr is None:
|
|
814
|
+
# Already in shell mode (all windows suspended); nothing to end,
|
|
815
|
+
# but the suspended windows still have to be forgotten.
|
|
816
|
+
for win in windows + suspended:
|
|
817
|
+
_unregister_window(win)
|
|
818
|
+
return
|
|
676
819
|
try:
|
|
677
|
-
backend.stop(scr)
|
|
820
|
+
(backend or CursesBackend()).stop(scr)
|
|
678
821
|
finally:
|
|
679
822
|
# One teardown per window, matching the per-window setup in
|
|
680
823
|
# _init_screen: the SIGINT handler is only restored once the last
|
|
681
824
|
# live window is gone (AUDIT.md §3.1).
|
|
682
|
-
for win in windows:
|
|
825
|
+
for win in windows + suspended:
|
|
683
826
|
_unregister_window(win)
|
|
684
827
|
signals.teardown_ctrl_c()
|
|
685
828
|
|
|
@@ -704,6 +847,10 @@ class ConsoleWindow:
|
|
|
704
847
|
|
|
705
848
|
@classmethod
|
|
706
849
|
def stop_all(cls) -> None:
|
|
850
|
+
"""Permanently tear down every window (the atexit hook)."""
|
|
851
|
+
if _SINGLETON_MODE:
|
|
852
|
+
cls._stop_singleton()
|
|
853
|
+
return
|
|
707
854
|
for win in cls.active_windows():
|
|
708
855
|
win.stop_curses()
|
|
709
856
|
|
|
@@ -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,76 @@ 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
|
+
|
|
288
358
|
def test_mixing_singleton_modes_asserts():
|
|
289
359
|
code = '''
|
|
290
360
|
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
|