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.
- {console_window-1.0.0 → console_window-1.0.2}/PKG-INFO +21 -6
- {console_window-1.0.0 → console_window-1.0.2}/README.md +19 -3
- {console_window-1.0.0 → console_window-1.0.2}/console_window/ConsoleWindow.py +390 -97
- {console_window-1.0.0 → console_window-1.0.2}/pyproject.toml +1 -1
- {console_window-1.0.0 → console_window-1.0.2}/.gitignore +0 -0
- {console_window-1.0.0 → console_window-1.0.2}/LICENSE +0 -0
- {console_window-1.0.0 → console_window-1.0.2}/console_window/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
2
|
Name: console-window
|
|
3
|
-
Version: 1.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.
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
29
|
-
a
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
the
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
"""
|
|
238
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
#
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
454
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
672
|
-
while
|
|
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 -
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|