console-window 1.3.2__tar.gz → 1.3.3__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.
@@ -0,0 +1,559 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ConsoleWindow Comprehensive Demo
4
+
5
+ This demo showcases the main features of ConsoleWindow:
6
+ - OptionSpinner for cycling through choices and triggering actions
7
+ - Screen classes (Main, Help, History, Theme)
8
+ - IncrementalSearchBar for inline filtering in header
9
+ - InlineConfirmation for inline prompts
10
+ - Theme/color support
11
+ - Flash and answer methods for user feedback
12
+
13
+ Usage:
14
+ .venv/bin/python3 demo.py
15
+
16
+ Or activate the venv first:
17
+ source .venv/bin/activate
18
+ python3 demo.py
19
+
20
+ Features demonstrated:
21
+ - Press q to quit
22
+ - Navigate with arrow keys or j/k
23
+ - Press v to cycle view modes (compact/detailed/full)
24
+ - Press s to cycle sort options (name/uid/home)
25
+ - Press / to filter items (inline search bar)
26
+ - Press SPACE to select an item (inline confirmation)
27
+ - Press a to demo the answer() prompt
28
+ - Press l to demo the alert() popup
29
+ - Press ? for help screen
30
+ - Press h for history screen
31
+ - Press t for themes (and cycle themes with t on theme screen)
32
+ """
33
+ # python: disable=invalid-name
34
+
35
+ import sys
36
+ import time
37
+ import traceback
38
+ import curses as cs
39
+ from types import SimpleNamespace
40
+ from console_window import (
41
+ ConsoleWindow, ConsoleWindowOpts, OptionSpinner,
42
+ IncrementalSearchBar, InlineConfirmation, Theme,
43
+ Screen, ScreenStack, Context
44
+ )
45
+
46
+ # Screen IDs
47
+ MAIN_ST = 0
48
+ HELP_ST = 1
49
+ HISTORY_ST = 2
50
+ THEME_ST = 3
51
+ SCREEN_NAMES = ('MAIN', 'HELP', 'HISTORY', 'THEMES')
52
+
53
+
54
+ class DemoApp:
55
+ """Main demo application"""
56
+ singleton = None
57
+
58
+ def __init__(self):
59
+ DemoApp.singleton = self
60
+ self.win = None
61
+ self.spin = None
62
+ self.stack = None
63
+ self.items = [] # Items to display in main screen
64
+ self.filter_text = ''
65
+ self.selected_items = set() # Track "selected" items for demo
66
+ self.operation_count = 0
67
+
68
+ # Inline confirmation handler
69
+ self.confirmation = InlineConfirmation()
70
+
71
+ # Incremental search bar
72
+ self.filter_bar = IncrementalSearchBar(
73
+ on_change=self._on_filter_change,
74
+ on_accept=self._on_filter_accept,
75
+ on_cancel=self._on_filter_cancel
76
+ )
77
+
78
+ # IMPORTANT: Create opts BEFORE ScreenStack to ensure proper object linking
79
+ # The OptionSpinner will use stack.obj, so it must be set correctly from the start
80
+ self.opts = SimpleNamespace()
81
+
82
+ # Create screen instances
83
+ screens = self.screens = {
84
+ MAIN_ST: MainScreen(self),
85
+ HELP_ST: HelpScreen(self),
86
+ HISTORY_ST: HistoryScreen(self),
87
+ THEME_ST: ThemeScreen(self),
88
+ }
89
+
90
+ # Configure console window
91
+ win_opts = ConsoleWindowOpts(
92
+ head_line=True,
93
+ body_rows=200,
94
+ head_rows=4,
95
+ min_cols_rows=(60,10),
96
+ pick_attr=cs.A_REVERSE,
97
+ ctrl_c_terminates=True,
98
+ )
99
+
100
+ self.win = ConsoleWindow(opts=win_opts,)
101
+ # Pass self.opts as spins_obj so stack.obj points to the right object
102
+ self.stack = ScreenStack(self.win, self.opts, SCREEN_NAMES, screens)
103
+
104
+ # Setup option spinner - it will inherit stack.obj (self.opts)
105
+ spin = self.spin = OptionSpinner(stack=self.stack)
106
+ themes = Theme.list_all()
107
+
108
+ # Add view mode and sort options
109
+ spin.add_key('quit', 'q - quit', genre='action')
110
+ spin.add_key('escape', 'ESC,ENTER - back',
111
+ keys=[27,10,cs.KEY_ENTER], genre='action')
112
+ spin.add_key('view_mode', 'v - view mode',
113
+ vals=['compact', 'detailed', 'full'], scope={MAIN_ST})
114
+ spin.add_key('sort_by', 's - sort by',
115
+ vals=['name', 'uid', 'home'], scope={MAIN_ST})
116
+ spin.add_key('theme', 't - theme', vals=themes, scope={THEME_ST})
117
+
118
+ # Add navigation keys (global)
119
+ spin.add_key('help_screen', '? - help', genre='action', scope={MAIN_ST})
120
+ spin.add_key('history_screen', 'h - history', genre='action', scope={MAIN_ST})
121
+ spin.add_key('theme_screen', 't - Theme Screen', genre='action', scope={MAIN_ST})
122
+
123
+ # Add main screen actions
124
+ spin.add_key('filter', '/ - filter', genre='action', scope={MAIN_ST})
125
+ spin.add_key('clear_filter', 'ESC - clear filter',
126
+ keys=[27], genre='action', scope=MAIN_ST)
127
+ spin.add_key('select', 'SPACE - select', keys=[ord(' ')],
128
+ genre='action', scope={MAIN_ST})
129
+ spin.add_key('answer_demo', 'a - answer demo',
130
+ genre='action', scope={MAIN_ST})
131
+ spin.add_key('alert_demo', 'l - alert demo',
132
+ genre='action', scope={MAIN_ST})
133
+ self.win.set_handled_keys(spin.keys)
134
+
135
+
136
+
137
+ # Load sample data from /etc/passwd
138
+ self._load_sample_data()
139
+
140
+ def _load_sample_data(self):
141
+ """Load sample items from /etc/passwd"""
142
+ try:
143
+ with open('/etc/passwd', 'r', encoding='utf-8') as f:
144
+ for line in f:
145
+ if line.strip() and not line.startswith('#'):
146
+ parts = line.split(':')
147
+ if len(parts) >= 3:
148
+ username = parts[0]
149
+ uid = parts[2]
150
+ home = parts[5] if len(parts) > 5 else ''
151
+ self.items.append({
152
+ 'username': username,
153
+ 'uid': uid,
154
+ 'home': home,
155
+ 'line': line.strip()
156
+ })
157
+ except Exception:
158
+ # Fallback sample data
159
+ self.items = [
160
+ {'username': 'demo1', 'uid': '1001', 'home': '/home/demo1',
161
+ 'line': 'demo1:x:1001:1001::/home/demo1:/bin/bash'},
162
+ {'username': 'demo2', 'uid': '1002', 'home': '/home/demo2',
163
+ 'line': 'demo2:x:1002:1002::/home/demo2:/bin/bash'},
164
+ {'username': 'test', 'uid': '1003', 'home': '/home/test',
165
+ 'line': 'test:x:1003:1003::/home/test:/bin/bash'},
166
+ ]
167
+
168
+ def _on_filter_change(self, text):
169
+ """Update filter as user types"""
170
+ self.filter_text = text.lower()
171
+
172
+ def _on_filter_accept(self, text):
173
+ """Filter accepted (ENTER pressed)"""
174
+ self.filter_text = text.lower()
175
+ self.win.passthrough_mode = False
176
+ self.win.pick_pos = 0 # Move to top
177
+
178
+ def _on_filter_cancel(self, original_text):
179
+ """Filter cancelled (ESC pressed)"""
180
+ self.filter_text = original_text.lower() if original_text else ''
181
+ self.win.passthrough_mode = False
182
+
183
+ def _start_operation(self):
184
+ """Start operation after confirmation"""
185
+ if self.confirmation.partition_name:
186
+ item_name = self.confirmation.partition_name
187
+ # Mark item as "processed"
188
+ self.selected_items.add(item_name)
189
+ self.operation_count += 1
190
+ # Flash success message
191
+ self.win.flash(f'✓ Processed {item_name}', duration=1.5)
192
+ self.confirmation.cancel()
193
+ self.win.passthrough_mode = False
194
+
195
+ def handle_key(self, key):
196
+ """Handle global key presses - ORDER MATTERS!"""
197
+ # Handle filter bar FIRST (highest priority when active)
198
+ if self.filter_bar.is_active:
199
+ if self.filter_bar.handle_key(key):
200
+ return None # Key was consumed
201
+
202
+ # Handle confirmation mode SECOND (high priority when active)
203
+ if self.confirmation.active:
204
+ result = self.confirmation.handle_key(key)
205
+ if result == 'confirmed':
206
+ self._start_operation()
207
+ elif result == 'cancelled':
208
+ self.confirmation.cancel()
209
+ self.win.passthrough_mode = False
210
+ return None # Key was consumed by confirmation
211
+
212
+ # Handle ENTER for screen navigation
213
+ if key in (cs.KEY_ENTER, 10):
214
+ if self.stack.curr.num != MAIN_ST:
215
+ self.stack.pop()
216
+ return None
217
+
218
+ # Handle spinner keys LAST (normal priority)
219
+ if key in self.spin.keys:
220
+ self.spin.do_key(key, self.win)
221
+
222
+ return None
223
+
224
+ def get_filtered_items(self):
225
+ """Get items matching current filter"""
226
+ if not self.filter_text:
227
+ return self.items
228
+ return [item for item in self.items
229
+ if self.filter_text in item['username'].lower() or
230
+ self.filter_text in item['home'].lower()]
231
+
232
+ def do_key(self, key):
233
+ """ TBD """
234
+ if not key:
235
+ return
236
+ # handle Inline confirmation / filter
237
+ if key in self.spin.keys:
238
+ self.spin.do_key(key, self.win)
239
+
240
+ def main_loop(self):
241
+ """Main application loop"""
242
+
243
+ while True:
244
+ current_screen = self.screens[self.stack.curr.num]
245
+ current_screen.draw_screen()
246
+ self.win.render()
247
+
248
+ key = self.win.prompt(seconds=3.0)
249
+ if key:
250
+ self.handle_key(key)
251
+ self.stack.perform_actions(self.spin)
252
+ self.win.clear()
253
+
254
+
255
+ class DemoScreen(Screen):
256
+ """Base screen class for demo screens"""
257
+ app: DemoApp
258
+
259
+ def __init__(self, name, app):
260
+ super().__init__(app)
261
+ self.app = app
262
+
263
+ def get_spinner(self):
264
+ """Get the option spinner"""
265
+ return self.app.spin if hasattr(self.app, 'spin') else None
266
+
267
+ def escape_ACTION(self):
268
+ """ go back """
269
+ app = self.app
270
+ app.stack.pop()
271
+
272
+ def quit_ACTION(self):
273
+ """Quit the application"""
274
+ app = self.app
275
+ win = app.win
276
+
277
+ if app.operation_count > 0:
278
+ win.flash(
279
+ f'Processed {app.operation_count} items. Goodbye!',
280
+ duration=1.5
281
+ )
282
+ time.sleep(1.5)
283
+ win.stop_curses()
284
+ sys.exit(0)
285
+
286
+ class MainScreen(DemoScreen):
287
+ """Main screen showing user list"""
288
+
289
+ def __init__(self, app):
290
+ super().__init__('MAIN', app)
291
+
292
+ def draw_screen(self):
293
+ """Draw main screen with item list"""
294
+ self.app.win.set_pick_mode(True)
295
+
296
+ self._draw_body()
297
+ self._draw_header()
298
+
299
+ def _draw_body(self):
300
+ """ Display items """
301
+ app = self.app
302
+ items = app.get_filtered_items()
303
+ by = app.opts.sort_by
304
+ by = 'username' if by == 'name' else by
305
+ items = sorted(items, key=lambda it: it[by])
306
+ view_mode = app.opts.view_mode
307
+ for item in items:
308
+ username = item['username']
309
+ uid = item['uid']
310
+ home = item['home']
311
+
312
+ # Determine status and color
313
+ if username in app.selected_items:
314
+ status = '✓ Processed'
315
+ attr = cs.color_pair(Theme.SUCCESS)
316
+ else:
317
+ status = ' Ready'
318
+ attr = cs.color_pair(0)
319
+
320
+ # Format line based on view mode
321
+ if view_mode == 'full':
322
+ line = f'{username:<16} {uid:<8} {home:<30} {status}'
323
+ elif view_mode == 'detailed':
324
+ line = f'{username:<16} {uid:<8} {status}'
325
+ else: # compact
326
+ line = f'{username:<20} {status}'
327
+
328
+ # Create context for picking
329
+ ctx = Context(genre='item', item=item)
330
+ app.win.add_body(line, attr=attr, context=ctx)
331
+
332
+ # Show inline confirmation if active for this item
333
+ if app.confirmation.active and \
334
+ app.confirmation.partition_name == username:
335
+ msg = f' ⚠️ Process {username}?'
336
+ if app.confirmation.mode == 'y':
337
+ msg += " - Press 'y' or ESC"
338
+ elif app.confirmation.mode == 'Y':
339
+ msg += " - Press 'Y' or ESC"
340
+
341
+ app.win.add_body(
342
+ msg,
343
+ attr=cs.color_pair(Theme.WARNING) | cs.A_BOLD,
344
+ context=Context(genre='DECOR')
345
+ )
346
+
347
+ def _draw_header(self):
348
+ app = self.app
349
+ # Build header with filter display using fancy_header for compact formatting
350
+ line = app.filter_bar.get_display_string(prefix='/')
351
+ if not line:
352
+ line = '/'
353
+ # Use fancy_header to show filter with bold/underline formatting
354
+
355
+ # Show spinner keys
356
+ line += f' [v]iew={app.opts.view_mode}'
357
+ line += f' [s]ort={app.opts.sort_by}'
358
+ line += ' [a]nsDemo'
359
+ line += ' l:alrtDemo'
360
+ line += ' SPC:process'
361
+ line += ' [t]hemes'
362
+ line += ' [h]ist'
363
+ line += ' ?:help'
364
+ app.win.add_fancy_header(f' {line}', mode="Underline")
365
+
366
+ # Column headers
367
+ view_mode = app.opts.view_mode
368
+ if view_mode == 'full':
369
+ header = f'{"User":<16} {"UID":<8} {"Home":<30} Status'
370
+ elif view_mode == 'detailed':
371
+ header = f'{"User":<16} {"UID":<8} Status'
372
+ else: # compact
373
+ header = f'{"User":<20} Status'
374
+
375
+ app.win.add_header(header, attr=cs.A_DIM | cs.A_UNDERLINE)
376
+
377
+ def filter_ACTION(self):
378
+ """Activate filter search bar"""
379
+ app = self.app
380
+ app.filter_bar.start(app.filter_text)
381
+ app.win.passthrough_mode = True
382
+
383
+ def clear_filter_ACTION(self):
384
+ """Clear filter search bar"""
385
+ app = self.app
386
+ app.filter_bar._text = ''
387
+ app.filter_text = ''
388
+ app.win.pick_pos = 0
389
+
390
+ def select_ACTION(self):
391
+ """Select/process the current item with confirmation"""
392
+ app = self.app
393
+ ctx = app.win.get_picked_context()
394
+ if ctx and hasattr(ctx, 'item'):
395
+ item = ctx.item
396
+ username = item['username']
397
+ # Start inline confirmation (mode 'y' = press 'y' to confirm)
398
+ app.confirmation.start('process', username, mode='y')
399
+ app.win.passthrough_mode = True
400
+
401
+ def answer_demo_ACTION(self):
402
+ """Demo the answer() method"""
403
+ app = self.app
404
+ win = app.win
405
+ response = win.answer('Enter your name (or ESC to cancel): ',
406
+ height=1)
407
+ if response:
408
+ win.flash(f'Hello, {response}!', duration=2.0)
409
+ else:
410
+ win.flash('Cancelled', duration=1.0)
411
+
412
+ def alert_demo_ACTION(self):
413
+ """Demo the alert() method - shows a simple modal message box"""
414
+ ctx = self.app.win.get_picked_context()
415
+ if ctx and hasattr(ctx, 'item'):
416
+ item = ctx.item
417
+ # alert() displays a single-paragraph message (newlines are ignored)
418
+ # Keep it simple and short
419
+ message = (
420
+ f"Selected user: {item['username']} "
421
+ f"(UID: {item['uid']}) "
422
+ f"Home: {item['home']}"
423
+ )
424
+ self.app.win.alert(message, title='User Info')
425
+ else:
426
+ self.app.win.alert(
427
+ 'No item selected! Please select an item from the list first.',
428
+ title='Selection Required'
429
+ )
430
+
431
+ def help_screen_ACTION(self):
432
+ """Navigate to help screen"""
433
+ self.app.stack.push(HELP_ST, self.app.win.pick_pos)
434
+
435
+ def history_screen_ACTION(self):
436
+ """Navigate to history screen"""
437
+ app = self.app
438
+ win = app.win
439
+ app.stack.push(HISTORY_ST, win.pick_pos)
440
+
441
+ def theme_screen_ACTION(self):
442
+ """Navigate to theme screen"""
443
+ app = self.app
444
+ win = app.win
445
+ app.stack.push(THEME_ST, win.pick_pos)
446
+
447
+
448
+ class HelpScreen(DemoScreen):
449
+ """Help screen"""
450
+
451
+ def __init__(self, app):
452
+ super().__init__('HELP', app)
453
+
454
+ def draw_screen(self):
455
+ """Draw help screen"""
456
+ app = self.app
457
+ app.win.set_pick_mode(False)
458
+
459
+ spinner = self.get_spinner()
460
+ if spinner:
461
+ # Use built-in help display
462
+ spinner.show_help_nav_keys(app.win)
463
+ spinner.show_help_body(app.win)
464
+
465
+
466
+ class HistoryScreen(DemoScreen):
467
+ """History screen showing operation log"""
468
+
469
+ def __init__(self, app):
470
+ super().__init__('HISTORY', app)
471
+
472
+ def draw_screen(self):
473
+ """Draw history screen"""
474
+ app = self.app
475
+ app.win.set_pick_mode(False)
476
+
477
+ app.win.add_header('OPERATION HISTORY', attr=cs.A_BOLD)
478
+ app.win.add_header(' Press ESC or ENTER to return',
479
+ attr=cs.A_DIM, resume=True)
480
+
481
+ if app.selected_items:
482
+ app.win.add_body('')
483
+ app.win.add_body(f'Total processed items: {len(app.selected_items)}')
484
+ app.win.add_body('')
485
+ for username in sorted(app.selected_items):
486
+ # Find the item details
487
+ item = next((i for i in app.items
488
+ if i['username'] == username), None)
489
+ if item:
490
+ line = f' ✓ {username:<16} uid={item["uid"]:<8} {item["home"]}'
491
+ app.win.add_body(line, attr=cs.color_pair(Theme.SUCCESS))
492
+ else:
493
+ app.win.add_body('')
494
+ app.win.add_body('No items have been processed yet.')
495
+ app.win.add_body('')
496
+ app.win.add_body('Go back to the main screen and press '
497
+ 'SPACE on an item to process it.')
498
+
499
+
500
+ class ThemeScreen(DemoScreen):
501
+ """Theme selection screen"""
502
+
503
+ def __init__(self, app):
504
+ super().__init__('THEMES', app)
505
+
506
+ def draw_screen(self):
507
+ """Draw theme screen with color samples"""
508
+ app = self.app
509
+ win = app.win
510
+ win.set_pick_mode(False)
511
+ if app.opts.theme != Theme.get_current():
512
+ Theme.set(app.opts.theme)
513
+
514
+ theme = app.opts.theme
515
+ win.add_header(f'COLOR THEMES: [t]heme={theme}',
516
+ attr=cs.A_BOLD)
517
+ win.add_header(' ESC:return', attr=cs.A_DIM, resume=True)
518
+
519
+ win.add_body('Color samples:')
520
+ win.add_body('')
521
+
522
+ # Color samples
523
+ color_samples = [
524
+ (Theme.DANGER, 'DANGER', 'Destructive operations'),
525
+ (Theme.SUCCESS, 'SUCCESS', 'Completed operations'),
526
+ (Theme.WARNING, 'WARNING', 'Warnings and cautions'),
527
+ (Theme.INFO, 'INFO', 'Informational messages'),
528
+ (Theme.EMPHASIS, 'EMPHASIS', 'Emphasized text'),
529
+ (Theme.ERROR, 'ERROR', 'Error messages'),
530
+ (Theme.PROGRESS, 'PROGRESS', 'Progress indicators'),
531
+ ]
532
+
533
+ for color_id, label, description in color_samples:
534
+ line = f' {label:<12} ████████ {description}'
535
+ win.add_body(line, attr=cs.color_pair(color_id))
536
+
537
+ def theme_screen(self):
538
+ """Cycle to next theme"""
539
+ themes = Theme.list_all()
540
+ current = Theme.get_current()
541
+ idx = themes.index(current) if current in themes else -1
542
+ next_theme = themes[(idx + 1) % len(themes)]
543
+ Theme.set(next_theme)
544
+
545
+ def main():
546
+ """Main entry point"""
547
+ try:
548
+ app = DemoApp()
549
+ app.main_loop()
550
+ except Exception as bummer:
551
+ app = DemoApp.singleton
552
+ if app and app.win:
553
+ app.win.stop_curses()
554
+ print('exception: ', str(bummer))
555
+ print(traceback.format_exc())
556
+
557
+
558
+ if __name__ == '__main__':
559
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 1.3.2
3
+ Version: 1.3.3
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
@@ -130,6 +130,4 @@ For more extensive examples, you can look at some projects using `console-window
130
130
  * viewer of /proc/meminfo that shows historical values
131
131
  * [vappman](https://github.com/joedefen/vappman)
132
132
  * convenince wrapper to mostly hide CLI of [AppMan](https://github.com/ivan-hc/AppMan), a life-cycle manager for 2000+ AppImages
133
- * [my-snaps](https://github.com/joedefen/my-snaps)
134
- * special purpose BTRFS snap manager
135
133
 
@@ -115,5 +115,3 @@ For more extensive examples, you can look at some projects using `console-window
115
115
  * viewer of /proc/meminfo that shows historical values
116
116
  * [vappman](https://github.com/joedefen/vappman)
117
117
  * convenince wrapper to mostly hide CLI of [AppMan](https://github.com/ivan-hc/AppMan), a life-cycle manager for 2000+ AppImages
118
- * [my-snaps](https://github.com/joedefen/my-snaps)
119
- * special purpose BTRFS snap manager
@@ -265,6 +265,70 @@ class Theme:
265
265
 
266
266
  return True
267
267
 
268
+ @classmethod
269
+ def create_picker_screen(cls, base_class):
270
+ """
271
+ Create a theme picker screen class dynamically.
272
+
273
+ Args:
274
+ base_class: The Screen base class to inherit from
275
+
276
+ Returns:
277
+ A new class that displays all themes and allows cycling through them
278
+
279
+ Usage:
280
+ ThemeScreen = Theme.create_picker_screen(Screen)
281
+ screens[THEME_ST] = ThemeScreen(app)
282
+ """
283
+ class ThemePickerScreen(base_class):
284
+ """Theme preview screen showing all available themes with color examples"""
285
+
286
+ def draw_screen(self):
287
+ """Draw the theme screen with color examples for all themes"""
288
+ app = self.app
289
+ win = app.win
290
+
291
+ win.set_pick_mode(False)
292
+
293
+ # Get current theme name (may be empty string for default)
294
+ current_theme = cls.get_current()
295
+ display_theme = current_theme if current_theme else '(default)'
296
+
297
+ # Add header showing current theme
298
+ win.add_header(f'COLOR THEME: {display_theme:^18}', attr=curses.A_BOLD)
299
+ win.add_header(' Press [t] to cycle themes, ESC to return', resume=True)
300
+
301
+ # Color purpose labels
302
+ color_labels = [
303
+ (cls.DANGER, 'DANGER', 'Destructive operations'),
304
+ (cls.SUCCESS, 'SUCCESS', 'Completed operations'),
305
+ (cls.OLD_SUCCESS, 'OLD_SUCCESS', 'Older completed operations'),
306
+ (cls.WARNING, 'WARNING', 'Caution/stopped states'),
307
+ (cls.INFO, 'INFO', 'Informational states'),
308
+ (cls.EMPHASIS, 'EMPHASIS', 'Emphasized text'),
309
+ (cls.ERROR, 'ERROR', 'Errors'),
310
+ (cls.PROGRESS, 'PROGRESS', 'Progress indicators'),
311
+ (cls.HOTSWAP, 'HOTSWAP', 'Recently added items'),
312
+ ]
313
+
314
+ # Show color examples for this theme
315
+ for color_id, label, description in color_labels:
316
+ # Create line with colored block and description
317
+ line = f'{label:12} ████████ {description}'
318
+ attr = curses.color_pair(color_id)
319
+ win.add_body(line, attr=attr)
320
+
321
+ def spin_theme_ACTION(self):
322
+ """Handle 't' key - cycle through themes"""
323
+ vals = cls.list_all()
324
+ value = cls.get_current()
325
+ idx = vals.index(value) if value in vals else -1
326
+ value = vals[(idx+1) % len(vals)] # choose next
327
+ cls.set(value)
328
+ self.app.opts.theme = value
329
+
330
+ return ThemePickerScreen
331
+
268
332
 
269
333
  class Context:
270
334
  """
