chipfoundry-cli 1.2.8__tar.gz → 1.2.9__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,15 +1,14 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: chipfoundry-cli
3
- Version: 1.2.8
3
+ Version: 1.2.9
4
4
  Summary: CLI tool to automate ChipFoundry project submission to SFTP server
5
5
  Home-page: https://chipfoundry.io
6
6
  License: Apache-2.0
7
7
  Author: ChipFoundry
8
8
  Author-email: marwan.abbas@chipfoundry.io
9
- Requires-Python: >=3.8.0
9
+ Requires-Python: >=3.8.1,<4.0
10
10
  Classifier: License :: OSI Approved :: Apache Software License
11
11
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.8
13
12
  Classifier: Programming Language :: Python :: 3.9
14
13
  Classifier: Programming Language :: Python :: 3.10
15
14
  Classifier: Programming Language :: Python :: 3.11
@@ -19,6 +18,7 @@ Requires-Dist: click (>=8.0.0,<9)
19
18
  Requires-Dist: httpx (>=0.24.0,<1.0)
20
19
  Requires-Dist: paramiko (>=3.0.0,<4)
21
20
  Requires-Dist: rich (>=13,<14)
21
+ Requires-Dist: textual (>=0.40.0,<1)
22
22
  Requires-Dist: toml (>=0.10,<1.0)
23
23
  Project-URL: Repository, https://github.com/chipfoundry/cf-cli
24
24
  Description-Content-Type: text/markdown
@@ -23,6 +23,13 @@ import sys
23
23
  import shutil
24
24
  import signal
25
25
 
26
+ # Textual imports for GPIO grid UI
27
+ from textual.app import App, ComposeResult
28
+ from textual.widgets import Button, Static, Footer, Header, Label
29
+ from textual.containers import Grid, Horizontal, Vertical, Container, ScrollableContainer
30
+ from textual.binding import Binding
31
+ from textual.screen import ModalScreen
32
+
26
33
  DEFAULT_SSH_KEY = os.path.expanduser('~/.ssh/chipfoundry-key')
27
34
  DEFAULT_SFTP_HOST = 'sftp.chipfoundry.io'
28
35
 
@@ -398,7 +405,7 @@ def gpio_config(project_root):
398
405
 
399
406
  # For openframe, GPIO config is not needed
400
407
  if project_type == 'openframe':
401
- console.print("[red]GPIO configuration is not available for openframe projects.[/red]")
408
+ console.print("[red]GPIO configuration is not available for openframe projects.[/red]")
402
409
  console.print("[yellow]Openframe projects do not use user_defines.v.[/yellow]")
403
410
  raise click.Abort()
404
411
 
@@ -407,154 +414,738 @@ def gpio_config(project_root):
407
414
  # Load existing GPIO configs from project.json or user_defines.v
408
415
  existing_configs = get_gpio_config_from_project_json(str(project_json_path))
409
416
  if not existing_configs and user_defines_path.exists():
410
- # Try to parse from user_defines.v
411
417
  existing_configs = parse_user_defines_v(str(user_defines_path))
412
418
 
413
419
  # Determine GPIO range based on project type
414
420
  if project_type == 'analog': # caravan
415
- # Caravan: GPIO 5-13 and 25-37 (GPIO 14-24 not available)
416
- # User sees: 5-13, then 14-26 (which map to 25-37 internally)
417
421
  available_gpios = list(range(5, 14)) + list(range(25, 38))
418
422
  user_to_real_map = {}
419
423
  user_num = 5
420
424
  for real_gpio in available_gpios:
421
425
  user_to_real_map[user_num] = real_gpio
422
426
  user_num += 1
423
- real_to_user_map = {v: k for k, v in user_to_real_map.items()}
424
- console.print("\n[bold cyan]GPIO Configuration (Caravan)[/bold cyan]")
425
- console.print("Configure GPIO pins 5-13, then 14-26 (GPIO 0-4 are fixed system pins)\n")
426
- console.print("[dim]Note: GPIO 14-24 are not available in Caravan. Numbers 14-26 map to GPIO 25-37.[/dim]\n")
427
+ gpio_label = "Caravan"
428
+ gpio_note = "Note: GPIO 14-24 unavailable. Numbers 14-26 map to GPIO 25-37."
429
+ user_gpio_range = list(range(5, 27))
427
430
  else: # digital (caravel)
428
- # Caravel: GPIO 5-37 all available
429
431
  available_gpios = list(range(5, 38))
430
432
  user_to_real_map = {gpio: gpio for gpio in available_gpios}
