Files
unshackle-SeFree/unshackle/core/utils/selector.py
CodeName393 dd19f405a4 Add selector
2026-02-09 02:21:04 +09:00

270 lines
9.7 KiB
Python

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]