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.
- {console_window-2.0.0.9 → console_window-2.0.1}/CHANGELOG.md +19 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/PKG-INFO +30 -2
- {console_window-2.0.0.9 → console_window-2.0.1}/README.md +29 -1
- {console_window-2.0.0.9 → console_window-2.0.1}/SampleApp.py +7 -5
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/__init__.py +1 -1
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/widgets.py +117 -6
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/window.py +31 -1
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_interactive.py +43 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_widgets.py +103 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/.gitignore +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/AUDIT.md +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/LICENSE +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/backend.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/context.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/geometry.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/header.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/keys.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/opts.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/screens.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/signals.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/spinner.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/textwidth.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/console_window/theme.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/pyproject.toml +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/runner +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_backend.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_context.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_dialogs.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_flash.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_geometry.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_header.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_low_colour.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_overflow.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_public_api.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_render_retry.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_sample_app.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_screens.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_signals.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_spinner.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_textwidth.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_theme.py +0 -0
- {console_window-2.0.0.9 → console_window-2.0.1}/tests/test_viewport_pad.py +0 -0
- {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.
|
|
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
|
-
|
|
311
|
+
prompt = f' ⚠️ Process {username}?'
|
|
310
312
|
if app.confirmation.mode == 'y':
|
|
311
|
-
|
|
313
|
+
prompt += " - Press 'y' or ESC"
|
|
312
314
|
elif app.confirmation.mode == 'Y':
|
|
313
|
-
|
|
315
|
+
prompt += " - Press 'Y' or ESC"
|
|
314
316
|
|
|
315
317
|
app.win.add_body(
|
|
316
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
222
|
+
self._insert_text(chr(key))
|
|
140
223
|
elif key in _BACKSPACE_KEYS: # Backspace (all variants)
|
|
141
|
-
self.
|
|
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
|
|
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
|