Files
unshackle/unshackle/core/utils/selector.py
2026-02-26 15:16:34 +09:00

434 lines
15 KiB
Python

import sys
import click
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 and collapsible headers.
"""
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,
collapse_on_start: bool = False
):
"""
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.
collapse_on_start: If True, child items are hidden initially.
"""
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 {}
# Parent-Child mapping for quick lookup
self.child_to_parent = {}
for parent, children in self.dependencies.items():
for child in children:
self.child_to_parent[child] = parent
self.cursor_index = 0
self.selected_indices = set()
self.scroll_offset = 0
# Tree view state
self.expanded_headers = set()
if not collapse_on_start:
# Expand all by default
self.expanded_headers.update(self.dependencies.keys())
def get_visible_indices(self) -> list[int]:
"""
Returns a sorted list of indices that should be currently visible.
A child is visible only if its parent is in self.expanded_headers.
"""
visible = []
for idx in range(len(self.options)):
# If it's a child, check if parent is expanded
if idx in self.child_to_parent:
parent = self.child_to_parent[idx]
if parent in self.expanded_headers:
visible.append(idx)
else:
# It's a header or independent item, always visible
visible.append(idx)
return visible
def get_renderable(self):
"""
Constructs and returns the renderable object (Table + Info) for the current state.
"""
visible_indices = self.get_visible_indices()
# Adjust scroll offset to ensure cursor is visible
if self.cursor_index not in visible_indices:
# Fallback if cursor got hidden (should be handled in move, but safety check)
self.cursor_index = visible_indices[0] if visible_indices else 0
try:
cursor_visual_pos = visible_indices.index(self.cursor_index)
except ValueError:
cursor_visual_pos = 0
self.cursor_index = visible_indices[0]
# Calculate logical page start/end based on VISIBLE items
start_idx = self.scroll_offset
end_idx = start_idx + self.page_size
# Dynamic scroll adjustment
if cursor_visual_pos < start_idx:
self.scroll_offset = cursor_visual_pos
elif cursor_visual_pos >= end_idx:
self.scroll_offset = cursor_visual_pos - self.page_size + 1
# Re-calc render range
render_indices = visible_indices[self.scroll_offset : self.scroll_offset + self.page_size]
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 idx in render_indices:
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(f"{option}")
content_text.style = style
table.add_row(indicator_text, content_text)
# Fill empty rows to maintain height
rows_rendered = len(render_indices)
for _ in range(self.page_size - rows_rendered):
table.add_row(Text(" "), Text(" "))
total_visible = len(visible_indices)
total_pages = (total_visible + self.page_size - 1) // self.page_size
if total_pages == 0:
total_pages = 1
current_page = (self.scroll_offset // self.page_size) + 1
if self.dependencies:
info_text = Text(
f"\n[Space]: Toggle [a]: All [e]: Fold/Unfold [E]: All Fold/Unfold\n[Enter]: Confirm [↑/↓]: Move [←/→]: Page (Page {current_page}/{total_pages})",
style="gray",
)
else:
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 through VISIBLE items only.
"""
visible_indices = self.get_visible_indices()
if not visible_indices:
return
try:
current_visual_idx = visible_indices.index(self.cursor_index)
except ValueError:
current_visual_idx = 0
new_visual_idx = (current_visual_idx + delta) % len(visible_indices)
self.cursor_index = visible_indices[new_visual_idx]
def change_page(self, delta: int):
"""
Changes the current page view by the specified delta (previous/next page).
"""
visible_indices = self.get_visible_indices()
if not visible_indices:
return
total_visible = len(visible_indices)
# Calculate current logical page
current_page = self.scroll_offset // self.page_size
total_pages = (total_visible + 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
# Move cursor to top of new page
try:
# Calculate what visual index corresponds to the start of the new page
new_visual_cursor = self.scroll_offset
if new_visual_cursor < len(visible_indices):
self.cursor_index = visible_indices[new_visual_cursor]
else:
self.cursor_index = visible_indices[-1]
except IndexError:
pass
def toggle_selection(self):
"""
Toggles the selection state of the item currently under the cursor.
"""
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_expand(self):
"""
Expands or collapses the current header.
"""
if self.cursor_index in self.dependencies:
if self.cursor_index in self.expanded_headers:
self.expanded_headers.remove(self.cursor_index)
else:
self.expanded_headers.add(self.cursor_index)
def toggle_expand_all(self):
"""
Toggles expansion state of ALL headers.
If all are expanded -> Collapse all.
Otherwise -> Expand all.
"""
if not self.dependencies:
return
all_headers = set(self.dependencies.keys())
if self.expanded_headers == all_headers:
self.expanded_headers.clear()
else:
self.expanded_headers = all_headers.copy()
def toggle_all(self):
"""
Toggles the selection of all items.
"""
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()
# Ctrl+C (0x03) or ESC (0x1b)
if key == b"\x03" or key == b"\x1b":
return "CANCEL"
# Special keys prefix (Arrow keys, etc., send 0xe0 or 0x00 first)
if key == b"\xe0" or key == b"\x00":
try:
key = msvcrt.getch()
if key == b"H": # Arrow Up
return "UP"
if key == b"P": # Arrow Down
return "DOWN"
if key == b"K": # Arrow Left
return "LEFT"
if key == b"M": # Arrow Right
return "RIGHT"
except Exception:
pass
try:
char = key.decode("utf-8", errors="ignore")
except Exception:
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 == "e":
return "EXPAND"
if char == "E":
return "EXPAND_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()
# Ctrl+C
if char == "\x03": return "CANCEL"
# ANSI Escape Sequences for Arrow Keys
mapping = {
"\x1b[A": "UP", # Escape + [ + A
"\x1b[B": "DOWN", # Escape + [ + B
"\x1b[C": "RIGHT", # Escape + [ + C
"\x1b[D": "LEFT", # Escape + [ + D
}
if char in mapping: return mapping[char]
# Handling manual Escape sequences
if char == "\x1b": # ESC
try:
next1 = click.getchar()
if next1 in ("[", "O"): # Sequence indicators
next2 = click.getchar()
if next2 == "A": # Arrow Up
return "UP"
if next2 == "B": # Arrow Down
return "DOWN"
if next2 == "C": # Arrow Right
return "RIGHT"
if next2 == "D": # Arrow Left
return "LEFT"
return "CANCEL"
except Exception:
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 == "e":
return "EXPAND"
if char == "E":
return "EXPAND_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 == "EXPAND":
self.toggle_expand()
elif action == "EXPAND_ALL":
self.toggle_expand_all()
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",
collapse_on_start: bool = False,
**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.
collapse_on_start: If True, child items are hidden initially.
"""
selector = Selector(
options=options,
cursor_style=cursor_style,
text_style="text",
page_size=page_size,
minimal_count=minimal_count,
collapse_on_start=collapse_on_start,
**kwargs,
)
selected_indices = selector.run()
if return_indices:
return selected_indices
return [options[i] for i in selected_indices]