console-window 2.0.1__tar.gz → 2.1.0__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.1 → console_window-2.1.0}/PKG-INFO +127 -5
- {console_window-2.0.1 → console_window-2.1.0}/README.md +126 -4
- {console_window-2.0.1 → console_window-2.1.0}/SampleApp.py +110 -65
- {console_window-2.0.1 → console_window-2.1.0}/console_window/__init__.py +11 -3
- console_window-2.1.0/console_window/keys.py +37 -0
- {console_window-2.0.1 → console_window-2.1.0}/console_window/opts.py +70 -3
- {console_window-2.0.1 → console_window-2.1.0}/console_window/screens.py +59 -0
- {console_window-2.0.1 → console_window-2.1.0}/console_window/spinner.py +15 -5
- {console_window-2.0.1 → console_window-2.1.0}/console_window/widgets.py +114 -1
- {console_window-2.0.1 → console_window-2.1.0}/console_window/window.py +320 -24
- {console_window-2.0.1 → console_window-2.1.0/tests}/CHANGELOG.md +89 -0
- console_window-2.1.0/tests/test_prompt_line.py +133 -0
- console_window-2.1.0/tests/test_prompt_window.py +152 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_public_api.py +2 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_sample_app.py +105 -0
- console_window-2.1.0/tests/test_screens.py +172 -0
- console_window-2.1.0/tests/test_status.py +240 -0
- console_window-2.1.0/tests/test_taken_nav_keys.py +251 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_widgets.py +10 -0
- console_window-2.0.1/console_window/keys.py +0 -18
- console_window-2.0.1/tests/test_screens.py +0 -62
- {console_window-2.0.1 → console_window-2.1.0}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/.gitignore +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/LICENSE +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/console_window/backend.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/console_window/context.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/console_window/geometry.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/console_window/header.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/console_window/signals.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/console_window/textwidth.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/console_window/theme.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/pyproject.toml +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/runner +0 -0
- {console_window-2.0.1 → console_window-2.1.0/tests}/AUDIT.md +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_backend.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_context.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_dialogs.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_flash.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_geometry.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_header.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_interactive.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_low_colour.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_overflow.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_render_retry.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_signals.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_spinner.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_textwidth.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_theme.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_viewport_pad.py +0 -0
- {console_window-2.0.1 → console_window-2.1.0}/tests/test_widechars.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: console-window
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
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
|
|
@@ -104,9 +104,11 @@ demonstrates:
|
|
|
104
104
|
skipped by navigation.
|
|
105
105
|
* **`IncrementalSearchBar`** — `/` filters the list as you type, using
|
|
106
106
|
`passthrough_mode` to hand keys to the bar.
|
|
107
|
-
*
|
|
108
|
-
"processing" the picked row
|
|
109
|
-
(
|
|
107
|
+
* **In-place prompts** — `SPACE` asks you to confirm with `y` before
|
|
108
|
+
"processing" the picked row through `begin_prompt()` /
|
|
109
|
+
`handle_prompt_key()`, and `N` edits a note in the header band with the
|
|
110
|
+
buffered text mode. Both use `PromptLine` underneath; completion callbacks
|
|
111
|
+
run after passthrough is restored, so they can report with `status()`.
|
|
110
112
|
* **Themes** — a `THEMES` screen of colour samples backed by
|
|
111
113
|
`Theme.list_all()` / `get_current()` / `set()`. The active theme is now
|
|
112
114
|
per-window state: `ConsoleWindowOpts(theme=...)` seeds `window.theme`,
|
|
@@ -121,7 +123,13 @@ demonstrates:
|
|
|
121
123
|
`answer()` returns `None` only when the user aborts; if the terminal cannot
|
|
122
124
|
fit the dialog it raises `TerminalTooSmallError` with the required and actual
|
|
123
125
|
sizes.
|
|
124
|
-
* **`flash()`** —
|
|
126
|
+
* **`status()` and `flash()`** — `status()` is a non-modal one-line message on
|
|
127
|
+
the separator row; `flash()` is a modal box that clears the screen. The demo
|
|
128
|
+
uses `status()` for "Processed", "Cancelled" and note-saved feedback.
|
|
129
|
+
* **`BasicTextScreen`** — the HISTORY screen is rebuilt each draw from a
|
|
130
|
+
`BasicTextScreen` via `set_text()` (headers plus `(text, attr)` lines), and
|
|
131
|
+
resolves its window at draw time because the screens are constructed before
|
|
132
|
+
the `ConsoleWindow`.
|
|
125
133
|
* **Token-based headers** — `HeaderLine` (`text`, `bracket_key`, `key`, `extend`)
|
|
126
134
|
rendered with `add_header_line()`, rather than string markup. Both header
|
|
127
135
|
helpers accept `line_attr`, an attribute OR'd into every span, so a whole
|
|
@@ -210,6 +218,120 @@ finally:
|
|
|
210
218
|
Only keys the context actually added are removed on exit, so a pre-existing
|
|
211
219
|
binding is left in place and nested uses are safe.
|
|
212
220
|
|
|
221
|
+
### Transient status on the separator row
|
|
222
|
+
|
|
223
|
+
`status()` declares a one-line message that is painted on the separator row —
|
|
224
|
+
the row that also owns the scroll indicator — so the listing stays visible.
|
|
225
|
+
Unlike `flash()`, which clears the whole screen, `status()` never takes over
|
|
226
|
+
the display:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
win.status('Backup tag failed', level='error', timeout=5.0)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The message appears on the next `render()`/`prompt()`; `status()` never renders
|
|
233
|
+
by itself. Levels `'info'`, `'warn'` and `'error'` select an attribute from
|
|
234
|
+
`ConsoleWindow.STATUS_LEVELS` (`info` dim, `warn` bold, `error` bold+reverse);
|
|
235
|
+
an unknown level falls back to `'info'` instead of raising, and `attr=` overrides
|
|
236
|
+
the level for that message only. `timeout=None` keeps the message until it is
|
|
237
|
+
replaced or cleared; any number expires it lazily at draw time. `status('')` and
|
|
238
|
+
`clear_status()` remove it, and `current_status` returns `(text, attr)`.
|
|
239
|
+
|
|
240
|
+
The exact rendering rule is: the text starts at column 0 of `head.view_cnt` and
|
|
241
|
+
is truncated to leave **one blank cell between it and the scroll indicator**.
|
|
242
|
+
If the window currently has no separator row, the text is still stored and
|
|
243
|
+
`status()` returns `False` (the message is deferred, not lost); a later resize
|
|
244
|
+
reveals it.
|
|
245
|
+
|
|
246
|
+
### In-place prompts
|
|
247
|
+
|
|
248
|
+
`PromptLine` is the window-independent state machine: text, an editable buffer,
|
|
249
|
+
an optional hint and completion callbacks. It never runs the callbacks itself;
|
|
250
|
+
`take()` returns `(answer, on_confirm, on_cancel)` after cancelling the widget.
|
|
251
|
+
The window wraps it with a lifecycle that keeps key routing and passthrough
|
|
252
|
+
consistent:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
win.begin_prompt('Overwrite the tag?', mode='y',
|
|
256
|
+
on_confirm=lambda answer: apply(answer))
|
|
257
|
+
while win.prompt_active:
|
|
258
|
+
key = win.prompt(seconds=0.5)
|
|
259
|
+
if win.handle_prompt_key(key) == 'continue':
|
|
260
|
+
continue # swallow every key while a prompt is live
|
|
261
|
+
break
|
|
262
|
+
win.render()
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
While `prompt_active` is true, route **every** key through
|
|
266
|
+
`handle_prompt_key()` and swallow a `'continue'` result, so no action key can
|
|
267
|
+
fire underneath a live prompt. `begin_prompt()` turns `passthrough_mode` on and
|
|
268
|
+
`end_prompt()` restores its previous value; completion callbacks run *after*
|
|
269
|
+
the prompt is gone and passthrough is restored, so a callback may safely begin
|
|
270
|
+
the next prompt (the "re-edit, then ENTER or ESC" re-ask path).
|
|
271
|
+
|
|
272
|
+
The display formats are fixed so applications do not have to reproduce them:
|
|
273
|
+
|
|
274
|
+
* single-key modes `Y`/`y`: `f'{prompt} [Press "{mode}" or ESC]'`, e.g.
|
|
275
|
+
`Really quit? [Press "y" or ESC]`;
|
|
276
|
+
* every other mode: `f'{prompt}: {buffer}{caret}{rest}'`, e.g. `Value: 5│`.
|
|
277
|
+
|
|
278
|
+
`get_hint_string(width)` returns the hint (truncated when a width is given), and
|
|
279
|
+
`get_choice_lines()` returns one row per choice, marking the currently resolved
|
|
280
|
+
one: `⮞ alpha` versus ` beta`. Drawing is the application's choice:
|
|
281
|
+
`add_prompt_header()` puts a screen-wide question in the header band, while
|
|
282
|
+
`add_prompt_lines()` appends a bold prompt row and a dim hint row as
|
|
283
|
+
`Context(genre='TRANSIENT', pickable=False)` from the application's own row
|
|
284
|
+
loop. The window never learns which item the prompt belongs to.
|
|
285
|
+
|
|
286
|
+
### Read-only text screens
|
|
287
|
+
|
|
288
|
+
`BasicTextScreen` is the small `Screen` for preformatted, scrollable text. It
|
|
289
|
+
resolves `app.win` at draw time, disables pick mode, adds each header before the
|
|
290
|
+
body, and adds each line with `add_body()` and no `Context` (so normal
|
|
291
|
+
navigation scrolls it). Content is normalised by `set_text()`, which is safe
|
|
292
|
+
before or after the first draw:
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
screen = BasicTextScreen(app, head='COMPARE',
|
|
296
|
+
lines=[('first difference', curses.A_BOLD),
|
|
297
|
+
'second difference'])
|
|
298
|
+
screen.set_text(lines=['replacement'])
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
`lines` items are `str` or `(str, attr)`; `head` may be a `str`, a
|
|
302
|
+
`HeaderLine`, or an iterable of either. There is no pick mode, no action
|
|
303
|
+
handler and no spinner: ESC/back remains the application's `escape_ACTION`.
|
|
304
|
+
|
|
305
|
+
### Taking navigation keys for the application
|
|
306
|
+
|
|
307
|
+
Navigation keys are reserved by default, but an app may take specific ones for
|
|
308
|
+
itself without monkeypatching the module global. Declare them on the window's
|
|
309
|
+
options; the mapping value is the label the help screen shows:
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
import curses
|
|
313
|
+
from console_window import ConsoleWindowOpts, OptionSpinner
|
|
314
|
+
|
|
315
|
+
opts = ConsoleWindowOpts(
|
|
316
|
+
taken_nav_keys={
|
|
317
|
+
curses.KEY_LEFT: 'prev page',
|
|
318
|
+
curses.KEY_RIGHT: 'next page',
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
win = ConsoleWindow(opts=opts)
|
|
322
|
+
spin = OptionSpinner(stack=stack, nav_keys=opts.nav_keys) # accept them in add_key()
|
|
323
|
+
spin.add_key('prev', '← - previous', genre='action', keys=[curses.KEY_LEFT])
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
A taken key is withdrawn from navigation dispatch for that window only: it no
|
|
327
|
+
longer scrolls, it is no longer advertised as navigation, and it takes the
|
|
328
|
+
normal `handled_keys`/`relax_handled_keys` path on return. Text editing is
|
|
329
|
+
unchanged — dialogs and widgets still use LEFT/RIGHT/HOME/END for the caret.
|
|
330
|
+
Because ownership lives in `ConsoleWindowOpts`, two windows in one process can
|
|
331
|
+
have different bindings; `NAVIGATION_KEYS` itself is never mutated. Taking every
|
|
332
|
+
vertical key is allowed (and logged as "reduced navigation"), since the
|
|
333
|
+
application is then responsible for its own scrolling.
|
|
334
|
+
|
|
213
335
|
`ScreenStack.perform_actions()` is the normal router: it walks the spinner
|
|
214
336
|
options, honours their scopes, and calls the matching `<attr>_ACTION` method.
|
|
215
337
|
For apps that dispatch an action themselves, `ScreenStack.act_in(action,
|
|
@@ -85,9 +85,11 @@ demonstrates:
|
|
|
85
85
|
skipped by navigation.
|
|
86
86
|
* **`IncrementalSearchBar`** — `/` filters the list as you type, using
|
|
87
87
|
`passthrough_mode` to hand keys to the bar.
|
|
88
|
-
*
|
|
89
|
-
"processing" the picked row
|
|
90
|
-
(
|
|
88
|
+
* **In-place prompts** — `SPACE` asks you to confirm with `y` before
|
|
89
|
+
"processing" the picked row through `begin_prompt()` /
|
|
90
|
+
`handle_prompt_key()`, and `N` edits a note in the header band with the
|
|
91
|
+
buffered text mode. Both use `PromptLine` underneath; completion callbacks
|
|
92
|
+
run after passthrough is restored, so they can report with `status()`.
|
|
91
93
|
* **Themes** — a `THEMES` screen of colour samples backed by
|
|
92
94
|
`Theme.list_all()` / `get_current()` / `set()`. The active theme is now
|
|
93
95
|
per-window state: `ConsoleWindowOpts(theme=...)` seeds `window.theme`,
|
|
@@ -102,7 +104,13 @@ demonstrates:
|
|
|
102
104
|
`answer()` returns `None` only when the user aborts; if the terminal cannot
|
|
103
105
|
fit the dialog it raises `TerminalTooSmallError` with the required and actual
|
|
104
106
|
sizes.
|
|
105
|
-
* **`flash()`** —
|
|
107
|
+
* **`status()` and `flash()`** — `status()` is a non-modal one-line message on
|
|
108
|
+
the separator row; `flash()` is a modal box that clears the screen. The demo
|
|
109
|
+
uses `status()` for "Processed", "Cancelled" and note-saved feedback.
|
|
110
|
+
* **`BasicTextScreen`** — the HISTORY screen is rebuilt each draw from a
|
|
111
|
+
`BasicTextScreen` via `set_text()` (headers plus `(text, attr)` lines), and
|
|
112
|
+
resolves its window at draw time because the screens are constructed before
|
|
113
|
+
the `ConsoleWindow`.
|
|
106
114
|
* **Token-based headers** — `HeaderLine` (`text`, `bracket_key`, `key`, `extend`)
|
|
107
115
|
rendered with `add_header_line()`, rather than string markup. Both header
|
|
108
116
|
helpers accept `line_attr`, an attribute OR'd into every span, so a whole
|
|
@@ -191,6 +199,120 @@ finally:
|
|
|
191
199
|
Only keys the context actually added are removed on exit, so a pre-existing
|
|
192
200
|
binding is left in place and nested uses are safe.
|
|
193
201
|
|
|
202
|
+
### Transient status on the separator row
|
|
203
|
+
|
|
204
|
+
`status()` declares a one-line message that is painted on the separator row —
|
|
205
|
+
the row that also owns the scroll indicator — so the listing stays visible.
|
|
206
|
+
Unlike `flash()`, which clears the whole screen, `status()` never takes over
|
|
207
|
+
the display:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
win.status('Backup tag failed', level='error', timeout=5.0)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The message appears on the next `render()`/`prompt()`; `status()` never renders
|
|
214
|
+
by itself. Levels `'info'`, `'warn'` and `'error'` select an attribute from
|
|
215
|
+
`ConsoleWindow.STATUS_LEVELS` (`info` dim, `warn` bold, `error` bold+reverse);
|
|
216
|
+
an unknown level falls back to `'info'` instead of raising, and `attr=` overrides
|
|
217
|
+
the level for that message only. `timeout=None` keeps the message until it is
|
|
218
|
+
replaced or cleared; any number expires it lazily at draw time. `status('')` and
|
|
219
|
+
`clear_status()` remove it, and `current_status` returns `(text, attr)`.
|
|
220
|
+
|
|
221
|
+
The exact rendering rule is: the text starts at column 0 of `head.view_cnt` and
|
|
222
|
+
is truncated to leave **one blank cell between it and the scroll indicator**.
|
|
223
|
+
If the window currently has no separator row, the text is still stored and
|
|
224
|
+
`status()` returns `False` (the message is deferred, not lost); a later resize
|
|
225
|
+
reveals it.
|
|
226
|
+
|
|
227
|
+
### In-place prompts
|
|
228
|
+
|
|
229
|
+
`PromptLine` is the window-independent state machine: text, an editable buffer,
|
|
230
|
+
an optional hint and completion callbacks. It never runs the callbacks itself;
|
|
231
|
+
`take()` returns `(answer, on_confirm, on_cancel)` after cancelling the widget.
|
|
232
|
+
The window wraps it with a lifecycle that keeps key routing and passthrough
|
|
233
|
+
consistent:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
win.begin_prompt('Overwrite the tag?', mode='y',
|
|
237
|
+
on_confirm=lambda answer: apply(answer))
|
|
238
|
+
while win.prompt_active:
|
|
239
|
+
key = win.prompt(seconds=0.5)
|
|
240
|
+
if win.handle_prompt_key(key) == 'continue':
|
|
241
|
+
continue # swallow every key while a prompt is live
|
|
242
|
+
break
|
|
243
|
+
win.render()
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
While `prompt_active` is true, route **every** key through
|
|
247
|
+
`handle_prompt_key()` and swallow a `'continue'` result, so no action key can
|
|
248
|
+
fire underneath a live prompt. `begin_prompt()` turns `passthrough_mode` on and
|
|
249
|
+
`end_prompt()` restores its previous value; completion callbacks run *after*
|
|
250
|
+
the prompt is gone and passthrough is restored, so a callback may safely begin
|
|
251
|
+
the next prompt (the "re-edit, then ENTER or ESC" re-ask path).
|
|
252
|
+
|
|
253
|
+
The display formats are fixed so applications do not have to reproduce them:
|
|
254
|
+
|
|
255
|
+
* single-key modes `Y`/`y`: `f'{prompt} [Press "{mode}" or ESC]'`, e.g.
|
|
256
|
+
`Really quit? [Press "y" or ESC]`;
|
|
257
|
+
* every other mode: `f'{prompt}: {buffer}{caret}{rest}'`, e.g. `Value: 5│`.
|
|
258
|
+
|
|
259
|
+
`get_hint_string(width)` returns the hint (truncated when a width is given), and
|
|
260
|
+
`get_choice_lines()` returns one row per choice, marking the currently resolved
|
|
261
|
+
one: `⮞ alpha` versus ` beta`. Drawing is the application's choice:
|
|
262
|
+
`add_prompt_header()` puts a screen-wide question in the header band, while
|
|
263
|
+
`add_prompt_lines()` appends a bold prompt row and a dim hint row as
|
|
264
|
+
`Context(genre='TRANSIENT', pickable=False)` from the application's own row
|
|
265
|
+
loop. The window never learns which item the prompt belongs to.
|
|
266
|
+
|
|
267
|
+
### Read-only text screens
|
|
268
|
+
|
|
269
|
+
`BasicTextScreen` is the small `Screen` for preformatted, scrollable text. It
|
|
270
|
+
resolves `app.win` at draw time, disables pick mode, adds each header before the
|
|
271
|
+
body, and adds each line with `add_body()` and no `Context` (so normal
|
|
272
|
+
navigation scrolls it). Content is normalised by `set_text()`, which is safe
|
|
273
|
+
before or after the first draw:
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
screen = BasicTextScreen(app, head='COMPARE',
|
|
277
|
+
lines=[('first difference', curses.A_BOLD),
|
|
278
|
+
'second difference'])
|
|
279
|
+
screen.set_text(lines=['replacement'])
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`lines` items are `str` or `(str, attr)`; `head` may be a `str`, a
|
|
283
|
+
`HeaderLine`, or an iterable of either. There is no pick mode, no action
|
|
284
|
+
handler and no spinner: ESC/back remains the application's `escape_ACTION`.
|
|
285
|
+
|
|
286
|
+
### Taking navigation keys for the application
|
|
287
|
+
|
|
288
|
+
Navigation keys are reserved by default, but an app may take specific ones for
|
|
289
|
+
itself without monkeypatching the module global. Declare them on the window's
|
|
290
|
+
options; the mapping value is the label the help screen shows:
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
import curses
|
|
294
|
+
from console_window import ConsoleWindowOpts, OptionSpinner
|
|
295
|
+
|
|
296
|
+
opts = ConsoleWindowOpts(
|
|
297
|
+
taken_nav_keys={
|
|
298
|
+
curses.KEY_LEFT: 'prev page',
|
|
299
|
+
curses.KEY_RIGHT: 'next page',
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
win = ConsoleWindow(opts=opts)
|
|
303
|
+
spin = OptionSpinner(stack=stack, nav_keys=opts.nav_keys) # accept them in add_key()
|
|
304
|
+
spin.add_key('prev', '← - previous', genre='action', keys=[curses.KEY_LEFT])
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
A taken key is withdrawn from navigation dispatch for that window only: it no
|
|
308
|
+
longer scrolls, it is no longer advertised as navigation, and it takes the
|
|
309
|
+
normal `handled_keys`/`relax_handled_keys` path on return. Text editing is
|
|
310
|
+
unchanged — dialogs and widgets still use LEFT/RIGHT/HOME/END for the caret.
|
|
311
|
+
Because ownership lives in `ConsoleWindowOpts`, two windows in one process can
|
|
312
|
+
have different bindings; `NAVIGATION_KEYS` itself is never mutated. Taking every
|
|
313
|
+
vertical key is allowed (and logged as "reduced navigation"), since the
|
|
314
|
+
application is then responsible for its own scrolling.
|
|
315
|
+
|
|
194
316
|
`ScreenStack.perform_actions()` is the normal router: it walks the spinner
|
|
195
317
|
options, honours their scopes, and calls the matching `<attr>_ACTION` method.
|
|
196
318
|
For apps that dispatch an action themselves, `ScreenStack.act_in(action,
|
|
@@ -9,12 +9,12 @@ from types import SimpleNamespace
|
|
|
9
9
|
|
|
10
10
|
from console_window import (
|
|
11
11
|
BasicHelpScreen,
|
|
12
|
+
BasicTextScreen,
|
|
12
13
|
ConsoleWindow,
|
|
13
14
|
ConsoleWindowOpts,
|
|
14
15
|
Context,
|
|
15
16
|
HeaderLine,
|
|
16
17
|
IncrementalSearchBar,
|
|
17
|
-
InlineConfirmation,
|
|
18
18
|
OptionSpinner,
|
|
19
19
|
Screen,
|
|
20
20
|
ScreenStack,
|
|
@@ -38,9 +38,12 @@ class SampleApp:
|
|
|
38
38
|
self.filter_text = ''
|
|
39
39
|
self.selected_items = set() # Track "selected" items for demo
|
|
40
40
|
self.operation_count = 0
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
|
|
41
|
+
# In-place prompt state. PromptLine itself lives on the window; these
|
|
42
|
+
# are the application's product decisions about which row/item a live
|
|
43
|
+
# prompt belongs to (the window deliberately never learns that).
|
|
44
|
+
self.prompt_item = None
|
|
45
|
+
self.note_item = None
|
|
46
|
+
self.notes = {}
|
|
44
47
|
|
|
45
48
|
# Incremental search bar
|
|
46
49
|
self.filter_bar = IncrementalSearchBar(
|
|
@@ -109,6 +112,8 @@ class SampleApp:
|
|
|
109
112
|
genre='action', scope={MAIN_ST})
|
|
110
113
|
spin.add_key('alert_demo', 'l - alert demo',
|
|
111
114
|
genre='action', scope={MAIN_ST})
|
|
115
|
+
# In-place, buffered prompt rendered as a header row (see note_ACTION).
|
|
116
|
+
spin.add_key('note', 'N - add a note', genre='action', scope={MAIN_ST})
|
|
112
117
|
spin.add_key('show_keys', 'K - show keys (demo mode)', genre='action')
|
|
113
118
|
|
|
114
119
|
# Folded in from the retired dev self-test.
|
|
@@ -171,33 +176,19 @@ class SampleApp:
|
|
|
171
176
|
self.filter_text = original_text.lower() if original_text else ''
|
|
172
177
|
self.win.passthrough_mode = False
|
|
173
178
|
|
|
174
|
-
def _start_operation(self):
|
|
175
|
-
if self.confirmation.identity:
|
|
176
|
-
item_name = self.confirmation.identity
|
|
177
|
-
# Mark item as "processed"
|
|
178
|
-
self.selected_items.add(item_name)
|
|
179
|
-
self.operation_count += 1
|
|
180
|
-
# Flash success message
|
|
181
|
-
self.win.flash(f'✓ Processed {item_name}', duration=1.5)
|
|
182
|
-
self.confirmation.cancel()
|
|
183
|
-
self.win.passthrough_mode = False
|
|
184
|
-
|
|
185
179
|
def handle_key(self, key):
|
|
186
|
-
#
|
|
180
|
+
# A live in-place prompt owns every key. This is the documented
|
|
181
|
+
# contract: nothing underneath the prompt (filter, spinner actions)
|
|
182
|
+
# may fire until the prompt has been dealt with.
|
|
183
|
+
if self.win.prompt_active:
|
|
184
|
+
self.win.handle_prompt_key(key)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
# Handle filter bar NEXT (highest priority when active)
|
|
187
188
|
if self.filter_bar.is_active:
|
|
188
189
|
if self.filter_bar.handle_key(key):
|
|
189
190
|
return None # Key was consumed
|
|
190
191
|
|
|
191
|
-
# Handle confirmation mode SECOND (high priority when active)
|
|
192
|
-
if self.confirmation.active:
|
|
193
|
-
result = self.confirmation.handle_key(key)
|
|
194
|
-
if result == 'confirmed':
|
|
195
|
-
self._start_operation()
|
|
196
|
-
elif result == 'cancelled':
|
|
197
|
-
self.confirmation.cancel()
|
|
198
|
-
self.win.passthrough_mode = False
|
|
199
|
-
return None # Key was consumed by confirmation
|
|
200
|
-
|
|
201
192
|
# Handle ENTER for screen navigation
|
|
202
193
|
if key in (cs.KEY_ENTER, 10):
|
|
203
194
|
if self.stack.curr.num != MAIN_ST:
|
|
@@ -303,21 +294,14 @@ class MainScreen(DemoScreen):
|
|
|
303
294
|
ctx = Context(genre='item', item=item)
|
|
304
295
|
app.win.add_body(line, attr=attr, context=ctx)
|
|
305
296
|
|
|
306
|
-
#
|
|
307
|
-
#
|
|
308
|
-
# the
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if app.confirmation.mode == 'y':
|
|
313
|
-
prompt += " - Press 'y' or ESC"
|
|
314
|
-
elif app.confirmation.mode == 'Y':
|
|
315
|
-
prompt += " - Press 'Y' or ESC"
|
|
316
|
-
|
|
317
|
-
app.win.add_body(
|
|
318
|
-
app.confirmation.get_display_string(prompt=prompt),
|
|
297
|
+
# Draw the in-place confirmation right after the row it belongs to.
|
|
298
|
+
# The wording and warning glyph stay here (product decisions); the
|
|
299
|
+
# window's prompt helper owns the display composition and marks both
|
|
300
|
+
# rows TRANSIENT/non-pickable for us.
|
|
301
|
+
if app.win.prompt_active and app.prompt_item == username:
|
|
302
|
+
app.win.add_prompt_lines(
|
|
319
303
|
attr=cs.color_pair(Theme.WARNING) | cs.A_BOLD,
|
|
320
|
-
|
|
304
|
+
hint_attr=cs.A_DIM,
|
|
321
305
|
)
|
|
322
306
|
|
|
323
307
|
if app.opts.stress:
|
|
@@ -361,6 +345,7 @@ class MainScreen(DemoScreen):
|
|
|
361
345
|
line.text(' ').key('SPC').text(':process')
|
|
362
346
|
# line.text(' ').bracket_key('p').text(f'ick={app.opts.pick_size}')
|
|
363
347
|
line.text(' ').bracket_key('n').text(f'ame={app.opts.name or "-"}')
|
|
348
|
+
line.text(' ').bracket_key('N').text(':note')
|
|
364
349
|
# line.text(' ').key('r').text('edraw')
|
|
365
350
|
# line.text(' ').key('z').text(':stress')
|
|
366
351
|
# line.text(' ').bracket_key('t').text('hemes')
|
|
@@ -384,6 +369,12 @@ class MainScreen(DemoScreen):
|
|
|
384
369
|
|
|
385
370
|
app.win.add_header(header, attr=cs.A_DIM | cs.A_UNDERLINE)
|
|
386
371
|
|
|
372
|
+
# A buffered note prompt is a screen-wide question, so it lives in the
|
|
373
|
+
# header band rather than under one row. add_prompt_header() renders
|
|
374
|
+
# the library's fixed ': buffer│' format.
|
|
375
|
+
if app.win.prompt_active and app.note_item:
|
|
376
|
+
app.win.add_prompt_header()
|
|
377
|
+
|
|
387
378
|
def filter_ACTION(self):
|
|
388
379
|
app = self.app
|
|
389
380
|
app.filter_bar.start(app.filter_text)
|
|
@@ -397,16 +388,68 @@ class MainScreen(DemoScreen):
|
|
|
397
388
|
app.filter_bar._text = '' # pylint: disable=protected-access
|
|
398
389
|
app.filter_text = ''
|
|
399
390
|
app.win.pick_pos = 0
|
|
391
|
+
app.win.status('Filter cleared', timeout=1.0)
|
|
400
392
|
|
|
401
393
|
def select_ACTION(self):
|
|
402
394
|
app = self.app
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
395
|
+
win = app.win
|
|
396
|
+
ctx = win.get_picked_context()
|
|
397
|
+
if not ctx or not hasattr(ctx, 'item'):
|
|
398
|
+
return
|
|
399
|
+
username = ctx.item['username']
|
|
400
|
+
app.prompt_item = username
|
|
401
|
+
|
|
402
|
+
def confirm(_answer):
|
|
403
|
+
app.prompt_item = None
|
|
404
|
+
app.selected_items.add(username)
|
|
405
|
+
app.operation_count += 1
|
|
406
|
+
# Non-modal: the listing stays visible on the next render.
|
|
407
|
+
win.status(f'✓ Processed {username}', timeout=1.5)
|
|
408
|
+
|
|
409
|
+
def cancel():
|
|
410
|
+
app.prompt_item = None
|
|
411
|
+
win.status('Cancelled', timeout=1.0)
|
|
412
|
+
|
|
413
|
+
# Single-key mode 'y'; the window enables passthrough for us and the
|
|
414
|
+
# callbacks run after the prompt is gone (so status() is safe here).
|
|
415
|
+
win.begin_prompt(
|
|
416
|
+
f' ⚠️ Process {username}?',
|
|
417
|
+
mode='y',
|
|
418
|
+
hint='Marks the row as ✓ Processed',
|
|
419
|
+
on_confirm=confirm,
|
|
420
|
+
on_cancel=cancel,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def note_ACTION(self):
|
|
424
|
+
app = self.app
|
|
425
|
+
win = app.win
|
|
426
|
+
ctx = win.get_picked_context()
|
|
427
|
+
if not ctx or not hasattr(ctx, 'item'):
|
|
428
|
+
win.status('Select an item first', level='warn', timeout=2.0)
|
|
429
|
+
return
|
|
430
|
+
username = ctx.item['username']
|
|
431
|
+
app.note_item = username
|
|
432
|
+
|
|
433
|
+
def save(answer):
|
|
434
|
+
app.note_item = None
|
|
435
|
+
app.notes[username] = answer
|
|
436
|
+
win.status(f'✓ Note saved for {username}', level='warn',
|
|
437
|
+
timeout=2.0)
|
|
438
|
+
|
|
439
|
+
def cancel():
|
|
440
|
+
app.note_item = None
|
|
441
|
+
win.status('Note cancelled', timeout=1.5)
|
|
442
|
+
|
|
443
|
+
# Buffered text mode. add_prompt_header() draws it in the header band,
|
|
444
|
+
# including the caret glyph and the fixed ': ' separator.
|
|
445
|
+
win.begin_prompt(
|
|
446
|
+
f'Note for {username}',
|
|
447
|
+
hint='ENTER to save, ESC to cancel',
|
|
448
|
+
mode='text',
|
|
449
|
+
seed=app.notes.get(username, ''),
|
|
450
|
+
on_confirm=save,
|
|
451
|
+
on_cancel=cancel,
|
|
452
|
+
)
|
|
410
453
|
|
|
411
454
|
def answer_demo_ACTION(self):
|
|
412
455
|
app = self.app
|
|
@@ -414,9 +457,9 @@ class MainScreen(DemoScreen):
|
|
|
414
457
|
response = win.answer('Enter your name (or ESC to cancel): ',
|
|
415
458
|
height=1)
|
|
416
459
|
if response:
|
|
417
|
-
win.
|
|
460
|
+
win.status(f'Hello, {response}!', level='warn', timeout=2.0)
|
|
418
461
|
else:
|
|
419
|
-
win.
|
|
462
|
+
win.status('Cancelled', timeout=1.0)
|
|
420
463
|
|
|
421
464
|
def alert_demo_ACTION(self):
|
|
422
465
|
ctx = self.app.win.get_picked_context()
|
|
@@ -472,32 +515,34 @@ class HelpScreen(DemoScreen, BasicHelpScreen):
|
|
|
472
515
|
class HistoryScreen(DemoScreen):
|
|
473
516
|
def __init__(self, app):
|
|
474
517
|
super().__init__('HISTORY', app)
|
|
518
|
+
# Constructed before the ConsoleWindow exists, exactly like the other
|
|
519
|
+
# screens: BasicTextScreen resolves app.win at draw time.
|
|
520
|
+
self.text_screen = BasicTextScreen(app)
|
|
475
521
|
|
|
476
522
|
def draw_screen(self):
|
|
477
523
|
app = self.app
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
app.win.add_header('OPERATION HISTORY', attr=cs.A_BOLD)
|
|
481
|
-
app.win.add_header(' Press ESC or ENTER to return',
|
|
482
|
-
attr=cs.A_DIM, resume=True)
|
|
483
|
-
|
|
524
|
+
lines = [(' Press ESC or ENTER to return', cs.A_DIM), '']
|
|
484
525
|
if app.selected_items:
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
app.win.add_body('')
|
|
526
|
+
lines.append(f'Total processed items: {len(app.selected_items)}')
|
|
527
|
+
lines.append('')
|
|
488
528
|
for username in sorted(app.selected_items):
|
|
489
529
|
# Find the item details
|
|
490
530
|
item = next((i for i in app.items
|
|
491
531
|
if i['username'] == username), None)
|
|
492
532
|
if item:
|
|
493
|
-
line = f' ✓ {username:<16} uid={item["uid"]:<8}
|
|
494
|
-
|
|
533
|
+
line = (f' ✓ {username:<16} uid={item["uid"]:<8} '
|
|
534
|
+
f'{item["home"]}')
|
|
535
|
+
lines.append((line, cs.color_pair(Theme.SUCCESS)))
|
|
495
536
|
else:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
537
|
+
lines.extend([
|
|
538
|
+
'No items have been processed yet.',
|
|
539
|
+
'',
|
|
540
|
+
'Go back to the main screen and press SPACE on an item '
|
|
541
|
+
'to process it.',
|
|
542
|
+
])
|
|
543
|
+
# Content is rebuilt each draw; nothing is cached across frames.
|
|
544
|
+
self.text_screen.set_text(lines, head=['OPERATION HISTORY'])
|
|
545
|
+
self.text_screen.draw_screen()
|
|
501
546
|
|
|
502
547
|
|
|
503
548
|
class ThemeScreen(Theme.create_picker_screen(DemoScreen)):
|
|
@@ -1,22 +1,29 @@
|
|
|
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.1.0"
|
|
4
4
|
|
|
5
5
|
from .context import Context
|
|
6
6
|
from .header import HeaderLine, Span
|
|
7
7
|
from .keys import NAVIGATION_KEYS
|
|
8
8
|
from .opts import ConsoleWindowOpts
|
|
9
|
-
from .screens import
|
|
9
|
+
from .screens import (
|
|
10
|
+
HOME_ST,
|
|
11
|
+
BasicHelpScreen,
|
|
12
|
+
BasicTextScreen,
|
|
13
|
+
Screen,
|
|
14
|
+
ScreenStack,
|
|
15
|
+
)
|
|
10
16
|
from .signals import reset_ctrl_c
|
|
11
17
|
from .spinner import OptionSpinner
|
|
12
18
|
from .theme import Theme
|
|
13
|
-
from .widgets import IncrementalSearchBar, InlineConfirmation
|
|
19
|
+
from .widgets import IncrementalSearchBar, InlineConfirmation, PromptLine
|
|
14
20
|
from .window import ConsoleWindow, TerminalTooSmallError
|
|
15
21
|
|
|
16
22
|
__all__ = [
|
|
17
23
|
'HOME_ST',
|
|
18
24
|
'NAVIGATION_KEYS',
|
|
19
25
|
'BasicHelpScreen',
|
|
26
|
+
'BasicTextScreen',
|
|
20
27
|
'ConsoleWindow',
|
|
21
28
|
'ConsoleWindowOpts',
|
|
22
29
|
'Context',
|
|
@@ -24,6 +31,7 @@ __all__ = [
|
|
|
24
31
|
'IncrementalSearchBar',
|
|
25
32
|
'InlineConfirmation',
|
|
26
33
|
'OptionSpinner',
|
|
34
|
+
'PromptLine',
|
|
27
35
|
'Screen',
|
|
28
36
|
'ScreenStack',
|
|
29
37
|
'Span',
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
|
|
5
|
+
# Keys reserved for cursor movement and scrolling. By default
|
|
6
|
+
# OptionSpinner.add_key rejects these with ValueError and demo-mode
|
|
7
|
+
# highlighting ignores them. An application may take specific keys for its own
|
|
8
|
+
# use through ``ConsoleWindowOpts(taken_nav_keys=...)``; that withdraws them
|
|
9
|
+
# from navigation dispatch for that window only.
|
|
10
|
+
#
|
|
11
|
+
# The set is a plain ``set`` for 2.0 compatibility, but the library never
|
|
12
|
+
# mutates it. Per-window ownership lives in ``ConsoleWindowOpts``: pass
|
|
13
|
+
# ``taken_nav_keys`` to withdraw a key from *that* window's navigation
|
|
14
|
+
# dispatch, and read ``opts.nav_keys`` for the effective (immutable) set.
|
|
15
|
+
# Never monkeypatch this module global; two windows with different bindings
|
|
16
|
+
# would contaminate each other.
|
|
17
|
+
NAVIGATION_KEYS: set[int] = {
|
|
18
|
+
curses.KEY_UP, curses.KEY_DOWN, curses.KEY_LEFT, curses.KEY_RIGHT,
|
|
19
|
+
curses.KEY_HOME, curses.KEY_END, curses.KEY_PPAGE, curses.KEY_NPAGE,
|
|
20
|
+
ord('j'), ord('k'), # Vi-style up / down
|
|
21
|
+
ord('0'), ord('$'), # First / last row
|
|
22
|
+
ord('H'), ord('M'), ord('L'), # Top / middle / bottom of page
|
|
23
|
+
ord('\x15'), # Ctrl-U (half page up)
|
|
24
|
+
ord('\x04'), # Ctrl-D (half page down)
|
|
25
|
+
ord('\x02'), # Ctrl-B (page up)
|
|
26
|
+
ord('\x06'), # Ctrl-F (page down)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# The subset of NAVIGATION_KEYS that moves the viewport vertically. When an
|
|
30
|
+
# application takes all of them (often to implement its own scrolling), the
|
|
31
|
+
# window still constructs, but "reduced navigation" deserves a warning.
|
|
32
|
+
VERTICAL_NAV_KEYS: frozenset[int] = frozenset({
|
|
33
|
+
curses.KEY_UP, curses.KEY_DOWN,
|
|
34
|
+
curses.KEY_HOME, curses.KEY_END, curses.KEY_PPAGE, curses.KEY_NPAGE,
|
|
35
|
+
ord('j'), ord('k'), ord('0'), ord('$'), ord('H'), ord('M'), ord('L'),
|
|
36
|
+
ord('\x15'), ord('\x04'), ord('\x02'), ord('\x06'),
|
|
37
|
+
})
|