Source code for promptprep.tui

"""A friendly terminal interface for choosing which files to process."""

import os
import sys
from typing import List, Set, Tuple, Dict

if sys.platform == "win32":
    # Raise ImportError immediately if on Windows
    # This prevents the rest of the file (and the curses import) from executing
    raise ImportError("promptprep.tui (curses module) is not supported on Windows.")
else:
    # Import curses only if not on Windows
    import curses


[docs] class FileSelector: """Lets you browse and select files using arrow keys and spacebar."""
[docs] def __init__(self, start_path: str): """Gets everything ready for file selection. Args: start_path: Where to start browsing from """ self.start_path = os.path.abspath(start_path) self.current_path = self.start_path self.cursor_pos = 0 self.offset = 0 self.selected_items: Dict[str, bool] = ( {} ) # Dictionary tracking selected items (True=include, False=exclude) self.exclude_dirs: Set[str] = set() # Set of excluded directory paths self.files: List[str] = [] self.show_hidden = False self.status_message = "" self.save_selections = ( False # Flag to indicate whether selections should be saved )
def _get_directory_contents(self) -> List[str]: """Gets a sorted list of files and folders, optionally showing hidden items.""" try: items = os.listdir(self.current_path) # Filter hidden files if show_hidden is False if not self.show_hidden: items = [item for item in items if not item.startswith(".")] # Add '..' for parent directory navigation (except at the root of the filesystem) if os.path.abspath(self.current_path) != os.path.abspath(os.path.sep): items.insert(0, "..") # Sort directories first, then files dirs = [ item for item in items if os.path.isdir(os.path.join(self.current_path, item)) ] files = [ item for item in items if not os.path.isdir(os.path.join(self.current_path, item)) ] return sorted(dirs) + sorted(files) except (PermissionError, FileNotFoundError): self.status_message = f"Cannot access directory: {self.current_path}" # Go back to parent directory self.current_path = os.path.dirname(self.current_path) return self._get_directory_contents() def _draw_screen(self, stdscr) -> None: """Shows the current directory contents and selection status on screen.""" stdscr.clear() height, width = stdscr.getmaxyx() # Draw title and current path title = "PromptPrep - Interactive File Selector" path_display = self.current_path if len(path_display) > width - 2: path_display = "..." + path_display[-(width - 5) :] stdscr.addstr(0, 0, title[: width - 1], curses.A_BOLD) stdscr.addstr(1, 0, path_display[: width - 1], curses.A_UNDERLINE) # Draw file list self.files = self._get_directory_contents() visible_items = height - 7 # Account for header and footer lines # Adjust offset if cursor moves outside visible area if self.cursor_pos < self.offset: self.offset = self.cursor_pos elif self.cursor_pos >= self.offset + visible_items: self.offset = self.cursor_pos - visible_items + 1 # Ensure offset doesn't go negative self.offset = max(0, min(self.offset, len(self.files) - visible_items)) # Ensure cursor is within range self.cursor_pos = max(0, min(self.cursor_pos, len(self.files) - 1)) # Draw visible files for i, item in enumerate( self.files[self.offset : self.offset + visible_items], 0 ): y_pos = i + 3 # Start at line 3 item_path = os.path.join(self.current_path, item) is_dir = os.path.isdir(item_path) is_selected = item_path in self.selected_items is_excluded = is_dir and item_path in self.exclude_dirs # Determine display attributes attrs = curses.A_NORMAL prefix = " " # Show selection status if is_selected: if self.selected_items[item_path]: prefix = "+ " # Include attrs |= curses.color_pair(1) # Green else: prefix = "- " # Exclude attrs |= curses.color_pair(2) # Red if is_excluded: prefix = "X " # Directory excluded attrs |= curses.color_pair(2) # Red # Highlight current cursor position if i + self.offset == self.cursor_pos: attrs |= curses.A_REVERSE # Add directory indicator display_name = item + ("/" if is_dir else "") # Truncate if too long max_name_length = width - 4 # Account for prefix and potential truncation if len(display_name) > max_name_length: display_name = display_name[: max_name_length - 3] + "..." # Draw the item stdscr.addstr(y_pos, 0, prefix + display_name, attrs) # Draw status line status_line = height - 3 if self.status_message: stdscr.addstr(status_line, 0, self.status_message[: width - 1]) # Draw help footer - removed H key reference footer_line = height - 2 help_text = "UP/DOWN: Navigate | ENTER: Open/Select | SPACE: Toggle Selection | A: Select All | T: Show Hidden | Q: Quit | S: Save" if len(help_text) > width: help_text = help_text[: width - 3] + "..." stdscr.addstr(footer_line, 0, help_text) # Draw selection count selection_line = height - 1 includes = sum(1 for value in self.selected_items.values() if value) excludes = len(self.selected_items) - includes + len(self.exclude_dirs) stdscr.addstr( selection_line, 0, f"Selected: {includes} includes, {excludes} excludes" ) stdscr.refresh() def _toggle_selection(self, path: str) -> None: """Cycles through include/exclude/unselected states for a file or directory.""" is_dir = os.path.isdir(path) # For directories, toggle between include, exclude dir, and none if is_dir: if path in self.selected_items: if self.selected_items[path]: # Currently included # Change to excluded self.selected_items[path] = False self.exclude_dirs.add(path) else: # Currently excluded # Remove selection del self.selected_items[path] self.exclude_dirs.discard(path) else: # Not selected, add as included self.selected_items[path] = True self.exclude_dirs.discard(path) # For files, toggle between include, exclude, and none else: if path in self.selected_items: if self.selected_items[path]: # Currently included # Change to excluded self.selected_items[path] = False else: # Currently excluded # Remove selection del self.selected_items[path] else: # Not selected, add as included self.selected_items[path] = True def _toggle_all_in_directory(self) -> None: """Selects or deselects all files in the current directory.""" all_selected = True any_selected = False # Check current state for item in self.files: if item == "..": continue item_path = os.path.join(self.current_path, item) if not os.path.isdir(item_path): # Only consider files if item_path in self.selected_items: any_selected = True if not self.selected_items[item_path]: all_selected = False else: all_selected = False # Toggle based on current state for item in self.files: if item == "..": continue item_path = os.path.join(self.current_path, item) if not os.path.isdir(item_path): # Only consider files if all_selected: # If all are selected, deselect all if item_path in self.selected_items: del self.selected_items[item_path] elif any_selected: # If some are selected, select all self.selected_items[item_path] = True else: # If none are selected, select all self.selected_items[item_path] = True def _handle_key(self, key, stdscr) -> bool: """Responds to your keyboard input. Returns: True to keep going, False to exit """ if key == curses.KEY_UP: self.cursor_pos = max(0, self.cursor_pos - 1) elif key == curses.KEY_DOWN: self.cursor_pos = min(len(self.files) - 1, self.cursor_pos + 1) elif key == ord("\n"): # Enter key # Get current item if not self.files: return True item = self.files[self.cursor_pos] item_path = os.path.join(self.current_path, item) # Handle directory navigation if os.path.isdir(item_path): if item == "..": # Go up to parent directory self.current_path = os.path.dirname( os.path.abspath(self.current_path) ) else: # Enter the directory self.current_path = item_path self.cursor_pos = 0 self.offset = 0 else: # Toggle selection for files self._toggle_selection(item_path) elif key == ord(" "): # Space key # Toggle selection state if not self.files: return True item = self.files[self.cursor_pos] item_path = os.path.join(self.current_path, item) self._toggle_selection(item_path) elif key == ord("a") or key == ord("A"): # Toggle all files in current directory self._toggle_all_in_directory() elif key == ord("t") or key == ord("T"): # Toggle showing hidden files self.show_hidden = not self.show_hidden self.cursor_pos = 0 self.offset = 0 elif key == ord("q") or key == ord("Q"): # Quit without saving self.save_selections = False self.status_message = "Exiting without saving selections" return False elif key == ord("s") or key == ord("S"): # Save and exit self.save_selections = True self.status_message = "Selections saved!" return False return True
[docs] def get_selections(self) -> Tuple[Set[str], Set[str], bool]: """Get the current selections and return them. Returns: A tuple of (include_files, exclude_dirs, save_selections) """ include_files = { os.path.relpath(path, self.start_path) for path, include in self.selected_items.items() if include } # We're not using exclude_files in the return value, so remove the assignment # Just return what we need return include_files, self.exclude_dirs, self.save_selections
[docs] def run(self, stdscr) -> Tuple[Set[str], Set[str], bool]: """Starts up the file selector interface. Args: stdscr: The main screen object from curses Returns: - Set of files to include - Set of directories to exclude - Whether to save these choices """ # Configure curses curses.curs_set(0) # Hide cursor curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_GREEN, -1) # Green for included curses.init_pair(2, curses.COLOR_RED, -1) # Red for excluded # Main loop while True: self._draw_screen(stdscr) key = stdscr.getch() if not self._handle_key(key, stdscr): break return self.get_selections()
[docs] def select_files_interactive(directory: str) -> Tuple[Set[str], Set[str], bool]: """Lets you pick files using an interactive menu. Args: directory: Where to start browsing Returns: - Set of files you want to include - Set of directories you want to skip - Whether you want to save these choices """ try: return curses.wrapper(FileSelector(directory).run) except Exception as e: print(f"Error in interactive mode: {e}", file=sys.stderr) return set(), set(), False