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.
- console_window-1.3.3/ConsoleWindowDemo.py +559 -0
- {console_window-1.3.2 → console_window-1.3.3}/PKG-INFO +1 -3
- {console_window-1.3.2 → console_window-1.3.3}/README.md +0 -2
- {console_window-1.3.2 → console_window-1.3.3}/console_window/ConsoleWindow.py +161 -88
- {console_window-1.3.2 → console_window-1.3.3}/pyproject.toml +1 -1
- {console_window-1.3.2 → console_window-1.3.3}/.gitignore +0 -0
- {console_window-1.3.2 → console_window-1.3.3}/LICENSE +0 -0
- {console_window-1.3.2 → console_window-1.3.3}/console_window/__init__.py +0 -0
|
@@ -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.
|
|
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
|
|
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}
|
|
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.
|
|
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
|
-
#
|
|
722
|
-
if self.stack
|
|
723
|
-
self.stack.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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|