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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: console-window
3
- Version: 1.3.4
3
+ Version: 1.4.1
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
@@ -479,70 +479,198 @@ def restore_ctrl_c():
479
479
 
480
480
 
481
481
  class InlineConfirmation:
482
- """Manages inline confirmation prompts for wipe/verify operations"""
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.confirm_type = None # 'wipe' or 'verify'
487
- self.partition_name = None
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 # 'Y', 'y', 'YES', 'yes', 'device'
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, confirm_type, partition_name, mode):
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.confirm_type = confirm_type
495
- self.partition_name = partition_name
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
- self.mode = mode
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.confirm_type = None
503
- self.partition_name = None
542
+ self.action_type = None
543
+ self.choices = None
504
544
  self.input_buffer = ''
505
545
 
506
- def get_expected(self):
507
- """Get the expected input string"""
508
- if self.mode == 'device':
509
- return self.partition_name
510
- return self.mode
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
- def is_single_key(self):
513
- """Check if this mode uses single key confirmation"""
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
- def handle_key(self, key):
517
- """Handle a key press during confirmation.
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
- Returns:
520
- 'confirmed' - user confirmed
521
- 'cancelled' - user cancelled (ESC)
522
- 'continue' - still gathering input
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
- if key == 27: # ESC
525
- return 'cancelled'
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
- if self.is_single_key():
528
- # Single key confirmation
529
- expected = self.get_expected()
530
- if key == ord(expected):
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
- # Typed confirmation
535
- if 32 <= key <= 126: # Printable ASCII
625
+ # Handle buffered input
626
+ if 32 <= key <= 126:
536
627
  self.input_buffer += chr(key)
537
- elif key in (curses.KEY_BACKSPACE, 127, 8): # 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 (curses.KEY_ENTER, 10): # ENTER
540
- if self.input_buffer == self.get_expected():
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
- # Wrong input - reset
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
- self.opts.demo_mode = enabled
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 printable keys plus special editing keys
2824
- # Special editing keys: backspace, arrows, home, end
2825
- editing_keys = {curses.KEY_BACKSPACE, 127, 8, 263, # backspace variants
2826
- curses.KEY_LEFT, curses.KEY_RIGHT,
2827
- curses.KEY_HOME, curses.KEY_END,
2828
- 1, 5} # Ctrl-A, Ctrl-E
2829
- if self.passthrough_mode and (32 <= key <= 126 or key in editing_keys or key in self.handled_keys):
2830
- # Update demo mode tracking for non-navigation keys
2831
- if self.opts.demo_mode and key not in NAVIGATION_KEYS:
2832
- self.last_demo_key = self._format_key_for_demo(key)
2833
- return key
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.3.4"
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