console-window 2.1.1__tar.gz → 2.1.2__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 (51) hide show
  1. {console_window-2.1.1 → console_window-2.1.2}/PKG-INFO +1 -1
  2. {console_window-2.1.1 → console_window-2.1.2}/console_window/__init__.py +1 -1
  3. {console_window-2.1.1 → console_window-2.1.2}/console_window/textwidth.py +31 -3
  4. {console_window-2.1.1 → console_window-2.1.2}/console_window/widgets.py +19 -3
  5. {console_window-2.1.1 → console_window-2.1.2}/console_window/window.py +95 -27
  6. {console_window-2.1.1 → console_window-2.1.2}/tests/CHANGELOG.md +24 -0
  7. console_window-2.1.2/tests/test_demo_mode.py +97 -0
  8. {console_window-2.1.1 → console_window-2.1.2}/tests/test_interactive.py +11 -0
  9. console_window-2.1.2/tests/test_prompt_window.py +288 -0
  10. {console_window-2.1.1 → console_window-2.1.2}/tests/test_textwidth.py +34 -0
  11. console_window-2.1.1/tests/test_demo_mode.py +0 -46
  12. console_window-2.1.1/tests/test_prompt_window.py +0 -152
  13. {console_window-2.1.1 → console_window-2.1.2}/.github/workflows/ci.yml +0 -0
  14. {console_window-2.1.1 → console_window-2.1.2}/.gitignore +0 -0
  15. {console_window-2.1.1 → console_window-2.1.2}/LICENSE +0 -0
  16. {console_window-2.1.1 → console_window-2.1.2}/README.md +0 -0
  17. {console_window-2.1.1 → console_window-2.1.2}/SampleApp.py +0 -0
  18. {console_window-2.1.1 → console_window-2.1.2}/console_window/backend.py +0 -0
  19. {console_window-2.1.1 → console_window-2.1.2}/console_window/context.py +0 -0
  20. {console_window-2.1.1 → console_window-2.1.2}/console_window/geometry.py +0 -0
  21. {console_window-2.1.1 → console_window-2.1.2}/console_window/header.py +0 -0
  22. {console_window-2.1.1 → console_window-2.1.2}/console_window/keys.py +0 -0
  23. {console_window-2.1.1 → console_window-2.1.2}/console_window/opts.py +0 -0
  24. {console_window-2.1.1 → console_window-2.1.2}/console_window/screens.py +0 -0
  25. {console_window-2.1.1 → console_window-2.1.2}/console_window/signals.py +0 -0
  26. {console_window-2.1.1 → console_window-2.1.2}/console_window/spinner.py +0 -0
  27. {console_window-2.1.1 → console_window-2.1.2}/console_window/theme.py +0 -0
  28. {console_window-2.1.1 → console_window-2.1.2}/pyproject.toml +0 -0
  29. {console_window-2.1.1 → console_window-2.1.2}/runner +0 -0
  30. {console_window-2.1.1 → console_window-2.1.2}/tests/AUDIT.md +0 -0
  31. {console_window-2.1.1 → console_window-2.1.2}/tests/test_backend.py +0 -0
  32. {console_window-2.1.1 → console_window-2.1.2}/tests/test_context.py +0 -0
  33. {console_window-2.1.1 → console_window-2.1.2}/tests/test_dialogs.py +0 -0
  34. {console_window-2.1.1 → console_window-2.1.2}/tests/test_flash.py +0 -0
  35. {console_window-2.1.1 → console_window-2.1.2}/tests/test_geometry.py +0 -0
  36. {console_window-2.1.1 → console_window-2.1.2}/tests/test_header.py +0 -0
  37. {console_window-2.1.1 → console_window-2.1.2}/tests/test_low_colour.py +0 -0
  38. {console_window-2.1.1 → console_window-2.1.2}/tests/test_overflow.py +0 -0
  39. {console_window-2.1.1 → console_window-2.1.2}/tests/test_prompt_line.py +0 -0
  40. {console_window-2.1.1 → console_window-2.1.2}/tests/test_public_api.py +0 -0
  41. {console_window-2.1.1 → console_window-2.1.2}/tests/test_render_retry.py +0 -0
  42. {console_window-2.1.1 → console_window-2.1.2}/tests/test_sample_app.py +0 -0
  43. {console_window-2.1.1 → console_window-2.1.2}/tests/test_screens.py +0 -0
  44. {console_window-2.1.1 → console_window-2.1.2}/tests/test_signals.py +0 -0
  45. {console_window-2.1.1 → console_window-2.1.2}/tests/test_spinner.py +0 -0
  46. {console_window-2.1.1 → console_window-2.1.2}/tests/test_status.py +0 -0
  47. {console_window-2.1.1 → console_window-2.1.2}/tests/test_taken_nav_keys.py +0 -0
  48. {console_window-2.1.1 → console_window-2.1.2}/tests/test_theme.py +0 -0
  49. {console_window-2.1.1 → console_window-2.1.2}/tests/test_viewport_pad.py +0 -0
  50. {console_window-2.1.1 → console_window-2.1.2}/tests/test_widechars.py +0 -0
  51. {console_window-2.1.1 → console_window-2.1.2}/tests/test_widgets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 2.1.1
3
+ Version: 2.1.2
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
@@ -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.1.1"
3
+ __version__ = "2.1.2"
4
4
 
5
5
  from .context import Context
6
6
  from .header import HeaderLine, Span
