console-window 2.0.0.7__tar.gz → 2.0.0.9__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.9}/CHANGELOG.md +14 -0
  2. {console_window-2.0.0.7 → console_window-2.0.0.9}/PKG-INFO +7 -2
  3. {console_window-2.0.0.7 → console_window-2.0.0.9}/README.md +6 -1
  4. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/__init__.py +1 -1
  5. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/window.py +83 -21
  6. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_backend.py +78 -0
  7. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_header.py +54 -0
  8. {console_window-2.0.0.7 → console_window-2.0.0.9}/.github/workflows/ci.yml +0 -0
  9. {console_window-2.0.0.7 → console_window-2.0.0.9}/.gitignore +0 -0
  10. {console_window-2.0.0.7 → console_window-2.0.0.9}/AUDIT.md +0 -0
  11. {console_window-2.0.0.7 → console_window-2.0.0.9}/LICENSE +0 -0
  12. {console_window-2.0.0.7 → console_window-2.0.0.9}/SampleApp.py +0 -0
  13. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/backend.py +0 -0
  14. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/context.py +0 -0
  15. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/geometry.py +0 -0
  16. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/header.py +0 -0
  17. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/keys.py +0 -0
  18. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/opts.py +0 -0
  19. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/screens.py +0 -0
  20. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/signals.py +0 -0
  21. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/spinner.py +0 -0
  22. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/textwidth.py +0 -0
  23. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/theme.py +0 -0
  24. {console_window-2.0.0.7 → console_window-2.0.0.9}/console_window/widgets.py +0 -0
  25. {console_window-2.0.0.7 → console_window-2.0.0.9}/pyproject.toml +0 -0
  26. {console_window-2.0.0.7 → console_window-2.0.0.9}/runner +0 -0
  27. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_context.py +0 -0
  28. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_demo_mode.py +0 -0
  29. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_dialogs.py +0 -0
  30. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_flash.py +0 -0
  31. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_geometry.py +0 -0
  32. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_interactive.py +0 -0
  33. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_low_colour.py +0 -0
  34. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_overflow.py +0 -0
  35. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_public_api.py +0 -0
  36. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_render_retry.py +0 -0
  37. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_sample_app.py +0 -0
  38. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_screens.py +0 -0
  39. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_signals.py +0 -0
  40. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_spinner.py +0 -0
  41. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_textwidth.py +0 -0
  42. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_theme.py +0 -0
  43. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_viewport_pad.py +0 -0
  44. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_widechars.py +0 -0
  45. {console_window-2.0.0.7 → console_window-2.0.0.9}/tests/test_widgets.py +0 -0
@@ -6,6 +6,10 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
  ## [Unreleased]
7
7
 
8
8
  ### Added
9
+ - `line_attr` on `add_fancy_header()`/`add_header_line()`: an attribute OR'd
10
+ into every span of the header (including plain spans and the raw line emitted
11
+ for `mode='Off'`), so a caller can dim a whole fancy header while keeping the
12
+ per-span bold/underline/reverse styling.
9
13
  - `suspend_curses()`/`resume_curses()` on `ConsoleWindow`: leave curses mode
10
14
  with `endwin()` while keeping the window (pads, content model, pick state)
11
15
  alive, then reattach a screen and repaint. This is the supported way for an