431
- real_to_user_map = {gpio: gpio for gpio in available_gpios}
432
- console.print("\n[bold cyan]GPIO Configuration (Caravel)[/bold cyan]")
433
- console.print("Configure GPIO pins 5-37 (GPIO 0-4 are fixed system pins)\n")
434
-
435
- # Create a list of GPIO mode options for selection, excluding "invalid"
436
- mode_options = [key for key in GPIO_MODES.keys() if key != "invalid"]
437
-
438
- # Show modes in a more compact table format
439
- table = Table(title="Available GPIO Modes", show_header=True, header_style="bold cyan")
440
- table.add_column("#", style="dim", width=4)
441
- table.add_column("Key", style="cyan", width=20)
442
- table.add_column("Description", style="white")
443
-
444
- for i, key in enumerate(mode_options, 1):
445
- table.add_row(str(i), key, GPIO_MODE_DESCRIPTIONS[key])
433
+ gpio_label = "Caravel"
434
+ gpio_note = None
435
+ user_gpio_range = list(range(5, 38))
436
+
437
+ total_pins = len(user_to_real_map)
438
+
439
+ # Mode shortcuts - short names that map to full mode keys
440
+ MODE_SHORTCUTS = {
441
+ "out": "user_output", "output": "user_output", "o": "user_output",
442
+ "in": "user_input_nopull", "input": "user_input_nopull", "i": "user_input_nopull",
443
+ "in-pd": "user_input_pulldown", "input-pd": "user_input_pulldown", "pulldown": "user_input_pulldown",
444
+ "in-pu": "user_input_pullup", "input-pu": "user_input_pullup", "pullup": "user_input_pullup",
445
+ "bidir": "user_bidirectional", "bidirectional": "user_bidirectional", "b": "user_bidirectional",
446
+ "analog": "user_analog", "ana": "user_analog", "a": "user_analog",
447
+ "out-mon": "user_output_monitored", "monitored": "user_output_monitored",
448
+ # Management modes
449
+ "mgmt-out": "mgmt_output", "mgmt-in": "mgmt_input_nopull",
450
+ "mgmt-bidir": "mgmt_bidirectional", "mgmt-analog": "mgmt_analog",
451
+ }
446
452
 
447
- console.print(table)
448
- console.print("[dim]Tip: Enter number or full key. [/dim]\n")
453
+ def resolve_mode(mode_str):
454
+ """Resolve a mode string (shortcut or full name) to the mode key."""
455
+ mode_str = mode_str.lower().strip()
456
+ if mode_str in MODE_SHORTCUTS:
457
+ return MODE_SHORTCUTS[mode_str]
458
+ # Check if it's already a valid mode key
459
+ mode_options = [key for key in GPIO_MODES.keys() if key != "invalid"]
460
+ if mode_str in mode_options:
461
+ return mode_str
462
+ # Partial match
463
+ matches = [m for m in mode_options if m.startswith(mode_str)]
464
+ if len(matches) == 1:
465
+ return matches[0]
466
+ return None
449
467
 
450
- gpio_configs = {}
468
+ def parse_gpio_range(input_str, valid_gpios):
469
+ """Parse '5-10', '5,7,9', or '5-10,15' into list of GPIO numbers."""
470
+ selected = set()
471
+ parts = input_str.replace(' ', '').split(',')
472
+ for part in parts:
473
+ if '-' in part:
474
+ try:
475
+ start, end = part.split('-', 1)
476
+ for g in range(int(start), int(end) + 1):
477
+ if g in valid_gpios:
478
+ selected.add(g)
479
+ except ValueError:
480
+ pass
481
+ else:
482
+ try:
483
+ g = int(part)
484
+ if g in valid_gpios:
485
+ selected.add(g)
486
+ except ValueError:
487
+ pass
488
+ return sorted(selected)
489
+
490
+ def format_gpio_ranges(gpio_list):
491
+ """Convert [5,6,7,10,11,15] to '5-7, 10-11, 15'."""
492
+ if not gpio_list:
493
+ return "-"
494
+ gpio_list = sorted(gpio_list)
495
+ ranges = []
496
+ start = gpio_list[0]
497
+ end = start
498
+ for g in gpio_list[1:]:
499
+ if g == end + 1:
500
+ end = g
501
+ else:
502
+ ranges.append(f"{start}-{end}" if start != end else str(start))
503
+ start = end = g
504
+ ranges.append(f"{start}-{end}" if start != end else str(start))
505
+ return ", ".join(ranges)
451
506
 
452
- # Helper function to find mode key from mode name or hex value
453
507
  def find_mode_key(mode_value):
454
508
  """Find the mode key for a given mode value."""
455
509
  if not mode_value:
456
510
  return None
457
- # Direct match
458
511
  for key, mode_name in GPIO_MODES.items():
459
- if mode_name == mode_value:
460
- # Don't return "invalid" if it's not in our selectable options
461
- if key != "invalid":
462
- return key
463
- # Check if it's a hex value or invalid - return None to indicate invalid
464
- if mode_value.startswith('0x') or mode_value.startswith("13'h") or 'INVALID' in mode_value:
465
- return None # Don't show "invalid", just show no default
512
+ if mode_name == mode_value and key != "invalid":
513
+ return key
466
514
  return None
467
515
 
