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.
Files changed (45) hide show
  1. {console_window-2.0.0.6 → console_window-2.0.0.7}/CHANGELOG.md +13 -0
  2. {console_window-2.0.0.6 → console_window-2.0.0.7}/PKG-INFO +27 -6
  3. {console_window-2.0.0.6 → console_window-2.0.0.7}/README.md +26 -5
  4. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/__init__.py +1 -1
  5. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/backend.py +3 -0
  6. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/window.py +158 -11
  7. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_backend.py +72 -2
  8. {console_window-2.0.0.6 → console_window-2.0.0.7}/.github/workflows/ci.yml +0 -0
  9. {console_window-2.0.0.6 → console_window-2.0.0.7}/.gitignore +0 -0
  10. {console_window-2.0.0.6 → console_window-2.0.0.7}/AUDIT.md +0 -0
  11. {console_window-2.0.0.6 → console_window-2.0.0.7}/LICENSE +0 -0
  12. {console_window-2.0.0.6 → console_window-2.0.0.7}/SampleApp.py +0 -0
  13. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/context.py +0 -0
  14. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/geometry.py +0 -0
  15. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/header.py +0 -0
  16. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/keys.py +0 -0
  17. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/opts.py +0 -0
  18. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/screens.py +0 -0
  19. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/signals.py +0 -0
  20. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/spinner.py +0 -0
  21. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/textwidth.py +0 -0
  22. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/theme.py +0 -0
  23. {console_window-2.0.0.6 → console_window-2.0.0.7}/console_window/widgets.py +0 -0
  24. {console_window-2.0.0.6 → console_window-2.0.0.7}/pyproject.toml +0 -0
  25. {console_window-2.0.0.6 → console_window-2.0.0.7}/runner +0 -0
  26. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_context.py +0 -0
  27. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_demo_mode.py +0 -0
  28. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_dialogs.py +0 -0
  29. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_flash.py +0 -0
  30. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_geometry.py +0 -0
  31. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_header.py +0 -0
  32. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_interactive.py +0 -0
  33. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_low_colour.py +0 -0
  34. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_overflow.py +0 -0
  35. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_public_api.py +0 -0
  36. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_render_retry.py +0 -0
  37. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_sample_app.py +0 -0
  38. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_screens.py +0 -0
  39. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_signals.py +0 -0
  40. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_spinner.py +0 -0
  41. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_textwidth.py +0 -0
  42. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_theme.py +0 -0
  43. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_viewport_pad.py +0 -0
  44. {console_window-2.0.0.6 → console_window-2.0.0.7}/tests/test_widechars.py +0 -0
  45. {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.6
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 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
+ 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 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
+ 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.6"
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
- self._stop_singleton()
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 mark every window stopped."""
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 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,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