@@ -135,6 +139,16 @@ This project follows [Semantic Versioning](https://semver.org/).
135
139
  No in-repo code used it.
136
140
 
137
141
  ### Fixed
142
+ - `win._start_curses()` once again *reattaches* the window. The 1.4.x
143
+ compatibility hook is still called in the wild (psistat's "dump event log"
144
+ path uses `self.window._start_curses()` to return from shell mode), but the
145
+ 2.0 implementation only created and returned a screen, leaving `win.scr` as
146
+ `None`; the next `clear()`/`render()` then raised
147
+ `AttributeError: 'NoneType' object has no attribute 'clear'`. It now resumes
148
+ a suspended window through `resume_curses()` (singleton mode reattaches every
149
+ window sharing the screen), so the pads and the content model survive, and
150
+ `clear()` while suspended resets the content model without dereferencing the
151
+ missing screen.
138
152
  - Starting a window on a terminal with fewer than 256 colours (including tmux's
139
153
  stock `TERM=screen`, whose terminfo entry has 8 colours) no longer crashes with
140
154
  `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.9
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
@@ -122,7 +122,9 @@ demonstrates:
122
122
  sizes.
123
123
  * **`flash()`** — transient, non-destructive status messages.
124
124
  * **Token-based headers** — `HeaderLine` (`text`, `bracket_key`, `key`, `extend`)
125
- rendered with `add_header_line()`, rather than string markup.
125
+ rendered with `add_header_line()`, rather than string markup. Both header
126
+ helpers accept `line_attr`, an attribute OR'd into every span, so a whole
127
+ header can be dimmed (`curses.A_DIM`) without losing its per-span styling.
126
128
  * **`Ctrl-C` as an application key** — `ctrl_c_terminates=False` and
127
129
  `keys={0x3, ord('q')}` on the quit action. This works only while no live
128
130
  window in the process has requested terminating Ctrl-C: once any window uses
@@ -223,6 +225,9 @@ screen handle, backend, dimensions, pad backgrounds and redraw bookkeeping.
223
225
  In singleton mode `suspend_curses()` suspends every window that shares the
224
226
  screen, and `resume_curses()` reattaches all of them. The legacy pair works the
225
227
  same way there: `ConsoleWindow.stop_curses(); ...; ConsoleWindow.start_curses()`.
228
+ The pre-2.0 instance hook `win._start_curses()` is also supported: it reattaches
229
+ that window (resuming a suspended screen) and returns the live screen, so the
230
+ old `win.stop_curses(); ...; win._start_curses()` sequence keeps working.
226
231
  Only `stop_all()` is a permanent teardown.
227
232
 
228
233
  The first successful construction latches the mode for the whole process.
@@ -103,7 +103,9 @@ demonstrates:
103
103
  sizes.
104
104
  * **`flash()`** — transient, non-destructive status messages.
105
105
  * **Token-based headers** — `HeaderLine` (`text`, `bracket_key`, `key`, `extend`)
106
- rendered with `add_header_line()`, rather than string markup.
106
+ rendered with `add_header_line()`, rather than string markup. Both header
107
+ helpers accept `line_attr`, an attribute OR'd into every span, so a whole
108
+ header can be dimmed (`curses.A_DIM`) without losing its per-span styling.
107
109
  * **`Ctrl-C` as an application key** — `ctrl_c_terminates=False` and
108
110
  `keys={0x3, ord('q')}` on the quit action. This works only while no live
109
111
  window in the process has requested terminating Ctrl-C: once any window uses
@@ -204,6 +206,9 @@ screen handle, backend, dimensions, pad backgrounds and redraw bookkeeping.
204
206
  In singleton mode `suspend_curses()` suspends every window that shares the
205
207
  screen, and `resume_curses()` reattaches all of them. The legacy pair works the
206
208
  same way there: `ConsoleWindow.stop_curses(); ...; ConsoleWindow.start_curses()`.
209
+ The pre-2.0 instance hook `win._start_curses()` is also supported: it reattaches
210
+ that window (resuming a suspended screen) and returns the live screen, so the
211
+ old `win.stop_curses(); ...; win._start_curses()` sequence keeps working.
207
212
  Only `stop_all()` is a permanent teardown.
208
213
 
209
214
  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.9"
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
 
@@ -952,7 +985,8 @@ class ConsoleWindow:
952
985
  self._add(self.body, text, attr, resume, context, is_body=True)
953
986
 
954
987
  def add_fancy_header(self, line: str | HeaderLine, mode: str = 'Underline',
955
- search_pattern_attr: int | None = None) -> None:
988
+ search_pattern_attr: int | None = None,
989
+ line_attr: int | None = None) -> None:
956
990
  """Add a header line with legacy ``[x]``/``KEY:``/``/pattern`` markup.
957
991
 
958
992
  String input is parsed unless ``mode='Off'``. A :class:`HeaderLine`
@@ -960,44 +994,67 @@ class ConsoleWindow:
960
994
  treated as markup too: in every mode except ``'Off'`` they are removed,
961
995
  while the enclosed key keeps its ``KEY`` styling. Use
962
996
  :meth:`add_header_line` when literal brackets are wanted.
997
+
998
+ *line_attr* is OR'd into the attribute of every span (and into the raw
999
+ line emitted for ``mode='Off'``), so a caller can, for example, dim a
1000
+ whole header with ``line_attr=curses.A_DIM`` while keeping each span's
1001
+ own bold/underline/reverse styling.
963
1002
  """
964
1003
  if isinstance(line, str):
965
1004
  if mode == 'Off':
966
- # Legacy: no markup processing, add the raw line unchanged.
967
- self.add_header(line)
1005
+ # Legacy: no markup processing, add the raw line unchanged
1006
+ # (except for the caller's whole-line attribute).
1007
+ self.add_header(line, attr=line_attr)
968
1008
  return
969
1009
  spans = parse_fancy_header(line)
970
1010
  else:
971
1011
  spans = list(line)
972
1012
  if mode != 'Off':
973
1013
  spans = strip_key_brackets(spans)
974
- self._emit_header_spans(spans, mode, search_pattern_attr)
1014
+ self._emit_header_spans(spans, mode, search_pattern_attr, line_attr)
975
1015
 
976
1016
  def add_header_line(self, header_line: HeaderLine | Iterable[Span],
977
1017
  mode: str = 'Underline',
978
- search_pattern_attr: int | None = None) -> None:
979
- self._emit_header_spans(list(header_line), mode, search_pattern_attr)
1018
+ search_pattern_attr: int | None = None,
1019
+ line_attr: int | None = None) -> None:
1020
+ """Render explicit header spans; see :meth:`add_fancy_header`.
1021
+
1022
+ *line_attr* is OR'd into every span's resolved attribute, including
1023
+ plain spans, so the whole line can carry an extra style such as
1024
+ ``curses.A_DIM``.
1025
+ """
1026
+ self._emit_header_spans(list(header_line), mode, search_pattern_attr,
1027
+ line_attr)
980
1028
 
981
1029
  @staticmethod
982
1030
  def _resolve_header_attr(role: str, mode: str,
983
- search_pattern_attr: int | None) -> int | None:
1031
+ search_pattern_attr: int | None,
1032
+ line_attr: int | None = None) -> int | None:
984
1033
  if mode == 'Off' or role == PLAIN:
985
- return None
986
- if role == LABEL:
987
- return curses.A_BOLD
988
- if role == SEARCH:
1034
+ attr = None
1035
+ elif role == LABEL:
1036
+ attr = curses.A_BOLD
1037
+ elif role == SEARCH:
989
1038
  if search_pattern_attr is not None:
990
- return search_pattern_attr
991
- return curses.A_BOLD | curses.A_REVERSE
1039
+ attr = search_pattern_attr
1040
+ else:
1041
+ attr = curses.A_BOLD | curses.A_REVERSE
992
1042
  # KEY (and any unknown role) follows the mode's key styling.
993
- if mode == 'Underline':
994
- return curses.A_UNDERLINE | curses.A_BOLD
995
- return curses.A_REVERSE
1043
+ elif mode == 'Underline':
1044
+ attr = curses.A_UNDERLINE | curses.A_BOLD
1045
+ else:
1046
+ attr = curses.A_REVERSE
1047
+ if line_attr is not None:
1048
+ attr = (attr or 0) | line_attr
1049
+ return attr
996
1050
 
997
1051
  def _emit_header_spans(self, spans: Iterable[Span], mode: str,
998
- search_pattern_attr: int | None = None) -> None:
1052
+ search_pattern_attr: int | None = None,
1053
+ line_attr: int | None = None) -> None:
999
1054
  result_sections = [
1000
- (span.text, self._resolve_header_attr(span.role, mode, search_pattern_attr))
1055
+ (span.text,
1056
+ self._resolve_header_attr(span.role, mode, search_pattern_attr,
1057
+ line_attr))
1001
1058
  for span in spans
1002
1059
  ]
1003
1060
 
@@ -2149,7 +2206,12 @@ class ConsoleWindow:
2149
2206
  pad.refresh(scroll_top, 0, row0 + 1, col0 + 1, row0 + visible_rows, col9 - 1)
2150
2207
 
2151
2208
  def clear(self) -> None:
2152
- self.scr.clear()
2209
+ # A suspended window has no screen handle until resume_curses()/
2210
+ # start_curses(); the content model still has to be reset so the next
2211
+ # repaint is complete. Calling clear() while suspended used to raise
2212
+ # AttributeError on self.scr.
2213
+ if self.scr is not None:
2214
+ self.scr.clear()
2153
2215
  # Viewport-sized pads: clearing costs one screenful, not one high-water
2154
2216
  # mark, and no shrink/grow decision is needed at a frame boundary.
2155
2217
  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
@@ -207,6 +207,48 @@ def test_add_fancy_header_headerline_preserves_explicit_roles():
207
207
  win.stop_curses()
208
208
 
209
209
 
210
+ def test_add_fancy_header_line_attr_dims_every_run():
211
+ # line_attr is OR'd into each span's own style, so a dimmed legend keeps
212
+ # its underlined keys and its explicitly styled search field.
213
+ win = _make_window()
214
+ try:
215
+ win.add_fancy_header('[v]iew /pat', mode='Underline',
216
+ search_pattern_attr=curses.A_BOLD,
217
+ line_attr=curses.A_DIM)
218
+ assert win.head.texts[0] == 'view /pat'
219
+ attrs = [attr for attr, _count in win.head.text_attrs[0]]
220
+ assert all(attr & curses.A_DIM for attr in attrs)
221
+ assert KEY_ATTR | curses.A_DIM in attrs
222
+ assert curses.A_BOLD | curses.A_DIM in attrs
223
+ finally:
224
+ win.stop_curses()
225
+
226
+
227
+ def test_add_fancy_header_line_attr_applies_in_off_mode():
228
+ # mode='Off' emits the raw string, but the whole-line attribute still
229
+ # applies (otherwise a caller could not dim it at all).
230
+ win = _make_window()
231
+ try:
232
+ win.add_fancy_header('[v]iew', mode='Off', line_attr=curses.A_DIM)
233
+ assert win.head.texts[0] == '[v]iew'
234
+ assert all(attr & curses.A_DIM
235
+ for attr, _count in win.head.text_attrs[0])
236
+ finally:
237
+ win.stop_curses()
238
+
239
+
240
+ def test_add_header_line_line_attr_dims_plain_spans():
241
+ win = _make_window()
242
+ try:
243
+ win.add_header_line(HeaderLine().text('plain').label('T'),
244
+ mode='Underline', line_attr=curses.A_DIM)
245
+ attrs = [attr for attr, _count in win.head.text_attrs[0]]
246
+ assert curses.A_DIM in attrs # the PLAIN run
247
+ assert curses.A_BOLD | curses.A_DIM in attrs # the LABEL run
248
+ finally:
249
+ win.stop_curses()
250
+
251
+
210
252
  def test_string_reparse_drops_keys_the_legacy_grammar_cannot_spell():
211
253
  # str(line) is pure text, so the legacy scanner's two KEY rules ('[x]' and
212
254
  # 'UPPERCASE:') are all that survive a round trip. 'l:' and '?:' match
@@ -253,3 +295,15 @@ def test_token_and_string_paths_converge_for_legacy_spelled_keys():
253
295
 
254
296
  def test_resolve_header_attr_off_is_plain(role):
255
297
  assert ConsoleWindow._resolve_header_attr(role, 'Off', None) is None
298
+
299
+
300
+ def test_resolve_header_attr_line_attr_combines_with_roles():
301
+ assert (ConsoleWindow._resolve_header_attr(
302
+ PLAIN, 'Underline', None, curses.A_DIM)
303
+ == curses.A_DIM)
304
+ assert (ConsoleWindow._resolve_header_attr(
305
+ KEY, 'Underline', None, curses.A_DIM)
306
+ == KEY_ATTR | curses.A_DIM)
307
+ assert (ConsoleWindow._resolve_header_attr(
308
+ SEARCH, 'Underline', curses.A_BOLD, curses.A_DIM)
309
+ == curses.A_BOLD | curses.A_DIM)