console-window 2.0.2__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.
Files changed (50) hide show
  1. {console_window-2.0.2 → console_window-2.1.0}/PKG-INFO +97 -5
  2. {console_window-2.0.2 → console_window-2.1.0}/README.md +96 -4
  3. {console_window-2.0.2 → console_window-2.1.0}/SampleApp.py +110 -65
  4. {console_window-2.0.2 → console_window-2.1.0}/console_window/__init__.py +11 -3
  5. {console_window-2.0.2 → console_window-2.1.0}/console_window/screens.py +59 -0
  6. {console_window-2.0.2 → console_window-2.1.0}/console_window/widgets.py +114 -1
  7. {console_window-2.0.2 → console_window-2.1.0}/console_window/window.py +198 -8
  8. {console_window-2.0.2 → console_window-2.1.0}/tests/CHANGELOG.md +41 -0
  9. console_window-2.1.0/tests/test_prompt_line.py +133 -0
  10. console_window-2.1.0/tests/test_prompt_window.py +152 -0
  11. {console_window-2.0.2 → console_window-2.1.0}/tests/test_public_api.py +2 -0
  12. {console_window-2.0.2 → console_window-2.1.0}/tests/test_sample_app.py +105 -0
  13. console_window-2.1.0/tests/test_screens.py +172 -0
  14. console_window-2.1.0/tests/test_status.py +240 -0
  15. {console_window-2.0.2 → console_window-2.1.0}/tests/test_widgets.py +10 -0
  16. console_window-2.0.2/tests/test_screens.py +0 -62
  17. {console_window-2.0.2 → console_window-2.1.0}/.github/workflows/ci.yml +0 -0
  18. {console_window-2.0.2 → console_window-2.1.0}/.gitignore +0 -0
  19. {console_window-2.0.2 → console_window-2.1.0}/LICENSE +0 -0
  20. {console_window-2.0.2 → console_window-2.1.0}/console_window/backend.py +0 -0
  21. {console_window-2.0.2 → console_window-2.1.0}/console_window/context.py +0 -0
  22. {console_window-2.0.2 → console_window-2.1.0}/console_window/geometry.py +0 -0
  23. {console_window-2.0.2 → console_window-2.1.0}/console_window/header.py +0 -0
  24. {console_window-2.0.2 → console_window-2.1.0}/console_window/keys.py +0 -0
  25. {console_window-2.0.2 → console_window-2.1.0}/console_window/opts.py +0 -0
  26. {console_window-2.0.2 → console_window-2.1.0}/console_window/signals.py +0 -0
  27. {console_window-2.0.2 → console_window-2.1.0}/console_window/spinner.py +0 -0
  28. {console_window-2.0.2 → console_window-2.1.0}/console_window/textwidth.py +0 -0
  29. {console_window-2.0.2 → console_window-2.1.0}/console_window/theme.py +0 -0
  30. {console_window-2.0.2 → console_window-2.1.0}/pyproject.toml +0 -0
  31. {console_window-2.0.2 → console_window-2.1.0}/runner +0 -0
  32. {console_window-2.0.2 → console_window-2.1.0}/tests/AUDIT.md +0 -0
  33. {console_window-2.0.2 → console_window-2.1.0}/tests/test_backend.py +0 -0
  34. {console_window-2.0.2 → console_window-2.1.0}/tests/test_context.py +0 -0
  35. {console_window-2.0.2 → console_window-2.1.0}/tests/test_demo_mode.py +0 -0
  36. {console_window-2.0.2 → console_window-2.1.0}/tests/test_dialogs.py +0 -0
  37. {console_window-2.0.2 → console_window-2.1.0}/tests/test_flash.py +0 -0
  38. {console_window-2.0.2 → console_window-2.1.0}/tests/test_geometry.py +0 -0
  39. {console_window-2.0.2 → console_window-2.1.0}/tests/test_header.py +0 -0
  40. {console_window-2.0.2 → console_window-2.1.0}/tests/test_interactive.py +0 -0
  41. {console_window-2.0.2 → console_window-2.1.0}/tests/test_low_colour.py +0 -0
  42. {console_window-2.0.2 → console_window-2.1.0}/tests/test_overflow.py +0 -0
  43. {console_window-2.0.2 → console_window-2.1.0}/tests/test_render_retry.py +0 -0
  44. {console_window-2.0.2 → console_window-2.1.0}/tests/test_signals.py +0 -0
  45. {console_window-2.0.2 → console_window-2.1.0}/tests/test_spinner.py +0 -0
  46. {console_window-2.0.2 → console_window-2.1.0}/tests/test_taken_nav_keys.py +0 -0
  47. {console_window-2.0.2 → console_window-2.1.0}/tests/test_textwidth.py +0 -0
  48. {console_window-2.0.2 → console_window-2.1.0}/tests/test_theme.py +0 -0
  49. {console_window-2.0.2 → console_window-2.1.0}/tests/test_viewport_pad.py +0 -0
  50. {console_window-2.0.2 → 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.2
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
- * **`InlineConfirmation`** — `SPACE` asks you to confirm with `y` before
108
- "processing" the picked row. The widget is also a curses-free line editor
109
- (`initial=''`, caret movement, Delete, and `get_display_string()`).
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()`** — transient, non-destructive status messages.
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
- * **`InlineConfirmation`** — `SPACE` asks you to confirm with `y` before
89
- "processing" the picked row. The widget is also a curses-free line editor
90
- (`initial=''`, caret movement, Delete, and `get_display_string()`).
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()`** — transient, non-destructive status messages.
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
- # Inline confirmation handler
43
- self.confirmation = InlineConfirmation()
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
- # Handle filter bar FIRST (highest priority when active)
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
- # 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.
309
- if app.confirmation.active and \
310
- app.confirmation.identity == username:
311
- prompt = f' ⚠️ Process {username}?'
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
- context=Context(genre='DECOR')
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
- ctx = app.win.get_picked_context()
404
- if ctx and hasattr(ctx, 'item'):
405
- item = ctx.item
406
- username = item['username']
407
- # Start inline confirmation (mode 'y' = press 'y' to confirm)
408
- app.confirmation.start('process', username, mode='y')
409
- app.win.passthrough_mode = True
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.flash(f'Hello, {response}!', duration=2.0)
460
+ win.status(f'Hello, {response}!', level='warn', timeout=2.0)
418
461
  else:
419
- win.flash('Cancelled', duration=1.0)
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
- app.win.set_pick_mode(False)
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
- app.win.add_body('')
486
- app.win.add_body(f'Total processed items: {len(app.selected_items)}')
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} {item["home"]}'
494
- app.win.add_body(line, attr=cs.color_pair(Theme.SUCCESS))
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
- app.win.add_body('')
497
- app.win.add_body('No items have been processed yet.')
498
- app.win.add_body('')
499
- app.win.add_body('Go back to the main screen and press '
500
- 'SPACE on an item to process it.')
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.2"
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 HOME_ST, BasicHelpScreen, Screen, ScreenStack
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',
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from types import SimpleNamespace
4
4
  from typing import TYPE_CHECKING, Protocol
