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.
Files changed (45) hide show
  1. {console_window-2.0.0.6 → console_window-2.0.0.8}/CHANGELOG.md +23 -0
  2. {console_window-2.0.0.6 → console_window-2.0.0.8}/PKG-INFO +30 -6
  3. {console_window-2.0.0.6 → console_window-2.0.0.8}/README.md +29 -5
  4. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/__init__.py +1 -1
  5. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/backend.py +3 -0
  6. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/window.py +199 -14
  7. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_backend.py +150 -2
  8. {console_window-2.0.0.6 → console_window-2.0.0.8}/.github/workflows/ci.yml +0 -0
  9. {console_window-2.0.0.6 → console_window-2.0.0.8}/.gitignore +0 -0
  10. {console_window-2.0.0.6 → console_window-2.0.0.8}/AUDIT.md +0 -0
  11. {console_window-2.0.0.6 → console_window-2.0.0.8}/LICENSE +0 -0
  12. {console_window-2.0.0.6 → console_window-2.0.0.8}/SampleApp.py +0 -0
  13. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/context.py +0 -0
  14. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/geometry.py +0 -0
  15. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/header.py +0 -0
  16. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/keys.py +0 -0
  17. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/opts.py +0 -0
  18. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/screens.py +0 -0
  19. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/signals.py +0 -0
  20. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/spinner.py +0 -0
  21. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/textwidth.py +0 -0
  22. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/theme.py +0 -0
  23. {console_window-2.0.0.6 → console_window-2.0.0.8}/console_window/widgets.py +0 -0
  24. {console_window-2.0.0.6 → console_window-2.0.0.8}/pyproject.toml +0 -0
  25. {console_window-2.0.0.6 → console_window-2.0.0.8}/runner +0 -0
  26. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_context.py +0 -0
  27. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_demo_mode.py +0 -0
  28. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_dialogs.py +0 -0
  29. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_flash.py +0 -0
  30. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_geometry.py +0 -0
  31. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_header.py +0 -0
  32. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_interactive.py +0 -0
  33. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_low_colour.py +0 -0
  34. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_overflow.py +0 -0
  35. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_public_api.py +0 -0
  36. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_render_retry.py +0 -0
  37. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_sample_app.py +0 -0
  38. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_screens.py +0 -0
  39. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_signals.py +0 -0
  40. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_spinner.py +0 -0
  41. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_textwidth.py +0 -0
  42. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_theme.py +0 -0
  43. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_viewport_pad.py +0 -0
  44. {console_window-2.0.0.6 → console_window-2.0.0.8}/tests/test_widechars.py +0 -0
  45. {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.6
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 unbound
199
- `ConsoleWindow.stop_curses()` tears that screen down. Calling
200
- `win.stop_curses()` on any singleton window does the same. A second window
201
- reuses the first window's screen and backend.
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 unbound
180
- `ConsoleWindow.stop_curses()` tears that screen down. Calling
181
- `win.stop_curses()` on any singleton window does the same. A second window
182
- reuses the first window's screen and backend.
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.6"
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._start_curses()
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 _start_curses(self) -> CursesSurface:
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
- self._stop_singleton()
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 mark every window stopped."""
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
- self.scr.clear()
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 test_singleton_mode_shares_the_process_screen_and_tears_down_globally():
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.stop_curses() # the pre-2.0 unbound call
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