console-window 2.0.2__tar.gz → 2.1.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.2 → console_window-2.1.1}/PKG-INFO +97 -5
- {console_window-2.0.2 → console_window-2.1.1}/README.md +96 -4
- {console_window-2.0.2 → console_window-2.1.1}/SampleApp.py +110 -65
- {console_window-2.0.2 → console_window-2.1.1}/console_window/__init__.py +11 -3
- {console_window-2.0.2 → console_window-2.1.1}/console_window/screens.py +68 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/widgets.py +116 -1
- {console_window-2.0.2 → console_window-2.1.1}/console_window/window.py +200 -8
- {console_window-2.0.2 → console_window-2.1.1}/tests/CHANGELOG.md +41 -0
- console_window-2.1.1/tests/test_prompt_line.py +133 -0
- console_window-2.1.1/tests/test_prompt_window.py +152 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_public_api.py +2 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_sample_app.py +105 -0
- console_window-2.1.1/tests/test_screens.py +172 -0
- console_window-2.1.1/tests/test_status.py +240 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_widgets.py +10 -0
- console_window-2.0.2/tests/test_screens.py +0 -62
- {console_window-2.0.2 → console_window-2.1.1}/.github/workflows/ci.yml +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/.gitignore +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/LICENSE +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/backend.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/context.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/geometry.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/header.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/keys.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/opts.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/signals.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/spinner.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/textwidth.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/console_window/theme.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/pyproject.toml +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/runner +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/AUDIT.md +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_backend.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_context.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_demo_mode.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_dialogs.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_flash.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_geometry.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_header.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_interactive.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_low_colour.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_overflow.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_render_retry.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_signals.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_spinner.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_taken_nav_keys.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_textwidth.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_theme.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_viewport_pad.py +0 -0
- {console_window-2.0.2 → console_window-2.1.1}/tests/test_widechars.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: console-window
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.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
|
|
@@ -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,90 @@ 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
|
+
|
|
213
305
|
### Taking navigation keys for the application
|
|
214
306
|
|
|
215
307
|
Navigation keys are reserved by default, but an app may take specific ones for
|
|
@@ -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,90 @@ 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
|
+
|
|
194
286
|
### Taking navigation keys for the application
|
|
195
287
|
|
|
196
288
|
Navigation keys are reserved by default, but an app may take specific ones for
|
|
@@ -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.
|
|
3
|
+
__version__ = "2.1.1"
|
|
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',
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Iterable
|
|
3
4
|
from types import SimpleNamespace
|
|
4
5
|
from typing import TYPE_CHECKING, Protocol
|
|
5
6
|
|
|
7
|
+
from .header import HeaderLine
|
|
6
8
|
from .spinner import OptionSpinner
|
|
7
9
|
|
|
8
10
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
@@ -14,6 +16,12 @@ class _AppLike(Protocol):
|
|
|
14
16
|
win: ConsoleWindow
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
#: One body row: plain text, or text paired with a curses attribute.
|
|
20
|
+
_LineItem = str | tuple[str, int | None]
|
|
21
|
+
#: One header entry, or a specification of one or more of them.
|
|
22
|
+
_HeadSpec = str | HeaderLine | Iterable[str | HeaderLine] | None
|
|
23
|
+
|
|
24
|
+
|
|
17
25
|
# Default home screen constant (applications should define their own)
|
|
18
26
|
HOME_ST = 0
|
|
19
27
|
|
|
@@ -247,3 +255,63 @@ class BasicHelpScreen(Screen):
|
|
|
247
255
|
if spinner:
|
|
248
256
|
spinner.show_help_nav_keys(win)
|
|
249
257
|
spinner.show_help_body(win)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class BasicTextScreen(Screen):
|
|
261
|
+
"""Read-only screen showing preformatted lines under optional headers.
|
|
262
|
+
|
|
263
|
+
The window is resolved at draw time: applications may build their Screen
|
|
264
|
+
objects before the ConsoleWindow exists (BasicHelpScreen relies on the same
|
|
265
|
+
rule). ``lines`` items are ``str`` or ``(str, attr)``; ``head`` is a
|
|
266
|
+
``str``, a :class:`HeaderLine`, or an iterable of either. Content is
|
|
267
|
+
normalised by :meth:`set_text`, so ``draw_screen()`` is a flat loop and
|
|
268
|
+
nothing is cached across draws.
|
|
269
|
+
|
|
270
|
+
There is no pick mode, no action handling and no spinner. ESC/back stays
|
|
271
|
+
the application's ``escape_ACTION``, and ``come_from_whitelist`` stays
|
|
272
|
+
permissive by default.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
come_from_whitelist = None
|
|
276
|
+
|
|
277
|
+
def __init__(self, app: _AppLike, lines: Iterable[_LineItem] = (),
|
|
278
|
+
head: _HeadSpec = ()) -> None:
|
|
279
|
+
super().__init__(app)
|
|
280
|
+
self._lines: list[tuple[str, int | None]] = []
|
|
281
|
+
self._heads: list[str | HeaderLine] = []
|
|
282
|
+
self.set_text(lines, head)
|
|
283
|
+
|
|
284
|
+
def set_text(self, lines: Iterable[_LineItem] = (),
|
|
285
|
+
head: _HeadSpec = ()) -> None:
|
|
286
|
+
"""Replace the screen's content. Safe before or after the first draw."""
|
|
287
|
+
self._lines = [self._normalize_line(item) for item in (lines or ())]
|
|
288
|
+
self._heads = self._normalize_heads(head)
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _normalize_line(item: _LineItem) -> tuple[str, int | None]:
|
|
292
|
+
if isinstance(item, str):
|
|
293
|
+
return item, None
|
|
294
|
+
text, attr = item
|
|
295
|
+
return str(text), attr
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _normalize_heads(head: _HeadSpec) -> list[str | HeaderLine]:
|
|
299
|
+
if head is None:
|
|
300
|
+
return []
|
|
301
|
+
if isinstance(head, (str, HeaderLine)):
|
|
302
|
+
return [head]
|
|
303
|
+
return list(head)
|
|
304
|
+
|
|
305
|
+
def draw_screen(self) -> None:
|
|
306
|
+
win = self.app.win
|
|
307
|
+
win.set_pick_mode(False)
|
|
308
|
+
for head_item in self._heads:
|
|
309
|
+
if isinstance(head_item, HeaderLine):
|
|
310
|
+
# Keep the declared span roles; add_header() takes plain text.
|
|
311
|
+
win.add_header_line(head_item)
|
|
312
|
+
else:
|
|
313
|
+
win.add_header(head_item)
|
|
314
|
+
for text, attr in self._lines:
|
|
315
|
+
# No Context: these are plain, scrollable rows (the same shape
|
|
316
|
+
# ViewScreen/CompareScreen produced before this helper existed).
|
|
317
|
+
win.add_body(text, attr=attr)
|