console-window 1.2.0__tar.gz → 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {console_window-1.2.0 → console_window-1.3.0}/PKG-INFO +3 -2
- {console_window-1.2.0 → console_window-1.3.0}/console_window/ConsoleWindow.py +413 -39
- {console_window-1.2.0 → console_window-1.3.0}/pyproject.toml +1 -1
- {console_window-1.2.0 → console_window-1.3.0}/.gitignore +0 -0
- {console_window-1.2.0 → console_window-1.3.0}/LICENSE +0 -0
- {console_window-1.2.0 → console_window-1.3.0}/README.md +0 -0
- {console_window-1.2.0 → console_window-1.3.0}/console_window/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: console-window
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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.8
|
|
@@ -11,6 +11,7 @@ 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
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
# console-window
|
|
@@ -60,7 +60,10 @@ Example usage:
|
|
|
60
60
|
# pylint: disable=too-many-instance-attributes,too-many-arguments
|
|
61
61
|
# pylint: disable=invalid-name,broad-except,too-many-branches,global-statement
|
|
62
62
|
# pylint: disable=line-too-long,too-many-statements,too-many-locals
|
|
63
|
+
# pylint: disable=too-many-return-statements,too-many-nested-blocks
|
|
64
|
+
# pylint: disable=multiple-statements,no-else-return
|
|
63
65
|
|
|
66
|
+
import os
|
|
64
67
|
import sys
|
|
65
68
|
import re
|
|
66
69
|
import traceback
|
|
@@ -87,6 +90,181 @@ NAVIGATION_KEYS = {
|
|
|
87
90
|
ord('\x06'), # Ctrl-F (page down)
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
# Theme configuration - encapsulates all color/theme management
|
|
94
|
+
class Theme:
|
|
95
|
+
"""Theme and color management for ConsoleWindow"""
|
|
96
|
+
|
|
97
|
+
# Semantic color pair constants (initialized in start_curses based on theme)
|
|
98
|
+
DEFAULT = 0 # Default terminal colors
|
|
99
|
+
DANGER = 1 # For destructive operations (wipe prompts)
|
|
100
|
+
SUCCESS = 2 # For completed operations
|
|
101
|
+
WARNING = 3 # For caution/stopped states
|
|
102
|
+
INFO = 4 # For informational states
|
|
103
|
+
EMPHASIS = 5 # For emphasized text
|
|
104
|
+
ERROR = 6 # For errors
|
|
105
|
+
PROGRESS = 7 # For progress indicators
|
|
106
|
+
HOTSWAP = 9 # For newly inserted devices (hot-swapped)
|
|
107
|
+
OLD_SUCCESS = 10 # For completed operations (dimmer)
|
|
108
|
+
|
|
109
|
+
# Current theme name (set in start_curses)
|
|
110
|
+
_current = None
|
|
111
|
+
|
|
112
|
+
# Available themes
|
|
113
|
+
THEMES = {
|
|
114
|
+
'dark-mono': {
|
|
115
|
+
'name': 'Dark Mono',
|
|
116
|
+
'default_fg': 253,
|
|
117
|
+
'default_bg': 232,
|
|
118
|
+
1: (196, 232), # DANGER
|
|
119
|
+
2: (46, 232), # SUCCESS - Bright green
|
|
120
|
+
10: (28, 232), # OLD_SUCCESS - Medium green, darker than SUCCESS but not too dark
|
|
121
|
+
3: (226, 232), # WARNING
|
|
122
|
+
4: (51, 232), # INFO
|
|
123
|
+
5: (255, 232), # EMPHASIS
|
|
124
|
+
6: (196, 232), # ERROR
|
|
125
|
+
7: (39, 232), # PROGRESS
|
|
126
|
+
9: (208, 232), # HOTSWAP
|
|
127
|
+
},
|
|
128
|
+
'light-mono': {
|
|
129
|
+
'name': 'Light Mono',
|
|
130
|
+
'default_fg': 233,
|
|
131
|
+
'default_bg': 253,
|
|
132
|
+
1: (196, 253), # DANGER
|
|
133
|
+
2: (34, 253), # SUCCESS - Bright visible green on light background
|
|
134
|
+
10: (22, 253), # OLD_SUCCESS - Darker muted green, clearly darker than SUCCESS
|
|
135
|
+
3: (208, 253), # WARNING
|
|
136
|
+
4: (25, 253), # INFO
|
|
137
|
+
5: (232, 253), # EMPHASIS
|
|
138
|
+
6: (196, 253), # ERROR
|
|
139
|
+
7: (27, 253), # PROGRESS
|
|
140
|
+
9: (166, 253), # HOTSWAP
|
|
141
|
+
},
|
|
142
|
+
'default': {
|
|
143
|
+
'name': 'Terminal Default',
|
|
144
|
+
'default_fg': -1,
|
|
145
|
+
'default_bg': -1,
|
|
146
|
+
1: (1, -1), # DANGER
|
|
147
|
+
2: (2, -1), # SUCCESS - Standard green
|
|
148
|
+
10: (28, -1), # OLD_SUCCESS - Medium green, dimmer than bright SUCCESS
|
|
149
|
+
3: (3, -1), # WARNING
|
|
150
|
+
4: (6, -1), # INFO
|
|
151
|
+
5: (7, -1), # EMPHASIS
|
|
152
|
+
6: (9, -1), # ERROR
|
|
153
|
+
7: (4, -1), # PROGRESS
|
|
154
|
+
9: (208, -1), # HOTSWAP
|
|
155
|
+
},
|
|
156
|
+
'solarized-dark': {
|
|
157
|
+
'name': 'Solarized Dark',
|
|
158
|
+
'default_fg': 244,
|
|
159
|
+
'default_bg': 234,
|
|
160
|
+
1: (160, 234), # DANGER
|
|
161
|
+
2: (70, 234), # SUCCESS - Brighter solarized green, clearly bright
|
|
162
|
+
10: (28, 234), # OLD_SUCCESS - Darker muted green, clearly dimmer than SUCCESS
|
|
163
|
+
3: (136, 234), # WARNING
|
|
164
|
+
4: (37, 234), # INFO
|
|
165
|
+
5: (230, 234), # EMPHASIS
|
|
166
|
+
6: (196, 234), # ERROR
|
|
167
|
+
7: (33, 234), # PROGRESS
|
|
168
|
+
9: (166, 234), # HOTSWAP
|
|
169
|
+
},
|
|
170
|
+
'solarized-light': {
|
|
171
|
+
'name': 'Solarized Light',
|
|
172
|
+
'default_fg': 240,
|
|
173
|
+
'default_bg': 230,
|
|
174
|
+
1: (160, 230), # DANGER
|
|
175
|
+
2: (70, 230), # SUCCESS - Brighter solarized green, clearly bright
|
|
176
|
+
10: (28, 230), # OLD_SUCCESS - Darker muted green, clearly dimmer than SUCCESS
|
|
177
|
+
3: (166, 230), # WARNING
|
|
178
|
+
4: (37, 230), # INFO
|
|
179
|
+
5: (235, 230), # EMPHASIS
|
|
180
|
+
6: (196, 230), # ERROR
|
|
181
|
+
7: (33, 230), # PROGRESS
|
|
182
|
+
9: (208, 230), # HOTSWAP
|
|
183
|
+
},
|
|
184
|
+
'gruvbox': {
|
|
185
|
+
'name': 'Gruvbox Dark',
|
|
186
|
+
'default_fg': 223,
|
|
187
|
+
'default_bg': 235,
|
|
188
|
+
1: (167, 235), # DANGER
|
|
189
|
+
2: (142, 235), # SUCCESS - Gruvbox green
|
|
190
|
+
10: (106, 235), # OLD_SUCCESS - Gruvbox medium green (dimmer)
|
|
191
|
+
3: (214, 235), # WARNING
|
|
192
|
+
4: (109, 235), # INFO
|
|
193
|
+
5: (229, 235), # EMPHASIS
|
|
194
|
+
6: (203, 235), # ERROR
|
|
195
|
+
7: (109, 235), # PROGRESS
|
|
196
|
+
9: (208, 235), # HOTSWAP
|
|
197
|
+
},
|
|
198
|
+
'nord': {
|
|
199
|
+
'name': 'Nord',
|
|
200
|
+
'default_fg': 252,
|
|
201
|
+
'default_bg': 236,
|
|
202
|
+
1: (203, 236), # DANGER
|
|
203
|
+
2: (150, 236), # SUCCESS - Nord aurora green, bright
|
|
204
|
+
10: (114, 236), # OLD_SUCCESS - Nord frost green (dimmer), darker but not too dark
|
|
205
|
+
3: (220, 236), # WARNING
|
|
206
|
+
4: (116, 236), # INFO
|
|
207
|
+
5: (231, 236), # EMPHASIS
|
|
208
|
+
6: (203, 236), # ERROR
|
|
209
|
+
7: (136, 236), # PROGRESS
|
|
210
|
+
9: (208, 236), # HOTSWAP
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def get_current(cls):
|
|
216
|
+
"""Get the name of the currently active color theme."""
|
|
217
|
+
return cls._current
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def list_all(cls):
|
|
221
|
+
"""List all available color themes."""
|
|
222
|
+
return list(cls.THEMES.keys())
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def set(cls, theme_name):
|
|
226
|
+
"""Change the current theme and reinitialize color pairs.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
theme_name: Name of theme from THEMES dict
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
bool: True if theme was set, False if invalid theme or colors not supported
|
|
233
|
+
"""
|
|
234
|
+
if theme_name not in cls.THEMES:
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
if not curses.has_colors():
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
cls._current = theme_name
|
|
241
|
+
# Update environment variable so it persists for session
|
|
242
|
+
os.environ['DWIPE_THEME'] = theme_name
|
|
243
|
+
theme = cls.THEMES[theme_name]
|
|
244
|
+
|
|
245
|
+
# Set terminal background and foreground from theme
|
|
246
|
+
if 'default_fg' in theme and 'default_bg' in theme:
|
|
247
|
+
default_fg = theme['default_fg']
|
|
248
|
+
default_bg = theme['default_bg']
|
|
249
|
+
# Always update the default color pair and screen background
|
|
250
|
+
# (even when -1, -1 to reset to terminal defaults)
|
|
251
|
+
curses.init_pair(8, default_fg, default_bg)
|
|
252
|
+
if ConsoleWindow.static_scr:
|
|
253
|
+
ConsoleWindow.static_scr.bkgd(' ', curses.color_pair(8))
|
|
254
|
+
# Also update pads if window exists
|
|
255
|
+
if ConsoleWindow.static_win:
|
|
256
|
+
ConsoleWindow.static_win.head.pad.bkgd(' ', curses.color_pair(8))
|
|
257
|
+
ConsoleWindow.static_win.body.pad.bkgd(' ', curses.color_pair(8))
|
|
258
|
+
|
|
259
|
+
# Reinitialize color pairs with new theme
|
|
260
|
+
for color_id in [cls.DANGER, cls.SUCCESS, cls.OLD_SUCCESS, cls.WARNING,
|
|
261
|
+
cls.INFO, cls.EMPHASIS, cls.ERROR, cls.PROGRESS, cls.HOTSWAP]:
|
|
262
|
+
if color_id in theme:
|
|
263
|
+
fg, bg = theme[color_id]
|
|
264
|
+
curses.init_pair(color_id, fg, bg)
|
|
265
|
+
|
|
266
|
+
return True
|
|
267
|
+
|
|
90
268
|
|
|
91
269
|
class Context:
|
|
92
270
|
"""
|
|
@@ -156,7 +334,7 @@ class ConsoleWindowOpts:
|
|
|
156
334
|
All options have sensible defaults.
|
|
157
335
|
"""
|
|
158
336
|
__slots__ = ['head_line', 'head_rows', 'body_rows', 'body_cols', 'keys',
|
|
159
|
-
'pick_mode', 'pick_size', 'mod_pick', 'pick_attr', 'ctrl_c_terminates',
|
|
337
|
+
'pick_mode', 'pick_size', 'mod_pick', 'pick_attr', 'pick_range', 'ctrl_c_terminates',
|
|
160
338
|
'return_if_pos_change', 'min_cols_rows', 'dialog_abort', 'dialog_return',
|
|
161
339
|
'single_cell_scroll_indicator', 'answer_show_redraws', 'strip_attrs_in_pick_mode',
|
|
162
340
|
'demo_mode']
|
|
@@ -174,6 +352,7 @@ class ConsoleWindowOpts:
|
|
|
174
352
|
:param pick_size: Number of rows highlighted as single 'pick' unit (default: 1)
|
|
175
353
|
:param mod_pick: Optional callable to modify highlighted text (default: None)
|
|
176
354
|
:param pick_attr: Curses attribute for highlighting picked items (default: curses.A_REVERSE)
|
|
355
|
+
:param pick_range: Column range [start, end] for pick highlighting; None highlights entire line (default: None)
|
|
177
356
|
:param ctrl_c_terminates: If True, Ctrl-C terminates; if False, returns key 3 (default: True)
|
|
178
357
|
:param return_if_pos_change: If True, prompt returns when pick position changes for immediate redraw (default: True)
|
|
179
358
|
:param min_cols_rows: Minimum terminal size as (cols, rows) tuple (default: (70, 20))
|
|
@@ -191,6 +370,7 @@ class ConsoleWindowOpts:
|
|
|
191
370
|
self.pick_size = kwargs.get('pick_size', 1)
|
|
192
371
|
self.mod_pick = kwargs.get('mod_pick', None)
|
|
193
372
|
self.pick_attr = kwargs.get('pick_attr', curses.A_REVERSE)
|
|
373
|
+
self.pick_range = kwargs.get('pick_range', None) # [start_col, end_col] or None
|
|
194
374
|
self.ctrl_c_terminates = kwargs.get('ctrl_c_terminates', True)
|
|
195
375
|
self.return_if_pos_change = kwargs.get('return_if_pos_change', True)
|
|
196
376
|
self.min_cols_rows = kwargs.get('min_cols_rows', (70, 20))
|
|
@@ -232,6 +412,72 @@ def restore_ctrl_c():
|
|
|
232
412
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
233
413
|
|
|
234
414
|
|
|
415
|
+
class InlineConfirmation:
|
|
416
|
+
"""Manages inline confirmation prompts for wipe/verify operations"""
|
|
417
|
+
|
|
418
|
+
def __init__(self):
|
|
419
|
+
self.active = False
|
|
420
|
+
self.confirm_type = None # 'wipe' or 'verify'
|
|
421
|
+
self.partition_name = None
|
|
422
|
+
self.input_buffer = ''
|
|
423
|
+
self.mode = None # 'Y', 'y', 'YES', 'yes', 'device'
|
|
424
|
+
|
|
425
|
+
def start(self, confirm_type, partition_name, mode):
|
|
426
|
+
"""Start a confirmation prompt"""
|
|
427
|
+
self.active = True
|
|
428
|
+
self.confirm_type = confirm_type
|
|
429
|
+
self.partition_name = partition_name
|
|
430
|
+
self.input_buffer = ''
|
|
431
|
+
self.mode = mode
|
|
432
|
+
|
|
433
|
+
def cancel(self):
|
|
434
|
+
"""Cancel the confirmation"""
|
|
435
|
+
self.active = False
|
|
436
|
+
self.confirm_type = None
|
|
437
|
+
self.partition_name = None
|
|
438
|
+
self.input_buffer = ''
|
|
439
|
+
|
|
440
|
+
def get_expected(self):
|
|
441
|
+
"""Get the expected input string"""
|
|
442
|
+
if self.mode == 'device':
|
|
443
|
+
return self.partition_name
|
|
444
|
+
return self.mode
|
|
445
|
+
|
|
446
|
+
def is_single_key(self):
|
|
447
|
+
"""Check if this mode uses single key confirmation"""
|
|
448
|
+
return self.mode in ('Y', 'y')
|
|
449
|
+
|
|
450
|
+
def handle_key(self, key):
|
|
451
|
+
"""Handle a key press during confirmation.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
'confirmed' - user confirmed
|
|
455
|
+
'cancelled' - user cancelled (ESC)
|
|
456
|
+
'continue' - still gathering input
|
|
457
|
+
"""
|
|
458
|
+
if key == 27: # ESC
|
|
459
|
+
return 'cancelled'
|
|
460
|
+
|
|
461
|
+
if self.is_single_key():
|
|
462
|
+
# Single key confirmation
|
|
463
|
+
expected = self.get_expected()
|
|
464
|
+
if key == ord(expected):
|
|
465
|
+
return 'confirmed'
|
|
466
|
+
return 'continue'
|
|
467
|
+
|
|
468
|
+
# Typed confirmation
|
|
469
|
+
if 32 <= key <= 126: # Printable ASCII
|
|
470
|
+
self.input_buffer += chr(key)
|
|
471
|
+
elif key in (curses.KEY_BACKSPACE, 127, 8): # Backspace
|
|
472
|
+
self.input_buffer = self.input_buffer[:-1]
|
|
473
|
+
elif key in (curses.KEY_ENTER, 10): # ENTER
|
|
474
|
+
if self.input_buffer == self.get_expected():
|
|
475
|
+
return 'confirmed'
|
|
476
|
+
# Wrong input - reset
|
|
477
|
+
self.input_buffer = ''
|
|
478
|
+
return 'continue'
|
|
479
|
+
|
|
480
|
+
|
|
235
481
|
class IncrementalSearchBar:
|
|
236
482
|
"""
|
|
237
483
|
Generic incremental search bar with text editing and cursor support.
|
|
@@ -817,6 +1063,7 @@ class ConsoleWindow:
|
|
|
817
1063
|
"""
|
|
818
1064
|
timeout_ms = 2000
|
|
819
1065
|
static_scr = None
|
|
1066
|
+
static_win = None # Reference to active ConsoleWindow instance for theme updates
|
|
820
1067
|
nav_keys = """
|
|
821
1068
|
Navigation: H/M/L: top/middle/end-of-page
|
|
822
1069
|
k, UP: up one row 0, HOME: first row
|
|
@@ -909,6 +1156,20 @@ class ConsoleWindow:
|
|
|
909
1156
|
text_attrs = [], # run-length encoded: [(attr, count), ...] per line
|
|
910
1157
|
contexts = [] # Context object per line (or None)
|
|
911
1158
|
)
|
|
1159
|
+
|
|
1160
|
+
# Apply theme background to pads
|
|
1161
|
+
if curses.has_colors():
|
|
1162
|
+
theme = Theme.THEMES.get(Theme.CURRENT_THEME, Theme.THEMES['default'])
|
|
1163
|
+
if 'default_fg' in theme and 'default_bg' in theme:
|
|
1164
|
+
default_fg = theme['default_fg']
|
|
1165
|
+
default_bg = theme['default_bg']
|
|
1166
|
+
if default_fg != -1 or default_bg != -1:
|
|
1167
|
+
self.head.pad.bkgd(' ', curses.color_pair(8))
|
|
1168
|
+
self.body.pad.bkgd(' ', curses.color_pair(8))
|
|
1169
|
+
|
|
1170
|
+
# Store reference for theme updates
|
|
1171
|
+
ConsoleWindow.static_win = self
|
|
1172
|
+
|
|
912
1173
|
self.mod_pick = self.opts.mod_pick # call back to modify highlighted row
|
|
913
1174
|
self.hor_line_cnt = 1 if self.opts.head_line else 0 # no. h-lines in header
|
|
914
1175
|
self.scroll_pos = 0 # how far down into body are we?
|
|
@@ -1176,7 +1437,7 @@ class ConsoleWindow:
|
|
|
1176
1437
|
def start_curses():
|
|
1177
1438
|
"""
|
|
1178
1439
|
Performs the Curses initial setup: initscr, noecho, cbreak, curs_set(0),
|
|
1179
|
-
keypad(1),
|
|
1440
|
+
keypad(1), sets up the timeout, and initializes color pairs.
|
|
1180
1441
|
|
|
1181
1442
|
:returns: The main screen object.
|
|
1182
1443
|
:rtype: _curses.window
|
|
@@ -1192,6 +1453,38 @@ class ConsoleWindow:
|
|
|
1192
1453
|
scr.keypad(1)
|
|
1193
1454
|
scr.timeout(ConsoleWindow.timeout_ms)
|
|
1194
1455
|
scr.clear()
|
|
1456
|
+
|
|
1457
|
+
# Initialize color support with theme
|
|
1458
|
+
if curses.has_colors():
|
|
1459
|
+
# global Theme.CURRENT_THEME
|
|
1460
|
+
curses.start_color()
|
|
1461
|
+
curses.use_default_colors() # Allow -1 for default terminal colors
|
|
1462
|
+
|
|
1463
|
+
# Select theme from environment variable or default
|
|
1464
|
+
theme_name = os.environ.get('DWIPE_THEME', 'default')
|
|
1465
|
+
if theme_name not in Theme.THEMES:
|
|
1466
|
+
theme_name = 'default'
|
|
1467
|
+
Theme.CURRENT_THEME = theme_name
|
|
1468
|
+
theme = Theme.THEMES[theme_name]
|
|
1469
|
+
|
|
1470
|
+
# Set terminal background and foreground from theme
|
|
1471
|
+
if 'default_fg' in theme and 'default_bg' in theme:
|
|
1472
|
+
default_fg = theme['default_fg']
|
|
1473
|
+
default_bg = theme['default_bg']
|
|
1474
|
+
# Initialize pair 0 equivalent for default colors
|
|
1475
|
+
# Note: Can't change pair 0, so we set screen background directly
|
|
1476
|
+
if default_fg != -1 or default_bg != -1:
|
|
1477
|
+
# Create a color pair for default colors and set screen background
|
|
1478
|
+
curses.init_pair(8, default_fg, default_bg)
|
|
1479
|
+
scr.bkgd(' ', curses.color_pair(8))
|
|
1480
|
+
|
|
1481
|
+
# Initialize semantic color pairs based on selected theme
|
|
1482
|
+
for color_id in [Theme.DANGER, Theme.SUCCESS, Theme.WARNING,
|
|
1483
|
+
Theme.INFO, Theme.EMPHASIS, Theme.ERROR, Theme.PROGRESS, Theme.HOTSWAP]:
|
|
1484
|
+
if color_id in theme:
|
|
1485
|
+
fg, bg = theme[color_id]
|
|
1486
|
+
curses.init_pair(color_id, fg, bg)
|
|
1487
|
+
|
|
1195
1488
|
return scr
|
|
1196
1489
|
|
|
1197
1490
|
def set_pick_mode(self, on=True, pick_size=1):
|
|
@@ -1212,6 +1505,23 @@ class ConsoleWindow:
|
|
|
1212
1505
|
if self.pick_mode and (not was_on or was_size != self.pick_size):
|
|
1213
1506
|
self.last_pick_pos = -2 # indicates need to clear them all
|
|
1214
1507
|
|
|
1508
|
+
def set_pick_range(self, start_col=None, end_col=None):
|
|
1509
|
+
"""
|
|
1510
|
+
Set the column range for pick highlighting.
|
|
1511
|
+
|
|
1512
|
+
:param start_col: Starting column (inclusive), None for full line
|
|
1513
|
+
:param end_col: Ending column (exclusive), None for full line
|
|
1514
|
+
:type start_col: int or None
|
|
1515
|
+
:type end_col: int or None
|
|
1516
|
+
"""
|
|
1517
|
+
if start_col is None or end_col is None:
|
|
1518
|
+
self.opts.pick_range = None
|
|
1519
|
+
else:
|
|
1520
|
+
self.opts.pick_range = [start_col, end_col]
|
|
1521
|
+
# Force redraw of pick line
|
|
1522
|
+
if self.pick_mode:
|
|
1523
|
+
self.last_pick_pos = -2
|
|
1524
|
+
|
|
1215
1525
|
@staticmethod
|
|
1216
1526
|
def stop_curses():
|
|
1217
1527
|
"""
|
|
@@ -1224,6 +1534,7 @@ class ConsoleWindow:
|
|
|
1224
1534
|
ConsoleWindow.static_scr.keypad(0)
|
|
1225
1535
|
curses.endwin()
|
|
1226
1536
|
ConsoleWindow.static_scr = None
|
|
1537
|
+
ConsoleWindow.static_win = None
|
|
1227
1538
|
restore_ctrl_c()
|
|
1228
1539
|
|
|
1229
1540
|
def calc(self):
|
|
@@ -1587,10 +1898,12 @@ class ConsoleWindow:
|
|
|
1587
1898
|
text = text.ljust(self.get_pad_width())
|
|
1588
1899
|
attrs = get_attrs(row_idx)
|
|
1589
1900
|
|
|
1590
|
-
self._draw_line_with_attrs(self.body.pad, row_idx, 0, text, attrs,
|
|
1901
|
+
self._draw_line_with_attrs(self.body.pad, row_idx, 0, text, attrs,
|
|
1902
|
+
extra_attr=self.opts.pick_attr,
|
|
1903
|
+
extra_attr_range=self.opts.pick_range)
|
|
1591
1904
|
self.last_pick_pos = pos1
|
|
1592
1905
|
|
|
1593
|
-
def _draw_line_with_attrs(self, pad, row, col_offset, text, attrs, extra_attr=None):
|
|
1906
|
+
def _draw_line_with_attrs(self, pad, row, col_offset, text, attrs, extra_attr=None, extra_attr_range=None):
|
|
1594
1907
|
"""
|
|
1595
1908
|
Draw a line using run-length encoded attributes.
|
|
1596
1909
|
|
|
@@ -1600,31 +1913,88 @@ class ConsoleWindow:
|
|
|
1600
1913
|
:param text: The text to draw
|
|
1601
1914
|
:param attrs: Run-length encoded attributes [(attr, count), ...] or None
|
|
1602
1915
|
:param extra_attr: Additional attribute to OR with each segment (e.g., A_REVERSE for highlighting)
|
|
1916
|
+
:param extra_attr_range: [start_col, end_col] range to apply extra_attr; None applies to entire line
|
|
1603
1917
|
"""
|
|
1604
1918
|
if not attrs:
|
|
1605
1919
|
# No stored attributes - draw with normal or extra_attr
|
|
1606
|
-
|
|
1607
|
-
|
|
1920
|
+
if extra_attr is not None and extra_attr_range:
|
|
1921
|
+
# Apply extra_attr only to the specified range
|
|
1922
|
+
start_col, end_col = extra_attr_range
|
|
1923
|
+
max_width = self.get_pad_width() - col_offset
|
|
1924
|
+
text = text[0:max_width]
|
|
1925
|
+
|
|
1926
|
+
# Before range
|
|
1927
|
+
if start_col > 0:
|
|
1928
|
+
pad.addstr(row, col_offset, text[0:start_col], curses.A_NORMAL)
|
|
1929
|
+
# Inside range
|
|
1930
|
+
range_text = text[start_col:end_col]
|
|
1931
|
+
if range_text:
|
|
1932
|
+
pad.addstr(row, col_offset + start_col, range_text, extra_attr)
|
|
1933
|
+
# After range
|
|
1934
|
+
if end_col < len(text):
|
|
1935
|
+
pad.addstr(row, col_offset + end_col, text[end_col:], curses.A_NORMAL)
|
|
1936
|
+
else:
|
|
1937
|
+
final_attr = extra_attr if extra_attr is not None else curses.A_NORMAL
|
|
1938
|
+
pad.addstr(row, col_offset, text[0:self.get_pad_width()-col_offset], final_attr)
|
|
1608
1939
|
return
|
|
1609
1940
|
|
|
1941
|
+
# Handle run-length encoded attributes with optional range highlighting
|
|
1942
|
+
if extra_attr_range:
|
|
1943
|
+
range_start, range_end = extra_attr_range
|
|
1944
|
+
else:
|
|
1945
|
+
range_start, range_end = 0, len(text)
|
|
1946
|
+
|
|
1610
1947
|
col = col_offset
|
|
1611
1948
|
pos = 0
|
|
1612
1949
|
for attr, count in attrs:
|
|
1613
1950
|
if pos >= len(text):
|
|
1614
1951
|
break
|
|
1952
|
+
|
|
1953
|
+
# Get the segment for this attribute run
|
|
1615
1954
|
segment = text[pos:pos+count]
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1955
|
+
segment_start = pos
|
|
1956
|
+
segment_end = pos + len(segment)
|
|
1957
|
+
|
|
1958
|
+
# Split segment into parts: before range, in range, after range
|
|
1959
|
+
parts = []
|
|
1960
|
+
|
|
1961
|
+
# Part before highlight range
|
|
1962
|
+
if segment_start < range_start and segment_end > segment_start:
|
|
1963
|
+
before_end = min(segment_end, range_start)
|
|
1964
|
+
before_text = text[segment_start:before_end]
|
|
1965
|
+
parts.append((before_text, attr, False))
|
|
1966
|
+
|
|
1967
|
+
# Part inside highlight range
|
|
1968
|
+
in_range_start = max(segment_start, range_start)
|
|
1969
|
+
in_range_end = min(segment_end, range_end)
|
|
1970
|
+
if in_range_start < in_range_end:
|
|
1971
|
+
in_range_text = text[in_range_start:in_range_end]
|
|
1972
|
+
parts.append((in_range_text, attr, True))
|
|
1973
|
+
|
|
1974
|
+
# Part after highlight range
|
|
1975
|
+
if segment_end > range_end and segment_start < segment_end:
|
|
1976
|
+
after_start = max(segment_start, range_end)
|
|
1977
|
+
after_text = text[after_start:segment_end]
|
|
1978
|
+
parts.append((after_text, attr, False))
|
|
1979
|
+
|
|
1980
|
+
# Draw each part
|
|
1981
|
+
for part_text, part_attr, in_highlight in parts:
|
|
1982
|
+
if not part_text:
|
|
1983
|
+
continue
|
|
1619
1984
|
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1985
|
+
final_attr = part_attr
|
|
1986
|
+
if extra_attr is not None and in_highlight:
|
|
1987
|
+
final_attr = part_attr | extra_attr
|
|
1988
|
+
|
|
1989
|
+
# Ensure we don't exceed pad width
|
|
1990
|
+
max_len = self.get_pad_width() - col
|
|
1991
|
+
if max_len <= 0:
|
|
1992
|
+
break
|
|
1993
|
+
part_text = part_text[0:max_len]
|
|
1994
|
+
|
|
1995
|
+
pad.addstr(row, col, part_text, final_attr)
|
|
1996
|
+
col += len(part_text)
|
|
1625
1997
|
|
|
1626
|
-
pad.addstr(row, col, segment, final_attr)
|
|
1627
|
-
col += len(segment)
|
|
1628
1998
|
pos += count
|
|
1629
1999
|
|
|
1630
2000
|
def _scroll_indicator_row(self):
|
|
@@ -1922,12 +2292,13 @@ class ConsoleWindow:
|
|
|
1922
2292
|
curses.doupdate()
|
|
1923
2293
|
|
|
1924
2294
|
|
|
1925
|
-
def answer(self, prompt='Type string [then Enter]', seed='', width=80, height=5, esc_abort=None):
|
|
2295
|
+
def answer(self, prompt='Type string [then Enter]', seed='', width=80, height=5, esc_abort=None, prompt_attr=None):
|
|
1926
2296
|
"""
|
|
1927
2297
|
Presents a modal dialog box with working horizontal scroll indicators.
|
|
1928
2298
|
Uses opts.dialog_abort to determine ESC behavior and opts.dialog_return for submit key.
|
|
1929
2299
|
|
|
1930
2300
|
:param esc_abort: DEPRECATED. Use opts.dialog_abort instead. If provided, overrides opts.dialog_abort.
|
|
2301
|
+
:param prompt_attr: Optional curses attribute for the prompt text (e.g., curses.color_pair(COLOR_RED))
|
|
1931
2302
|
"""
|
|
1932
2303
|
# Handle deprecated esc_abort parameter for backward compatibility
|
|
1933
2304
|
if esc_abort is not None:
|
|
@@ -1955,7 +2326,7 @@ class ConsoleWindow:
|
|
|
1955
2326
|
input_string = list(seed)
|
|
1956
2327
|
cursor_pos = len(input_string)
|
|
1957
2328
|
v_scroll_top = 0
|
|
1958
|
-
last_esc_time = None # For ESC-ESC tracking
|
|
2329
|
+
last_esc_time = None # For ESC-ESC tracking
|
|
1959
2330
|
|
|
1960
2331
|
def calculate_geometry(self):
|
|
1961
2332
|
# ... (Geometry calculation logic remains the same) ...
|
|
@@ -1991,7 +2362,7 @@ class ConsoleWindow:
|
|
|
1991
2362
|
while True:
|
|
1992
2363
|
try:
|
|
1993
2364
|
success, row0, row9, col0, text_win_width = calculate_geometry(self)
|
|
1994
|
-
|
|
2365
|
+
|
|
1995
2366
|
# --- RESIZE/TOO SMALL CHECK ---
|
|
1996
2367
|
if not success:
|
|
1997
2368
|
min_cols, min_rows = self.opts.min_cols_rows
|
|
@@ -2023,7 +2394,10 @@ class ConsoleWindow:
|
|
|
2023
2394
|
indicator = '*' if debug_redraw_toggle else '+'
|
|
2024
2395
|
self.scr.addstr(row0, col0, indicator)
|
|
2025
2396
|
|
|
2026
|
-
|
|
2397
|
+
if prompt_attr is not None:
|
|
2398
|
+
self.scr.addstr(row0, col0 + 1, prompt[:text_win_width], prompt_attr | curses.A_BOLD)
|
|
2399
|
+
else:
|
|
2400
|
+
self.scr.addstr(row0, col0 + 1, prompt[:text_win_width])
|
|
2027
2401
|
|
|
2028
2402
|
# --- Core Display and Scroll Indicator Logic ---
|
|
2029
2403
|
|
|
@@ -2043,14 +2417,14 @@ class ConsoleWindow:
|
|
|
2043
2417
|
total_wrapped_lines = (len(input_string) + text_win_width - 1) // text_win_width
|
|
2044
2418
|
if len(input_string) == 0:
|
|
2045
2419
|
total_wrapped_lines = 1
|
|
2046
|
-
|
|
2420
|
+
|
|
2047
2421
|
# Display the visible lines
|
|
2048
2422
|
for r in range(height):
|
|
2049
2423
|
current_wrapped_line_idx = v_scroll_top + r
|
|
2050
|
-
|
|
2424
|
+
|
|
2051
2425
|
start_char_idx = current_wrapped_line_idx * text_win_width
|
|
2052
2426
|
end_char_idx = start_char_idx + text_win_width
|
|
2053
|
-
|
|
2427
|
+
|
|
2054
2428
|
if start_char_idx > len(input_string) and r > 0:
|
|
2055
2429
|
break
|
|
2056
2430
|
|
|
@@ -2059,32 +2433,32 @@ class ConsoleWindow:
|
|
|
2059
2433
|
current_h_scroll_start = 0
|
|
2060
2434
|
|
|
2061
2435
|
is_cursor_line = (current_wrapped_line_idx == wrapped_line_idx)
|
|
2062
|
-
|
|
2436
|
+
|
|
2063
2437
|
if is_cursor_line:
|
|
2064
2438
|
line_to_display = raw_wrapped_line[h_scroll_start:]
|
|
2065
2439
|
current_h_scroll_start = h_scroll_start
|
|
2066
|
-
|
|
2440
|
+
|
|
2067
2441
|
# 1. Clear the content area (important for redraw integrity)
|
|
2068
2442
|
self.scr.addstr(row0 + 1 + r, col0 + 1, ' ' * text_win_width)
|
|
2069
2443
|
# 2. Display the text
|
|
2070
2444
|
self.scr.addstr(row0 + 1 + r, col0 + 1, line_to_display[:text_win_width])
|
|
2071
|
-
|
|
2445
|
+
|
|
2072
2446
|
# --- SCROLL INDICATOR LOGIC ---
|
|
2073
2447
|
if is_cursor_line:
|
|
2074
|
-
left_indicator = curses.ACS_VLINE
|
|
2448
|
+
left_indicator = curses.ACS_VLINE
|
|
2075
2449
|
right_indicator = curses.ACS_VLINE
|
|
2076
|
-
|
|
2450
|
+
|
|
2077
2451
|
# Left Indicator Check
|
|
2078
2452
|
if current_h_scroll_start > 0:
|
|
2079
2453
|
# If content is scrolled right, show '<'
|
|
2080
|
-
left_indicator = ord('<')
|
|
2454
|
+
left_indicator = ord('<')
|
|
2081
2455
|
|
|
2082
2456
|
# Right Indicator Check
|
|
2083
2457
|
full_line_len = len(raw_wrapped_line)
|
|
2084
2458
|
if full_line_len > current_h_scroll_start + text_win_width:
|
|
2085
2459
|
# If there's more content to the right, show '>'
|
|
2086
|
-
right_indicator = ord('>')
|
|
2087
|
-
|
|
2460
|
+
right_indicator = ord('>')
|
|
2461
|
+
|
|
2088
2462
|
# Draw Indicators (overwrite the border's vertical line)
|
|
2089
2463
|
self.scr.addch(row0 + 1 + r, col0, left_indicator)
|
|
2090
2464
|
self.scr.addch(row0 + 1 + r, col0 + text_win_width + 1, right_indicator)
|
|
@@ -2154,7 +2528,7 @@ class ConsoleWindow:
|
|
|
2154
2528
|
return None # Double ESC within timeout
|
|
2155
2529
|
last_esc_time = current_time
|
|
2156
2530
|
# Single ESC - just update time and continue
|
|
2157
|
-
|
|
2531
|
+
|
|
2158
2532
|
elif key == curses.KEY_UP:
|
|
2159
2533
|
target_pos = cursor_pos - text_win_width
|
|
2160
2534
|
cursor_pos = max(0, target_pos)
|
|
@@ -2162,7 +2536,7 @@ class ConsoleWindow:
|
|
|
2162
2536
|
elif key == curses.KEY_DOWN:
|
|
2163
2537
|
target_pos = cursor_pos + text_win_width
|
|
2164
2538
|
cursor_pos = min(len(input_string), target_pos)
|
|
2165
|
-
|
|
2539
|
+
|
|
2166
2540
|
# ... [KEY_LEFT, KEY_RIGHT, HOME, END, edits, ASCII] ...
|
|
2167
2541
|
elif key == curses.KEY_LEFT: cursor_pos = max(0, cursor_pos - 1)
|
|
2168
2542
|
elif key == curses.KEY_RIGHT: cursor_pos = min(len(input_string), cursor_pos + 1)
|
|
@@ -2193,12 +2567,12 @@ class ConsoleWindow:
|
|
|
2193
2567
|
elif 32 <= key <= 126:
|
|
2194
2568
|
input_string.insert(cursor_pos, chr(key))
|
|
2195
2569
|
cursor_pos += 1
|
|
2196
|
-
|
|
2570
|
+
|
|
2197
2571
|
# --- Explicit Resize Handler ---
|
|
2198
2572
|
elif key == curses.KEY_RESIZE:
|
|
2199
2573
|
curses.update_lines_cols()
|
|
2200
2574
|
continue
|
|
2201
|
-
|
|
2575
|
+
|
|
2202
2576
|
except curses.error:
|
|
2203
2577
|
# Catch exceptions from drawing outside bounds during resize
|
|
2204
2578
|
self.scr.clear()
|
|
@@ -2679,6 +3053,7 @@ class Screen:
|
|
|
2679
3053
|
"""
|
|
2680
3054
|
self.app = app # Reference to main application instance
|
|
2681
3055
|
self.win = app.win
|
|
3056
|
+
self._cached_spinner = None # Lazy-loaded spinner reference
|
|
2682
3057
|
|
|
2683
3058
|
def draw_screen(self):
|
|
2684
3059
|
"""
|
|
@@ -2738,8 +3113,8 @@ class Screen:
|
|
|
2738
3113
|
if spinner:
|
|
2739
3114
|
spinner.show_help_nav_keys(self.win)
|
|
2740
3115
|
"""
|
|
2741
|
-
#
|
|
2742
|
-
if
|
|
3116
|
+
# Return cached spinner if already found
|
|
3117
|
+
if self._cached_spinner is not None:
|
|
2743
3118
|
return self._cached_spinner
|
|
2744
3119
|
|
|
2745
3120
|
# Search through app's attributes for an OptionSpinner instance
|
|
@@ -2753,8 +3128,7 @@ class Screen:
|
|
|
2753
3128
|
except (AttributeError, TypeError):
|
|
2754
3129
|
continue
|
|
2755
3130
|
|
|
2756
|
-
#
|
|
2757
|
-
self._cached_spinner = None
|
|
3131
|
+
# Not found - return None (don't cache to allow retry)
|
|
2758
3132
|
return None
|
|
2759
3133
|
|
|
2760
3134
|
def handle_action(self, action_name):
|
|
@@ -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.
|
|
26
|
+
version = "1.3.0"
|
|
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
|
|
File without changes
|