Selector Update

This commit is contained in:
CodeName393
2026-02-18 02:41:00 +09:00
committed by GitHub
parent 0217086abf
commit b9bf8fddf5

View File

@@ -1,23 +1,34 @@
import sys import sys
import click import click
from rich.console import Group from rich.console import Group
from rich.live import Live from rich.live import Live
from rich.padding import Padding from rich.padding import Padding
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from unshackle.core.console import console from unshackle.core.console import console
IS_WINDOWS = sys.platform == "win32" """
if IS_WINDOWS: Select module for unshackle
import msvcrt Author: CodeName393
==========================
[Acknowledgment]
The interactive selection concept and user interface design of this module
were inspired by the 'beaupy' library (MIT License).
(https://github.com/petereon/beaupy)
[Note]
1. This code is an original implementation written from scratch and does not contain source code from the 'beaupy' library.
2. Parts of the implementation in this module were developed with the assistance of AI.
"""
IS_WINDOWS = sys.platform == "win32"
if IS_WINDOWS: import msvcrt
else: import termios
class Selector: class Selector:
""" """
A custom interactive selector class using the Rich library. A custom interactive selector class using the Rich library.
Allows for multi-selection of items with pagination. Allows for multi-selection of items with pagination and collapsible headers.
""" """
def __init__( def __init__(
@@ -28,7 +39,7 @@ class Selector:
page_size: int = 8, page_size: int = 8,
minimal_count: int = 0, minimal_count: int = 0,
dependencies: dict[int, list[int]] = None, dependencies: dict[int, list[int]] = None,
prefixes: list[str] = None, collapse_on_start: bool = False
): ):
""" """
Initialize the Selector. Initialize the Selector.
@@ -40,6 +51,7 @@ class Selector:
page_size: Number of items to show per page. page_size: Number of items to show per page.
minimal_count: Minimum number of items that must be selected. minimal_count: Minimum number of items that must be selected.
dependencies: Dictionary mapping parent index to list of child indices. dependencies: Dictionary mapping parent index to list of child indices.
collapse_on_start: If True, child items are hidden initially.
""" """
self.options = options self.options = options
self.cursor_style = cursor_style self.cursor_style = cursor_style
@@ -48,77 +60,159 @@ class Selector:
self.minimal_count = minimal_count self.minimal_count = minimal_count
self.dependencies = dependencies or {} 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.cursor_index = 0
self.selected_indices = set() self.selected_indices = set()
self.scroll_offset = 0 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): def get_renderable(self):
""" """
Constructs and returns the renderable object (Table + Info) for the current state. 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 = 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("Indicator", justify="right", no_wrap=True)
table.add_column("Option", overflow="ellipsis", no_wrap=True) table.add_column("Option", overflow="ellipsis", no_wrap=True)
for i in range(self.page_size): for idx in render_indices:
idx = self.scroll_offset + i option = self.options[idx]
is_cursor = idx == self.cursor_index
is_selected = idx in self.selected_indices
if idx < len(self.options): symbol = "[X]" if is_selected else "[ ]"
option = self.options[idx] style = self.cursor_style if is_cursor else self.text_style
is_cursor = idx == self.cursor_index indicator_text = Text(f"{symbol}", style=style)
is_selected = idx in self.selected_indices
symbol = "[X]" if is_selected else "[ ]" content_text = Text.from_markup(f"{option}")
style = self.cursor_style if is_cursor else self.text_style content_text.style = style
indicator_text = Text(f"{symbol}", style=style)
content_text = Text.from_markup(option) table.add_row(indicator_text, content_text)
content_text.style = style
table.add_row(indicator_text, content_text) # Fill empty rows to maintain height
else: rows_rendered = len(render_indices)
table.add_row(Text(" "), Text(" ")) for _ in range(self.page_size - rows_rendered):
table.add_row(Text(" "), Text(" "))
total_pages = (len(self.options) + self.page_size - 1) // self.page_size 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 current_page = (self.scroll_offset // self.page_size) + 1
info_text = Text( if self.dependencies:
f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})", info_text = Text(
style="gray", 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)) return Padding(Group(table, info_text), (0, 5))
def move_cursor(self, delta: int): def move_cursor(self, delta: int):
""" """
Moves the cursor up or down by the specified delta. Moves the cursor up or down through VISIBLE items only.
Updates the scroll offset if the cursor moves out of the current view.
""" """
self.cursor_index = (self.cursor_index + delta) % len(self.options) visible_indices = self.get_visible_indices()
new_page_idx = self.cursor_index // self.page_size if not visible_indices:
self.scroll_offset = new_page_idx * self.page_size 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): def change_page(self, delta: int):
""" """
Changes the current page view by the specified delta (previous/next page). Changes the current page view by the specified delta (previous/next page).
Also moves the cursor to the first item of the new 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 current_page = self.scroll_offset // self.page_size
total_pages = (len(self.options) + self.page_size - 1) // self.page_size total_pages = (total_visible + self.page_size - 1) // self.page_size
new_page = current_page + delta new_page = current_page + delta
if 0 <= new_page < total_pages: if 0 <= new_page < total_pages:
self.scroll_offset = new_page * self.page_size self.scroll_offset = new_page * self.page_size
first_idx_of_page = self.scroll_offset
if first_idx_of_page < len(self.options): # Move cursor to top of new page
self.cursor_index = first_idx_of_page try:
else: # Calculate what visual index corresponds to the start of the new page
self.cursor_index = len(self.options) - 1 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): def toggle_selection(self):
""" """
Toggles the selection state of the item currently under the cursor. Toggles the selection state of the item currently under the cursor.
Propagates selection to children if defined in dependencies.
""" """
target_indices = {self.cursor_index} target_indices = {self.cursor_index}
@@ -132,35 +226,72 @@ class Selector:
else: else:
self.selected_indices.difference_update(target_indices) self.selected_indices.difference_update(target_indices)
def toggle_expand(self, expand: bool = None):
"""
Expands or collapses the current header.
Args:
expand: True to expand, False to collapse, None to toggle.
"""
if self.cursor_index in self.dependencies:
if expand is None:
if self.cursor_index in self.expanded_headers:
self.expanded_headers.remove(self.cursor_index)
else:
self.expanded_headers.add(self.cursor_index)
elif expand:
self.expanded_headers.add(self.cursor_index)
else:
if self.cursor_index in self.expanded_headers:
self.expanded_headers.remove(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): def toggle_all(self):
""" """
Toggles the selection of all items. Toggles the selection of all items.
If all are selected, clears selection. Otherwise, selects all.
""" """
if len(self.selected_indices) == len(self.options): if len(self.selected_indices) == len(self.options):
self.selected_indices.clear() self.selected_indices.clear()
else: else:
self.selected_indices = set(range(len(self.options))) self.selected_indices = set(range(len(self.options)))
def flush_input(self):
"""
Forcefully flushes the STDIN buffer in a Linux environment
to discard unread input.
"""
if not IS_WINDOWS:
try:
# TCIFLUSH: 수신했지만 읽지 않은 데이터를 버림
termios.tcflush(sys.stdin, termios.TCIFLUSH)
except Exception:
pass
def get_input_windows(self): 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() key = msvcrt.getch()
# Ctrl+C (0x03) or ESC (0x1b)
if key == b"\x03" or key == b"\x1b": if key == b"\x03" or key == b"\x1b":
return "CANCEL" return "CANCEL"
# Special keys prefix (Arrow keys, etc., send 0xe0 or 0x00 first)
if key == b"\xe0" or key == b"\x00": if key == b"\xe0" or key == b"\x00":
try: try:
key = msvcrt.getch() key = msvcrt.getch()
if key == b"H": if key == b"H": return "UP" # Arrow Up
return "UP" if key == b"P": return "DOWN" # Arrow Down
if key == b"P": if key == b"K": return "LEFT" # Arrow Left
return "DOWN" if key == b"M": return "RIGHT" # Arrow Right
if key == b"K":
return "LEFT"
if key == b"M":
return "RIGHT"
except Exception: except Exception:
pass pass
@@ -169,83 +300,61 @@ class Selector:
except Exception: except Exception:
return None return None
if char in ("\r", "\n"): if char in ("\r", "\n"): return "ENTER"
return "ENTER" if char == " ": return "SPACE"
if char == " ": if char in ("q", "Q"): return "QUIT"
return "SPACE" if char in ("a", "A"): return "ALL"
if char in ("q", "Q"): if char == "e": return "EXPAND"
return "QUIT" if char == "E": return "EXPAND_ALL"
if char in ("a", "A"): if char in ("w", "W", "k", "K"): return "UP"
return "ALL" if char in ("s", "S", "j", "J"): return "DOWN"
if char in ("w", "W", "k", "K"): if char in ("h", "H"): return "LEFT"
return "UP" if char in ("d", "D", "l", "L"): return "RIGHT"
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 return None
def get_input_unix(self): 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() char = click.getchar()
if char == "\x03": # Ctrl+C
return "CANCEL" if char == "\x03": return "CANCEL"
# ANSI Escape Sequences for Arrow Keys
mapping = { mapping = {
"\x1b[A": "UP", "\x1b[A": "UP", # Escape + [ + A
"\x1b[B": "DOWN", "\x1b[B": "DOWN", # Escape + [ + B
"\x1b[C": "RIGHT", "\x1b[C": "RIGHT", # Escape + [ + C
"\x1b[D": "LEFT", "\x1b[D": "LEFT", # Escape + [ + D
} }
if char in mapping: if char in mapping: return mapping[char]
return mapping[char]
if char == "\x1b": # Handling manual Escape sequences
if char == "\x1b": # ESC
try: try:
next1 = click.getchar() next1 = click.getchar()
if next1 in ("[", "O"): if next1 in ("[", "O"): # Sequence indicators
next2 = click.getchar() next2 = click.getchar()
if next2 == "A": if next2 == "A": return "UP" # Arrow Up
return "UP" if next2 == "B": return "DOWN" # Arrow Down
if next2 == "B": if next2 == "C": return "RIGHT" # Arrow Right
return "DOWN" if next2 == "D": return "LEFT" # Arrow Left
if next2 == "C":
return "RIGHT"
if next2 == "D":
return "LEFT"
return "CANCEL" return "CANCEL"
except Exception: except Exception:
return "CANCEL" return "CANCEL"
if char in ("\r", "\n"): if char in ("\r", "\n"): return "ENTER"
return "ENTER" if char == " ": return "SPACE"
if char == " ": if char in ("q", "Q"): return "QUIT"
return "SPACE" if char in ("a", "A"): return "ALL"
if char in ("q", "Q"): if char == "e": return "EXPAND"
return "QUIT" if char == "E": return "EXPAND_ALL"
if char in ("a", "A"): if char in ("w", "W", "k", "K"): return "UP"
return "ALL" if char in ("s", "S", "j", "J"): return "DOWN"
if char in ("w", "W", "k", "K"): if char in ("h", "H"): return "LEFT"
return "UP" if char in ("d", "D", "l", "L"): return "RIGHT"
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 return None
def run(self) -> list[int]: def run(self) -> list[int]:
""" # Flush the input buffer before starting the Live context.
Starts the main event loop for the selector. self.flush_input()
Renders the UI and processes input until confirmed or cancelled.
Returns:
list[int]: A sorted list of selected indices.
"""
try: try:
with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live: with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live:
while True: while True:
@@ -263,6 +372,10 @@ class Selector:
self.change_page(-1) self.change_page(-1)
elif action == "RIGHT": elif action == "RIGHT":
self.change_page(1) self.change_page(1)
elif action == "EXPAND":
self.toggle_expand(expand=None)
elif action == "EXPAND_ALL":
self.toggle_expand_all()
elif action == "SPACE": elif action == "SPACE":
self.toggle_selection() self.toggle_selection()
elif action == "ALL": elif action == "ALL":
@@ -282,17 +395,11 @@ def select_multiple(
page_size: int = 8, page_size: int = 8,
return_indices: bool = True, return_indices: bool = True,
cursor_style: str = "pink", cursor_style: str = "pink",
collapse_on_start: bool = False,
**kwargs, **kwargs,
) -> list[int]: ) -> list[int]:
""" """
Drop-in replacement using custom Selector with global console. 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( selector = Selector(
options=options, options=options,
@@ -300,6 +407,7 @@ def select_multiple(
text_style="text", text_style="text",
page_size=page_size, page_size=page_size,
minimal_count=minimal_count, minimal_count=minimal_count,
collapse_on_start=collapse_on_start,
**kwargs, **kwargs,
) )