@@ -337,7 +401,7 @@ class ConsoleWindowOpts:
337
401
  'pick_mode', 'pick_size', 'mod_pick', 'pick_attr', 'pick_range', 'ctrl_c_terminates',
338
402
  'return_if_pos_change', 'min_cols_rows', 'dialog_abort', 'dialog_return',
339
403
  'single_cell_scroll_indicator', 'answer_show_redraws', 'strip_attrs_in_pick_mode',
340
- 'demo_mode']
404
+ 'demo_mode', 'relax_handled_keys']
341
405
 
342
406
  def __init__(self, **kwargs):
343
407
  """
@@ -360,6 +424,7 @@ class ConsoleWindowOpts:
360
424
  :param dialog_return: Which key submits dialogs: "ENTER", "TAB" (default: "ENTER")
361
425
  :param single_cell_scroll_indicator: If True, shows single-cell position dot; if False, shows proportional range (default: False)
362
426
  :param strip_attrs_in_pick_mode: If True, strips attributes in pick mode (old behavior); if False, preserves them (default: False)
427
+ :param relax_handled_keys: If True, passes all non-navigation keys to app without checking handled_keys list (default: True)
363
428
  """
364
429
  self.head_line = kwargs.get('head_line', True)
365
430
  self.head_rows = kwargs.get('head_rows', 50)
