From dd19f405a453b2350df99c82056be6743a6381e5 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Mon, 9 Feb 2026 02:21:04 +0900 Subject: [PATCH] Add selector --- unshackle/core/utils/selector.py | 269 +++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 unshackle/core/utils/selector.py diff --git a/unshackle/core/utils/selector.py b/unshackle/core/utils/selector.py new file mode 100644 index 0000000..07d0fab --- /dev/null +++ b/unshackle/core/utils/selector.py @@ -0,0 +1,269 @@ +import click +import sys +from rich.console import Group +from rich.live import Live +from rich.padding import Padding +from rich.table import Table +from rich.text import Text +from unshackle.core.console import console + +IS_WINDOWS = sys.platform == "win32" +if IS_WINDOWS: + import msvcrt + +class Selector: + """ + A custom interactive selector class using the Rich library. + Allows for multi-selection of items with pagination. + """ + def __init__( + self, + options: list[str], + cursor_style: str = "pink", + text_style: str = "text", + page_size: int = 8, + minimal_count: int = 0, + dependencies: dict[int, list[int]] = None, + prefixes: list[str] = None + ): + """ + Initialize the Selector. + + Args: + options: List of strings to select from. + cursor_style: Rich style for the highlighted cursor item. + text_style: Rich style for normal items. + page_size: Number of items to show per page. + minimal_count: Minimum number of items that must be selected. + dependencies: Dictionary mapping parent index to list of child indices. + """ + self.options = options + self.cursor_style = cursor_style + self.text_style = text_style + self.page_size = page_size + self.minimal_count = minimal_count + self.dependencies = dependencies or {} + + self.cursor_index = 0 + self.selected_indices = set() + self.scroll_offset = 0 + + def get_renderable(self): + """ + Constructs and returns the renderable object (Table + Info) for the current state. + """ + table = Table(show_header=False, show_edge=False, box=None, pad_edge=False, padding=(0, 1, 0, 0)) + table.add_column("Indicator", justify="right", no_wrap=True) + table.add_column("Option", overflow="ellipsis", no_wrap=True) + + for i in range(self.page_size): + idx = self.scroll_offset + i + + if idx < len(self.options): + option = self.options[idx] + is_cursor = (idx == self.cursor_index) + is_selected = (idx in self.selected_indices) + + symbol = "[X]" if is_selected else "[ ]" + style = self.cursor_style if is_cursor else self.text_style + indicator_text = Text(f"{symbol}", style=style) + + content_text = Text.from_markup(option) + content_text.style = style + + table.add_row(indicator_text, content_text) + else: + table.add_row(Text(" "), Text(" ")) + + total_pages = (len(self.options) + self.page_size - 1) // self.page_size + current_page = (self.scroll_offset // self.page_size) + 1 + + info_text = Text( + f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})", + style="gray" + ) + + return Padding(Group(table, info_text), (0, 5)) + + def move_cursor(self, delta: int): + """ + Moves the cursor up or down by the specified delta. + Updates the scroll offset if the cursor moves out of the current view. + """ + self.cursor_index = (self.cursor_index + delta) % len(self.options) + new_page_idx = self.cursor_index // self.page_size + self.scroll_offset = new_page_idx * self.page_size + + def change_page(self, delta: int): + """ + Changes the current page view by the specified delta (previous/next page). + Also moves the cursor to the first item of the new page. + """ + current_page = self.scroll_offset // self.page_size + total_pages = (len(self.options) + self.page_size - 1) // self.page_size + new_page = current_page + delta + + if 0 <= new_page < total_pages: + self.scroll_offset = new_page * self.page_size + first_idx_of_page = self.scroll_offset + if first_idx_of_page < len(self.options): + self.cursor_index = first_idx_of_page + else: + self.cursor_index = len(self.options) - 1 + + def toggle_selection(self): + """ + Toggles the selection state of the item currently under the cursor. + Propagates selection to children if defined in dependencies. + """ + target_indices = {self.cursor_index} + + if self.cursor_index in self.dependencies: + target_indices.update(self.dependencies[self.cursor_index]) + + should_select = self.cursor_index not in self.selected_indices + + if should_select: + self.selected_indices.update(target_indices) + else: + self.selected_indices.difference_update(target_indices) + + def toggle_all(self): + """ + Toggles the selection of all items. + If all are selected, clears selection. Otherwise, selects all. + """ + if len(self.selected_indices) == len(self.options): + self.selected_indices.clear() + else: + self.selected_indices = set(range(len(self.options))) + + def get_input_windows(self): + """ + Captures and parses keyboard input on Windows systems using msvcrt. + Returns command strings like 'UP', 'DOWN', 'ENTER', etc. + """ + key = msvcrt.getch() + if key == b'\x03' or key == b'\x1b': + return 'CANCEL' + if key == b'\xe0' or key == b'\x00': + try: + key = msvcrt.getch() + if key == b'H': return 'UP' + if key == b'P': return 'DOWN' + if key == b'K': return 'LEFT' + if key == b'M': return 'RIGHT' + except: pass + + try: char = key.decode('utf-8', errors='ignore') + except: return None + + if char in ('\r', '\n'): return 'ENTER' + if char == ' ': return 'SPACE' + if char in ('q', 'Q'): return 'QUIT' + if char in ('a', 'A'): return 'ALL' + if char in ('w', 'W', 'k', 'K'): return 'UP' + if char in ('s', 'S', 'j', 'J'): return 'DOWN' + if char in ('h', 'H'): return 'LEFT' + if char in ('d', 'D', 'l', 'L'): return 'RIGHT' + return None + + def get_input_unix(self): + """ + Captures and parses keyboard input on Unix/Linux systems using click.getchar(). + Returns command strings like 'UP', 'DOWN', 'ENTER', etc. + """ + char = click.getchar() + if char == '\x03': + return 'CANCEL' + mapping = { + '\x1b[A': 'UP', + '\x1b[B': 'DOWN', + '\x1b[C': 'RIGHT', + '\x1b[D': 'LEFT', + } + if char in mapping: + return mapping[char] + if char == '\x1b': + try: + next1 = click.getchar() + if next1 in ('[', 'O'): + next2 = click.getchar() + if next2 == 'A': return 'UP' + if next2 == 'B': return 'DOWN' + if next2 == 'C': return 'RIGHT' + if next2 == 'D': return 'LEFT' + return 'CANCEL' + except: + return 'CANCEL' + + if char in ('\r', '\n'): return 'ENTER' + if char == ' ': return 'SPACE' + if char in ('q', 'Q'): return 'QUIT' + if char in ('a', 'A'): return 'ALL' + if char in ('w', 'W', 'k', 'K'): return 'UP' + if char in ('s', 'S', 'j', 'J'): return 'DOWN' + if char in ('h', 'H'): return 'LEFT' + if char in ('d', 'D', 'l', 'L'): return 'RIGHT' + return None + + def run(self) -> list[int]: + """ + Starts the main event loop for the selector. + Renders the UI and processes input until confirmed or cancelled. + + Returns: + list[int]: A sorted list of selected indices. + """ + try: + with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live: + while True: + live.update(self.get_renderable(), refresh=True) + if IS_WINDOWS: action = self.get_input_windows() + else: action = self.get_input_unix() + + if action == 'UP': self.move_cursor(-1) + elif action == 'DOWN': self.move_cursor(1) + elif action == 'LEFT': self.change_page(-1) + elif action == 'RIGHT': self.change_page(1) + elif action == 'SPACE': self.toggle_selection() + elif action == 'ALL': self.toggle_all() + elif action in ('ENTER', 'QUIT'): + if len(self.selected_indices) >= self.minimal_count: + return sorted(list(self.selected_indices)) + elif action == 'CANCEL': raise KeyboardInterrupt + except KeyboardInterrupt: + return [] + +def select_multiple( + options: list[str], + minimal_count: int = 1, + page_size: int = 8, + return_indices: bool = True, + cursor_style: str = "pink", + **kwargs +) -> list[int]: + """ + Drop-in replacement using custom Selector with global console. + + Args: + options: List of options to display. + minimal_count: Minimum number of selections required. + page_size: Number of items per page. + return_indices: If True, returns indices; otherwise returns the option strings. + cursor_style: Style color for the cursor. + """ + selector = Selector( + options=options, + cursor_style=cursor_style, + text_style="text", + page_size=page_size, + minimal_count=minimal_count, + **kwargs + ) + + selected_indices = selector.run() + + if return_indices: + return selected_indices + return [options[i] for i in selected_indices]