468
- # Helper function to check if a mode is invalid
469
- def is_invalid_mode(mode_value):
470
- """Check if a mode value is invalid."""
471
- if not mode_value:
472
- return True
473
- if mode_value == GPIO_MODES.get("invalid"):
474
- return True
475
- if mode_value.startswith('0x') or mode_value.startswith("13'h") or 'INVALID' in mode_value:
476
- return True
477
- return False
516
+ def display_summary(gpio_configs, user_to_real_map):
517
+ """Display a summary of GPIO configuration grouped by mode."""
518
+ mode_groups = {}
519
+ for user_gpio, real_gpio in user_to_real_map.items():
520
+ mode_value = gpio_configs.get(real_gpio)
521
+ mode_key = find_mode_key(mode_value) if mode_value else None
522
+ if mode_key not in mode_groups:
523
+ mode_groups[mode_key] = []
524
+ mode_groups[mode_key].append(user_gpio)
525
+
526
+ console.print()
527
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
528
+ table.add_column("Mode", style="cyan", width=24)
529
+ table.add_column("Count", justify="right", width=6)
530
+ table.add_column("GPIOs", style="white")
531
+
532
+ # Sort by count (most common first)
533
+ for mode_key in sorted(mode_groups.keys(), key=lambda k: -len(mode_groups[k])):
534
+ gpios = mode_groups[mode_key]
535
+ display_name = mode_key if mode_key else "[red]unconfigured[/red]"
536
+ style = ""
537
+ if mode_key:
538
+ if "output" in mode_key: style = "[green]"
539
+ elif "input" in mode_key: style = "[cyan]"
540
+ elif "bidirectional" in mode_key: style = "[yellow]"
541
+ elif "analog" in mode_key: style = "[magenta]"
542
+ display_name = f"{style}{mode_key}[/]"
543
+ table.add_row(display_name, str(len(gpios)), format_gpio_ranges(gpios))
544
+
545
+ console.print(table)
546
+
547
+ # Initialize gpio_configs
548
+ gpio_configs = existing_configs.copy() if existing_configs else {}
549
+
550
+ # ========================
551
+ # HEADER
552
+ # ========================
553
+
554
+
555
+ def get_mode_display(mode_key):
556
+ """Get display name for a mode."""
557
+ names = {
558
+ "user_output": "user output",
559
+ "user_output_monitored": "user output monitored",
560
+ "user_input_nopull": "user input",
561
+ "user_input_pulldown": "user input pulldown",
562
+ "user_input_pullup": "user input pullup",
563
+ "user_bidirectional": "user bidirectional",
564
+ "user_analog": "user analog",
565
+ "mgmt_output": "mgmt output",
566
+ "mgmt_input_nopull": "mgmt input",
567
+ "mgmt_input_pulldown": "mgmt input pulldown",
568
+ "mgmt_input_pullup": "mgmt input pullup",
569
+ "mgmt_bidirectional": "mgmt bidirectional",
570
+ "mgmt_analog": "mgmt analog",
571
+ }
572
+ return names.get(mode_key, "not set")
573
+
574
+ def get_mode_color(mode_key):
575
+ """Get color for a mode."""
576
+ if not mode_key:
577
+ return "red"
578
+ elif "output" in mode_key:
579
+ return "green"
580
+ elif "input" in mode_key:
581
+ return "cyan"
582
+ elif "bidirectional" in mode_key:
583
+ return "yellow"
584
+ elif "analog" in mode_key:
585
+ return "magenta"
586
+ return "white"
587
+
588
+ # ========================
589
+ # STEP 2: Textual Grid UI for GPIO Configuration
590
+ # ========================
591
+
592
+ # All available modes for the selector
593
+ ALL_MODES = [
594
+ ("user_output", "User Output"),
595
+ ("user_output_monitored", "User Output Monitored"),
596
+ ("user_input_nopull", "User Input (no pull)"),
597
+ ("user_input_pullup", "User Input (pull-up)"),
598
+ ("user_input_pulldown", "User Input (pull-down)"),
599
+ ("user_bidirectional", "User Bidirectional"),
600
+ ("user_analog", "User Analog"),
601
+ ("mgmt_output", "Mgmt Output"),
602
+ ("mgmt_input_nopull", "Mgmt Input (no pull)"),
603
+ ("mgmt_input_pullup", "Mgmt Input (pull-up)"),
604
+ ("mgmt_input_pulldown", "Mgmt Input (pull-down)"),
605
+ ("mgmt_bidirectional", "Mgmt Bidirectional"),
606
+ ("mgmt_analog", "Mgmt Analog"),
607
+ ]
608
+
609
+ class NoKeyScrollContainer(ScrollableContainer):
610
+ """ScrollableContainer that doesn't capture arrow keys."""
611
+ can_focus = False
612
+ BINDINGS = []
613
+
614
+ class GPIOButton(Static):
615
+ """A widget representing a single GPIO pin."""
616
+
617
+ can_focus = True
618
+
619
+ def __init__(self, gpio_num: int, mode_key: str = None, **kwargs):
620
+ self.gpio_num = gpio_num
621
+ self.mode_key = mode_key
622
+ self.is_selected = False
623
+ # Create initial label - use two lines
624
+ abbrev = get_mode_display(mode_key) if mode_key else "not set"
625
+ super().__init__(f"[b]{gpio_num}[/b]\n{abbrev}", **kwargs)
626
+ self.id = f"gpio_{gpio_num}"
627
+
628
+ def _update_display(self):
629
+ """Update the display text."""
630
+ abbrev = get_mode_display(self.mode_key) if self.mode_key else "not set"
631
+ self.update(f"[b]{self.gpio_num}[/b]\n{abbrev}")
632
+
633
+ def _update_style(self):
634
+ """Update widget style based on mode and selection."""
635
+ color = get_mode_color(self.mode_key)
636
+ if self.is_selected:
637
+ self.add_class("selected")
638
+ else:
639
+ self.remove_class("selected")
640
+ self.styles.color = color
641
+ self.styles.border = ("solid", color)
642
+
643
+ def on_mount(self):
644
+ """Set initial style on mount."""
645
+ self._update_style()
646
+
647
+ def set_mode(self, mode_key: str):
648
+ """Update the GPIO mode."""
649
+ self.mode_key = mode_key
650
+ self._update_display()
651
+ self._update_style()
652
+
653
+ def toggle_selected(self):
654
+ """Toggle selection state."""
655
+ self.is_selected = not self.is_selected
656
+ self._update_style()
657
+
658
+ def deselect(self):
659
+ """Clear selection."""
660
+ self.is_selected = False
661
+ self._update_style()
662
+
663
+ def on_click(self):
664
+ """Handle click - toggle selection."""
665
+ self.toggle_selected()
666
+ # Update current index to this button
667
+ try:
668
+ self.app.current_index = self.app.gpio_list.index(self.gpio_num)
669
+ self.app._highlight_current()
670
+ except (ValueError, AttributeError):
671
+ pass
672
+ self.app._update_status()
478
673
 