@@ -380,6 +445,7 @@ class ConsoleWindowOpts:
380
445
  self.answer_show_redraws = kwargs.get('answer_show_redraws', False)
381
446
  self.strip_attrs_in_pick_mode = kwargs.get('strip_attrs_in_pick_mode', False)
382
447
  self.demo_mode = kwargs.get('demo_mode', False)
448
+ self.relax_handled_keys = kwargs.get('relax_handled_keys', True)
383
449
 
384
450
  # Validate dialog_abort
385
451
  if self.dialog_abort not in [None, 'ESC', 'ESC-ESC']:
@@ -647,29 +713,30 @@ class IncrementalSearchBar:
647
713
 
648
714
  return False
649
715
 
650
- def get_display_string(self, prefix='/', suffix=''):
716
+ def get_display_string(self, prefix='/', suffix='', cursor_char='█'):
651
717
  """
652
718
  Get formatted display string with cursor indicator.
653
719
 
654
- When active (in search mode), shows cursor position with '|'.
720
+ When active (in search mode), shows cursor position with cursor_char (default: block).
655
721
  When inactive, just shows the text with prefix/suffix if text is non-empty.
656
722
 
657
723
  :param prefix: String to show before the search text (default: '/')
658
724
  :param suffix: String to show after the search text (default: '')
725
+ :param cursor_char: Character to use for cursor (default: '█' block)
659
726
  :returns: Formatted string for display
660
727
 
661
728
  Example:
662
- Active with cursor: ' /hello| world'
663
- Active at end: ' /hello world|'
729
+ Active with cursor: ' /hello█ world'
730
+ Active at end: ' /hello world█'
664
731
  Inactive with text: ' /hello world'
665
732
  Inactive without text: ''
666
733
  """
