console-window 1.0.0__tar.gz → 1.0.2__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: console-window
3
- Version: 1.0.0
3
+ Version: 1.0.2
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.8
@@ -11,7 +11,6 @@ Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: POSIX :: Linux
12
12
  Classifier: Topic :: Terminals
13
13
  Classifier: Topic :: Software Development :: User Interfaces
14
- License-File: LICENSE
15
14
 
16
15
 
17
16
  # console-window
@@ -34,7 +33,9 @@ The wrapper handles several advanced terminal interaction patterns automatically
34
33
 
35
34
  * **Padded Content Management:** Uses separate `head` and `body` pads, allowing for a static header area while the main content area can be scrolled independently.
36
35
  * **Seamless Resizing:** Automatically detects terminal size changes (`KEY_RESIZE`) and recalculates layout dimensions, with robust error handling to prevent common `curses.error` crashes during resizing.
37
- * **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously. When using pick mode, scrolling automatically tracks the highlighted element to keep it visible.
36
+ * **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously.
37
+ * When using pick mode, scrolling automatically tracks the highlighted element to keep it visible in `.pick_pos`.
38
+ * To use that value, you must separately be able to associate `.pick_pos` to its value by, say, keeping an array of values for every added line to the `body`.
38
39
  * **Intuitive Navigation:** Provides built-in handling for common navigation keys (`j`/`k`, `UP`/`DOWN`, Page Up/Down, `H`/`M`/`L` for home/middle/last viewable line, etc.).
39
40
  * **Dynamic Options Management (`OptionSpinner`):** The companion class simplifies creation of application settings, allowing users to **toggle options** or input strings using single keypresses (e.g., `p` to toggle pick mode).
40
41
  * **Blocking Popups:** Includes fully implemented, blocking pop-up methods for user input (`.answer()`) and alerts (`.alert()`), handling the necessary temporary screen takeover and cursor management.
@@ -45,7 +46,7 @@ This example demonstrates setting up the window, using the `OptionSpinner` for c
45
46
 
46
47
  ```python
47
48
  import curses
48
- from ConsoleWindow import Window, OptionSpinner, SimpleNamespace
49
+ from console_window import ConsoleWindow, OptionSpinner
49
50
 
50
51
  def main_app_loop(stdscr):
51
52
  # 1. Setup Options Manager
@@ -58,8 +59,10 @@ def main_app_loop(stdscr):
58
59
  spin.add_key('pick_size', 's - #rows in pick', vals=[1, 2, 3])
59
60
  spin.add_key('name', 'n - select name', prompt='Provide Your Name:')
60
61
 
62
+ other_keys = {ord('q'), ord('Q')}
63
+
61
64
  # 2. Initialize Window
62
- win = Window(head_line=True, keys=spin.keys)
65
+ win = ConsoleWindow(head_line=True, keys=spin.keys^other_keys)
63
66
  opts.name = "[hit 'n' to enter name]"
64
67
  loop_count = 0
65
68
 
@@ -104,4 +107,16 @@ if __name__ == '__main__':
104
107
  ## License
105
108
  MIT
106
109
 
110
+ ## Projects Using console-window
111
+ For more extensive examples, you can look at some projects using `console-window`:
112
+
113
+ * [efibootdude](https://github.com/joedefen/efibootdude)
114
+ * simple wrapper for `efibootmgr`
115
+ * one of the smaller projects
116
+ * demonstrates a context sensitive available keys lines (i.e., first line) where the actions depend on current line.
117
+ * [my-snaps](https://github.com/joedefen/my-snaps)
118
+ * special purpose BTRFS snap manager
119
+ * [vappman](https://github.com/joedefen/vappman)
120
+ * convenince wrapper to mostly hide CLI of [AppMan](https://github.com/ivan-hc/AppMan), a life-cycle manager for 2000+ AppImages
121
+
107
122
 
@@ -19,7 +19,9 @@ The wrapper handles several advanced terminal interaction patterns automatically
19
19
 
20
20
  * **Padded Content Management:** Uses separate `head` and `body` pads, allowing for a static header area while the main content area can be scrolled independently.
21
21
  * **Seamless Resizing:** Automatically detects terminal size changes (`KEY_RESIZE`) and recalculates layout dimensions, with robust error handling to prevent common `curses.error` crashes during resizing.
22
- * **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously. When using pick mode, scrolling automatically tracks the highlighted element to keep it visible.
22
+ * **Scroll & Pick Synchronization:** The `Window` class manages **scroll position** and a separate **pick position** (highlighted row) simultaneously.
23
+ * When using pick mode, scrolling automatically tracks the highlighted element to keep it visible in `.pick_pos`.
24
+ * To use that value, you must separately be able to associate `.pick_pos` to its value by, say, keeping an array of values for every added line to the `body`.
23
25
  * **Intuitive Navigation:** Provides built-in handling for common navigation keys (`j`/`k`, `UP`/`DOWN`, Page Up/Down, `H`/`M`/`L` for home/middle/last viewable line, etc.).
24
26
  * **Dynamic Options Management (`OptionSpinner`):** The companion class simplifies creation of application settings, allowing users to **toggle options** or input strings using single keypresses (e.g., `p` to toggle pick mode).
25
27
  * **Blocking Popups:** Includes fully implemented, blocking pop-up methods for user input (`.answer()`) and alerts (`.alert()`), handling the necessary temporary screen takeover and cursor management.
@@ -30,7 +32,7 @@ This example demonstrates setting up the window, using the `OptionSpinner` for c
30
32
 
31
33
  ```python
32
34
  import curses
33
- from ConsoleWindow import Window, OptionSpinner, SimpleNamespace
35
+ from console_window import ConsoleWindow, OptionSpinner
34
36
 