479
- # Helper function to find matching mode by partial input
480
- def find_matching_mode(user_input):
481
- """Find mode that matches user input (partial match or full key)."""
482
- user_input_lower = user_input.lower()
674
+ class ModeSelectScreen(ModalScreen):
675
+ """Modal screen for selecting GPIO mode."""
483
676
 
484
- # Check for exact key match
485
- if user_input in mode_options:
486
- return user_input
677
+ BINDINGS = [
678
+ Binding("escape", "cancel", "Cancel"),
679
+ Binding("up", "move_up", "Up", show=False, priority=True),
680
+ Binding("down", "move_down", "Down", show=False, priority=True),
681
+ Binding("enter", "select_current", "Select", show=False, priority=True),
682
+ ]
487
683
 
488
- # Check for partial matches (case-insensitive)
489
- matches = [key for key in mode_options if key.lower().startswith(user_input_lower)]
490
- if len(matches) == 1:
491
- return matches[0]
492
- elif len(matches) > 1:
493
- # Multiple matches - return list so we can show them
494
- return matches
684
+ CSS = """
685
+ ModeSelectScreen {
686
+ align: center middle;
687
+ }
495
688
 
496
- return None
497
-
498
- # Configure each available GPIO
499
- for user_gpio_num in sorted(user_to_real_map.keys()):
500
- real_gpio_num = user_to_real_map[user_gpio_num]
689
+ #mode-dialog {
690
+ width: 60;
691
+ height: auto;
692
+ max-height: 90%;
693
+ padding: 1 2;
694
+ background: $surface;
695
+ border: solid cyan;
696
+ overflow-y: auto;
697
+ }
501
698
 
502
- # Get current value if exists (using real GPIO number)
503
- current_mode = existing_configs.get(real_gpio_num) if existing_configs else None
504
- detected_key = find_mode_key(current_mode) if current_mode else None
505
- is_invalid = is_invalid_mode(current_mode)
699
+ #mode-title {
700
+ text-align: center;
701
+ text-style: bold;
702
+ margin-bottom: 1;
703
+ color: cyan;
704
+ }
506
705
 
507
- # Show GPIO number with mapping info for caravan
508
- gpio_display = f"GPIO {user_gpio_num}"
509
- if project_type == 'analog' and user_gpio_num >= 14:
510
- # Show the real GPIO number for caravan
511
- gpio_display = f"GPIO {user_gpio_num} (GPIO {real_gpio_num})"
706
+ .section-label {
707
+ text-style: bold;
708
+ margin-top: 1;
709
+ color: white;
710
+ }
512
711
 
513
- # Build prompt with detected default (only show if valid)
514
- if detected_key and not is_invalid:
515
- prompt_text = f"{gpio_display} ([cyan]{detected_key}[/cyan]): "
516
- else:
517
- prompt_text = f"{gpio_display}: "
712
+ .mode-btn {
713
+ width: 100%;
714
+ margin: 0;
715
+ }
716
+
717
+ .mode-btn.current-mode {
718
+ border: double white;
719
+ }
720
+
721
+ .mode-btn-output {
722
+ color: green;
723
+ }
518
724
 
519
- while True:
520
- user_input = console.input(prompt_text).strip()
725
+ .mode-btn-input {
726
+ color: cyan;
727
+ }
728
+
729
+ .mode-btn-bidir {
730
+ color: yellow;
731
+ }
732
+
733
+ .mode-btn-analog {
734
+ color: magenta;
735
+ }
736
+
737
+ #cancel-row {
738
+ margin-top: 1;
739
+ align: center middle;
740
+ }
741
+ """
742
+
743
+ def __init__(self, gpio_nums: list, **kwargs):
744
+ super().__init__(**kwargs)
745
+ self.gpio_nums = gpio_nums
746
+ self.current_idx = 0
747
+ self.mode_buttons = []
748
+
749
+ def compose(self) -> ComposeResult:
750
+ gpio_str = ", ".join(str(g) for g in self.gpio_nums[:5])
751
+ if len(self.gpio_nums) > 5:
752
+ gpio_str += f"... ({len(self.gpio_nums)} total)"
521
753
 