667
734
  if self._active:
668
- # Show cursor position with |
735
+ # Show cursor position with cursor_char (block by default for visibility)
669
736
  before = self._text[:self._cursor_pos]
670
737
  after = self._text[self._cursor_pos:]
671
738
  # Add space after prefix to prevent pattern matching in fancy_header
672
- return f'{prefix} {before}|{after}{suffix}'
739
+ return f'{prefix} {before}{cursor_char}{after}{suffix}'
673
740
  elif self._text:
674
741
  return f'{prefix}{self._text}{suffix}'
675
742
  else:
@@ -707,20 +774,52 @@ class OptionSpinner:
707
774
  :param stack: Optional ScreenStack reference for scope-aware key bindings.
708
775
  If stack.obj is None, it will be set to this spinner's default_obj.
709
776
  :type stack: ScreenStack or None
777
+
778
+ .. warning::
779
+ If you plan to set a custom default_obj, either:
780
+
781
+ 1. Pass it via ScreenStack: ``ScreenStack(win, my_opts, ...)`` then
782
+ ``OptionSpinner(stack=stack)`` (recommended)
783
+ 2. Set it immediately after creation before calling add_key():
784
+ ``spin = OptionSpinner(stack=stack); spin.default_obj = my_opts``
785
+
786
+ The default_obj property automatically syncs with stack.obj when set.
710
787
  """
711
788
  self.options, self.keys = [], []
712
789
  self.margin = 4 # + actual width (1st column right pos)
713
790
  self.align = self.margin # + actual width (1st column right pos)
714
- self.default_obj = SimpleNamespace() # if not given one
791
+ self._default_obj = SimpleNamespace() # if not given one (private)
715
792
  self.attr_to_option = {} # given an attribute, find its option ns
716
793
  self.key_to_option = {} # given key, options namespace
717
794
  self.keys = set()
718
795
  self.stack = stack # Optional ScreenStack for scope-aware bindings
719
796
  self.key_scopes = {} # Maps (key, screen) -> option_ns for scope tracking
720
797
 
721
- # If stack has None obj, give it our default_obj
722
- if self.stack and self.stack.obj is None:
723
- self.stack.obj = self.default_obj
798
+ # Sync with stack.obj: use stack's obj if available, otherwise provide ours
799
+ if self.stack:
800
+ if self.stack.obj is not None:
801
+ # Stack already has an obj - use it as our default_obj
802
+ self._default_obj = self.stack.obj
803
+ else:
804
+ # Stack has no obj - give it ours
805
+ self.stack.obj = self._default_obj
806
+
807
+ @property
808
+ def default_obj(self):
809
+ """
810
+ The default object for storing option values.
811
+
812
+ When set, automatically synchronizes with stack.obj to prevent
813
+ object reference fragmentation.
814
+ """
815
+ return self._default_obj
816
+
817
+ @default_obj.setter
818
+ def default_obj(self, value):
819
+ """Set default_obj and keep stack.obj synchronized."""
820
+ self._default_obj = value
821
+ if self.stack:
822
+ self.stack.obj = value # Keep in sync!
724
823
 
725
824
  @staticmethod
726
825
  def _make_option_ns():
@@ -787,7 +886,7 @@ class OptionSpinner:
787
886
  # Find all screens that implement this action (have method with name = attr)
788
887
  effective_scope = set()
789
888
  for screen_num, screen_obj in self.stack.screen_objects.items():
790
- if hasattr(screen_obj, ns.attr):
889
+ if hasattr(screen_obj, f'{ns.attr}_ACTION'):
791
890
  effective_scope.add(screen_num)
792
891
  # If no screens found, default to all screens
793
892
  if not effective_scope:
@@ -1186,6 +1285,7 @@ class ConsoleWindow:
1186
1285
  self.last_demo_key = '' # Last key pressed in demo mode
1187
1286
  self.max_header_len = 0 # Max visible header length from previous render
1188
1287
  self.passthrough_mode = False # When True, all printable keys pass through
1288
+ self.relax_handled_keys = self.opts.relax_handled_keys # When True, pass non-nav keys without checking handled_keys
1189
1289
  self._set_screen_dims()
1190
1290
  self.calc()
1191
1291
 
@@ -1551,78 +1651,6 @@ class ConsoleWindow:
1551
1651
  self.body_base = self.head.view_cnt + self.hor_line_cnt
1552
1652
  return not same
1553
1653
 
1554
- def _put(self, ns, *args, context=None):
1555
- """
1556
- Adds text to the head/body pad using a mixed argument list.
1557
-
1558
- Allows interleaving of text (str/bytes) and curses attributes (int).
1559
- Text segments before an attribute are flushed with that attribute.
1560
-
1561
- :param context: Optional Context object with metadata for this line.
1562
- :type context: Context or None
1563
- """
1564
- def flush(attr=None):
1565
- nonlocal self, is_body, row, text, seg, first, attrs_list
1566
- if attr is None:
1567
- attr = curses.A_NORMAL
1568
- # Apply stripping logic if needed
1569
- if is_body and self.pick_mode and self.opts.strip_attrs_in_pick_mode:
1570
- attr = curses.A_NORMAL
1571
-
1572
- if seg and first:
1573
- ns.pad.addstr(row, 0, seg[0:self.get_pad_width()], attr)
1574
- attrs_list.append((attr, len(seg)))
1575
- elif seg:
1576
- _, x = ns.pad.getyx()
1577
- cols = self.get_pad_width() - x
1578
- if cols > 0:
1579
- actual_seg = seg[0:cols]
1580
- ns.pad.addstr(actual_seg, attr)
1581
- attrs_list.append((attr, len(actual_seg)))
1582
- text += seg
1583
- seg, first, attr = '', False, None
1584
-
1585
- is_body = bool(id(ns) == id(self.body))
1586
- if ns.row_cnt < ns.rows:
1587
- row = max(ns.row_cnt, 0)
1588
- text, seg, first = '', '', True
1589
- attrs_list = [] # run-length encoded attributes
1590
- for arg in args:
1591
- if isinstance(arg, bytes):
1592
- arg = arg.decode('utf-8')
1593
- if isinstance(arg, str):
1594
- seg += arg # note: add w/o spacing
1595
- elif arg is None or isinstance(arg, (int)):
1596
- # assume arg is attribute ... flushes text
1597
- flush(attr=arg)
1598
- flush()
1599
- ns.texts.append(text) # text only history
1600
- ns.text_attrs.append(attrs_list) # run-length encoded attributes
1601
- ns.contexts.append(context) # Context metadata (or None)
1602
- ns.row_cnt += 1
1603
-
1604
- def put_head(self, *args, context=None):
1605
- """
1606
- Adds a line of text to the header pad, supporting mixed text and attributes.
1607
-
1608
- :param args: Mixed arguments of str/bytes (text) and int (curses attributes).
1609
- :param context: Optional Context object with metadata for this line.
1610
- :type args: Any
1611
- :type context: Context or None
1612
- """
1613
- self._put(self.head, *args, context=context)
1614
-
1615
- def put_body(self, *args, context=None):
1616
- """
1617
- Adds a line of text to the body pad, supporting mixed text and attributes.
1618
-
1619
- :param args: Mixed arguments of str/bytes (text) and int (curses attributes).
1620
- :param context: Optional Context object with metadata for this line.
1621
- :type args: Any
1622
- :type context: Context or None
1623
- """
1624
- self._put(self.body, *args, context=context)
1625
-
1626
1654
  def _add(self, ns, text, attr=None, resume=False, context=None):
1627
1655
  """Internal method to add text to pad using its namespace (simpler version of _put)."""
1628
1656
  is_body = bool(id(ns) == id(self.body))
@@ -1683,7 +1711,7 @@ class ConsoleWindow:
1683
1711
  """
