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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 1.2.0
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), and sets up the timeout.
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, extra_attr=self.opts.pick_attr)
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
- final_attr = extra_attr if extra_attr is not None else curses.A_NORMAL
1607
- pad.addstr(row, col_offset, text[0:self.get_pad_width()-col_offset], final_attr)
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
- final_attr = attr
1617
- if extra_attr is not None:
1618
- final_attr = attr | extra_attr
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
- # Ensure we don't exceed pad width
1621
- max_len = self.get_pad_width() - col
1622
- if max_len <= 0:
1623
- break
1624
- segment = segment[0:max_len]
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
- self.scr.addstr(row0, col0 + 1, prompt[:text_win_width])
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
- # Check if we've already cached the spinner
2742
- if hasattr(self, '_cached_spinner'):
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
- # Cache None if not found to avoid repeated searches
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.2.0"
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