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.
- {console_window-2.1.1 → console_window-2.1.2}/PKG-INFO +1 -1
- {console_window-2.1.1 → console_window-2.1.2}/console_window/__init__.py +1 -1
- {console_window-2.1.1 → console_window-2.1.2}/console_window/textwidth.py +31 -3
- {console_window-2.1.1 → console_window-2.1.2}/console_window/widgets.py +19 -3
- {console_window-2.1.1 → console_window-2.1.2}/console_window/window.py +95 -27
- {console_window-2.1.1 → console_window-2.1.2}/tests/CHANGELOG.md +24 -0
- console_window-2.1.2/tests/test_demo_mode.py +97 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_interactive.py +11 -0
- console_window-2.1.2/tests/test_prompt_window.py +288 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_textwidth.py +34 -0
- console_window-2.1.1/tests/test_demo_mode.py +0 -46
- console_window-2.1.1/tests/test_prompt_window.py +0 -152
- {console_window-2.1.1 → console_window-2.1.2}/.github/workflows/ci.yml +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/.gitignore +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/LICENSE +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/README.md +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/SampleApp.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/backend.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/context.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/geometry.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/header.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/keys.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/opts.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/screens.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/signals.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/spinner.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/console_window/theme.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/pyproject.toml +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/runner +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/AUDIT.md +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_backend.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_context.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_dialogs.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_flash.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_geometry.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_header.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_low_colour.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_overflow.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_prompt_line.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_public_api.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_render_retry.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_sample_app.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_screens.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_signals.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_spinner.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_status.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_taken_nav_keys.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_theme.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_viewport_pad.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_widechars.py +0 -0
- {console_window-2.1.1 → console_window-2.1.2}/tests/test_widgets.py +0 -0
|
@@ -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.
|
|
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 +=
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
if
|
|
560
|
-
return
|
|
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
|
-
|
|
611
|
+
if 32 < key < 127:
|
|
573
612
|
return f' {chr(key)} '
|
|
574
|
-
#
|
|
575
|
-
|
|
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
|
|
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
|
|
1199
|
-
|
|
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.
|
|
1203
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|