522
- if not user_input:
523
- # If current mode is invalid, require input
524
- if is_invalid_mode(current_mode):
525
- console.print(f"[red]{gpio_display} is currently invalid. Please enter a valid mode (1-{len(mode_options)} or mode key).[/red]")
526
- continue
527
- # Use current value if user just presses enter and we have a valid one
528
- if current_mode:
529
- gpio_configs[real_gpio_num] = current_mode
530
- break
531
- else:
532
- # No current value and no input - require input
533
- console.print(f"[red]{gpio_display} has no configuration. Please enter a valid mode (1-{len(mode_options)} or mode key).[/red]")
534
- continue
535
- elif user_input.isdigit():
536
- # User selected by number
537
- choice_num = int(user_input)
538
- if 1 <= choice_num <= len(mode_options):
539
- selected_key = mode_options[choice_num - 1]
540
- gpio_configs[real_gpio_num] = GPIO_MODES[selected_key]
541
- break
754
+ with Vertical(id="mode-dialog"):
755
+ yield Label(f"Select mode for GPIO: {gpio_str}", id="mode-title")
756
+ yield Label("[dim]Use ↑↓ arrows and Enter, or click[/dim]")
757
+
758
+ # User modes section
759
+ yield Label("── User Modes ──", classes="section-label")
760
+ for mode_key, mode_name in ALL_MODES:
761
+ if mode_key.startswith("user_"):
762
+ if "output" in mode_key:
763
+ btn_class = "mode-btn mode-btn-output"
764
+ elif "input" in mode_key:
765
+ btn_class = "mode-btn mode-btn-input"
766
+ elif "bidirectional" in mode_key:
767
+ btn_class = "mode-btn mode-btn-bidir"
768
+ elif "analog" in mode_key:
769
+ btn_class = "mode-btn mode-btn-analog"
770
+ else:
771
+ btn_class = "mode-btn"
772
+ yield Button(mode_name, id=f"mode_{mode_key}", classes=btn_class)
773
+
774
+ # Management modes section
775
+ yield Label("── Management Modes ──", classes="section-label")
776
+ for mode_key, mode_name in ALL_MODES:
777
+ if mode_key.startswith("mgmt_"):
778
+ if "output" in mode_key:
779
+ btn_class = "mode-btn mode-btn-output"
780
+ elif "input" in mode_key:
781
+ btn_class = "mode-btn mode-btn-input"
782
+ elif "bidirectional" in mode_key:
783
+ btn_class = "mode-btn mode-btn-bidir"
784
+ elif "analog" in mode_key:
785
+ btn_class = "mode-btn mode-btn-analog"
786
+ else:
787
+ btn_class = "mode-btn"
788
+ yield Button(mode_name, id=f"mode_{mode_key}", classes=btn_class)
789
+
790
+ with Horizontal(id="cancel-row"):
791
+ yield Button("Cancel", variant="error", id="cancel-btn")
792
+
793
+ def on_mount(self) -> None:
794
+ """Initialize on mount."""
795
+ # Collect all mode buttons
796
+ self.mode_buttons = list(self.query(".mode-btn"))
797
+ if self.mode_buttons:
798
+ self._highlight_current()
799
+
800
+ def _highlight_current(self) -> None:
801
+ """Highlight current button."""
802
+ for i, btn in enumerate(self.mode_buttons):
803
+ if i == self.current_idx:
804
+ btn.add_class("current-mode")
805
+ btn.focus()
542
806
  else:
543
- console.print(f"[red]Invalid choice. Please enter 1-{len(mode_options)}.[/red]")
807
+ btn.remove_class("current-mode")
808
+
809
+ def action_move_up(self) -> None:
810
+ """Move selection up."""
811
+ if self.current_idx > 0:
812
+ self.current_idx -= 1
813
+ self._highlight_current()
814
+
815
+ def action_move_down(self) -> None:
816
+ """Move selection down."""
817
+ if self.current_idx < len(self.mode_buttons) - 1:
818
+ self.current_idx += 1
819
+ self._highlight_current()
820
+
821
+ def action_select_current(self) -> None:
822
+ """Select the current mode."""
823
+ if 0 <= self.current_idx < len(self.mode_buttons):
824
+ btn = self.mode_buttons[self.current_idx]
825
+ mode_key = btn.id[5:] # Remove "mode_" prefix
826
+ self.dismiss(mode_key)
827
+
828
+ def on_button_pressed(self, event: Button.Pressed) -> None:
829
+ if event.button.id == "cancel-btn":
830
+ self.dismiss(None)
831
+ elif event.button.id.startswith("mode_"):
832
+ mode_key = event.button.id[5:] # Remove "mode_" prefix
833
+ self.dismiss(mode_key)
834
+
835
+ def action_cancel(self):
836
+ self.dismiss(None)
837
+
838
+ class GPIOGridApp(App):
839
+ """Textual app for GPIO grid configuration."""
840
+
841
+ GRID_COLS = 4 # Number of columns in the grid
842
+
843
+ CSS = """
844
+ Screen {
845
+ align: center middle;
846
+ }
847
+
848
+ #main-container {
849
+ width: 100%;
850
+ height: 100%;
851
+ padding: 1;
852
+ }
853
+
854
+ #title {
855
+ text-align: center;
856
+ text-style: bold;
857
+ color: cyan;
858
+ margin-bottom: 1;
859
+ }
860
+
861
+ #grid-scroll {
862
+ height: 1fr;
863
+ width: 100%;
864
+ }
865
+
866
+ #gpio-grid {
867
+ grid-size: 4;
868
+ grid-gutter: 1;
869
+ padding: 1;
870
+ height: auto;
871
+ width: 100%;
872
+ }
873
+
874
+ GPIOButton {
875
+ width: 1fr;
876
+ min-width: 24;
877
+ height: 4;
878
+ text-align: center;
879
+ border: solid green;
880
+ content-align: center middle;
881
+ padding: 0 1;
882
+ }
883
+
884
+ GPIOButton.current {
885
+ border: double cyan;
886
+ background: $primary;
887
+ }
888
+
889
+ GPIOButton.selected {
890
+ background: $success;
891
+ color: black;
892
+ }
893
+
894
+ #legend {
895
+ text-align: center;
896
+ color: grey;
897
+ margin-top: 1;
898
+ }
899
+
900
+ #status {
901
+ text-align: center;
902
+ margin-top: 1;
903
+ color: yellow;
904
+ }
905
+
906
+ #mode-dialog {
907
+ width: 70%;
908
+ max-width: 80;
909
+ height: auto;
910
+ padding: 2;
911
+ background: $surface;
912
+ border: solid cyan;
913
+ }
914
+
915
+ #mode-title {
916
+ text-align: center;
917
+ text-style: bold;
918
+ margin-bottom: 1;
919
+ }
920
+
921
+ #mode-select {
922
+ width: 100%;
923
+ margin-bottom: 1;
924
+ }
925
+
926
+ #mode-buttons {
927
+ align: center middle;
928
+ margin-top: 1;
929
+ }
930
+
931
+ #mode-buttons Button {
932
+ margin: 0 1;
933
+ }
934
+ """
935
+
936
+ BINDINGS = [
937
+ Binding("up", "nav_up", "Up", show=False, priority=True),
938
+ Binding("down", "nav_down", "Down", show=False, priority=True),
939
+ Binding("left", "nav_left", "Left", show=False, priority=True),
940
+ Binding("right", "nav_right", "Right", show=False, priority=True),
941
+ Binding("space", "toggle", "Select", priority=True),
942
+ Binding("enter", "open_mode", "Set Mode", priority=True),
943
+ Binding("a", "select_all", "Select All"),
944
+ Binding("n", "select_none", "Clear"),
945
+ Binding("d", "done", "Save & Exit"),
946
+ Binding("q", "quit", "Quit"),
947
+ ]
948
+
949
+ def __init__(self, gpio_configs, user_to_real_map, user_gpio_range, gpio_label="", **kwargs):
950
+ super().__init__(**kwargs)
951
+ self.gpio_configs = gpio_configs
952
+ self.user_to_real_map = user_to_real_map
953
+ self.user_gpio_range = user_gpio_range
954
+ self.gpio_label = gpio_label
955
+ self.gpio_buttons = {}
956
+ self.gpio_list = sorted(user_to_real_map.keys())
957
+ self.current_index = 0
958
+
959
+ def compose(self) -> ComposeResult:
960
+ yield Header(show_clock=False)
961
+
962
+ with Vertical(id="main-container"):
963
+ yield Label(f"GPIO Configuration ({self.gpio_label}) - Arrows: navigate, Space: select, Enter: set mode, D: done", id="title")
964
+
965
+ with NoKeyScrollContainer(id="grid-scroll"):
966
+ with Grid(id="gpio-grid"):
967
+ for gpio_num in self.gpio_list:
968
+ real_gpio = self.user_to_real_map[gpio_num]
969
+ mode_value = self.gpio_configs.get(real_gpio)
970
+ mode_key = find_mode_key(mode_value) if mode_value else None
971
+ btn = GPIOButton(gpio_num, mode_key)
972
+ self.gpio_buttons[gpio_num] = btn
973
+ yield btn
974
+
975
+ yield Label("[Space] toggle [Enter] set mode [A] select all [N] select none [D] done", id="legend")
976
+ yield Label("", id="status")
977
+
978
+ yield Footer()
979
+
980
+ def on_mount(self) -> None:
981
+ """Initialize on mount."""
982
+ if self.gpio_list:
983
+ self.current_index = 0
984
+ self._highlight_current()
985
+ self._update_status()
986
+
987
+ def _highlight_current(self) -> None:
988
+ """Highlight the GPIO at current_index and scroll into view."""
989
+ # Remove highlight from all
990
+ for btn in self.gpio_buttons.values():
991
+ btn.remove_class("current")
992
+ # Add highlight to current and scroll into view
993
+ if 0 <= self.current_index < len(self.gpio_list):
994
+ gpio_num = self.gpio_list[self.current_index]
995
+ btn = self.gpio_buttons[gpio_num]
996
+ btn.add_class("current")
997
+ btn.scroll_visible()
998
+
999
+ def _is_modal_active(self) -> bool:
1000
+ """Check if a modal screen is currently active."""
1001
+ return len(self.screen_stack) > 1
1002
+
1003
+ def action_nav_up(self) -> None:
1004
+ """Move up one row."""
1005
+ if self._is_modal_active():
1006
+ self.screen.action_move_up()
1007
+ return
1008
+ new_idx = self.current_index - self.GRID_COLS
1009
+ if new_idx >= 0:
1010
+ self.current_index = new_idx
1011
+ self._highlight_current()
1012
+
1013
+ def action_nav_down(self) -> None:
1014
+ """Move down one row."""
1015
+ if self._is_modal_active():
1016
+ self.screen.action_move_down()
1017
+ return
1018
+ new_idx = self.current_index + self.GRID_COLS
1019
+ if new_idx < len(self.gpio_list):
1020
+ self.current_index = new_idx
1021
+ self._highlight_current()
1022
+
1023
+ def action_nav_left(self) -> None:
1024
+ """Move left one column."""
1025
+ if self._is_modal_active():
1026
+ return # No left/right in modal
1027
+ if self.current_index > 0:
1028
+ self.current_index -= 1
1029
+ self._highlight_current()
1030
+
1031
+ def action_nav_right(self) -> None:
1032
+ """Move right one column."""
1033
+ if self._is_modal_active():
1034
+ return # No left/right in modal
1035
+ if self.current_index < len(self.gpio_list) - 1:
1036
+ self.current_index += 1
1037
+ self._highlight_current()
1038
+
1039
+ def action_toggle(self) -> None:
1040
+ """Toggle selection of current GPIO."""
1041
+ if self._is_modal_active():
1042
+ return
1043
+ if 0 <= self.current_index < len(self.gpio_list):
1044
+ gpio_num = self.gpio_list[self.current_index]
1045
+ self.gpio_buttons[gpio_num].toggle_selected()
1046
+ self._update_status()
1047
+
1048
+ def action_open_mode(self) -> None:
1049
+ """Open mode selector or confirm selection in modal."""
1050
+ if self._is_modal_active():
1051
+ self.screen.action_select_current()
1052
+ return
1053
+ self.action_set_mode()
1054
+
1055
+ def _get_selected_gpios(self) -> list:
1056
+ """Get list of selected GPIO numbers."""
1057
+ return [num for num, btn in self.gpio_buttons.items() if btn.is_selected]
1058
+
1059
+ def _update_status(self):
1060
+ """Update the status label."""
1061
+ selected = self._get_selected_gpios()
1062
+ status = self.query_one("#status", Label)
1063
+ if selected:
1064
+ status.update(f"Selected: {', '.join(str(g) for g in sorted(selected))} ({len(selected)} pins)")
544
1065
  else:
545
- # Try to find matching mode
546
- match_result = find_matching_mode(user_input)
1066
+ status.update("No pins selected - Space to select, Enter to set mode for focused pin")
1067
+
1068
+ def action_toggle_select(self) -> None:
1069
+ """Toggle selection of focused GPIO."""
1070
+ focused = self.focused
1071
+ if isinstance(focused, GPIOButton):
1072
+ focused.toggle_selected()
1073
+ self._update_status()
1074
+
1075
+ def action_select_all(self) -> None:
1076
+ """Select all GPIOs."""
1077
+ for btn in self.gpio_buttons.values():
1078
+ btn.is_selected = True
1079
+ btn._update_style()
1080
+ self._update_status()
1081
+
1082
+ def action_select_none(self) -> None:
1083
+ """Clear all selections."""
1084
+ for btn in self.gpio_buttons.values():
1085
+ btn.deselect()
1086
+ self._update_status()
1087
+
1088
+ def action_set_mode(self) -> None:
1089
+ """Open mode selection for selected GPIOs."""
1090
+ selected = self._get_selected_gpios()
1091
+ if not selected:
1092
+ # If nothing selected, use the focused one
1093
+ focused = self.focused
1094
+ if isinstance(focused, GPIOButton):
1095
+ selected = [focused.gpio_num]
1096
+
1097
+ if selected:
1098
+ self.push_screen(ModeSelectScreen(selected), self._apply_mode)
1099
+
1100
+ def _apply_mode(self, mode_key: str) -> None:
1101
+ """Apply the selected mode to selected GPIOs."""
1102
+ if mode_key:
1103
+ selected = self._get_selected_gpios()
1104
+ if not selected:
1105
+ focused = self.focused
1106
+ if isinstance(focused, GPIOButton):
1107
+ selected = [focused.gpio_num]
547
1108
 