1684
1712
  self._add(self.body, text, attr, resume, context)
1685
1713
 
1686
- def add_fancy_header(self, line, mode='Underline'):
1714
+ def add_fancy_header(self, line, mode='Underline', search_pattern_attr=None):
1687
1715
  """
1688
1716
  Parses header line and adds it with fancy formatting.
1689
1717
 
@@ -1699,8 +1727,10 @@ class ConsoleWindow:
1699
1727
 
1700
1728
  :param line: The header text to add with formatting
1701
1729
  :param mode: Formatting mode ('Off', 'Underline', or 'Reverse')
1730
+ :param search_pattern_attr: Optional custom attribute for /pattern highlighting
1702
1731
  :type line: str
1703
1732
  :type mode: str
1733
+ :type search_pattern_attr: int or None
1704
1734
  """
1705
1735
  if mode == 'Off':
1706
1736
  # Fancy mode off, just add the line normally
@@ -1765,7 +1795,9 @@ class ConsoleWindow:
1765
1795
  current_text = ""
1766
1796
 
1767
1797
  full_pattern = match.group(0) # includes the /
1768
- result_sections.append((full_pattern, curses.A_BOLD | curses.A_REVERSE))
1798
+ # Use custom attr if provided, otherwise default bold+reverse
1799
+ pattern_attr = search_pattern_attr if search_pattern_attr is not None else (curses.A_BOLD | curses.A_REVERSE)
1800
+ result_sections.append((full_pattern, pattern_attr))
1769
1801
  i += len(full_pattern)