5
5
 
6
+ from .header import HeaderLine
6
7
  from .spinner import OptionSpinner
7
8
 
8
9
  if TYPE_CHECKING: # pragma: no cover - typing only
@@ -247,3 +248,61 @@ class BasicHelpScreen(Screen):
247
248
  if spinner:
248
249
  spinner.show_help_nav_keys(win)
249
250
  spinner.show_help_body(win)
251
+
252
+
253
+ class BasicTextScreen(Screen):
254
+ """Read-only screen showing preformatted lines under optional headers.
255
+
256
+ The window is resolved at draw time: applications may build their Screen
257
+ objects before the ConsoleWindow exists (BasicHelpScreen relies on the same
258
+ rule). ``lines`` items are ``str`` or ``(str, attr)``; ``head`` is a
259
+ ``str``, a :class:`HeaderLine`, or an iterable of either. Content is
260
+ normalised by :meth:`set_text`, so ``draw_screen()`` is a flat loop and
261
+ nothing is cached across draws.
262
+
263
+ There is no pick mode, no action handling and no spinner. ESC/back stays
264
+ the application's ``escape_ACTION``, and ``come_from_whitelist`` stays
265
+ permissive by default.
266
+ """
267
+
268
+ come_from_whitelist = None
269
+
270
+ def __init__(self, app: _AppLike, lines=(), head=()) -> None:
271
+ super().__init__(app)
272
+ self._lines: list[tuple[str, int | None]] = []
273
+ self._heads: list[str | HeaderLine] = []
274
+ self.set_text(lines, head)
275
+
276
+ def set_text(self, lines=(), head=()) -> None:
277
+ """Replace the screen's content. Safe before or after the first draw."""
278
+ self._lines = [self._normalize_line(item) for item in (lines or ())]
279
+ self._heads = self._normalize_heads(head)
280
+
281
+ @staticmethod
282
+ def _normalize_line(item) -> tuple[str, int | None]:
283
+ if isinstance(item, str):
284
+ return item, None
285
+ text, attr = item
286
+ return str(text), attr
287
+
288
+ @staticmethod
289
+ def _normalize_heads(head) -> list[str | HeaderLine]:
290
+ if head is None:
291
+ return []
292
+ if isinstance(head, (str, HeaderLine)):
293
+ return [head]
294
+ return list(head)
295
+
296
+ def draw_screen(self) -> None:
297
+ win = self.app.win
298
+ win.set_pick_mode(False)
299
+ for head_item in self._heads:
300
+ if isinstance(head_item, HeaderLine):
301
+ # Keep the declared span roles; add_header() takes plain text.
302
+ win.add_header_line(head_item)
303
+ else:
304
+ win.add_header(head_item)
305
+ for text, attr in self._lines:
306
+ # No Context: these are plain, scrollable rows (the same shape
307
+ # ViewScreen/CompareScreen produced before this helper existed).
308
+ win.add_body(text, attr=attr)