@@ -21,6 +21,14 @@ __all__ = [
21
21
  # characters (ZWJ, variation selectors, BOM, …) and control characters.
22
22
  _ZERO_WIDTH_CATEGORIES = frozenset({'Mn', 'Me', 'Cf', 'Cc'})
23
23
 
24
+ # Variation selectors. VS16 asks for *emoji* presentation, which terminals
25
+ # draw two cells wide even when the base character's own East-Asian width is
26
+ # Narrow/Ambiguous (U+26A0 WARNING SIGN, U+2714 HEAVY CHECK MARK, U+2139
27
+ # INFORMATION SOURCE). VS15 asks for text presentation and needs no
28
+ # adjustment: such bases already count as one cell.
29
+ _VS16 = '\ufe0f'
30
+ _VS15 = '\ufe0e' # no adjustment needed; named for symmetry with _VS16
31
+
24
32
  # Any character that is not printable ASCII needs per-character measurement.
25
33
  # Everything else is exactly one cell wide, so measurement can skip whole
26
34
  # ASCII runs in C and only visit the glyphs that actually differ.
@@ -51,6 +59,26 @@ def _non_ascii_char_width(ch: str) -> int:
51
59
  return 1
52
60
 
53
61
 
62
+ def _string_char_width(text: str, index: int) -> int:
63
+ """Width of ``text[index]`` with variation selectors folded into the base.
64
+
65
+ ``char_width()`` stays per code point on purpose: the cursor helpers move
66
+ by code point and must never be able to land between a base and its
67
+ selector. String-level measurement is where the one context-dependent
68
+ rule belongs -- a VS16 after a narrow, non-ASCII base makes that cluster
69
+ two cells wide, which is how terminals draw ``⚠️``. A base that already
70
+ counts two cells (⌚ U+231A, ⌛ U+231B) keeps its width, and ASCII is left
71
+ alone because terminals do not double it.
72
+ """
73
+ ch = text[index]
74
+ if ch != _VS16 or index == 0:
75
+ return char_width(ch)
76
+ base = text[index - 1]
77
+ if base.isascii() or char_width(base) != 1:
78
+ return 0
79
+ return 1
80
+
81
+
54
82
  def char_width(ch: str) -> int:
55
83
  if not ch:
56
84
  return 0
@@ -70,7 +98,7 @@ def string_width(text: str) -> int:
70
98
  # without needing a whole-string cache large enough for every body row.
71
99
  width = len(text)
72
100
  for match in _SPECIAL_CHAR.finditer(text):
73
- width += char_width(match.group()) - 1
101
+ width += _string_char_width(text, match.start()) - 1
74
102
  return width
75
103
 
76
104
 
@@ -92,7 +120,7 @@ def truncate(text: str, max_width: int) -> str:
92
120
  if total + run > max_width:
93
121
  return text[:ascii_start + max_width - total]
94
122
  total += run
95
- w = char_width(match.group())
123
+ w = _string_char_width(text, match.start())
96
124
  if total + w > max_width:
97
125
  return text[:match.start()]
98
126
  total += w
@@ -125,7 +153,7 @@ def cell_to_index(text: str, col: int) -> int:
125
153
  if total + run > col:
126
154
  return ascii_start + col - total
127
155
  total += run
128
- w = char_width(match.group())
156
+ w = _string_char_width(text, match.start())
129
157
  if total + w > col:
130
158
  return match.start()
131
159
  total += w
@@ -333,15 +333,24 @@ class PromptLine:
333
333
  :class:`InlineConfirmation`; note that ``InlineConfirmation`` forces
334
334
  ``mode='choices'`` when ``choices`` is supplied -- that is intended, not a
335
335
  bug. The caller's ``prompt`` text is used verbatim, so decorations such as
336
- a warning glyph stay in application code rather than in the library.
336
+ a warning glyph stay in application code rather than in the library. The
337
+ same goes for the trailing answer hint: pass *answer_hint* to replace the
338
+ default ``[Press "y" or ESC]`` wording, which lets an application bracket
339
+ the whole line with its own glyphs.
337
340
  """
338
341
 
342
+ #: Default trailing hint for the one-key confirmation modes. ``{mode}`` is
343
+ #: replaced with the live mode letter, so ``'y'`` and ``'Y'`` both label
344
+ #: themselves without the caller tracking it.
345
+ DEFAULT_ANSWER_HINT = '[Press "{mode}" or ESC]'
346
+
339
347
  def __init__(self) -> None:
340
348
  self._editor = InlineConfirmation()
341
349
  #: Public state, mirrored from start() so a caller can inspect it.
342
350
  self.active: bool = False
343
351
  self.prompt: str = ''
344
352
  self.hint: str = ''
353
+ self.answer_hint: str = ''
345
354
  self._on_confirm: Callable[[str], object] | None = None
346
355
  self._on_cancel: Callable[[], object] | None = None
347
356
  self._answer: str | None = None
@@ -352,13 +361,15 @@ class PromptLine:
352
361
  choices: str | list[str] | tuple[str, ...] | None = None,
353
362
  on_confirm: Callable[[str], object] | None = None,
354
363
  on_cancel: Callable[[], object] | None = None,
355
- min_abbrev_chars: int | None = None) -> None:
364
+ min_abbrev_chars: int | None = None,
365
+ answer_hint: str = '') -> None:
356
366
  self._editor.start(action_type=None, identity=identity, choices=choices,
357
367
  mode=mode, min_abbrev_chars=min_abbrev_chars,
358
368
  initial=seed)
359
369
  self.active = True
360
370
  self.prompt = prompt
361
371
  self.hint = hint
372
+ self.answer_hint = answer_hint
362
373
  self._on_confirm = on_confirm
363
374
  self._on_cancel = on_cancel
364
375
  self._answer = None
@@ -398,6 +409,7 @@ class PromptLine:
398
409
  self._editor.cancel()
399
410
  self.prompt = ''
400
411
  self.hint = ''
412
+ self.answer_hint = ''
401
413
  self._on_confirm = None
402
414
  self._on_cancel = None
403
415
  self._answer = None
@@ -409,7 +421,11 @@ class PromptLine:
409
421
  return ''
410
422
  mode = self._editor.mode or ''
411
423
  if mode in ('Y', 'y'):
412
- return f'{self.prompt} [Press "{mode}" or ESC]'
424
+ hint = self.answer_hint or self.DEFAULT_ANSWER_HINT
425
+ # replace() rather than format(): the template is caller text and
426
+ # may legitimately contain braces (``["y" or ESC] {⚠️}`` style
427
+ # decorations are not worth a formatting error at paint time).
428
+ return f'{self.prompt} {hint.replace("{mode}", mode)}'
413
429
  buffer = self._editor.get_display_string('', cursor_char)
414
430
  return f'{self.prompt}: {buffer}'
415
431
 
@@ -91,11 +91,39 @@ _NAV_HELP_PREFIX = 'Navigation: '
91
91
  _NAV_APP_PREFIX = 'App: '
92
92
 
93
93
 
94
- def _nav_key_label(key: int) -> str:
95
- label = NAV_KEY_LABELS.get(key)
94
+ # Every key curses knows, keyed by its integer code. Deriving the table from
95
+ # the module constants keeps terminal-specific keys (F-keys, shift-arrows,
96
+ # keypad) covered without a hardcoded list. KEY_MIN/KEY_MAX are range
97
+ # sentinels rather than keys, and KEY_MIN aliases KEY_BREAK, so both are
98
+ # skipped; the surviving alias names a real key.
99
+ _CURSES_KEY_NAMES: dict[int, str] = {
100
+ getattr(curses, name): name.removeprefix('KEY_')
101
+ for name in dir(curses)
102
+ if name.startswith('KEY_') and name not in ('KEY_MIN', 'KEY_MAX')
103
+ }
104
+
105
+
106
+ def _key_label(key: int) -> str:
107
+ """Best human-readable name for a curses key code.
108
+
109
+ A ``curses.KEY_*`` name wins (``LEFT``, ``F5``, ``RESIZE``), then control
110
+ characters in caret notation (``^U``), then a printable character. An
111
+ unknown code is shown as ``<code>`` rather than a bare ``???``, so a label
112
+ never hides *which* key arrived.
113
+ """
114
+ label = _CURSES_KEY_NAMES.get(key)
96
115
  if label is not None:
97
116
  return label
98
- return chr(key) if 32 <= key < 127 else f'<{key}>'
117
+ if 0 <= key < 32:
118
+ return f'^{chr(key + 64)}'
119
+ if 32 <= key < 127:
120
+ return chr(key)
121
+ return f'<{key}>'
122
+
123
+
124
+ def _nav_key_label(key: int) -> str:
125
+ label = NAV_KEY_LABELS.get(key)
126
+ return label if label is not None else _key_label(key)
99
127
 
100
128
 
101
129
  def _pair_nav_entries(entries: list[str], prefix: str) -> list[str]:
@@ -163,6 +191,22 @@ _PASSTHROUGH_EDITING_KEYS = frozenset({
163
191
  1, 5, # Ctrl-A, Ctrl-E
164
192
  })
165
193
 
194
+ # Compact demo-mode names for the keys whose plain curses name would be
195
+ # unwieldy or ambiguous in the header indicator. Printable keys are handled
196
+ # separately (centred with spaces), so this table only covers the rest.
197
+ _DEMO_KEY_LABELS: dict[int, str] = {
198
+ 27: 'ESC',
199
+ ord('\t'): 'TAB',
200
+ 10: 'ENT', # newline
201
+ 13: 'ENT', # carriage return (KEY_ENTER on some terminals)
202
+ curses.KEY_ENTER: 'ENT',
203
+ ord(' '): 'SPC',
204
+ 8: 'BSP',
205
+ 127: 'BSP',
206
+ curses.KEY_BACKSPACE: 'BSP',
207
+ curses.KEY_DC: 'DEL',
208
+ }
209
+
166
210
  # Live ConsoleWindow instances, held by weak reference and keyed by creation
167
211
  # order. The registry keeps creation order (so ``primary_window()`` is
168
212
  # deterministic) and never keeps a window alive. It serves both models:
@@ -502,6 +546,11 @@ class ConsoleWindow:
502
546
  # ends, so a callback can start the next prompt (see begin_prompt()).
503
547
  self.prompt_line = PromptLine()
504
548
  self._prompt_prev_passthrough = False
549
+ # Extra attribute for the separator rule while a prompt is live, so the
550
+ # chrome can recede behind the only interactive line on screen. None
551
+ # (the default) leaves the rule undifferentiated. This is app policy,
552
+ # like pick_attr: the window only decides *when* to apply it.
553
+ self.prompt_separator_attr: int | None = None
505
554
  # Transient status shown on the separator row (see status()). Kept on
506
555
  # the window rather than a pad because the row belongs to the screen,
507
556
  # not to either pad (render_once() paints it directly on self.scr).
@@ -555,25 +604,14 @@ class ConsoleWindow:
555
604
  return self.opts.demo_mode
556
605
 
557
606
  def _format_key_for_demo(self, key: int) -> str:
558
- # Special multi-character keys
559
- if key == 27: # ESC
560
- return 'ESC'
561
- elif key == ord('\t'):
562
- return 'TAB'
563
- elif key == ord('\n') or key == curses.KEY_ENTER or key == 10 or key == 13:
564
- return 'ENT'
565
- elif key == ord(' '):
566
- return 'SPC'
567
- elif key == curses.KEY_BACKSPACE or key == 127 or key == 8:
568
- return 'BSP'
569
- elif key == curses.KEY_DC:
570
- return 'DEL'
607
+ label = _DEMO_KEY_LABELS.get(key)
608
+ if label is not None:
609
+ return label
571
610
  # Printable single character keys - center with spaces
572
- elif 32 < key < 127:
611
+ if 32 < key < 127:
573
612
  return f' {chr(key)} '
574
- # Default for unrecognized keys
575
- else:
576
- return '???'
613
+ # Named curses key, control character, or an explicit <code>
614
+ return _key_label(key)
577
615
 
578
616
  def get_pad_width(self) -> int:
579
617
  return min(self.cols-1, self.body_cols)
@@ -1153,17 +1191,24 @@ class ConsoleWindow:
1153
1191
  identity: str | None = None, seed: str = '',
1154
1192
  choices: str | list[str] | tuple[str, ...] | None = None,
1155
1193
  on_confirm: Callable[[str], object] | None = None,
1156
- on_cancel: Callable[[], object] | None = None) -> bool:
1194
+ on_cancel: Callable[[], object] | None = None,
1195
+ answer_hint: str = '') -> bool:
1157
1196
  """Start an in-place prompt and enable passthrough key routing.
1158
1197
 
1159
1198
  The window *is* the receiver, so there is no window-existence branch
1160
1199
  here; an application whose window may be ``None`` guards at its own
1161
1200
  call site. Every key must then go through :meth:`handle_prompt_key`
1162
1201
  and a ``'continue'`` result must be swallowed.
1202
+
1203
+ *answer_hint* replaces the default trailing ``[Press "y" or ESC]`` text
1204
+ for the one-key modes; ``{mode}`` in it is replaced with the live mode
1205
+ letter. Use it when the application wants its own bracketing or a
1206
+ trailing decoration (see :class:`PromptLine`).
1163
1207
  """
1164
1208
  self.prompt_line.start(prompt, hint=hint, mode=mode, identity=identity,
1165
1209
  seed=seed, choices=choices,
1166
- on_confirm=on_confirm, on_cancel=on_cancel)
1210
+ on_confirm=on_confirm, on_cancel=on_cancel,
1211
+ answer_hint=answer_hint)
1167
1212
  self._prompt_prev_passthrough = self.passthrough_mode
1168
1213
  self.passthrough_mode = True
1169
1214
  return True
@@ -1195,12 +1240,30 @@ class ConsoleWindow:
1195
1240
  on_cancel()
1196
1241
  return result
1197
1242
 
1198
- def add_prompt_header(self, attr: int | None = None) -> bool:
1199
- """Draw the prompt as a header row; False when no prompt is active."""
1243
+ def add_prompt_header(self, attr: int | None = None,
1244
+ align: str | None = None) -> bool:
1245
+ """Draw the prompt as a header row; False when no prompt is active.
1246
+
1247
+ *align* of ``'center'`` pads the row so the prompt sits in the middle of
1248
+ the usable width. Centering is done here rather than by the caller
1249
+ because the display width of the line is only known after the prompt is
1250
+ composed (glyphs, an emoji presentation selector and the hint all
1251
+ count), and a caller-side ``len()`` would be one cell out for exactly
1252
+ the lines that need centering most.
1253
+ """
1200
1254
  if not self.prompt_line.active:
1201
1255
  return False
1202
- self.add_header(self.prompt_line.get_display_string(),
1203
- attr=attr if attr is not None else curses.A_BOLD)
1256
+ text = self.prompt_line.get_display_string()
1257
+ attr = attr if attr is not None else curses.A_BOLD
1258
+ if align == 'center':
1259
+ lead = max(0, (self.get_pad_width() - textwidth.string_width(text)) // 2)
1260
+ if lead:
1261
+ # Two adds on one row: the pad spaces carry the prompt's own
1262
+ # attribute so the whole band, not just its text, is painted.
1263
+ self.add_header(' ' * lead, attr=attr)
1264
+ self.add_header(text, attr=attr, resume=True)
1265
+ return True
1266
+ self.add_header(text, attr=attr)
1204
1267
  return True
1205
1268
 
1206
1269
  def add_prompt_lines(self, attr: int | None = None,
@@ -1761,7 +1824,12 @@ class ConsoleWindow:
1761
1824
  self.scr.addstr(pos, 0, '>', curses.A_REVERSE)
1762
1825
 
1763
1826
  def _draw_separator_and_indicator(self) -> None:
1764
- self.scr.hline(self.head.view_cnt, 0, curses.ACS_HLINE, self.cols)
1827
+ # While an in-place prompt is live it is the only thing keys can reach,
1828
+ # so an application may ask for the rule to recede behind it.
1829
+ rule_attr = (self.prompt_separator_attr
1830
+ if self.prompt_line.active and self.prompt_separator_attr is not None
1831
+ else curses.A_NORMAL)
1832
+ self.scr.hline(self.head.view_cnt, 0, curses.ACS_HLINE, self.cols, rule_attr)
1765
1833
  ind_pos = self._scroll_indicator_col()
1766
1834
  bot, cnt = self._scroll_indicator_span(ind_pos) if ind_pos >= 0 else (-1, 0)
1767
1835
  # Status first: it must not be able to cover the indicator, and it must
@@ -35,6 +35,11 @@ This project follows [Semantic Versioning](https://semver.org/).
35
35
  body rows without `Context` so normal navigation scrolls them.
36
36
 
37
37
  ### Changed
38
+ - Demo-mode key labels no longer fall back to `???` for anything curses can
39
+ name: `curses.KEY_*` codes are rendered from the curses constant names (so a
40
+ taken `KEY_LEFT`/`KEY_RIGHT` shows `LEFT`/`RIGHT`, and F-keys, shift-arrows,
41
+ `HOME`, `RESIZE`, etc. show their own names), control characters use caret
42
+ notation (`^U`), and a code curses does not know is shown as `<code>`.
38
43
  - `SampleApp.py` now exercises the new API: `SPACE` uses the window prompt
39
44
  lifecycle with a `PromptLine` single-key confirmation, `N` adds a buffered
40
45
  header note prompt, non-modal feedback uses `status()`, and the HISTORY
@@ -98,6 +103,17 @@ This project follows [Semantic Versioning](https://semver.org/).
98
103
  into every span of the header (including plain spans and the raw line emitted
99
104
  for `mode='Off'`), so a caller can dim a whole fancy header while keeping the
100
105
  per-span bold/underline/reverse styling.
106
+ - `answer_hint` on `begin_prompt()`/`PromptLine.start()`: replaces the default
107
+ trailing `[Press "y" or ESC]` text for the `Y`/`y` modes, with `{mode}`
108
+ substituted for the live mode letter. The template is plain text (`.replace`,
109
+ not `.format`), so an application can bracket its own question, e.g.
110
+ `'["{mode}" or ESC] ⚠️'`, without escaping braces.
111
+ - `add_prompt_header(align='center')`: pads the prompt row so it sits in the
112
+ middle of the pad width. The offset is measured in display cells from the
113
+ composed line, so wide glyphs and emoji presentation selectors stay centred.
114
+ - `ConsoleWindow.prompt_separator_attr`: an attribute applied to the separator
115
+ rule only while an in-place prompt is live (`None`, the default, keeps the
116
+ rule plain). Lets the chrome recede behind the one line keys can reach.
101
117
  - `suspend_curses()`/`resume_curses()` on `ConsoleWindow`: leave curses mode
102
118
  with `endwin()` while keeping the window (pads, content model, pick state)
103
119
  alive, then reattach a screen and repaint. This is the supported way for an
@@ -246,6 +262,14 @@ This project follows [Semantic Versioning](https://semver.org/).
246
262
  No in-repo code used it.
247
263
 
248
264
  ### Fixed
265
+ - `textwidth` now counts an emoji presentation selector (`U+FE0F`) as a cell
266
+ when it follows a narrow, non-ASCII base, so `⚠️` measures two cells as
267
+ terminals draw it instead of one. Every measurement that follows the glyph
268
+ (`string_width()`, `truncate()`, `ljust()`/`rjust()`, `cell_to_index()`) was
269
+ one cell short, which shifted following text left and padded rows short --
270
+ visible as a crammed space after the glyph. A base that is already two cells
271
+ wide (`⌚`, `⌛`) and ASCII bases are left alone, and `char_width()` stays per
272
+ code point so the cursor can never land between a base and its selector.
249
273
  - `win._start_curses()` once again *reattaches* the window. The 1.4.x
250
274
  compatibility hook is still called in the wild (psistat's "dump event log"
251
275
  path uses `self.window._start_curses()` to return from shell mode), but the
@@ -0,0 +1,97 @@
1
+ import curses
2
+
3
+ import pytest
4
+
5
+ from console_window.opts import ConsoleWindowOpts
6
+ from console_window.window import ConsoleWindow
7
+
8
+
9
+ def make_window(demo_mode=False, last_demo_key=''):
10
+ win = object.__new__(ConsoleWindow)
11
+ win.opts = ConsoleWindowOpts(demo_mode=demo_mode)
12
+ win.last_demo_key = last_demo_key
13
+ return win
14
+
15
+
16
+ def test_toggle_on_preserves_last_demo_key():
17
+ # The audit bug: ``enabled is None`` must not be treated as "disable".
18
+ win = make_window(demo_mode=False, last_demo_key='k')
19
+ assert win.set_demo_mode(None) is True
20
+ assert win.opts.demo_mode is True
21
+ assert win.last_demo_key == 'k'
22
+
23
+
24
+ def test_toggle_off_clears_last_demo_key():
25
+ win = make_window(demo_mode=True, last_demo_key='k')
26
+ assert win.set_demo_mode(None) is False
27
+ assert win.opts.demo_mode is False
28
+ assert win.last_demo_key == ''
29
+
30
+
31
+ def test_explicit_enable_preserves_last_demo_key():
32
+ win = make_window(demo_mode=False, last_demo_key='k')
33
+ assert win.set_demo_mode(True) is True
34
+ assert win.opts.demo_mode is True
35
+ assert win.last_demo_key == 'k'
36
+
37
+
38
+ def test_explicit_disable_clears_last_demo_key():
39
+ win = make_window(demo_mode=True, last_demo_key='k')
40
+ assert win.set_demo_mode(False) is False
41
+ assert win.opts.demo_mode is False
42
+ assert win.last_demo_key == ''
43
+
44
+
45
+ def test_return_value_tracks_new_state():
46
+ win = make_window(demo_mode=False)
47
+ assert win.set_demo_mode(True) is True
48
+ assert win.set_demo_mode(True) is True
49
+ assert win.set_demo_mode(False) is False
50
+ assert win.set_demo_mode(None) is True # toggles back on
51
+
52
+
53
+ @pytest.mark.parametrize(('key', 'label'), [
54
+ (curses.KEY_LEFT, 'LEFT'),
55
+ (curses.KEY_RIGHT, 'RIGHT'),
56
+ (curses.KEY_UP, 'UP'),
57
+ (curses.KEY_DOWN, 'DOWN'),
58
+ (curses.KEY_HOME, 'HOME'),
59
+ (curses.KEY_END, 'END'),
60
+ (curses.KEY_PPAGE, 'PPAGE'),
61
+ (curses.KEY_NPAGE, 'NPAGE'),
62
+ (curses.KEY_F5, 'F5'),
63
+ (curses.KEY_BTAB, 'BTAB'),
64
+ (curses.KEY_RESIZE, 'RESIZE'),
65
+ ])
66
+ def test_curses_keys_get_readable_demo_labels(key, label):
67
+ # A taken LEFT/RIGHT reaches demo mode as an application key; the header
68
+ # must name it instead of showing the old '???' fallback.
69
+ assert make_window()._format_key_for_demo(key) == label # pylint: disable=protected-access
70
+
71
+
72
+ @pytest.mark.parametrize(('key', 'label'), [
73
+ (ord('p'), ' p '),
74
+ (27, 'ESC'),
75
+ (ord('\t'), 'TAB'),
76
+ (10, 'ENT'),
77
+ (13, 'ENT'),
78
+ (curses.KEY_ENTER, 'ENT'),
79
+ (ord(' '), 'SPC'),
80
+ (8, 'BSP'),
81
+ (127, 'BSP'),
82
+ (curses.KEY_BACKSPACE, 'BSP'),
83
+ (curses.KEY_DC, 'DEL'),
84
+ (ord('\x15'), '^U'),
85
+ (0, '^@'),
86
+ (31, '^_'),
87
+ ])
88
+ def test_known_keys_keep_their_compact_demo_labels(key, label):
89
+ assert make_window()._format_key_for_demo(key) == label # pylint: disable=protected-access
90
+
91
+
92
+ def test_unknown_keys_show_their_code_instead_of_question_marks():
93
+ win = make_window()
94
+ assert win._format_key_for_demo(999) == '<999>' # pylint: disable=protected-access
95
+ # Regression: no key code may fall back to the old '???' placeholder.
96
+ for key in [*range(0, 300), 999, 1000]:
97
+ assert win._format_key_for_demo(key) != '???' # pylint: disable=protected-access
@@ -95,6 +95,17 @@ def test_prompt_passthrough_records_demo_indicator(make):
95
95
  assert win.last_demo_key == ' p '
96
96
 
97
97
 
98
+ def test_taken_arrow_key_is_named_in_demo_indicator(make):
99
+ # grub-wiz takes LEFT/RIGHT for itself; the demo header must name the key
100
+ # it recorded instead of showing the old '???' placeholder.
101
+ win, backend = make(demo_mode=True,
102
+ taken_nav_keys={curses.KEY_LEFT: 'prev',
103
+ curses.KEY_RIGHT: 'next'})
104
+ backend.queue_keys(curses.KEY_LEFT)
105
+ assert win.prompt(seconds=0.5) == curses.KEY_LEFT
106
+ assert win.last_demo_key == 'LEFT'
107
+
108
+
98
109
  def test_prompt_passthrough_forwards_escape_without_handled_keys(make):
99
110
  # The gap that forced every InlineConfirmation caller to discover
100
111
  # handled_keys: ESC must reach a passthrough editor as a normal key.
@@ -0,0 +1,288 @@
1
+ """ConsoleWindow's in-place prompt lifecycle and drawing helpers."""
2
+
3
+ import curses
4
+
5
+ import console_window.window as window_mod
6
+ from console_window import textwidth
7
+ from console_window.backend import HeadlessBackend
8
+ from console_window.opts import ConsoleWindowOpts
9
+ from console_window.window import ConsoleWindow
10
+
11
+
12
+ def _window(rows=10, cols=40, **opts):
13
+ return ConsoleWindow(opts=ConsoleWindowOpts(**opts),
14
+ backend=HeadlessBackend(rows=rows, cols=cols),
15
+ singleton=False)
16
+
17
+
18
+ def test_begin_prompt_enables_passthrough_and_end_restores_it():
19
+ win = _window()
20
+ try:
21
+ assert win.passthrough_mode is False
22
+ assert win.begin_prompt('Proceed?') is True
23
+ assert win.prompt_active is True
24
+ assert win.passthrough_mode is True
25
+
26
+ win.end_prompt()
27
+
28
+ assert win.prompt_active is False
29
+ assert win.passthrough_mode is False
30
+ finally:
31
+ win.stop_curses()
32
+
33
+
34
+ def test_begin_prompt_preserves_an_already_enabled_passthrough():
35
+ win = _window()
36
+ try:
37
+ win.passthrough_mode = True
38
+ win.begin_prompt('Proceed?')
39
+
40
+ win.end_prompt()
41
+
42
+ assert win.passthrough_mode is True
43
+ finally:
44
+ win.stop_curses()
45
+
46
+
47
+ def test_callback_runs_after_passthrough_is_restored():
48
+ win = _window()
49
+ try:
50
+ seen = []
51
+
52
+ def on_confirm(answer):
53
+ seen.append((answer, win.passthrough_mode, win.prompt_active))
54
+
55
+ win.begin_prompt('Proceed?', mode='y', on_confirm=on_confirm)
56
+ assert win.handle_prompt_key(ord('y')) == 'confirmed'
57
+
58
+ assert seen == [('y', False, False)]
59
+ finally:
60
+ win.stop_curses()
61
+
62
+
63
+ def test_callback_may_begin_the_next_prompt():
64
+ win = _window()
65
+ try:
66
+ def ask_again(_answer):
67
+ assert win.passthrough_mode is False
68
+ win.begin_prompt('Again?', mode='y')
69
+
70
+ win.begin_prompt('First?', mode='y', on_confirm=ask_again)
71
+ win.handle_prompt_key(ord('y'))
72
+
73
+ assert win.prompt_active is True
74
+ assert win.prompt_line.prompt == 'Again?'
75
+ assert win.passthrough_mode is True
76
+
77
+ assert win.handle_prompt_key(ord('y')) == 'confirmed'
78
+ assert win.prompt_active is False
79
+ assert win.passthrough_mode is False
80
+ finally:
81
+ win.stop_curses()
82
+
83
+
84
+ def test_handle_prompt_key_is_continue_when_inactive():
85
+ win = _window()
86
+ try:
87
+ assert win.handle_prompt_key(ord('y')) == 'continue'
88
+ finally:
89
+ win.stop_curses()
90
+
91
+
92
+ def test_add_prompt_header_is_a_noop_when_inactive():
93
+ win = _window()
94
+ try:
95
+ before = win.head.row_cnt
96
+ assert win.add_prompt_header() is False
97
+ assert win.head.row_cnt == before
98
+ finally:
99
+ win.stop_curses()
100
+
101
+
102
+ def test_add_prompt_lines_draws_prompt_then_dim_hint():
103
+ win = _window()
104
+ try:
105
+ win.begin_prompt('Confirm?', hint='Press Y', mode='text', seed='5')
106
+
107
+ assert win.add_prompt_lines() is True
108
+
109
+ assert win.body.texts == ['Confirm?: 5│', 'Press Y']
110
+ assert win.body.text_attrs[0] == [(curses.A_BOLD, len('Confirm?: 5│'))]
111
+ assert win.body.text_attrs[1] == [(curses.A_DIM, len('Press Y'))]
112
+ for context in win.body.contexts:
113
+ assert context.genre == 'TRANSIENT'
114
+ assert context.pickable is False
115
+ finally:
116
+ win.stop_curses()
117
+
118
+
119
+ def test_add_prompt_header_draws_the_prompt_as_a_bold_header():
120
+ win = _window()
121
+ try:
122
+ win.begin_prompt('Proceed?', mode='y')
123
+
124
+ assert win.add_prompt_header() is True
125
+ assert win.head.texts == ['Proceed? [Press "y" or ESC]']
126
+ assert win.head.text_attrs[0] == [
127
+ (curses.A_BOLD, len('Proceed? [Press "y" or ESC]'))]
128
+ finally:
129
+ win.stop_curses()
130
+
131
+
132
+ def test_add_prompt_lines_truncates_the_hint():
133
+ win = _window(rows=10, cols=12)
134
+ try:
135
+ win.begin_prompt('X', hint='h' * 100, mode='text')
136
+
137
+ win.add_prompt_lines()
138
+
139
+ hint_text = win.body.texts[1]
140
+ assert len(hint_text) == max(10, win.cols - 2)
141
+ finally:
142
+ win.stop_curses()
143
+
144
+
145
+ def test_every_key_is_consumable_while_active():
146
+ win = _window()
147
+ try:
148
+ win.begin_prompt('Value', mode='text')
149
+
150
+ for key in (ord('j'), curses.KEY_DOWN, ord('q')):
151
+ assert win.handle_prompt_key(key) == 'continue'
152
+ assert win.prompt_active is True
153
+ finally:
154
+ win.stop_curses()
155
+
156
+
157
+ # --- answer hint ------------------------------------------------------------
158
+
159
+ def test_answer_hint_replaces_the_default_suffix():
160
+ win = _window()
161
+ try:
162
+ win.begin_prompt('Proceed?', mode='y',
163
+ answer_hint='["{mode}" or ESC] ⚠️')
164
+
165
+ assert win.prompt_line.get_display_string() == 'Proceed? ["y" or ESC] ⚠️'
166
+ finally:
167
+ win.stop_curses()
168
+
169
+
170
+ def test_answer_hint_substitutes_the_live_mode_letter():
171
+ win = _window()
172
+ try:
173
+ win.begin_prompt('Proceed?', mode='Y',
174
+ answer_hint='["{mode}" or ESC]')
175
+
176
+ assert win.prompt_line.get_display_string() == 'Proceed? ["Y" or ESC]'
177
+ finally:
178
+ win.stop_curses()
179
+
180
+
181
+ def test_answer_hint_is_plain_text_not_a_format_string():
182
+ # Braces are caller text; a stray '{' must not raise at paint time.
183
+ win = _window()
184
+ try:
185
+ win.begin_prompt('Proceed?', mode='y',
186
+ answer_hint='["{mode}" or ESC] {⚠️}')
187
+
188
+ assert win.prompt_line.get_display_string() == 'Proceed? ["y" or ESC] {⚠️}'
189
+ finally:
190
+ win.stop_curses()
191
+
192
+
193
+ def test_answer_hint_is_cleared_with_the_prompt():
194
+ win = _window()
195
+ try:
196
+ win.begin_prompt('Proceed?', mode='y', answer_hint='["{mode}" or ESC]')
197
+ win.end_prompt()
198
+
199
+ assert win.prompt_line.answer_hint == ''
200
+ finally:
201
+ win.stop_curses()
202
+
203
+
204
+ # --- centered prompt header -------------------------------------------------
205
+
206
+ def test_add_prompt_header_centers_within_the_pad_width():
207
+ win = _window(rows=10, cols=40)
208
+ try:
209
+ win.begin_prompt('Proceed?', mode='y')
210
+
211
+ assert win.add_prompt_header(align='center') is True
212
+
213
+ display = 'Proceed? [Press "y" or ESC]'
214
+ lead = (win.get_pad_width() - textwidth.string_width(display)) // 2
215
+ assert win.head.texts == [' ' * lead + display]
216
+ finally:
217
+ win.stop_curses()
218
+
219
+
220
+ def test_centering_measures_emoji_presentation_width():
221
+ # Each ⚠️ is two cells but two code points (base + VS16), so a correct
222
+ # measurement equals the code-point count. Counting only the base (the
223
+ # pre-VS16 behaviour) came out two cells short and pushed the line right,
224
+ # which is what made the space after the glyph look cramped.
225
+ win = _window(rows=10, cols=40)
226
+ try:
227
+ win.begin_prompt('⚠️ Go?', mode='y', answer_hint='["{mode}" or ESC] ⚠️')
228
+
229
+ display = win.prompt_line.get_display_string()
230
+ assert textwidth.string_width(display) == len(display)
231
+
232
+ win.add_prompt_header(align='center')
233
+
234
+ lead = (win.get_pad_width() - textwidth.string_width(display)) // 2
235
+ assert win.head.texts == [' ' * lead + display]
236
+ finally:
237
+ win.stop_curses()
238
+
239
+
240
+ def test_add_prompt_header_align_never_adds_padding_when_too_wide():
241
+ # Pad width is cols-1, so a 7-column window cannot hold the prompt plus a
242
+ # pad: centering must degrade to plain (left-aligned, truncated) drawing.
243
+ win = _window(rows=10, cols=8)
244
+ try:
245
+ win.begin_prompt('Proceed?', mode='y')
246
+
247
+ win.add_prompt_header(align='center')
248
+
249
+ assert win.head.texts[0] == 'Proceed'
250
+ finally:
251
+ win.stop_curses()
252
+
253
+
254
+ # --- separator dimming ------------------------------------------------------
255
+
256
+ def _hline_attrs(win):
257
+ return [args[4] for _name, args, _kwargs in win._backend.screen.calls_named('hline')]
258
+
259
+
260
+ def test_separator_dims_only_while_a_prompt_is_live(monkeypatch):
261
+ if not hasattr(window_mod.curses, 'ACS_HLINE'):
262
+ monkeypatch.setattr(window_mod.curses, 'ACS_HLINE', '-', raising=False)
263
+ win = _window(rows=10, cols=40)
264
+ try:
265
+ win.prompt_separator_attr = curses.A_DIM
266
+
267
+ win._draw_separator_and_indicator()
268
+ assert _hline_attrs(win)[-1] == curses.A_NORMAL
269
+
270
+ win.begin_prompt('Proceed?', mode='y')
271
+ win._draw_separator_and_indicator()
272
+ assert _hline_attrs(win)[-1] == curses.A_DIM
273
+ finally:
274
+ win.stop_curses()
275
+
276
+
277
+ def test_separator_stays_plain_when_no_attr_is_configured(monkeypatch):
278
+ if not hasattr(window_mod.curses, 'ACS_HLINE'):
279
+ monkeypatch.setattr(window_mod.curses, 'ACS_HLINE', '-', raising=False)
280
+ win = _window(rows=10, cols=40)
281
+ try:
282
+ win.begin_prompt('Proceed?', mode='y')
283
+
284
+ win._draw_separator_and_indicator()
285
+
286
+ assert _hline_attrs(win)[-1] == curses.A_NORMAL
287
+ finally:
288
+ win.stop_curses()
@@ -3,6 +3,8 @@ from console_window import textwidth as tw
3
3
  CJK = '世' # U+4E16, East-Asian Wide -> 2 cells
4
4
  COMBINING = '\u0301' # combining acute accent -> 0 cells
5
5
  ZWJ = '\u200d' # zero-width joiner (Cf) -> 0 cells
6
+ VS16 = '\ufe0f' # emoji presentation selector
7
+ WARN = f'\u26a0{VS16}' # ⚠️: ambiguous base + VS16, drawn 2 cells by terminals
6
8
 
7
9
  # --- char_width / string_width ----------------------------------------------
8
10
 
@@ -33,6 +35,38 @@ def test_string_width_ignores_combining_and_zwj():
33
35
  assert tw.string_width(f'e{COMBINING}') == 1 # é as e + combining accent
34
36
  assert tw.string_width(f'{CJK}{ZWJ}{CJK}') == 4
35
37
 
38
+
39
+ def test_string_width_counts_emoji_presentation_as_two_cells():
40
+ # ⚠️ is U+26A0 (East-Asian Ambiguous) + VS16. Terminals honour VS16 and
41
+ # draw it two cells wide, so a one-cell measurement shifts every following
42
+ # character by a column and crams the space after the glyph.
43
+ assert tw.char_width('\u26a0') == 1 # per code point: unchanged
44
+ assert tw.string_width(WARN) == 2
45
+ assert tw.string_width(f'{WARN} x') == 4
46
+
47
+
48
+ def test_string_width_does_not_widen_wide_or_ascii_bases_again():
49
+ assert tw.string_width(f'\u231a{VS16}') == 2 # ⌚ already wide
50
+ assert tw.string_width(f'a{VS16}') == 1 # terminals keep ASCII narrow
51
+ assert tw.string_width(VS16) == 0 # selector with no base
52
+
53
+
54
+ def test_ljust_pads_emoji_presentation_rows_to_the_requested_cells():
55
+ padded = tw.ljust(f'{WARN} x', 6)
56
+ assert padded == f'{WARN} x '
57
+ assert tw.string_width(padded) == 6
58
+
59
+
60
+ def test_truncate_keeps_the_presentation_selector_with_its_base():
61
+ assert tw.truncate(f'{WARN} commit', 2) == WARN
62
+ assert tw.truncate(f'{WARN} commit', 1) == '\u26a0'
63
+
64
+
65
+ def test_cell_to_index_agrees_with_string_width_for_emoji():
66
+ text = f'{WARN} x'
67
+ assert tw.cell_to_index(text, 2) == len(WARN)
68
+ assert tw.cell_to_index(text, tw.string_width(text)) == len(text)
69
+
36
70
  # --- truncate / ljust / rjust -----------------------------------------------
37
71
 
38
72
  def test_truncate_narrow():
@@ -1,46 +0,0 @@
1
- from console_window.opts import ConsoleWindowOpts
2
- from console_window.window import ConsoleWindow
3
-
4
-
5
- def make_window(demo_mode=False, last_demo_key=''):
6
- win = object.__new__(ConsoleWindow)
7
- win.opts = ConsoleWindowOpts(demo_mode=demo_mode)
8
- win.last_demo_key = last_demo_key
9
- return win
10
-
11
-
12
- def test_toggle_on_preserves_last_demo_key():
13
- # The audit bug: ``enabled is None`` must not be treated as "disable".
14
- win = make_window(demo_mode=False, last_demo_key='k')
15
- assert win.set_demo_mode(None) is True
16
- assert win.opts.demo_mode is True
17
- assert win.last_demo_key == 'k'
18
-
19
-
20
- def test_toggle_off_clears_last_demo_key():
21
- win = make_window(demo_mode=True, last_demo_key='k')
22
- assert win.set_demo_mode(None) is False
23
- assert win.opts.demo_mode is False
24
- assert win.last_demo_key == ''
25
-
26
-
27
- def test_explicit_enable_preserves_last_demo_key():
28
- win = make_window(demo_mode=False, last_demo_key='k')
29
- assert win.set_demo_mode(True) is True
30
- assert win.opts.demo_mode is True
31
- assert win.last_demo_key == 'k'
32
-
33
-
34
- def test_explicit_disable_clears_last_demo_key():
35
- win = make_window(demo_mode=True, last_demo_key='k')
36
- assert win.set_demo_mode(False) is False
37
- assert win.opts.demo_mode is False
38
- assert win.last_demo_key == ''
39
-
40
-
41
- def test_return_value_tracks_new_state():
42
- win = make_window(demo_mode=False)
43
- assert win.set_demo_mode(True) is True
44
- assert win.set_demo_mode(True) is True
45
- assert win.set_demo_mode(False) is False
46
- assert win.set_demo_mode(None) is True # toggles back on
@@ -1,152 +0,0 @@
1
- """ConsoleWindow's in-place prompt lifecycle and drawing helpers."""
2
-
3
- import curses
4
-
5
- from console_window.backend import HeadlessBackend
6
- from console_window.opts import ConsoleWindowOpts
7
- from console_window.window import ConsoleWindow
8
-
9
-
10
- def _window(rows=10, cols=40, **opts):
11
- return ConsoleWindow(opts=ConsoleWindowOpts(**opts),
12
- backend=HeadlessBackend(rows=rows, cols=cols),
13
- singleton=False)
14
-
15
-
16
- def test_begin_prompt_enables_passthrough_and_end_restores_it():
17
- win = _window()
18
- try:
19
- assert win.passthrough_mode is False
20
- assert win.begin_prompt('Proceed?') is True
21
- assert win.prompt_active is True
22
- assert win.passthrough_mode is True
23
-
24
- win.end_prompt()
25
-
26
- assert win.prompt_active is False
27
- assert win.passthrough_mode is False
28
- finally:
29
- win.stop_curses()
30
-
31
-
32
- def test_begin_prompt_preserves_an_already_enabled_passthrough():
33
- win = _window()
34
- try:
35
- win.passthrough_mode = True
36
- win.begin_prompt('Proceed?')
37
-
38
- win.end_prompt()
39
-
40
- assert win.passthrough_mode is True
41
- finally:
42
- win.stop_curses()
43
-
44
-
45
- def test_callback_runs_after_passthrough_is_restored():
46
- win = _window()
47
- try:
48
- seen = []
49
-
50
- def on_confirm(answer):
51
- seen.append((answer, win.passthrough_mode, win.prompt_active))
52
-
53
- win.begin_prompt('Proceed?', mode='y', on_confirm=on_confirm)
54
- assert win.handle_prompt_key(ord('y')) == 'confirmed'
55
-
56
- assert seen == [('y', False, False)]
57
- finally:
58
- win.stop_curses()
59
-
60
-
61
- def test_callback_may_begin_the_next_prompt():
62
- win = _window()
63
- try:
64
- def ask_again(_answer):
65
- assert win.passthrough_mode is False
66
- win.begin_prompt('Again?', mode='y')
67
-
68
- win.begin_prompt('First?', mode='y', on_confirm=ask_again)
69
- win.handle_prompt_key(ord('y'))
70
-
71
- assert win.prompt_active is True
72
- assert win.prompt_line.prompt == 'Again?'
73
- assert win.passthrough_mode is True
74
-
75
- assert win.handle_prompt_key(ord('y')) == 'confirmed'
76
- assert win.prompt_active is False
77
- assert win.passthrough_mode is False
78
- finally:
79
- win.stop_curses()
80
-
81
-
82
- def test_handle_prompt_key_is_continue_when_inactive():
83
- win = _window()
84
- try:
85
- assert win.handle_prompt_key(ord('y')) == 'continue'
86
- finally:
87
- win.stop_curses()
88
-
89
-
90
- def test_add_prompt_header_is_a_noop_when_inactive():
91
- win = _window()
92
- try:
93
- before = win.head.row_cnt
94
- assert win.add_prompt_header() is False
95
- assert win.head.row_cnt == before
96
- finally:
97
- win.stop_curses()
98
-
99
-
100
- def test_add_prompt_lines_draws_prompt_then_dim_hint():
101
- win = _window()
102
- try:
103
- win.begin_prompt('Confirm?', hint='Press Y', mode='text', seed='5')
104
-
105
- assert win.add_prompt_lines() is True
106
-
107
- assert win.body.texts == ['Confirm?: 5│', 'Press Y']
108
- assert win.body.text_attrs[0] == [(curses.A_BOLD, len('Confirm?: 5│'))]
109
- assert win.body.text_attrs[1] == [(curses.A_DIM, len('Press Y'))]
110
- for context in win.body.contexts:
111
- assert context.genre == 'TRANSIENT'
112
- assert context.pickable is False
113
- finally:
114
- win.stop_curses()
115
-
116
-
117
- def test_add_prompt_header_draws_the_prompt_as_a_bold_header():
118
- win = _window()
119
- try:
120
- win.begin_prompt('Proceed?', mode='y')
121
-
122
- assert win.add_prompt_header() is True
123
- assert win.head.texts == ['Proceed? [Press "y" or ESC]']
124
- assert win.head.text_attrs[0] == [
125
- (curses.A_BOLD, len('Proceed? [Press "y" or ESC]'))]
126
- finally:
127
- win.stop_curses()
128
-
129
-
130
- def test_add_prompt_lines_truncates_the_hint():
131
- win = _window(rows=10, cols=12)
132
- try:
133
- win.begin_prompt('X', hint='h' * 100, mode='text')
134
-
135
- win.add_prompt_lines()
136
-
137
- hint_text = win.body.texts[1]
138
- assert len(hint_text) == max(10, win.cols - 2)
139
- finally:
140
- win.stop_curses()
141
-
142
-
143
- def test_every_key_is_consumable_while_active():
144
- win = _window()
145
- try:
146
- win.begin_prompt('Value', mode='text')
147
-
148
- for key in (ord('j'), curses.KEY_DOWN, ord('q')):
149
- assert win.handle_prompt_key(key) == 'continue'
150
- assert win.prompt_active is True
151
- finally:
152
- win.stop_curses()
File without changes
File without changes
File without changes