console-window 1.3.4__tar.gz → 1.4.1__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.4 → console_window-1.4.1}/PKG-INFO +1 -1
- {console_window-1.3.4 → console_window-1.4.1}/console_window/ConsoleWindow.py +195 -52
- {console_window-1.3.4 → console_window-1.4.1}/pyproject.toml +1 -1
- {console_window-1.3.4 → console_window-1.4.1}/.gitignore +0 -0
- {console_window-1.3.4 → console_window-1.4.1}/ConsoleWindowDemo.py +0 -0
- {console_window-1.3.4 → console_window-1.4.1}/LICENSE +0 -0
- {console_window-1.3.4 → console_window-1.4.1}/README.md +0 -0
- {console_window-1.3.4 → console_window-1.4.1}/console_window/__init__.py +0 -0
|
@@ -479,70 +479,198 @@ def restore_ctrl_c():
|
|
|
479
479
|
|
|
480
480
|
|
|
481
481
|
class InlineConfirmation:
|
|
482
|
-
"""
|
|
482
|
+
"""
|
|
483
|
+
Manages stateful inline confirmation prompts for destructive or critical operations.
|
|
484
|
+
|
|
485
|
+
This class handles the transition from standard application input to a
|
|
486
|
+
confirmation sub-state, supporting several verification modes. When a match
|
|
487
|
+
is found in 'choices' mode, the input_buffer is updated to the full
|
|
488
|
+
canonical string so the calling application can use it directly.
|
|
489
|
+
|
|
490
|
+
Modes:
|
|
491
|
+
- 'Y'/'y': Single-key immediate confirmation.
|
|
492
|
+
- 'yes'/'YES': Explicit string confirmation (case-sensitive for 'YES').
|
|
493
|
+
- 'identity': Requires typing a specific string (e.g., a device node like 'sda1').
|
|
494
|
+
- 'choices': Flexible matching against a list of valid strings.
|
|
495
|
+
|
|
496
|
+
:param min_abbrev_chars: Minimum characters required for abbreviated choice matching.
|
|
497
|
+
When None (default), automatically calculates based on choices:
|
|
498
|
+
(min chars to disambiguate) + 1, capped at longest choice length.
|
|
499
|
+
:type min_abbrev_chars: int or None
|
|
500
|
+
|
|
501
|
+
Methods:
|
|
502
|
+
- get_hint(): Returns a hint string when input_buffer is empty (display in dimmed/italic style)
|
|
503
|
+
"""
|
|
504
|
+
MODES = 'Y y YES yes identity choices'.split()
|
|
483
505
|
|
|
484
|
-
def __init__(self):
|
|
506
|
+
def __init__(self, min_abbrev_chars=None):
|
|
485
507
|
self.active = False
|
|
486
|
-
self.
|
|
487
|
-
self.
|
|
508
|
+
self.action_type = None # e.g., 'wipe' or 'verify'
|
|
509
|
+
self.identity = None # e.g., 'sda1'
|
|
510
|
+
self.choices = None
|
|
488
511
|
self.input_buffer = ''
|
|
489
|
-
self.mode = None
|
|
512
|
+
self.mode = None
|
|
513
|
+
self.min_abbrev_chars = min_abbrev_chars
|
|
514
|
+
self._calculated_min_abbrev = None # Cached calculated value
|
|
490
515
|
|
|
491
|
-
def start(self,
|
|
492
|
-
"""Start a confirmation prompt"""
|
|
516
|
+
def start(self, action_type=None, identity=None, choices=None, mode=None, min_abbrev_chars=None):
|
|
493
517
|
self.active = True
|
|
494
|
-
self.
|
|
495
|
-
self.
|
|
518
|
+
self.action_type = action_type
|
|
519
|
+
self.identity = identity
|
|
520
|
+
self.choices = (choices if isinstance(choices, (list, tuple))
|
|
521
|
+
else [choices] if isinstance(choices, str) else None)
|
|
496
522
|
self.input_buffer = ''
|
|
497
|
-
|
|
523
|
+
|
|
524
|
+
# Override min_abbrev_chars if provided
|
|
525
|
+
if min_abbrev_chars is not None:
|
|
526
|
+
self.min_abbrev_chars = min_abbrev_chars
|
|
527
|
+
self._calculated_min_abbrev = None
|
|
528
|
+
else:
|
|
529
|
+
# Reset calculated value so it's recalculated for new choices
|
|
530
|
+
self._calculated_min_abbrev = None
|
|
531
|
+
|
|
532
|
+
# Logic to determine mode based on provided arguments
|
|
533
|
+
self.mode = 'choices' if self.choices else mode if mode else 'yes'
|
|
534
|
+
if self.mode not in self.MODES:
|
|
535
|
+
self.mode = 'yes'
|
|
536
|
+
if self.mode == 'choices' and not self.choices:
|
|
537
|
+
self.mode = 'yes'
|
|
498
538
|
|
|
499
539
|
def cancel(self):
|
|
500
540
|
"""Cancel the confirmation"""
|
|
501
541
|
self.active = False
|
|
502
|
-
self.
|
|
503
|
-
self.
|
|
542
|
+
self.action_type = None
|
|
543
|
+
self.choices = None
|
|
504
544
|
self.input_buffer = ''
|
|
505
545
|
|
|
506
|
-
def
|
|
507
|
-
"""
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
546
|
+
def _calculate_min_abbrev_chars(self):
|
|
547
|
+
"""
|
|
548
|
+
Calculate minimum prefix length needed to disambiguate choices.
|
|
549
|
+
Returns: min_length + 1, capped at longest choice length.
|
|
550
|
+
"""
|
|
551
|
+
if not self.choices or len(self.choices) <= 1:
|
|
552
|
+
return 1
|
|
511
553
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
return self.mode in ('Y', 'y')
|
|
554
|
+
# Find minimum prefix length to distinguish all choices (case-insensitive)
|
|
555
|
+
max_len = max(len(c) for c in self.choices)
|
|
515
556
|
|
|
516
|
-
|
|
517
|
-
|
|
557
|
+
for prefix_len in range(1, max_len + 1):
|
|
558
|
+
prefixes = {}
|
|
559
|
+
for choice in self.choices:
|
|
560
|
+
prefix = choice[:prefix_len].lower()
|
|
561
|
+
if prefix in prefixes:
|
|
562
|
+
# Collision found
|
|
563
|
+
break
|
|
564
|
+
prefixes[prefix] = choice
|
|
565
|
+
else:
|
|
566
|
+
# No collision at this length - we can disambiguate
|
|
567
|
+
# Add 1 to make errors less likely, but cap at longest choice
|
|
568
|
+
return min(prefix_len + 1, max_len)
|
|
518
569
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
570
|
+
# If we get here, choices can't be disambiguated (e.g., "Yes" and "yes")
|
|
571
|
+
# Still return a reasonable value
|
|
572
|
+
return max_len
|
|
573
|
+
|
|
574
|
+
def _get_effective_min_abbrev_chars(self):
|
|
575
|
+
"""Get the effective min_abbrev_chars value (explicit or calculated)."""
|
|
576
|
+
if self.min_abbrev_chars is not None:
|
|
577
|
+
return self.min_abbrev_chars
|
|
578
|
+
|
|
579
|
+
# Calculate if not cached
|
|
580
|
+
if self._calculated_min_abbrev is None:
|
|
581
|
+
self._calculated_min_abbrev = self._calculate_min_abbrev_chars()
|
|
582
|
+
|
|
583
|
+
return self._calculated_min_abbrev
|
|
584
|
+
|
|
585
|
+
def _resolve_choice(self):
|
|
523
586
|
"""
|
|
524
|
-
|
|
525
|
-
|
|
587
|
+
Tiered matching logic for choices. Returns the full string if
|
|
588
|
+
a unique match is found, otherwise None.
|
|
589
|
+
"""
|
|
590
|
+
buf = self.input_buffer
|
|
591
|
+
if not buf: return None
|
|
592
|
+
buf_l = buf.lower()
|
|
593
|
+
|
|
594
|
+
# 1. Perfect matches
|
|
595
|
+
if buf in self.choices: return buf
|
|
596
|
+
|
|
597
|
+
# 2. Case-insensitive matches
|
|
598
|
+
ci_matches = [c for c in self.choices if c.lower() == buf_l]
|
|
599
|
+
if len(ci_matches) == 1: return ci_matches[0]
|
|
600
|
+
|
|
601
|
+
# 3. Leading substring (min_abbrev_chars+ chars only)
|
|
602
|
+
min_chars = self._get_effective_min_abbrev_chars()
|
|
603
|
+
if len(buf) >= min_chars:
|
|
604
|
+
# Case-sensitive substring
|
|
605
|
+
ss_matches = [c for c in self.choices if c.startswith(buf)]
|
|
606
|
+
if len(ss_matches) == 1: return ss_matches[0]
|
|
526
607
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
608
|
+
# Case-insensitive substring
|
|
609
|
+
ss_ci_matches = [c for c in self.choices if c.lower().startswith(buf_l)]
|
|
610
|
+
if len(ss_ci_matches) == 1: return ss_ci_matches[0]
|
|
611
|
+
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
def handle_key(self, key):
|
|
615
|
+
if key == 27: return 'cancelled'
|
|
616
|
+
|
|
617
|
+
# Handle single-key modes
|
|
618
|
+
if self.mode in ('Y', 'y'):
|
|
619
|
+
expected = self.identity if self.mode == 'identity' else self.mode
|
|
620
|
+
if key == ord(expected[0]):
|
|
621
|
+
self.input_buffer = expected
|
|
531
622
|
return 'confirmed'
|
|
532
623
|
return 'continue'
|
|
533
624
|
|
|
534
|
-
#
|
|
535
|
-
if 32 <= key <= 126:
|
|
625
|
+
# Handle buffered input
|
|
626
|
+
if 32 <= key <= 126:
|
|
536
627
|
self.input_buffer += chr(key)
|
|
537
|
-
elif key in (curses.KEY_BACKSPACE,
|
|
628
|
+
elif key in (127, 8, curses.KEY_BACKSPACE, 263): # Backspace (all variants)
|
|
538
629
|
self.input_buffer = self.input_buffer[:-1]
|
|
539
|
-
elif key in (
|
|
540
|
-
|
|
630
|
+
elif key in (10, 13): # Enter
|
|
631
|
+
match = None
|
|
632
|
+
|
|
633
|
+
if self.mode == 'choices':
|
|
634
|
+
match = self._resolve_choice()
|
|
635
|
+
elif self.mode == 'identity':
|
|
636
|
+
if self.input_buffer == self.identity: match = self.identity
|
|
637
|
+
elif self.mode == 'YES':
|
|
638
|
+
if self.input_buffer == 'YES': match = 'YES'
|
|
639
|
+
else: # 'yes' or other permissive modes
|
|
640
|
+
if self.input_buffer.lower() == 'yes': match = self.input_buffer
|
|
641
|
+
|
|
642
|
+
if match:
|
|
643
|
+
self.input_buffer = match # Canonicalize
|
|
541
644
|
return 'confirmed'
|
|
542
|
-
|
|
543
|
-
self.input_buffer = ''
|
|
645
|
+
|
|
646
|
+
self.input_buffer = '' # Reset on failure
|
|
647
|
+
|
|
544
648
|
return 'continue'
|
|
545
649
|
|
|
650
|
+
def get_hint(self):
|
|
651
|
+
"""
|
|
652
|
+
Returns a hint string for display when input_buffer is empty.
|
|
653
|
+
Intended to be shown in dimmed/italic style.
|
|
654
|
+
"""
|
|
655
|
+
if self.input_buffer:
|
|
656
|
+
return ''
|
|
657
|
+
|
|
658
|
+
if self.mode in ('Y', 'y'):
|
|
659
|
+
return f'Press {self.mode}'
|
|
660
|
+
elif self.mode == 'YES':
|
|
661
|
+
return 'Type YES + ENTER or ESC'
|
|
662
|
+
elif self.mode == 'yes':
|
|
663
|
+
return 'Type yes + ENTER or ESC'
|
|
664
|
+
elif self.mode == 'identity':
|
|
665
|
+
return f'Type {self.identity} + ENTER or ESC'
|
|
666
|
+
elif self.mode == 'choices':
|
|
667
|
+
if len(self.choices) == 1:
|
|
668
|
+
return f'Type {self.choices[0]} + ENTER or ESC'
|
|
669
|
+
else:
|
|
670
|
+
min_chars = self._get_effective_min_abbrev_chars()
|
|
671
|
+
return f'{min_chars}+ chars + ENTER or ESC'
|
|
672
|
+
return ''
|
|
673
|
+
|
|
546
674
|
|
|
547
675
|
class IncrementalSearchBar:
|
|
548
676
|
"""
|
|
@@ -902,6 +1030,14 @@ class OptionSpinner:
|
|
|
902
1030
|
|
|
903
1031
|
# Process each key for scope subtraction
|
|
904
1032
|
for key in ns.keys:
|
|
1033
|
+
# Check for conflicts with navigation keys
|
|
1034
|
+
if key in NAVIGATION_KEYS:
|
|
1035
|
+
key_char = chr(key) if 32 <= key < 127 else f'<{key}>'
|
|
1036
|
+
raise ValueError(
|
|
1037
|
+
f'Key {key_char} ({key}) conflicts with a navigation key. '
|
|
1038
|
+
f'Navigation keys are reserved for cursor movement and scrolling.'
|
|
1039
|
+
)
|
|
1040
|
+
|
|
905
1041
|
# Perform subtraction FIRST: remove new scope from existing options with same key
|
|
906
1042
|
for existing_ns in self.options:
|
|
907
1043
|
if key in existing_ns.keys and hasattr(existing_ns, 'effective_scope'):
|
|
@@ -1308,7 +1444,7 @@ class ConsoleWindow:
|
|
|
1308
1444
|
|
|
1309
1445
|
def set_demo_mode(self, enabled):
|
|
1310
1446
|
"""
|
|
1311
|
-
Enable or disable demo mode.
|
|
1447
|
+
Enable or disable demo mode. Passing None will toggle it.
|
|
1312
1448
|
|
|
1313
1449
|
When demo mode is enabled, the last non-navigation key pressed is shown
|
|
1314
1450
|
in reverse video at the end of the first header line.
|
|
@@ -1316,9 +1452,13 @@ class ConsoleWindow:
|
|
|
1316
1452
|
:param enabled: True to enable demo mode, False to disable
|
|
1317
1453
|
:type enabled: bool
|
|
1318
1454
|
"""
|
|
1319
|
-
|
|
1455
|
+
if enabled is None: # toggle if not explicit
|
|
1456
|
+
self.opts.demo_mode = not (self.opts.demo_mode)
|
|
1457
|
+
else:
|
|
1458
|
+
self.opts.demo_mode = bool(enabled)
|
|
1320
1459
|
if not enabled:
|
|
1321
1460
|
self.last_demo_key = ''
|
|
1461
|
+
return self.opts.demo_mode
|
|
1322
1462
|
|
|
1323
1463
|
def _format_key_for_demo(self, key):
|
|
1324
1464
|
"""
|
|
@@ -2820,17 +2960,20 @@ class ConsoleWindow:
|
|
|
2820
2960
|
break
|
|
2821
2961
|
|
|
2822
2962
|
# App keys...
|
|
2823
|
-
# In passthrough mode, return all
|
|
2824
|
-
#
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2963
|
+
# In passthrough mode, return all keys to the application (printable, editing, and navigation)
|
|
2964
|
+
# This allows InlineConfirmation and IncrementalSearchBar to decide which keys to handle
|
|
2965
|
+
if self.passthrough_mode:
|
|
2966
|
+
# Return all printable chars, editing keys, navigation keys, and handled_keys
|
|
2967
|
+
# Let the application decide what to do with each key
|
|
2968
|
+
editing_keys = {curses.KEY_BACKSPACE, 127, 8, 263, # backspace variants
|
|
2969
|
+
curses.KEY_LEFT, curses.KEY_RIGHT,
|
|
2970
|
+
curses.KEY_HOME, curses.KEY_END,
|
|
2971
|
+
1, 5} # Ctrl-A, Ctrl-E
|
|
2972
|
+
if (32 <= key <= 126 or key in editing_keys or key in NAVIGATION_KEYS or key in self.handled_keys):
|
|
2973
|
+
# Update demo mode tracking for non-navigation keys
|
|
2974
|
+
if self.opts.demo_mode and key not in NAVIGATION_KEYS:
|
|
2975
|
+
self.last_demo_key = self._format_key_for_demo(key)
|
|
2976
|
+
return key
|
|
2834
2977
|
|
|
2835
2978
|
# If relax_handled_keys is False, only pass keys explicitly in handled_keys
|
|
2836
2979
|
# (Navigation keys will still be handled below)
|
|
@@ -23,7 +23,7 @@ name = "console_window"
|
|
|
23
23
|
# UNIFIED PROJECT METADATA (Replaces [tool.flit.metadata])
|
|
24
24
|
[project]
|
|
25
25
|
name = "console-window"
|
|
26
|
-
version = "1.
|
|
26
|
+
version = "1.4.1"
|
|
27
27
|
description = "A robust curses wrapper for creating Text User Interfaces (TUIs)."
|
|
28
28
|
readme = "README.md"
|
|
29
29
|
requires-python = ">=3.8"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|