console-window 2.0.0.9__tar.gz → 2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {console_window-2.0.0.9 → console_window-2.0.1}/CHANGELOG.md +19 -0
  2. {console_window-2.0.0.9 → console_window-2.0.1}/PKG-INFO +30 -2
  3. {console_window-2.0.0.9 → console_window-2.0.1}/README.md +29 -1
  4. {console_window-2.0.0.9 → console_window-2.0.1}/SampleApp.py +7 -5
  5. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/__init__.py +1 -1
  6. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/widgets.py +117 -6
  7. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/window.py +31 -1
  8. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_interactive.py +43 -0
  9. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_widgets.py +103 -0
  10. {console_window-2.0.0.9 → console_window-2.0.1}/.github/workflows/ci.yml +0 -0
  11. {console_window-2.0.0.9 → console_window-2.0.1}/.gitignore +0 -0
  12. {console_window-2.0.0.9 → console_window-2.0.1}/AUDIT.md +0 -0
  13. {console_window-2.0.0.9 → console_window-2.0.1}/LICENSE +0 -0
  14. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/backend.py +0 -0
  15. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/context.py +0 -0
  16. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/geometry.py +0 -0
  17. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/header.py +0 -0
  18. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/keys.py +0 -0
  19. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/opts.py +0 -0
  20. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/screens.py +0 -0
  21. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/signals.py +0 -0
  22. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/spinner.py +0 -0
  23. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/textwidth.py +0 -0
  24. {console_window-2.0.0.9 → console_window-2.0.1}/console_window/theme.py +0 -0
  25. {console_window-2.0.0.9 → console_window-2.0.1}/pyproject.toml +0 -0
  26. {console_window-2.0.0.9 → console_window-2.0.1}/runner +0 -0
  27. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_backend.py +0 -0
  28. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_context.py +0 -0
  29. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_demo_mode.py +0 -0
  30. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_dialogs.py +0 -0
  31. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_flash.py +0 -0
  32. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_geometry.py +0 -0
  33. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_header.py +0 -0
  34. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_low_colour.py +0 -0
  35. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_overflow.py +0 -0
  36. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_public_api.py +0 -0
  37. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_render_retry.py +0 -0
  38. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_sample_app.py +0 -0
  39. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_screens.py +0 -0
  40. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_signals.py +0 -0
  41. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_spinner.py +0 -0
  42. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_textwidth.py +0 -0
  43. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_theme.py +0 -0
  44. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_viewport_pad.py +0 -0
  45. {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_widechars.py +0 -0
@@ -65,8 +65,27 @@ This project follows [Semantic Versioning](https://semver.org/).
65
65
  without a TTY. Coverage of `window.py` rises from 55% to 84% (missing
66
66
  statements 444 → 161), so the half of the engine users actually feel is now
67
67
  smoke-tested in CI (AUDIT.md §3.2).
68
+ - `InlineConfirmation` is now a real line editor, matching the
69
+ `IncrementalSearchBar` beside it: `start(..., initial='')` seeds the buffer
70
+ with the caret at the end, and `LEFT`/`RIGHT`/`HOME`/`END` (also Ctrl-A/
71
+ Ctrl-E), `KEY_DC` and Backspace edit at the caret instead of only appending.
72
+ `cursor_pos` exposes the string index, `cursor_cell` the terminal column
73
+ (measured with `textwidth`), and `get_display_string(prompt='',
74
+ cursor_char='│')` renders the prompt plus the caret for a body row.
75
+ Movement, insertion and deletion use the `textwidth` helpers so wide
76
+ characters and combining marks cannot split a grapheme.
77
+ - `ConsoleWindow.temp_handled_keys(*keys)`: a context manager that adds keys to
78
+ `handled_keys` for one prompt and removes only the keys it actually added, so
79
+ an application key can be forwarded reversibly and nested uses leave existing
80
+ bindings intact.
68
81
 
69
82
  ### Changed
83
+ - Passthrough mode now forwards ESC (`27`) and Delete (`curses.KEY_DC`) as
84
+ editing keys, in addition to printable characters, Backspace, the arrow keys
85
+ and Home/End. This closes the gap that forced every `InlineConfirmation`
86
+ caller to discover `win.handled_keys.add(27)` to receive the cancel key; the
87
+ resize-abort path already treated `27` as an application key when bound, so
88
+ the behaviour is consistent rather than a silent widening.
70
89
  - Every `pylint` warning now states why it is deliberate (AUDIT.md §3.3), taking
71
90
  `pylint --disable=C,R` from 9.94/10 to **10.00/10**. Protected-access and
72
91
  protocol-signature findings carry scoped disables with an adjacent reason,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 2.0.0.9
3
+ Version: 2.0.1
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
@@ -105,7 +105,8 @@ demonstrates:
105
105
  * **`IncrementalSearchBar`** — `/` filters the list as you type, using
106
106
  `passthrough_mode` to hand keys to the bar.
107
107
  * **`InlineConfirmation`** — `SPACE` asks you to confirm with `y` before
108
- "processing" the picked row.
108
+ "processing" the picked row. The widget is also a curses-free line editor
109
+ (`initial=''`, caret movement, Delete, and `get_display_string()`).
109
110
  * **Themes** — a `THEMES` screen of colour samples backed by
110
111
  `Theme.list_all()` / `get_current()` / `set()`. The active theme is now
111
112
  per-window state: `ConsoleWindowOpts(theme=...)` seeds `window.theme`,
@@ -182,6 +183,33 @@ For anything beyond a single list, subclass `Screen` and drive it with
182
183
  `ScreenStack` — that is what the sample app does, and it is the paradigm the
183
184
  library is designed around.
184
185
 
186
+ ### Inline editing and key forwarding
187
+
188
+ `InlineConfirmation` is a curses-free line editor. `start(..., initial='x')`
189
+ seeds the buffer with the caret at the end; `LEFT`/`RIGHT`, Home/End (and
190
+ Ctrl-A/Ctrl-E), Backspace and Delete edit at the caret. The
191
+ `get_display_string(prompt='…')` method returns the prompt plus the buffer and
192
+ caret glyph, so wording and layout stay in the application, while `cursor_pos`
193
+ (string index) and `cursor_cell` (terminal column, measured with `textwidth`)
194
+ are available to callers that need them.
195
+
196
+ Passthrough widgets receive printable ASCII, navigation keys, Backspace,
197
+ Delete, Home/End, ESC and the keys in `handled_keys` while
198
+ `passthrough_mode` is `True`. For any additional application key, use the
199
+ reversible context manager rather than mutating `handled_keys` by hand:
200
+
201
+ ```python
202
+ win.passthrough_mode = True
203
+ try:
204
+ with win.temp_handled_keys(27):
205
+ key = win.prompt(seconds=0.5)
206
+ finally:
207
+ win.passthrough_mode = False
208
+ ```
209
+
210
+ Only keys the context actually added are removed on exit, so a pre-existing
211
+ binding is left in place and nested uses are safe.
212
+
185
213
  `ScreenStack.perform_actions()` is the normal router: it walks the spinner
186
214
  options, honours their scopes, and calls the matching `<attr>_ACTION` method.
187
215
  For apps that dispatch an action themselves, `ScreenStack.act_in(action,
@@ -86,7 +86,8 @@ demonstrates:
86
86
  * **`IncrementalSearchBar`** — `/` filters the list as you type, using
87
87
  `passthrough_mode` to hand keys to the bar.
88
88
  * **`InlineConfirmation`** — `SPACE` asks you to confirm with `y` before
89
- "processing" the picked row.
89
+ "processing" the picked row. The widget is also a curses-free line editor
90
+ (`initial=''`, caret movement, Delete, and `get_display_string()`).
90
91
  * **Themes** — a `THEMES` screen of colour samples backed by
91
92
  `Theme.list_all()` / `get_current()` / `set()`. The active theme is now
92
93
  per-window state: `ConsoleWindowOpts(theme=...)` seeds `window.theme`,
@@ -163,6 +164,33 @@ For anything beyond a single list, subclass `Screen` and drive it with
163
164
  `ScreenStack` — that is what the sample app does, and it is the paradigm the
164
165
  library is designed around.
165
166
 
167
+ ### Inline editing and key forwarding
168
+
169
+ `InlineConfirmation` is a curses-free line editor. `start(..., initial='x')`
170
+ seeds the buffer with the caret at the end; `LEFT`/`RIGHT`, Home/End (and
171
+ Ctrl-A/Ctrl-E), Backspace and Delete edit at the caret. The
172
+ `get_display_string(prompt='…')` method returns the prompt plus the buffer and
173
+ caret glyph, so wording and layout stay in the application, while `cursor_pos`
174
+ (string index) and `cursor_cell` (terminal column, measured with `textwidth`)
175
+ are available to callers that need them.
176
+
177
+ Passthrough widgets receive printable ASCII, navigation keys, Backspace,
178
+ Delete, Home/End, ESC and the keys in `handled_keys` while
179
+ `passthrough_mode` is `True`. For any additional application key, use the
180
+ reversible context manager rather than mutating `handled_keys` by hand:
181
+
182
+ ```python
183
+ win.passthrough_mode = True
184
+ try:
185
+ with win.temp_handled_keys(27):
186
+ key = win.prompt(seconds=0.5)
187
+ finally:
188
+ win.passthrough_mode = False
189
+ ```
190
+
191
+ Only keys the context actually added are removed on exit, so a pre-existing
192
+ binding is left in place and nested uses are safe.
193
+
166
194
  `ScreenStack.perform_actions()` is the normal router: it walks the spinner
167
195
  options, honours their scopes, and calls the matching `<attr>_ACTION` method.
168
196
  For apps that dispatch an action themselves, `ScreenStack.act_in(action,
@@ -303,17 +303,19 @@ class MainScreen(DemoScreen):
303
303
  ctx = Context(genre='item', item=item)
304
304
  app.win.add_body(line, attr=attr, context=ctx)
305
305
 
306
- # Show inline confirmation if active for this item
306
+ # Show inline confirmation if active for this item. The wording and
307
+ # the warning glyph stay here (product decisions); the widget owns
308
+ # the buffer/caret composition.
307
309
  if app.confirmation.active and \
308
310
  app.confirmation.identity == username:
309
- msg = f' ⚠️ Process {username}?'
311
+ prompt = f' ⚠️ Process {username}?'
310
312
  if app.confirmation.mode == 'y':
311
- msg += " - Press 'y' or ESC"
313
+ prompt += " - Press 'y' or ESC"
312
314
  elif app.confirmation.mode == 'Y':
313
- msg += " - Press 'Y' or ESC"
315
+ prompt += " - Press 'Y' or ESC"
314
316
 
315
317
  app.win.add_body(
316
- msg,
318
+ app.confirmation.get_display_string(prompt=prompt),
317
319
  attr=cs.color_pair(Theme.WARNING) | cs.A_BOLD,
318
320
  context=Context(genre='DECOR')
319
321
  )
@@ -1,6 +1,6 @@
1
1
  # Single source of truth for the package version: pyproject.toml declares
2
2
  # ``dynamic = ["version"]``, so flit reads this value instead of duplicating it.
3
- __version__ = "2.0.0.9"
3
+ __version__ = "2.0.1"
4
4
 
5
5
  from .context import Context
6
6
  from .header import HeaderLine, Span
@@ -4,6 +4,7 @@ import curses
4
4
  from typing import Callable, ClassVar
5
5
 
6
6
  from .header import HeaderLine
7
+ from .textwidth import cell_to_index, char_width, string_width
7
8
 
8
9
  # Keys treated as backspace (all terminal variants). Shared by both widgets so
9
10
  # the list cannot drift between them.
@@ -18,20 +19,23 @@ class InlineConfirmation:
18
19
  self.action_type: str | None = None # e.g., 'wipe' or 'verify'
19
20
  self.identity: str | None = None # e.g., 'sda1'
20
21
  self.choices: list[str] | None = None
21
- self.input_buffer: str = ''
22
+ self._input_buffer: str = ''
23
+ self._cursor_pos: int = 0
22
24
  self.mode: str | None = None
23
25
  self.min_abbrev_chars: int | None = min_abbrev_chars
24
26
  self._calculated_min_abbrev: int | None = None # Cached calculated value
25
27
 
26
28
  def start(self, action_type: str | None = None, identity: str | None = None,
27
29
  choices: str | list[str] | tuple[str, ...] | None = None,
28
- mode: str | None = None, min_abbrev_chars: int | None = None) -> None:
30
+ mode: str | None = None, min_abbrev_chars: int | None = None,
31
+ initial: str = '') -> None:
29
32
  self.active = True
30
33
  self.action_type = action_type
31
34
  self.identity = identity
32
35
  self.choices = (list(choices) if isinstance(choices, (list, tuple))
33
36
  else [choices] if isinstance(choices, str) else None)
34
- self.input_buffer = ''
37
+ self._input_buffer = initial
38
+ self._cursor_pos = len(initial)
35
39
 
36
40
  # Override min_abbrev_chars if provided
37
41
  if min_abbrev_chars is not None:
@@ -52,7 +56,86 @@ class InlineConfirmation:
52
56
  self.active = False
53
57
  self.action_type = None
54
58
  self.choices = None
55
- self.input_buffer = ''
59
+ self._input_buffer = ''
60
+ self._cursor_pos = 0
61
+
62
+ @property
63
+ def input_buffer(self) -> str:
64
+ return self._input_buffer
65
+
66
+ @input_buffer.setter
67
+ def input_buffer(self, value: str) -> None:
68
+ # Callers and older code seed the buffer by assignment; keeping the
69
+ # caret at the end means the next key appends where a reader expects.
70
+ self._input_buffer = value
71
+ self._cursor_pos = len(value)
72
+
73
+ @property
74
+ def cursor_pos(self) -> int:
75
+ return self._cursor_pos
76
+
77
+ @property
78
+ def cursor_cell(self) -> int:
79
+ """Caret column in terminal cells (wide chars occupy two)."""
80
+ pos = self._snap_index(self._cursor_pos)
81
+ return string_width(self._input_buffer[:pos])
82
+
83
+ def _snap_index(self, index: int) -> int:
84
+ # The caret is a string index, but a display-cell split must not land
85
+ # between a base character and its combining marks. textwidth maps the
86
+ # index's cell back to a safe code-point boundary.
87
+ index = max(0, min(index, len(self._input_buffer)))
88
+ return cell_to_index(self._input_buffer,
89
+ string_width(self._input_buffer[:index]))
90
+
91
+ def _move_cursor_left(self) -> None:
92
+ pos = self._snap_index(self._cursor_pos)
93
+ if pos <= 0:
94
+ self._cursor_pos = 0
95
+ return
96
+ pos -= 1
97
+ while pos > 0 and char_width(self._input_buffer[pos]) == 0:
98
+ pos -= 1
99
+ self._cursor_pos = pos
100
+
101
+ def _move_cursor_right(self) -> None:
102
+ pos = self._snap_index(self._cursor_pos)
103
+ end = len(self._input_buffer)
104
+ if pos >= end:
105
+ self._cursor_pos = end
106
+ return
107
+ pos += 1
108
+ while pos < end and char_width(self._input_buffer[pos]) == 0:
109
+ pos += 1
110
+ self._cursor_pos = pos
111
+
112
+ def _insert_text(self, text: str) -> None:
113
+ pos = self._snap_index(self._cursor_pos)
114
+ self._input_buffer = (self._input_buffer[:pos] + text
115
+ + self._input_buffer[pos:])
116
+ self._cursor_pos = pos + len(text)
117
+
118
+ def _delete_backward(self) -> None:
119
+ end = self._snap_index(self._cursor_pos)
120
+ if end <= 0:
121
+ self._cursor_pos = 0
122
+ return
123
+ self._cursor_pos = end
124
+ self._move_cursor_left()
125
+ self._input_buffer = (self._input_buffer[:self._cursor_pos]
126
+ + self._input_buffer[end:])
127
+
128
+ def _delete_forward(self) -> None:
129
+ start = self._snap_index(self._cursor_pos)
130
+ end = len(self._input_buffer)
131
+ if start >= end:
132
+ self._cursor_pos = start
133
+ return
134
+ pos = start + 1
135
+ while pos < end and char_width(self._input_buffer[pos]) == 0:
136
+ pos += 1
137
+ self._input_buffer = self._input_buffer[:start] + self._input_buffer[pos:]
138
+ self._cursor_pos = start
56
139
 
57
140
  def _calculate_min_abbrev_chars(self) -> int:
58
141
  choices = self.choices
@@ -136,9 +219,19 @@ class InlineConfirmation:
136
219
 
137
220
  # Handle buffered input
138
221
  if 32 <= key <= 126:
139
- self.input_buffer += chr(key)
222
+ self._insert_text(chr(key))
140
223
  elif key in _BACKSPACE_KEYS: # Backspace (all variants)
141
- self.input_buffer = self.input_buffer[:-1]
224
+ self._delete_backward()
225
+ elif key == curses.KEY_DC:
226
+ self._delete_forward()
227
+ elif key == curses.KEY_LEFT:
228
+ self._move_cursor_left()
229
+ elif key == curses.KEY_RIGHT:
230
+ self._move_cursor_right()
231
+ elif key in (curses.KEY_HOME, 1): # Home / Ctrl-A
232
+ self._cursor_pos = 0
233
+ elif key in (curses.KEY_END, 5): # End / Ctrl-E
234
+ self._cursor_pos = len(self._input_buffer)
142
235
  elif key in (10, 13): # Enter
143
236
  return self._handle_enter()
144
237
 
@@ -176,6 +269,24 @@ class InlineConfirmation:
176
269
  # 'yes' or other permissive modes
177
270
  return self.input_buffer if self.input_buffer.lower() == 'yes' else None
178
271
 
272
+ def get_display_string(self, prompt: str = '', cursor_char: str = '│') -> str:
273
+ """Return the prompt and editable buffer with a caret glyph.
274
+
275
+ The caret index is split on a textwidth cell boundary, so a pasted
276
+ wide character or combining mark cannot put the glyph mid-grapheme.
277
+ Single-key modes (``Y``/``y``) have no editable text and return the
278
+ prompt unchanged; an inactive widget returns ``''`` so a caller can
279
+ decide when the row should disappear.
280
+ """
281
+ if not self.active:
282
+ return ''
283
+ if self.mode in ('Y', 'y'):
284
+ return prompt
285
+ pos = self._snap_index(self._cursor_pos)
286
+ before = self._input_buffer[:pos]
287
+ after = self._input_buffer[pos:]
288
+ return f'{prompt}{before}{cursor_char}{after}'
289
+
179
290
  #: Hints whose text does not depend on runtime state.
180
291
  _STATIC_HINTS: ClassVar[dict[str, str]] = {
181
292
  'YES': 'Type YES + ENTER or ESC',
@@ -14,7 +14,8 @@ import sys
14
14
  import time
15
15
  import warnings
16
16
  import weakref
17
- from collections.abc import Iterable
17
+ from collections.abc import Iterable, Iterator
18
+ from contextlib import contextmanager
18
19
  from curses.textpad import rectangle
19
20
  from types import SimpleNamespace
20
21
  from typing import TYPE_CHECKING, Callable, ClassVar, cast
@@ -58,8 +59,13 @@ _CTRL_PAGE_UP, _CTRL_HALF_DOWN, _CTRL_PAGE_DOWN, _CTRL_HALF_UP = 2, 4, 6, 21
58
59
 
59
60
  # Editing keys that passthrough mode hands to the application unchanged, in
60
61
  # addition to printable characters, navigation keys and `handled_keys`.
62
+ # ESC and DELETE are editing keys too: an in-place widget such as
63
+ # InlineConfirmation must be able to cancel and rub out at the caret, and
64
+ # callers should not have to discover `handled_keys` to make that work.
61
65
  _PASSTHROUGH_EDITING_KEYS = frozenset({
62
66
  curses.KEY_BACKSPACE, 127, 8, 263, # backspace variants
67
+ curses.KEY_DC, # delete at the caret
68
+ 27, # ESC: cancel an in-place edit
63
69
  curses.KEY_LEFT, curses.KEY_RIGHT,
64
70
  curses.KEY_HOME, curses.KEY_END,
65
71
  1, 5, # Ctrl-A, Ctrl-E
@@ -391,6 +397,30 @@ class ConsoleWindow:
391
397
  else:
392
398
  self.handled_keys = set()
393
399
 
400
+ @contextmanager
401
+ def temp_handled_keys(self, *keys: int) -> Iterator[ConsoleWindow]:
402
+ """Temporarily add key codes to ``handled_keys``.
403
+
404
+ ``prompt()`` only returns a key that is in ``handled_keys`` (or one of
405
+ the built-in passthrough editing keys). This context manager makes
406
+ that forwarding reversible by construction: only the keys it actually
407
+ added are removed on exit, so nested uses leave a pre-existing binding
408
+ intact.
409
+
410
+ Example: forwarding an application key for one inline edit::
411
+
412
+ win.passthrough_mode = True
413
+ with win.temp_handled_keys(27):
414
+ key = win.prompt(seconds=0.5)
415
+ win.passthrough_mode = False
416
+ """
417
+ added = {key for key in keys if key not in self.handled_keys}
418
+ self.handled_keys.update(added)
419
+ try:
420
+ yield self
421
+ finally:
422
+ self.handled_keys.difference_update(added)
423
+
394
424
  def set_demo_mode(self, enabled: bool | None) -> bool:
395
425
  if enabled is None: # toggle if not explicit
396
426
  self.opts.demo_mode = not (self.opts.demo_mode)
@@ -95,6 +95,49 @@ def test_prompt_passthrough_records_demo_indicator(make):
95
95
  assert win.last_demo_key == ' p '
96
96
 
97
97
 
98
+ def test_prompt_passthrough_forwards_escape_without_handled_keys(make):
99
+ # The gap that forced every InlineConfirmation caller to discover
100
+ # handled_keys: ESC must reach a passthrough editor as a normal key.
101
+ win, backend = make()
102
+ win.passthrough_mode = True
103
+ backend.queue_keys(27)
104
+ assert win.prompt(seconds=0.5) == 27
105
+
106
+
107
+ def test_prompt_passthrough_forwards_delete(make):
108
+ win, backend = make()
109
+ win.passthrough_mode = True
110
+ backend.queue_keys(curses.KEY_DC)
111
+ assert win.prompt(seconds=0.5) == curses.KEY_DC
112
+
113
+
114
+ def test_temp_handled_keys_is_reversible(make):
115
+ win, backend = make(relax_handled_keys=False)
116
+ with win.temp_handled_keys(ord('x')) as scoped:
117
+ assert scoped is win
118
+ assert ord('x') in win.handled_keys
119
+ backend.queue_keys(ord('x'))
120
+ assert win.prompt(seconds=0.5) == ord('x')
121
+ assert ord('x') not in win.handled_keys
122
+
123
+
124
+ def test_temp_handled_keys_preserves_preexisting_binding(make):
125
+ win, _backend = make(keys={ord('q')}, relax_handled_keys=False)
126
+ with win.temp_handled_keys(ord('q'), ord('x')):
127
+ assert ord('q') in win.handled_keys
128
+ assert ord('x') in win.handled_keys
129
+ assert ord('q') in win.handled_keys
130
+ assert ord('x') not in win.handled_keys
131
+
132
+
133
+ def test_temp_handled_keys_restores_on_exception(make):
134
+ win, _backend = make()
135
+ with pytest.raises(RuntimeError):
136
+ with win.temp_handled_keys(27):
137
+ raise RuntimeError('boom')
138
+ assert 27 not in win.handled_keys
139
+
140
+
98
141
  # --- prompt(): Ctrl-C as an application key --------------------------------
99
142
 
100
143
  def test_prompt_returns_etx_when_ctrl_c_is_handled(monkeypatch, make):
@@ -182,3 +182,106 @@ def test_searchbar_display_string_inactive():
182
182
  def test_searchbar_display_string_empty():
183
183
  bar = IncrementalSearchBar()
184
184
  assert bar.get_display_string() == ''
185
+
186
+
187
+ # --- InlineConfirmation: caret and line editing ----------------------------
188
+
189
+ def test_inline_start_seeds_buffer_and_caret():
190
+ c = InlineConfirmation()
191
+ c.start(mode='text', initial='hello')
192
+ assert c.input_buffer == 'hello'
193
+ assert c.cursor_pos == 5
194
+ c.handle_key(ord('!'))
195
+ assert c.input_buffer == 'hello!'
196
+ assert c.cursor_pos == 6
197
+ assert c.handle_key(10) == 'confirmed'
198
+
199
+
200
+ def test_inline_cursor_movement_and_insertion():
201
+ c = InlineConfirmation()
202
+ c.start(mode='text', initial='abc')
203
+ c.handle_key(curses.KEY_LEFT)
204
+ c.handle_key(curses.KEY_LEFT)
205
+ c.handle_key(ord('X'))
206
+ assert c.input_buffer == 'aXbc'
207
+ assert c.cursor_pos == 2
208
+
209
+
210
+ def test_inline_home_end_and_delete_at_caret():
211
+ c = InlineConfirmation()
212
+ c.start(mode='text', initial='abc')
213
+ c.handle_key(curses.KEY_HOME)
214
+ assert c.cursor_pos == 0
215
+ c.handle_key(curses.KEY_DC)
216
+ assert c.input_buffer == 'bc'
217
+ assert c.cursor_pos == 0
218
+ c.handle_key(curses.KEY_END)
219
+ assert c.cursor_pos == 2
220
+ c.handle_key(curses.KEY_DC) # at end: no-op
221
+ assert c.input_buffer == 'bc'
222
+
223
+
224
+ def test_inline_ctrl_a_and_ctrl_e_move_the_caret():
225
+ c = InlineConfirmation()
226
+ c.start(mode='text', initial='abc')
227
+ c.handle_key(1)
228
+ assert c.cursor_pos == 0
229
+ c.handle_key(5)
230
+ assert c.cursor_pos == 3
231
+
232
+
233
+ def test_inline_backspace_deletes_before_the_caret():
234
+ c = InlineConfirmation()
235
+ c.start(mode='text', initial='abcd')
236
+ c.handle_key(curses.KEY_LEFT) # caret before 'd'
237
+ c.handle_key(curses.KEY_BACKSPACE)
238
+ assert c.input_buffer == 'abd'
239
+ assert c.cursor_pos == 2
240
+
241
+
242
+ def test_inline_input_buffer_assignment_keeps_caret_in_range():
243
+ c = InlineConfirmation()
244
+ c.start(mode='text', initial='abc')
245
+ c.input_buffer = 'z'
246
+ assert c.cursor_pos == 1
247
+
248
+
249
+ def test_inline_display_string_has_prompt_and_caret():
250
+ c = InlineConfirmation()
251
+ c.start(mode='text', initial='abc')
252
+ c.handle_key(curses.KEY_LEFT)
253
+ assert c.get_display_string(prompt='> ', cursor_char='|') == '> ab|c'
254
+
255
+
256
+ def test_inline_display_string_inactive_is_empty():
257
+ c = InlineConfirmation()
258
+ c.start(mode='text', initial='abc')
259
+ c.cancel()
260
+ assert c.get_display_string(prompt='> ') == ''
261
+
262
+
263
+ def test_inline_display_string_single_key_mode_has_no_caret():
264
+ c = InlineConfirmation()
265
+ c.start(mode='y')
266
+ assert c.get_display_string(prompt='Process?') == 'Process?'
267
+
268
+
269
+ def test_inline_wide_and_combining_caret_uses_cell_width():
270
+ c = InlineConfirmation()
271
+ c.start(mode='text', initial='a\u0301界')
272
+ assert c.cursor_pos == 3
273
+ assert c.cursor_cell == 3 # 'a' + combining mark = 1 cell, '界' = 2
274
+ c.handle_key(curses.KEY_LEFT) # skip the whole wide character
275
+ assert c.cursor_cell == 1
276
+ c.handle_key(curses.KEY_LEFT) # skip the base + combining mark
277
+ assert c.cursor_pos == 0
278
+
279
+
280
+ def test_inline_display_split_never_splits_a_grapheme():
281
+ c = InlineConfirmation()
282
+ c.start(mode='text')
283
+ # Place the caret between the base character and its combining mark; the
284
+ # display split snaps forward so the glyph stays after the whole grapheme.
285
+ c._input_buffer = 'a\u0301'
286
+ c._cursor_pos = 1
287
+ assert c.get_display_string(cursor_char='|') == 'a\u0301|'
File without changes
File without changes