548
- if match_result is None:
549
- console.print(f"[red]No match found for '{user_input}'. Try a number (1-{len(mode_options)}), partial key, or full key.[/red]")
550
- elif isinstance(match_result, list):
551
- # Multiple matches found
552
- console.print(f"[yellow]Multiple matches found: {', '.join(match_result)}[/yellow]")
553
- console.print(f"[dim]Please be more specific or use a number (1-{len(mode_options)}).[/dim]")
554
- else:
555
- # Single match found
556
- gpio_configs[real_gpio_num] = GPIO_MODES[match_result]
557
- break
1109
+ for gpio_num in selected:
1110
+ real_gpio = self.user_to_real_map[gpio_num]
1111
+ self.gpio_configs[real_gpio] = GPIO_MODES[mode_key]
1112
+ self.gpio_buttons[gpio_num].set_mode(mode_key)
1113
+
1114
+ # Clear selection after applying
1115
+ for btn in self.gpio_buttons.values():
1116
+ btn.deselect()
1117
+ self._update_status()
1118
+
1119
+ def action_done(self) -> None:
1120
+ """Save and exit."""
1121
+ self.exit(result=self.gpio_configs)
1122
+
1123
+ def action_quit(self) -> None:
1124
+ """Quit without explicit save (but configs are already updated)."""
1125
+ self.exit(result=self.gpio_configs)
1126
+
1127
+ # Run the Textual grid app
1128
+
1129
+ app = GPIOGridApp(gpio_configs, user_to_real_map, user_gpio_range, gpio_label)
1130
+ gpio_configs = app.run()
1131
+
1132
+ # ========================
1133
+ # SUMMARY & SAVE
1134
+ # ========================
1135
+ console.print("\n[bold]Final Configuration:[/bold]")
1136
+ display_summary(gpio_configs, user_to_real_map)
1137
+
1138
+ # Check for unconfigured
1139
+ unconfigured = [g for g in user_to_real_map.keys()
1140
+ if not gpio_configs.get(user_to_real_map[g]) or
1141
+ find_mode_key(gpio_configs.get(user_to_real_map[g])) is None]
1142
+
1143
+ if unconfigured:
1144
+ console.print(f"\n[yellow]Warning: {len(unconfigured)} pins still unconfigured: {format_gpio_ranges(unconfigured)}[/yellow]")
1145
+ confirm = console.input("Save anyway? (y/n): ").strip().lower()
1146
+ if confirm not in ('y', 'yes'):
1147
+ console.print("[yellow]Aborted.[/yellow]")
1148
+ raise click.Abort()
558
1149
 
559
1150
  # Save to project.json
560
1151
  save_gpio_config_to_project_json(str(project_json_path), gpio_configs)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "1.2.8"
3
+ version = "1.2.9"
4
4
  description = "CLI tool to automate ChipFoundry project submission to SFTP server"
5
5
  authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
6
6
  readme = "README.md"
@@ -10,12 +10,13 @@ repository = "https://github.com/chipfoundry/cf-cli"
10
10
  packages = [{ include = "chipfoundry_cli" }]
11
11
 
12
12
  [tool.poetry.dependencies]
13
- python = ">=3.8.0"
13
+ python = ">=3.8.1,<4.0"
14
14
  click = ">=8.0.0,<9"
15
15
  rich = ">=13,<14"
16
16
  paramiko = ">=3.0.0,<4"
17
17
  toml = ">=0.10,<1.0"
18
18
  httpx = ">=0.24.0,<1.0"
19
+ textual = ">=0.40.0,<1"
19
20
 
20
21
  [tool.poetry.dev-dependencies]
21
22
  wheel = "*"
File without changes