1770
1802
  else:
1771
1803
  # Not a key pattern, just regular character
@@ -2799,7 +2831,10 @@ class ConsoleWindow:
2799
2831
  if self.opts.demo_mode and key not in NAVIGATION_KEYS:
2800
2832
  self.last_demo_key = self._format_key_for_demo(key)
2801
2833
  return key
2802
- elif key in self.handled_keys:
2834
+
2835
+ # If relax_handled_keys is False, only pass keys explicitly in handled_keys
2836
+ # (Navigation keys will still be handled below)
2837
+ if not self.relax_handled_keys and key in self.handled_keys:
2803
2838
  # Update demo mode tracking for non-navigation keys
2804
2839
  if self.opts.demo_mode and key not in NAVIGATION_KEYS:
2805
2840
  self.last_demo_key = self._format_key_for_demo(key)
@@ -2880,6 +2915,13 @@ class ConsoleWindow:
2880
2915
  else:
2881
2916
  # Only render here if we're not going to redraw immediately
2882
2917
  self.render()
2918
+ else:
2919
+ # Navigation key didn't match - if relax_handled_keys is True, pass key to app
2920
+ if self.relax_handled_keys:
2921
+ # Update demo mode tracking for non-navigation keys
2922
+ if self.opts.demo_mode and key not in NAVIGATION_KEYS:
2923
+ self.last_demo_key = self._format_key_for_demo(key)
2924
+ return key
2883
2925
  return None
