mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-10 08:29:00 +00:00
Add selector
This commit is contained in:
269
unshackle/core/utils/selector.py
Normal file
269
unshackle/core/utils/selector.py
Normal file
@@ -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]
|
||||||
Reference in New Issue
Block a user