35
37
  def main_app_loop(stdscr):
36
38
  # 1. Setup Options Manager
@@ -43,8 +45,10 @@ def main_app_loop(stdscr):
43
45
  spin.add_key('pick_size', 's - #rows in pick', vals=[1, 2, 3])
44
46
  spin.add_key('name', 'n - select name', prompt='Provide Your Name:')
45
47
 
48
+ other_keys = {ord('q'), ord('Q')}
49
+
46
50
  # 2. Initialize Window
47
- win = Window(head_line=True, keys=spin.keys)
51
+ win = ConsoleWindow(head_line=True, keys=spin.keys^other_keys)
48
52
  opts.name = "[hit 'n' to enter name]"
49
53
  loop_count = 0
50
54
 
@@ -89,3 +93,15 @@ if __name__ == '__main__':
89
93
  ## License
90
94
  MIT
91
95
 
96
+ ## Projects Using console-window
97
+ For more extensive examples, you can look at some projects using `console-window`:
98
+
99
+ * [efibootdude](https://github.com/joedefen/efibootdude)
100
+ * simple wrapper for `efibootmgr`
101
+ * one of the smaller projects
102
+ * demonstrates a context sensitive available keys lines (i.e., first line) where the actions depend on current line.
103
+ * [my-snaps](https://github.com/joedefen/my-snaps)
104
+ * special purpose BTRFS snap manager
105
+ * [vappman](https://github.com/joedefen/vappman)
106
+ * convenince wrapper to mostly hide CLI of [AppMan](https://github.com/ivan-hc/AppMan), a life-cycle manager for 2000+ AppImages
107
+
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
3
  """
4
- Custom Wrapper for python curses.
4
+ Custom Wrapper for python curses, providing structured window management
5
+ and an interactive option/key handler.
6
+
7
+ This module provides two main classes: :py:class:`ConsoleWindow` for handling
8
+ the screen, scrolling, and input; and :py:class:`OptionSpinner` for managing
9
+ a set of key-driven application settings.
5
10
  """
6
11
  # pylint: disable=too-many-instance-attributes,too-many-arguments
7
12
  # pylint: disable=invalid-name,broad-except,too-many-branches,global-statement
@@ -16,27 +21,42 @@ from types import SimpleNamespace
16
21
  from curses.textpad import rectangle, Textbox
17
22
  dump_str = None
18
23
 
24
+ ctrl_c_flag = False
25
+
26
+ def ctrl_c_handler(sig, frame):
27
+ """
28
+ Custom handler for SIGINT (Ctrl-C).
29
+ Sets a global flag to be checked by the main input loop.
30
+ """
31
+ global ctrl_c_flag
32
+ ctrl_c_flag = True
33
+
19
34
  def ignore_ctrl_c():
20
- """" Ignore SIGINT """
35
+ """
36
+ Ignores the **SIGINT** signal (Ctrl-C) to prevent immediate termination.
37
+ Used during curses operation.
38
+ """
21
39
  signal.signal(signal.SIGINT, signal.SIG_IGN)
22
40
 
23
41
  def restore_ctrl_c():
24
- """" Handle SIGINT """
42
+ """
43
+ Restores the default signal handler for **SIGINT** (Ctrl-C).
44
+ Called upon curses shutdown.
45
+ """
25
46
  signal.signal(signal.SIGINT, signal.default_int_handler)
26
47
 
27
48
  class OptionSpinner:
28
- """Manage a bunch of options where the value is rotate thru
29
- a fixed set of values pressing a key."""
30
- def __init__(self):
31
- """Give the object with the attribute to change its
32
- value (e.g., options from argparse or "self" from
33
- the object managing the window).
49
+ """
50
+ Manages a set of application options where the value can be rotated through
51
+ a fixed set of values (spinner) or requested via a dialog box (prompt) by
52
+ pressing a single key.
34
53
 
35
- And array of specs like:
36
- ['a - allow auto suggestions', 'allow_auto', True, False],
37
- ['/ - filter pattern', 'filter_str', self.filter_str],
38
- A spec can have a trailing None + more comments shown after
39
- the value.
54
+ It also generates a formatted help screen based on the registered options.
55
+ """
56
+ def __init__(self):
57
+ """
58
+ Initializes the OptionSpinner, setting up internal mappings for options
59
+ and keys.
40
60
  """
41
61
  self.options, self.keys = [], []
42
62
  self.margin = 4 # + actual width (1st column right pos)
@@ -48,6 +68,7 @@ class OptionSpinner:
48
68
 
49
69
  @staticmethod
50
70
  def _make_option_ns():
71
+ """Internal helper to create a default namespace for an option."""
51
72
  return SimpleNamespace(
52
73
  keys=[],
53
74
  descr='',
@@ -59,7 +80,17 @@ class OptionSpinner:
59
80
  )
60
81
 
61
82
  def get_value(self, attr, coerce=False):
62
- """Get the value of the given attribute."""
83
+ """
84
+ Get the current value of the given attribute.
85
+
86
+ :param attr: The name of the attribute (e.g., 'help_mode').
87
+ :param coerce: If True, ensures the value is one of the valid 'vals'
88
+ or an empty string for prompted options.
89
+ :type attr: str
90
+ :type coerce: bool
91
+ :returns: The current value of the option attribute.
92
+ :rtype: Any
93
+ """
63
94
  ns = self.attr_to_option.get(attr, None)
64
95
  obj = ns.obj if ns else None
65
96
  value = getattr(obj, attr, None) if obj else None
@@ -75,7 +106,7 @@ class OptionSpinner:
75
106
  return value
76
107
 
77
108
  def _register(self, ns):
78
- """ Create the mappings needed"""
109
+ """Create the internal mappings needed for a new option namespace."""
79
110
  assert ns.attr not in self.attr_to_option
80
111
  self.attr_to_option[ns.attr] = ns
81
112
  for key in ns.keys:
@@ -87,7 +118,22 @@ class OptionSpinner:
87
118
  self.get_value(ns.attr, coerce=True)
88
119
 
89
120
  def add(self, obj, specs):
90
- """ Compatibility Method."""
121
+ """
122
+ **Compatibility Method.** Adds options using an older array-of-specs format.
123
+
124
+ A spec is a list or tuple like::
125
+
126
+ ['a - allow auto suggestions', 'allow_auto', True, False],
127
+ ['/ - filter pattern', 'filter_str', self.filter_str],
128
+
129
+ The key is derived from the first character of the description string.
130
+ It is recommended to use :py:meth:`add_key` for new code.
131
+
132
+ :param obj: The object holding the option attributes (e.g., an argparse namespace).
133
+ :param specs: An iterable of option specifications.
134
+ :type obj: Any
135
+ :type specs: list
136
+ """
91
137
  for spec in specs:
92
138
  ns = self._make_option_ns()
93
139
  ns.descr = spec[0]
@@ -102,7 +148,32 @@ class OptionSpinner:
102
148
  self._register(ns)
103
149
 
104
150
  def add_key(self, attr, descr, obj=None, vals=None, prompt=None, keys=None, comments=None):
105
- """ Standard method"""
151
+ """
152
+ Adds an option that is toggled by a key press.
153
+
154
+ The option can be a **spinner** (rotates through a list of ``vals``) or
155
+ a **prompt** (requests string input via a dialog).
156
+
157
+ :param attr: The name of the attribute for the value; referenced as ``obj.attr``.
158
+ :param descr: The description of the key (for help screen).
159
+ :param obj: The object holding the value. If None, uses ``self.default_obj``.
160
+ :param vals: A list of values. If provided, the option is a spinner.
161
+ :param prompt: A prompt string. If provided instead of ``vals``, the key press
162
+ will call :py:meth:`ConsoleWindow.answer`.
163
+ :param keys: A single key code or a list of key codes (integers or characters)
164
+ that will trigger this option. If None, uses the first letter of
165
+ ``descr``.
166
+ :param comments: Additional line(s) for the help screen item (string or list of strings).
167
+ :type attr: str
168
+ :type descr: str
169
+ :type obj: Any
170
+ :type vals: list or None
171
+ :type prompt: str or None
172
+ :type keys: int or list or tuple or None
173
+ :type comments: str or list or tuple or None
174
+ :raises AssertionError: If both ``vals`` and ``prompt`` are provided, or neither is.
175
+ :raises AssertionError: If a key is already registered.
176
+ """
106
177
  ns = self._make_option_ns()
107
178
  if keys:
108
179
  ns.keys = list(keys) if isinstance(keys, (list, tuple, set)) else [keys]
@@ -116,18 +187,29 @@ class OptionSpinner:
116
187
  ns.attr = attr
117
188
  ns.obj = obj if obj else self.default_obj
118
189
  ns.vals, ns.prompt = vals, prompt
119
- assert bool(ns.vals) ^ bool(ns.prompt)
190
+ assert bool(ns.vals) ^ bool(ns.prompt) # Must be EITHER vals OR prompt
120
191
  self._register(ns)
121
192
 
122
193
  @staticmethod
123
194
  def show_help_nav_keys(win):
124
- """For help screens, show the navigation keys. """
195
+ """
196
+ Displays the standard navigation keys blurb in the provided ConsoleWindow.
197
+
198
+ :param win: The :py:class:`ConsoleWindow` instance to write to.
199
+ :type win: ConsoleWindow
200
+ """
125
201
  for line in ConsoleWindow.get_nav_keys_blurb().splitlines():
126
202
  if line:
127
203
  win.add_header(line)
128
204
 
129
205
  def show_help_body(self, win):
130
- """ Write the help page section."""
206
+ """
207
+ Writes the formatted list of all registered options and their current
208
+ values to the body of the provided :py:class:`ConsoleWindow`.
209
+
210
+ :param win: The :py:class:`ConsoleWindow` instance to write to.
211
+ :type win: ConsoleWindow
212
+ """
131
213
  win.add_body('Type keys to alter choice:', curses.A_UNDERLINE)
132
214
 
133
215
  for ns in self.options:
@@ -150,7 +232,19 @@ class OptionSpinner:
150
232
  win.add_body(f'{"":>{self.align}}: {comment}')
151
233
 
152
234
  def do_key(self, key, win):
153
- """Do the automated processing of a key."""
235
+ """
236
+ Processes a registered key press.
237
+
238
+ If the option is a spinner, it rotates to the next value. If it
239
+ requires a prompt, it calls ``win.answer()`` to get user input.
240
+
241
+ :param key: The key code received from :py:meth:`ConsoleWindow.prompt`.
242
+ :param win: The :py:class:`ConsoleWindow` instance for dialogs.
243
+ :type key: int
244
+ :type win: ConsoleWindow
245
+ :returns: The new value of the option, or None if the key is unhandled.
246
+ :rtype: Any or None
247
+ """
154
248
  ns = self.key_to_option.get(key, None)
155
249
  if ns is None:
156
250
  return None
@@ -164,25 +258,68 @@ class OptionSpinner:
164
258
  return value
165
259
 
166
260
  class ConsoleWindow:
167
- """ Layer above curses to encapsulate what we need """
261
+ """
262
+ A high-level wrapper around the curses library that provides a structured
263
+ interface for terminal applications.
264
+
265
+ The screen is divided into a fixed-size **Header** area and a scrollable
266
+ **Body** area, separated by an optional line. It manages screen
267
+ initialization, cleanup, rendering, and user input including scrolling
268
+ and an optional item selection (pick) mode.
269
+ """
168
270
  timeout_ms = 200
169
271
  static_scr = None
170
272
  nav_keys = """
171
- Navigation: H/M/L: top/middle/end-of-page
172
- k, UP: up one row 0, HOME: first row
173
- j, DOWN: down one row $, END: last row
174
- Ctrl-u: half-page up Ctrl-b, PPAGE: page up
175
- Ctrl-d: half-page down Ctrl-f, NPAGE: page down
273
+ Navigation: H/M/L: top/middle/end-of-page
274
+ k, UP: up one row 0, HOME: first row
275
+ j, DOWN: down one row $, END: last row
276
+ Ctrl-u: half-page up Ctrl-b, PPAGE: page up
277
+ Ctrl-d: half-page down Ctrl-f, NPAGE: page down
176
278
  """
177
279
  def __init__(self, head_line=True, head_rows=50, body_rows=200,
178
280
  body_cols=200, keys=None, pick_mode=False, pick_size=1,
179
281
  mod_pick=None, ctrl_c_terminates=True):
282
+ """
283
+ Initializes the ConsoleWindow, sets up internal pads, and starts curses mode.
284
+
285
+ :param head_line: If True, draws a horizontal line between header and body.
286
+ :param head_rows: The maximum capacity of the internal header pad.
287
+ :param body_rows: The maximum capacity of the internal body pad (scroll history).
288
+ :param body_cols: The maximum width for content pads.
289
+ :param keys: A collection of key codes to be explicitly returned by :py:meth:`prompt`.
290
+ :param pick_mode: If True, enables item highlighting/selection mode in the body.
291
+ :param pick_size: The number of rows to be highlighted as a single 'pick' unit.
292
+ :param mod_pick: An optional callable to modify the highlighted text before drawing.
293
+ :param ctrl_c_terminates: If True (default), Ctrl-C terminates the application
294
+ (SIGINT is ignored). If False, Ctrl-C is caught by
295
+ a signal handler and reported as key code 3.
296
+ :type head_line: bool
297
+ :type head_rows: int
298
+ :type body_rows: int
299
+ :type body_cols: int
300
+ :type keys: list or set or None
301
+ :type pick_mode: bool
302
+ :type pick_size: int
303
+ :type mod_pick: callable or None
304
+ :type ctrl_c_terminates: bool
305
+ """
306
+ # Modify signal handlers based on user choice
307
+ global ignore_ctrl_c, restore_ctrl_c
180
308
  if ctrl_c_terminates:
181
309
  # then never want to ignore_ctrl_c (so defeat the ignorer/restorer)
182
- global ignore_ctrl_c, restore_ctrl_c
183
310
  def noop():
184
311
  return
185
312
  ignore_ctrl_c = restore_ctrl_c = noop
313
+ self.ctrl_c_terminates = ctrl_c_terminates
314
+ else:
315
+ # If not terminating, override the original signal functions
316
+ # to set the custom handler, which will pass key 3 via the flag.
317
+ def _setup_ctrl_c():
318
+ signal.signal(signal.SIGINT, ctrl_c_handler)
319
+ def _restore_ctrl_c():
320
+ signal.signal(signal.SIGINT, signal.default_int_handler)
321
+ ignore_ctrl_c = _setup_ctrl_c
322
+ restore_ctrl_c = _restore_ctrl_c
186
323
 
187
324
  self.scr = self._start_curses()
188
325
 
@@ -213,20 +350,32 @@ class ConsoleWindow:
213
350
  self.body_cols, self.body_rows = body_cols, body_rows
214
351
  self.scroll_view_size = 0 # no. viewable lines of the body
215
352
  self.handled_keys = set(keys) if isinstance(keys, (set, list)) else []
353
+ self.pending_keys = set()
216
354
  self._set_screen_dims()
217
355
  self.calc()
218
356
 
219
357
  def get_pad_width(self):
220
- """ how much space to actually draw chars? """
358
+ """
359
+ Returns the maximum usable column width for content drawing.
360
+
361
+ :returns: The width in columns.
362
+ :rtype: int
363
+ """
221
364
  return min(self.cols-1, self.body_cols)
222
365
 
223
366
  @staticmethod
224
367
  def get_nav_keys_blurb():
225
- """For a help screen, describe the nav keys"""
368
+ """
369
+ Returns a multiline string describing the default navigation key bindings
370
+ for use in help screens.
371
+
372
+ :returns: String of navigation keys.
373
+ :rtype: str
374
+ """
226
375
  return textwrap.dedent(ConsoleWindow.nav_keys)
227
376
 
228
377
  def _set_screen_dims(self):
229
- """Recalculate dimensions ... return True if geometry changed."""
378
+ """Recalculate dimensions based on current terminal size."""
230
379
  rows, cols = self.scr.getmaxyx()
231
380
  same = bool(rows == self.rows and cols == self.cols)
232
381
  self.rows, self.cols = rows, cols
@@ -234,8 +383,14 @@ class ConsoleWindow:
234
383
 
235
384
  @staticmethod
236
385
  def _start_curses():
237
- """ Curses initial setup. Note: not using curses.wrapper because we
238
- don't wish to change the colors. """
386
+ """
387
+ Performs the Curses initial setup: initscr, noecho, cbreak, curs_set(0),
388
+ keypad(1), and sets up the timeout.
389
+
390
+ :returns: The main screen object.
391
+ :rtype: _curses.window
392
+ """
393
+ # The signal setup is handled in __init__ (via ignore_ctrl_c call below)
239
394
  atexit.register(ConsoleWindow.stop_curses)
240
395
  ignore_ctrl_c()
241
396
  ConsoleWindow.static_scr = scr = curses.initscr()
@@ -248,7 +403,17 @@ class ConsoleWindow:
248
403
  return scr
249
404
 
250
405
  def set_pick_mode(self, on=True, pick_size=1):
251
- """Set whether in highlight mode."""
406
+ """
407
+ Toggles the item highlighting/selection mode for the body area.
408
+
409
+ If pick mode is enabled or the pick size changes, it forces a redraw
410
+ of all body lines to clear any previous highlighting attributes.
411
+
412
+ :param on: If True, enables pick mode.
413
+ :param pick_size: The number of consecutive rows to highlight as one unit.
414
+ :type on: bool
415
+ :type pick_size: int
416
+ """
252
417
  was_on, was_size = self.pick_mode, self.pick_size
253
418
  self.pick_mode = bool(on)
254
419
  self.pick_size = max(pick_size, 1)
@@ -257,7 +422,10 @@ class ConsoleWindow:
257
422
 
258
423
  @staticmethod
259
424
  def stop_curses():
260
- """ Curses shutdown (registered to be called on exit). """
425
+ """
426
+ Curses shutdown (registered to be called on exit). Restores the terminal
427
+ to its pre-curses state.
428
+ """
261
429
  if ConsoleWindow.static_scr:
262
430
  curses.nocbreak()
263
431
  curses.echo()
@@ -267,7 +435,12 @@ class ConsoleWindow:
267
435
  restore_ctrl_c()
268
436
 
269
437
  def calc(self):
270
- """Recalculate dimensions ... return True if geometry changed."""
438
+ """
439
+ Recalculates the screen geometry, viewable areas, and maximum scroll position.
440
+
441
+ :returns: True if the screen geometry has changed, False otherwise.
442
+ :rtype: bool
443
+ """
271
444
  same = self._set_screen_dims()
272
445
  self.head.view_cnt = min(self.rows - self.hor_line_cnt, self.head.row_cnt)
273
446
  self.scroll_view_size = self.rows - self.head.view_cnt - self.hor_line_cnt
@@ -276,11 +449,11 @@ class ConsoleWindow:
276
449
  return not same
277
450
 
278
451
  def _put(self, ns, *args):
279
- """ Add text to head/body pad using its namespace. args:
280
- - bytes (converted to str/unicode)
281
- - str (unicode)
282
- - None (same as curses.A_NORMAL)
283
- - int (curses attribute)
452
+ """
453
+ Adds text to the head/body pad using a mixed argument list.
454
+
455
+ Allows interleaving of text (str/bytes) and curses attributes (int).
456
+ Text segments before an attribute are flushed with that attribute.
284
457
  """
285
458
  def flush(attr=None):
286
459
  nonlocal self, is_body, row, text, seg, first
@@ -313,15 +486,25 @@ class ConsoleWindow:
313
486
  ns.row_cnt += 1
314
487
 
315
488
  def put_head(self, *args):
316
- """ Put a line above the line."""
489
+ """
490
+ Adds a line of text to the header pad, supporting mixed text and attributes.
491
+
492
+ :param args: Mixed arguments of str/bytes (text) and int (curses attributes).
493
+ :type args: Any
494
+ """
317
495
  self._put(self.head, *args)
318
496
 
319
497
  def put_body(self, *args):
320
- """ Put a line below the line."""
498
+ """
499
+ Adds a line of text to the body pad, supporting mixed text and attributes.
500
+
501
+ :param args: Mixed arguments of str/bytes (text) and int (curses attributes).
502
+ :type args: Any
503
+ """
321
504
  self._put(self.body, *args)
322
505
 
323
506
  def _add(self, ns, text, attr=None, resume=False):
324
- """ Add text to head/body pad using its namespace"""
507
+ """Internal method to add text to pad using its namespace (simpler version of _put)."""
325
508
  is_body = bool(id(ns) == id(self.body))
326
509
  if ns.row_cnt < ns.rows:
327
510
  row = max(ns.row_cnt - (1 if resume else 0), 0)
@@ -339,17 +522,52 @@ class ConsoleWindow:
339
522
  ns.row_cnt += 1
340
523
 
341
524
  def add_header(self, text, attr=None, resume=False):
342
- """Add text to header"""
525
+ """
526
+ Adds a line of text to the header pad.
527
+
528
+ :param text: The text to add.
529
+ :param attr: Curses attribute (e.g., curses.A_BOLD).
530
+ :param resume: If True, adds the text to the current, incomplete line.
531
+ :type text: str
532
+ :type attr: int or None
533
+ :type resume: bool
534
+ """
343
535
  self._add(self.head, text, attr, resume)
344
536
 
345
537
  def add_body(self, text, attr=None, resume=False):
346
- """ Add text to body (below header and header line)"""
538
+ """
539
+ Adds a line of text to the body pad.
540
+
541
+ :param text: The text to add.
542
+ :param attr: Curses attribute (e.g., curses.A_BOLD).
543
+ :param resume: If True, adds the text to the current, incomplete line.
544
+ :type text: str
545
+ :type attr: int or None
546
+ :type resume: bool
547
+ """
347
548
  self._add(self.body, text, attr, resume)
348
549
 
349
550
  def draw(self, y, x, text, text_attr=None, width=None, leftpad=False, header=False):
350
- """Draws the given text (as utf-8 or unicode) at position (row=y,col=x)
351
- with optional text attributes and width.
352
- This is more compatible with my older, simpler Window class.
551
+ """
552
+ Draws the given text at a specific position (row=y, col=x) on a pad.
553
+
554
+ This method is useful for structured or overlay drawing, but is less
555
+ efficient than the standard add/put methods.
556
+
557
+ :param y: The row index on the pad.
558
+ :param x: The column index on the pad.
559
+ :param text: The text to draw (str or bytes).
560
+ :param text_attr: Optional curses attribute.
561
+ :param width: Optional fixed width for the drawn text (pads/truncates).
562
+ :param leftpad: If True and ``width`` is used, left-pads with spaces.
563
+ :param header: If True, draws to the header pad, otherwise to the body pad.
564
+ :type y: int
565
+ :type x: int
566
+ :type text: str or bytes
567
+ :type text_attr: int or None
568
+ :type width: int or None
569
+ :type leftpad: bool
570
+ :type header: bool
353
571
  """
354
572
  ns = self.head if header else self.body
355
573
  text_attr = text_attr if text_attr else curses.A_NORMAL
@@ -379,15 +597,15 @@ class ConsoleWindow:
379
597
  ns.texts[y] = ns.texts[y][:x].ljust(x) + uni + ns.texts[y][x+len(uni):]
380
598
  ns.pad.addstr(y, x, text, text_attr)
381
599
  except curses.error:
382
- # this sucks, but curses returns an error if drawing the last character
383
- # on the screen always. this can happen if resizing screen even if
384
- # special care is taken. So, we just ignore errors. Anyhow, you cannot
385
- # get decent error handling.
600
+ # curses errors on drawing the last character on the screen; ignore
386
601
  pass
387
602
 
388
603
 
389
604
  def highlight_picked(self):
390
- """Highlight the current pick and un-highlight the previous pick."""
605
+ """
606
+ Highlights the current selection and un-highlights the previous one.
607
+ Called internally during :py:meth:`render_once` when in pick mode.
608
+ """
391
609
  def get_text(pos):
392
610
  nonlocal self
393
611
  return self.body.texts[pos][0:self.cols] if pos < len(self.body.texts) else ''
@@ -414,10 +632,7 @@ class ConsoleWindow:
414
632
  self.last_pick_pos = pos1
415
633
 
416
634
  def _scroll_indicator_row(self):
417
- """ Compute the absolute scroll indicator row:
418
- - We want the top to be only when scroll_pos==0
419
- - We want the bottom to be only when scroll_pos=max_scroll_pos-1
420
- """
635
+ """Internal helper to compute the scroll indicator row position."""
421
636
  if self.max_scroll_pos <= 1:
422
637
  return self.body_base
423
638
  y2, y1 = self.scroll_view_size-1, 1
@@ -427,10 +642,7 @@ class ConsoleWindow:
427
642
  return min(self.body_base + int(max(pos, 0)), self.rows-1)
428
643
 
429
644
  def _scroll_indicator_col(self):
430
- """ Compute the absolute scroll indicator col:
431
- - We want the left to be only when scroll_pos==0
432
- - We want the right to be only when scroll_pos=max_scroll_pos-1
433
- """
645
+ """Internal helper to compute the scroll indicator column position."""
434
646
  if self.pick_mode:
435
647
  return self._calc_indicator(
436
648
  self.pick_pos, 0, self.body.row_cnt-1, 0, self.cols-1)
@@ -438,6 +650,7 @@ class ConsoleWindow:
438
650
  self.scroll_pos, 0, self.max_scroll_pos, 0, self.cols-1)
439
651
 
440
652
  def _calc_indicator(self, pos, pos0, pos9, ind0, ind9):
653
+ """Internal helper to calculate indicator position based on content position."""
441
654
  if self.max_scroll_pos <= 0:
442
655
  return -1 # not scrollable
443
656
  if pos9 - pos0 <= 0:
@@ -450,8 +663,12 @@ class ConsoleWindow:
450
663
  return min(max(ind, ind0+1), ind9-1)
451
664
 
452
665
  def render(self):
453
- """Draw everything added. In a loop cuz curses is a
454
- piece of shit."""
666
+ """
667
+ Draws the content of the pads to the visible screen.
668
+
669
+ This method wraps :py:meth:`render_once` in a loop to handle spurious
670
+ ``curses.error`` exceptions that can occur during screen resizing.
671
+ """
455
672
  for _ in range(128):
456
673
  try:
457
674
  self.render_once()
@@ -482,10 +699,16 @@ class ConsoleWindow:
482
699
 
483
700
 
484
701
  def fix_positions(self, delta=0):
485
- """ Ensure the vertical positions are on the playing field """
702
+ """
703
+ Ensures the vertical scroll and pick positions are within valid boundaries,
704
+ adjusting the scroll position to keep the pick cursor visible.
705
+
706
+ :param delta: An optional change in position (e.g., from key presses).
707
+ :type delta: int
708
+ :returns: The indent amount for the body content (1 if pick mode is active, 0 otherwise).
709
+ :rtype: int
710
+ """
486
711
  self.calc()
487
- # if self.scroll_view_size <= 0:
488
- # self.scr.refresh()
489
712
  if self.pick_mode:
490
713
  self.pick_pos += delta
491
714
  else:
@@ -519,7 +742,10 @@ class ConsoleWindow:
519
742
  return indent
520
743
 
521
744
  def render_once(self):
522
- """Draw everything added."""
745
+ """
746
+ Performs the actual rendering of header, horizontal line, and body pads.
747
+ Handles pick highlighting and scroll bar drawing.
748
+ """
523
749
 
524
750
  indent = self.fix_positions()
525
751
 
@@ -538,7 +764,7 @@ class ConsoleWindow:
538
764
  width = self.scroll_view_size/self.body.row_cnt*self.cols
539
765
  bot = max(int(round(ind_pos-width/2)), 1)
540
766
  top = min(int(round(ind_pos+width/2)), self.cols-1)
541
- cnt = top - bot
767
+ cnt = max(top - bot, 1)
542
768
  # self.scr.addstr(self.head.view_cnt, bot, '-'*cnt, curses.A_REVERSE)
543
769
  # self.scr.hline(self.head.view_cnt, bot, curses.ACS_HLINE, curses.A_REVERSE, cnt)
544
770
  for idx in range(bot, bot+cnt):
@@ -561,10 +787,20 @@ class ConsoleWindow:
561
787
 
562
788
  def answer(self, prompt='Type string [then Enter]', seed='', width=80):
563
789
  """
564
- Popup with manual text input handling for unlimited length.
565
-
566
- This function replaces the curses.textpad.Textbox to handle arbitrarily
567
- long strings by managing a separate input buffer and display window.
790
+ Presents a modal dialog box for manual text input, handling arbitrarily
791
+ long strings.
792
+
793
+ This custom function replaces ``curses.textpad.Textbox`` to manage
794
+ a separate input buffer and display window with horizontal scrolling.
795
+
796
+ :param prompt: The text prompt displayed above the input field.
797
+ :param seed: The initial string value in the input field.
798
+ :param width: The maximum visible width of the input box.
799
+ :type prompt: str
800
+ :type seed: str
801
+ :type width: int
802
+ :returns: The string entered by the user upon pressing Enter.
803
+ :rtype: str
568
804
  """
569
805
  input_string = list(seed)
570
806
  cursor_pos = len(input_string)
@@ -603,6 +839,7 @@ class ConsoleWindow:
603
839
  key = self.scr.getch()
604
840
 
605
841
  if key in [10, 13]: # Enter key
842
+ curses.curs_set(0) # Restore cursor visibility
606
843
  return "".join(input_string)
607
844
  elif key in [curses.KEY_BACKSPACE, 127, 8]: # Backspace
608
845
  if cursor_pos > 0:
@@ -624,16 +861,24 @@ class ConsoleWindow:
624
861
  cursor_pos += 1
625
862
 
626
863
  def alert(self, title='ALERT', message='', height=1, width=80):
627
- """Alert box"""
864
+ """
865
+ Displays a blocking, modal alert box with a title and message.
866
+
867
+ Waits for the user to press **ENTER** to acknowledge and dismiss the box.
868
+
869
+ :param title: The title text for the alert box.
870
+ :param message: The message body content.
871
+ :param height: The height of the message area (number of lines).
872
+ :param width: The visible width of the message area.
873
+ :type title: str
874
+ :type message: str
875
+ :type height: int
876
+ :type width: int
877
+ """
628
878
  def mod_key(key):
879
+ """Internal function to map Enter/Key_Enter to an arbitrary key code 7 for Textbox.edit to exit."""
629
880
  return 7 if key in (10, curses.KEY_ENTER) else key
630
881
 
631
- # need 3 extra cols for rectangle (so we don't draw in southeast corner)
632
- # and 3 rows (top/prompt/bottom)
633
- # +Prompt--- -----------------+
634
- # | First line for message... |
635
- # | Last line for message. |
636
- # +-----------Press ENTER to ack+
637
882
  if self.rows < 2+height or self.cols < 30:
638
883
  return
639
884
  width = min(width, self.cols-3) # max text width
@@ -646,7 +891,7 @@ class ConsoleWindow:
646
891
  for row in range(self.rows):
647
892
  self.scr.insstr(row, 0, ' '*self.cols, curses.A_REVERSE)
648
893
  pad = curses.newpad(20, 200)
649
- win = curses.newwin(1, 1, row9-1, col9-2) # input window
894
+ win = curses.newwin(1, 1, row9-1, col9-2) # input window (dummy for Textbox)
650
895
  rectangle(self.scr, row0, col0, row9, col9)
651
896
  self.scr.addstr(row0, col0+1, title[0:width], curses.A_REVERSE)
652
897
  pad.addstr(message)
@@ -654,11 +899,17 @@ class ConsoleWindow:
654
899
  self.scr.addstr(row9, col0+1+width-len(ending), ending)
655
900
  self.scr.refresh()
656
901
  pad.refresh(0, 0, row0+1, col0+1, row9-1, col9-1)
902
+
903
+ # Use a dummy Textbox with a dummy window to block until Enter is pressed
904
+ curses.curs_set(0) # ensure cursor is off
657
905
  Textbox(win).edit(mod_key).strip()
658
906
  return
659
907
 
660
908
  def clear(self):
661
- """Clear in prep for new screen"""
909
+ """
910
+ Clears all content from both the header and body pads and resets internal
911
+ counters in preparation for adding new screen content.
912
+ """
662
913
  self.scr.clear()
663
914
  self.head.pad.clear()
664
915
  self.body.pad.clear()
@@ -666,18 +917,44 @@ class ConsoleWindow:
666
917
  self.head.row_cnt = self.body.row_cnt = 0
667
918
 
668
919
  def prompt(self, seconds=1.0):
669
- """Here is where we sleep waiting for commands or timeout"""
920
+ """
921
+ Waits for user input for up to ``seconds``.
922
+
923
+ Handles terminal resize events and built-in navigation keys, updating
924
+ scroll/pick position as needed.
925
+
926
+ :param seconds: The maximum time (float) to wait for input.
927
+ :type seconds: float
928
+ :returns: The key code if it is one of the application-defined ``keys``,
929
+ or None on timeout or if a navigation key was pressed.
930
+ :rtype: int or None
931
+ """
932
+ global ctrl_c_flag
670
933
  ctl_b, ctl_d, ctl_f, ctl_u = 2, 4, 6, 21
671
- elapsed = 0.0
672
- while elapsed < seconds:
934
+ begin_mono = time.monotonic()
935
+ while True:
936
+ if time.monotonic() - begin_mono >= seconds:
937
+ break
938
+ while self.pending_keys:
939
+ key = self.pending_keys.pop()
940
+ if key in self.handled_keys:
941
+ return key
942
+
673
943
  key = self.scr.getch()
944
+ if ctrl_c_flag:
945
+ if key in self.handled_keys:
946
+ self.pending_keys.add(key)
947
+ ctrl_c_flag = False # Reset flag
948
+ if 0x3 in self.handled_keys:
949
+ return 0x3 # Return the ETX key code
950
+ continue
951
+
674
952
  if key == curses.ERR:
675
- elapsed += self.timeout_ms / 1000
676
953
  continue
954
+
955
+
677
956
  if key in (curses.KEY_RESIZE, ) or curses.is_term_resized(self.rows, self.cols):
678
- # self.scr.erase()
679
957
  self._set_screen_dims()
680
- # self.render()
681
958
  break
682
959
 
683
960
  # App keys...
@@ -721,7 +998,6 @@ class ConsoleWindow:
721
998
 
722
999
  if pos != was_pos:
723
1000
  self.render()
724
- # ignore unhandled keys
725
1001
  return None
726
1002
 
727
1003
  def no_runner():
@@ -729,37 +1005,54 @@ def no_runner():
729
1005
 
730
1006
  if __name__ == '__main__':
731
1007
  def main():
1008
+ import sys
732
1009
  """Test program"""
733
1010
  def do_key(key):
734
- nonlocal spin, win, opts
1011
+ nonlocal spin, win, opts, pick_values
735
1012
  value = spin.do_key(key, win)
736
1013
  if key in (ord('p'), ord('s')):
737
1014
  win.set_pick_mode(on=opts.pick_mode, pick_size=opts.pick_size)
1015
+ if not opts.pick_mode:
1016
+ opts.prev_pick = pick_values[win.pick_pos//win.pick_size]
738
1017
  elif key == ord('n'):
739
1018
  win.alert(title='Info', message=f'got: {value}')
1019
+ elif key in (ord('q'), 0x3):
1020
+ sys.exit(key)
740
1021
  return value
741
1022
 
742
1023
  spin = OptionSpinner()
743
1024
  spin.add_key('help_mode', '? - toggle help screen', vals=[False, True])
744
- spin.add_key('pick_mode', 'p - toggle pick mode', vals=[False, True])
1025
+ spin.add_key('pick_mode', 'p - oggle pick mode, turn off to pick current line', vals=[False, True])
745
1026
  spin.add_key('pick_size', 's - #rows in pick', vals=[1, 2, 3])
746
1027
  spin.add_key('name', 'n - select name', prompt='Provide Your Name:')
747
1028
  spin.add_key('mult', 'm - row multiplier', vals=[0.5, 0.9, 1.0, 1.1, 2, 4, 16])
748
1029
  opts = spin.default_obj
1030
+ other_keys = {0x3, ord('q')}
749
1031
 
750
- win = ConsoleWindow(head_line=True, keys=spin.keys)
751
- opts.name = "[hit 'n' to enter name]"
1032
+ win = ConsoleWindow(head_line=True, keys=spin.keys^other_keys,
1033
+ ctrl_c_terminates=False, body_rows=4000)
1034
+ opts.name = ""
1035
+ opts.prev_pick = 'n/a'
1036
+ pick_values = []
752
1037
  for loop in range(100000000000):
753
1038
  body_size = int(round(win.scroll_view_size*opts.mult))
1039
+ # body_size = 4000 # temp to test scroll pos indicator when big
754
1040
  if opts.help_mode:
755
1041
  win.set_pick_mode(False)
756
1042
  spin.show_help_nav_keys(win)
757
1043
  spin.show_help_body(win)
1044
+ win.put_body('Other Keys:', curses.A_UNDERLINE)
1045
+ win.put_body(' q, CTRL-C: quit program')
758
1046
  else:
759
1047
  win.set_pick_mode(opts.pick_mode, opts.pick_size)
760
- win.add_header(f'Header: {loop} "{opts.name}"')
1048
+ win.add_header(f'{time.monotonic():.3f} [p]ick={opts.pick_mode}'
1049
+ + f' s:#rowsInPick={opts.pick_size} [n]ame [m]ult={opts.pick_size} [q]uit')
1050
+ win.add_header(f'Header: {loop} name="{opts.name}" {opts.prev_pick=}')
1051
+ pick_values = []
761
1052
  for idx, line in enumerate(range(body_size//opts.pick_size)):
762
- win.add_body(f'Main pick: {loop}.{line}')
1053
+ value = f'{loop}.{line}'
1054
+ win.put_body(f'Main pick: {value}')
1055
+ pick_values.append(value)
763
1056
  for num in range(1, opts.pick_size):
764
1057
  win.draw(num+idx*opts.pick_size, 0, f' addon: {loop}.{line}')
765
1058
  win.render()
@@ -775,4 +1068,4 @@ if __name__ == '__main__':
775
1068
  print("exception:", str(exce))
776
1069
  print(traceback.format_exc())
777
1070
  if dump_str:
778
- print(dump_str)
1071
+ print(dump_str)
@@ -23,7 +23,7 @@ name = "console_window"
23
23
  # UNIFIED PROJECT METADATA (Replaces [tool.flit.metadata])
24
24
  [project]
25
25
  name = "console-window"
26
- version = "1.0.0"
26
+ version = "1.0.2"
27
27
  description = "A robust curses wrapper for creating Text User Interfaces (TUIs)."
28
28
  readme = "README.md"
29
29
  requires-python = ">=3.8"
File without changes