|
| 1 | +"""Interactive menu system for animation selection.""" |
| 2 | + |
| 3 | +import sys |
| 4 | +import os |
| 5 | +import time |
| 6 | +import threading |
| 7 | +import platform |
| 8 | +from typing import List, Optional |
| 9 | + |
| 10 | +from .types import AnimationSequence, AnimationConfig |
| 11 | +from .repository import FileSystemAnimationRepository |
| 12 | +from pathlib import Path |
| 13 | + |
| 14 | + |
| 15 | +class InteractiveMenu: |
| 16 | + """Interactive menu with arrow key navigation and animation preview.""" |
| 17 | + |
| 18 | + def __init__(self, config: AnimationConfig) -> None: |
| 19 | + self.config = config |
| 20 | + self.repository = FileSystemAnimationRepository(config) |
| 21 | + self.animations: List[AnimationSequence] = [] |
| 22 | + self.current_index = 0 |
| 23 | + self.preview_thread: Optional[threading.Thread] = None |
| 24 | + self.stop_preview = False |
| 25 | + |
| 26 | + def _get_char(self) -> str: |
| 27 | + """Get a single character from stdin.""" |
| 28 | + try: |
| 29 | + if platform.system() == "Windows": |
| 30 | + import msvcrt |
| 31 | + |
| 32 | + char = msvcrt.getch().decode("utf-8") |
| 33 | + if char == "\xe0": # Special key prefix on Windows |
| 34 | + char += msvcrt.getch().decode("utf-8") |
| 35 | + return char |
| 36 | + else: |
| 37 | + import termios |
| 38 | + import tty |
| 39 | + |
| 40 | + fd = sys.stdin.fileno() |
| 41 | + old_settings = termios.tcgetattr(fd) |
| 42 | + try: |
| 43 | + tty.setraw(fd) |
| 44 | + char = sys.stdin.read(1) |
| 45 | + if char == "\x1b": # ESC sequence |
| 46 | + char += sys.stdin.read(2) |
| 47 | + return char |
| 48 | + finally: |
| 49 | + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) |
| 50 | + except Exception: |
| 51 | + # Fallback to regular input |
| 52 | + return input() |
| 53 | + |
| 54 | + def _clear_screen(self) -> None: |
| 55 | + """Clear the terminal screen.""" |
| 56 | + os.system("clear" if os.name != "nt" else "cls") |
| 57 | + |
| 58 | + def _load_animations(self) -> bool: |
| 59 | + """Load all available animations.""" |
| 60 | + animation_dirs = self.repository.discover_animations() |
| 61 | + |
| 62 | + if not animation_dirs: |
| 63 | + return False |
| 64 | + |
| 65 | + self.animations = [] |
| 66 | + for directory in animation_dirs: |
| 67 | + try: |
| 68 | + animation = self.repository.load_animation(directory) |
| 69 | + self.animations.append(animation) |
| 70 | + except Exception: |
| 71 | + continue # Skip invalid animations |
| 72 | + |
| 73 | + return len(self.animations) > 0 |
| 74 | + |
| 75 | + def _stop_current_preview(self) -> None: |
| 76 | + """Stop the current preview animation.""" |
| 77 | + self.stop_preview = True |
| 78 | + if self.preview_thread and self.preview_thread.is_alive(): |
| 79 | + self.preview_thread.join(timeout=0.5) |
| 80 | + |
| 81 | + def _start_preview(self, animation: AnimationSequence) -> None: |
| 82 | + """Start preview animation in a separate thread.""" |
| 83 | + self._stop_current_preview() |
| 84 | + self.stop_preview = False |
| 85 | + |
| 86 | + def preview_loop(): |
| 87 | + frame_index = 0 |
| 88 | + while not self.stop_preview: |
| 89 | + if frame_index >= len(animation.frames): |
| 90 | + frame_index = 0 |
| 91 | + |
| 92 | + frame = animation.frames[frame_index] |
| 93 | + |
| 94 | + # Calculate preview area position |
| 95 | + preview_start_line = 6 + len(self.animations) |
| 96 | + |
| 97 | + # Clear preview area more thoroughly - clear 15 lines |
| 98 | + for i in range(15): |
| 99 | + print( |
| 100 | + f"\033[{preview_start_line + i};1H\033[2K", end="" |
| 101 | + ) # Clear entire line |
| 102 | + |
| 103 | + # Position cursor and show title |
| 104 | + print(f"\033[{preview_start_line};1H", end="") |
| 105 | + print("Preview:") |
| 106 | + |
| 107 | + # Clean and display frame content |
| 108 | + # Remove trailing whitespace and newlines |
| 109 | + clean_content = frame.content.rstrip() |
| 110 | + frame_lines = clean_content.split("\n") if clean_content else [] |
| 111 | + |
| 112 | + # Display each line, ensuring we clear any potential overlap |
| 113 | + for i, line in enumerate(frame_lines): |
| 114 | + line_num = preview_start_line + 1 + i |
| 115 | + # Move to line and clear it completely, then write content |
| 116 | + print(f"\033[{line_num};1H\033[2K{line.rstrip()}", end="") |
| 117 | + |
| 118 | + sys.stdout.flush() |
| 119 | + |
| 120 | + frame_index += 1 |
| 121 | + time.sleep(animation.metadata.frame_rate) |
| 122 | + |
| 123 | + self.preview_thread = threading.Thread(target=preview_loop, daemon=True) |
| 124 | + self.preview_thread.start() |
| 125 | + |
| 126 | + def _render_menu(self) -> None: |
| 127 | + """Render the interactive menu.""" |
| 128 | + self._clear_screen() |
| 129 | + |
| 130 | + print("🎭 \033[1mWelcome to LazyB!\033[0m") |
| 131 | + print("Use ↑↓ arrows to select animation, Enter to start, or 'q' to quit") |
| 132 | + print("LazyB will press Shift every 3 minutes to keep your apps active.\n") |
| 133 | + |
| 134 | + for i, animation in enumerate(self.animations): |
| 135 | + if i == self.current_index: |
| 136 | + # Highlighted option |
| 137 | + print(f"🎬 \033[1;36m► {animation.metadata.name}\033[0m") |
| 138 | + else: |
| 139 | + # Normal option |
| 140 | + print(f" {animation.metadata.name}") |
| 141 | + |
| 142 | + print() |
| 143 | + |
| 144 | + # Start preview for current selection |
| 145 | + if self.animations: |
| 146 | + current_animation = self.animations[self.current_index] |
| 147 | + self._start_preview(current_animation) |
| 148 | + |
| 149 | + def _handle_input(self) -> Optional[AnimationSequence]: |
| 150 | + """Handle user input and return selected animation or None if quit.""" |
| 151 | + while True: |
| 152 | + char = self._get_char() |
| 153 | + |
| 154 | + if char == "q": |
| 155 | + return None |
| 156 | + elif char == "\r" or char == "\n": # Enter |
| 157 | + return self.animations[self.current_index] |
| 158 | + elif char == "\x1b[A" or char == "\xe0H": # Up arrow (Unix/Windows) |
| 159 | + self.current_index = (self.current_index - 1) % len(self.animations) |
| 160 | + self._render_menu() |
| 161 | + elif char == "\x1b[B" or char == "\xe0P": # Down arrow (Unix/Windows) |
| 162 | + self.current_index = (self.current_index + 1) % len(self.animations) |
| 163 | + self._render_menu() |
| 164 | + |
| 165 | + def show_menu(self) -> Optional[AnimationSequence]: |
| 166 | + """Show interactive menu and return selected animation.""" |
| 167 | + if not self._load_animations(): |
| 168 | + print("❌ No animations found!") |
| 169 | + return None |
| 170 | + |
| 171 | + try: |
| 172 | + self._render_menu() |
| 173 | + selected = self._handle_input() |
| 174 | + self._stop_current_preview() |
| 175 | + return selected |
| 176 | + except KeyboardInterrupt: |
| 177 | + self._stop_current_preview() |
| 178 | + print("\n👋 Goodbye!") |
| 179 | + return None |
| 180 | + finally: |
| 181 | + self._clear_screen() |
| 182 | + |
| 183 | + |
| 184 | +def create_interactive_menu( |
| 185 | + config: Optional[AnimationConfig] = None, |
| 186 | +) -> InteractiveMenu: |
| 187 | + """Create and return an interactive menu instance.""" |
| 188 | + if config is None: |
| 189 | + config = AnimationConfig(animation_root_dir=Path("animations")) |
| 190 | + return InteractiveMenu(config) |
0 commit comments