2884
2926
 
2885
2927
  # =============================================================================
@@ -3214,8 +3256,31 @@ class ScreenStack:
3214
3256
  Args:
3215
3257
  win (ConsoleWindow): The console window instance
3216
3258
  spins_obj: Application object (typically holds OptionSpinner and state)
3259
+ IMPORTANT: If using OptionSpinner, pass your options namespace here
3260
+ to ensure proper object linking. See warning below.
3217
3261
  screens (tuple): Tuple of screen names (e.g., ['HOME', 'SETTINGS', 'HELP'])
3218
3262
  screen_objects (dict): Dict mapping screen numbers to Screen instances
3263
+
3264
+ .. warning::
3265
+ **Initialization Order Matters!**
3266
+
3267
+ When using ScreenStack with OptionSpinner, follow this pattern:
3268
+
3269
+ .. code-block:: python
3270
+
3271
+ # CORRECT - Pass opts to ScreenStack
3272
+ opts = SimpleNamespace()
3273
+ stack = ScreenStack(win, opts, SCREENS, screen_objects)
3274
+ spinner = OptionSpinner(stack=stack)
3275
+ # spinner.default_obj now points to opts via stack.obj
3276
+
3277
+ # INCORRECT - Don't pass None then reassign
3278
+ stack = ScreenStack(win, None, SCREENS, screen_objects)
3279
+ spinner = OptionSpinner(stack=stack)
3280
+ spinner.default_obj = opts # Creates object fragmentation!
3281
+
3282
+ The OptionSpinner syncs with stack.obj, so they must use the same object
3283
+ from the start to avoid different parts of the code using different namespaces.
3219
3284
  """
3220
3285
  self.win = win
3221
3286
  self.obj = spins_obj
@@ -3226,7 +3291,7 @@ class ScreenStack:
3226
3291
  # Push home screen (HOME_ST=0) as the initial screen
3227
3292
  self.push(HOME_ST, 0)
3228
3293
 
3229
- def push(self, screen, prev_pos, force=False):
3294
+ def push(self, screen, prev_pos=None, force=False):
3230
3295
  """
3231
3296
  Push a new screen onto the stack with validation and loop prevention.
3232
3297
 
@@ -3238,6 +3303,8 @@ class ScreenStack:
3238
3303
  Returns:
3239
3304
  Previous position if successful, None if blocked by validation
3240
3305
  """
3306
+ if prev_pos is None:
3307
+ prev_pos = self.win.pick_pos
3241
3308
  # Loop prevention: Check if screen is already on the stack
3242
3309
  if not force and self.curr and screen == self.curr.num:
3243
3310
  # Trying to push the current screen again - ignore
@@ -3285,6 +3352,12 @@ class ScreenStack:
3285
3352
  self.win.pick_pos = self.win.scroll_pos = 0
3286
3353
  return 0
3287
3354
 
3355
+ def get_curr_obj(self):
3356
+ """ get the current screen object """
3357
+ if self.curr:
3358
+ return self.screen_objects.get(self.curr.num, None)
3359
+ return None
3360
+
3288
3361
  def pop(self, force=False):
3289
3362
  """
3290
3363
  Pop the top screen from the stack.
@@ -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.3.2"
26
+ version = "1.3.3"
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