console-window 2.0.0.7__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.7 → console_window-2.0.0.8}/CHANGELOG.md +10 -0
  2. {console_window-2.0.0.7 → console_window-2.0.0.8}/PKG-INFO +4 -1
  3. {console_window-2.0.0.7 → console_window-2.0.0.8}/README.md +3 -0
  4. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/__init__.py +1 -1
  5. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/window.py +41 -3
  6. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_backend.py +78 -0
  7. {console_window-2.0.0.7 → console_window-2.0.0.8}/.github/workflows/ci.yml +0 -0
  8. {console_window-2.0.0.7 → console_window-2.0.0.8}/.gitignore +0 -0
  9. {console_window-2.0.0.7 → console_window-2.0.0.8}/AUDIT.md +0 -0
  10. {console_window-2.0.0.7 → console_window-2.0.0.8}/LICENSE +0 -0
  11. {console_window-2.0.0.7 → console_window-2.0.0.8}/SampleApp.py +0 -0
  12. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/backend.py +0 -0
  13. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/context.py +0 -0
  14. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/geometry.py +0 -0
  15. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/header.py +0 -0
  16. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/keys.py +0 -0
  17. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/opts.py +0 -0
  18. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/screens.py +0 -0
  19. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/signals.py +0 -0
  20. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/spinner.py +0 -0
  21. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/textwidth.py +0 -0
  22. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/theme.py +0 -0
  23. {console_window-2.0.0.7 → console_window-2.0.0.8}/console_window/widgets.py +0 -0
  24. {console_window-2.0.0.7 → console_window-2.0.0.8}/pyproject.toml +0 -0
  25. {console_window-2.0.0.7 → console_window-2.0.0.8}/runner +0 -0
  26. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_context.py +0 -0
  27. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_demo_mode.py +0 -0
  28. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_dialogs.py +0 -0
  29. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_flash.py +0 -0
  30. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_geometry.py +0 -0
  31. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_header.py +0 -0
  32. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_interactive.py +0 -0
  33. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_low_colour.py +0 -0
  34. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_overflow.py +0 -0
  35. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_public_api.py +0 -0
  36. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_render_retry.py +0 -0
  37. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_sample_app.py +0 -0
  38. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_screens.py +0 -0
  39. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_signals.py +0 -0
  40. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_spinner.py +0 -0
  41. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_textwidth.py +0 -0
  42. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_theme.py +0 -0
  43. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_viewport_pad.py +0 -0
  44. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_widechars.py +0 -0
  45. {console_window-2.0.0.7 → console_window-2.0.0.8}/tests/test_widgets.py +0 -0
@@ -135,6 +135,16 @@ This project follows [Semantic Versioning](https://semver.org/).
135
135
  No in-repo code used it.
136
136
 
137
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.
138
148
  - Starting a window on a terminal with fewer than 256 colours (including tmux's
139
149
  stock `TERM=screen`, whose terminfo entry has 8 colours) no longer crashes with
140
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.7
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
@@ -223,6 +223,9 @@ screen handle, backend, dimensions, pad backgrounds and redraw bookkeeping.
223
223
  In singleton mode `suspend_curses()` suspends every window that shares the
224
224
  screen, and `resume_curses()` reattaches all of them. The legacy pair works the
225
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.
226
229
  Only `stop_all()` is a permanent teardown.
227
230
 
228
231
  The first successful construction latches the mode for the whole process.
@@ -204,6 +204,9 @@ screen handle, backend, dimensions, pad backgrounds and redraw bookkeeping.
204
204
  In singleton mode `suspend_curses()` suspends every window that shares the
205
205
  screen, and `resume_curses()` reattaches all of them. The legacy pair works the
206
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.
207
210
  Only `stop_all()` is a permanent teardown.
208
211
 
209
212
  The first successful construction latches the mode for the whole process.
@@ -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.7"
3
+ __version__ = "2.0.0.8"
4
4
 
5
5
  from .context import Context
6
6
  from .header import HeaderLine, Span
@@ -291,7 +291,7 @@ class ConsoleWindow:
291
291
  self._backend = ConsoleWindow._static_backend or self._backend
292
292
  self.scr = ConsoleWindow.static_scr
293
293
  else:
294
- self.scr = self._start_curses()
294
+ self.scr = self._new_screen()
295
295
  if self.singleton:
296
296
  ConsoleWindow.static_scr = self.scr
297
297
  ConsoleWindow._static_backend = self._backend
@@ -581,9 +581,42 @@ class ConsoleWindow:
581
581
 
582
582
  return True
583
583
 
584
- def _start_curses(self) -> CursesSurface:
584
+ def _new_screen(self) -> CursesSurface:
585
+ """Create and initialise a brand-new screen for this window."""
585
586
  return self._init_screen(self._backend, self.theme)
586
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
+
587
620
  def _attach_screen(self, scr: CursesSurface, backend: RenderBackend) -> None:
588
621
  """Point this window at a (re)started screen and force a full repaint.
589
622
 
@@ -2149,7 +2182,12 @@ class ConsoleWindow:
2149
2182
  pad.refresh(scroll_top, 0, row0 + 1, col0 + 1, row0 + visible_rows, col9 - 1)
2150
2183
 
2151
2184
  def clear(self) -> None:
2152
- 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()
2153
2191
  # Viewport-sized pads: clearing costs one screenful, not one high-water
2154
2192
  # mark, and no shrink/grow decision is needed at a frame boundary.
2155
2193
  self.head.pad.clear()
@@ -355,6 +355,84 @@ def test_per_instance_suspend_and_resume_curses():
355
355
  assert win not in ConsoleWindow.active_windows()
356
356
 
357
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
+
358
436
  def test_mixing_singleton_modes_asserts():
359
437
  code = '''
360
438
  from console_window.backend import HeadlessBackend