Merge pull request #79 from CodeName393/select-title-update

Select title update
This commit is contained in:
Sp5rky
2026-02-26 08:02:31 -07:00
committed by GitHub
2 changed files with 193 additions and 53 deletions

View File

@@ -994,6 +994,10 @@ class dl:
self.log.error("--require-subs and --s-lang cannot be used together") self.log.error("--require-subs and --s-lang cannot be used together")
sys.exit(1) sys.exit(1)
if select_titles and wanted:
self.log.error("--select-titles and -w/--wanted cannot be used together")
sys.exit(1)
# Check if dovi_tool is available when hybrid mode is requested # Check if dovi_tool is available when hybrid mode is requested
if any(r == Video.Range.HYBRID for r in range_): if any(r == Video.Range.HYBRID for r in range_):
from unshackle.core.binaries import DoviTool from unshackle.core.binaries import DoviTool
@@ -1152,7 +1156,7 @@ class dl:
# Note: Headers are not mapped to actual title indices # Note: Headers are not mapped to actual title indices
# Format display name # Format display name
display_name = ((t.name[:35].rstrip() + "") if len(t.name) > 35 else t.name) if t.name else None display_name = ((t.name[:30].rstrip() + "") if len(t.name) > 30 else t.name) if t.name else None
# Apply indentation only for multiple seasons # Apply indentation only for multiple seasons
prefix = " " if multiple_seasons else "" prefix = " " if multiple_seasons else ""
@@ -1172,9 +1176,18 @@ class dl:
# Execute selector with dependencies (headers select all children) # Execute selector with dependencies (headers select all children)
selected_ui_idx = select_multiple( selected_ui_idx = select_multiple(
selection_titles, minimal_count=1, page_size=8, return_indices=True, dependencies=dependencies selection_titles,
minimal_count=1,
page_size=8,
return_indices=True,
dependencies=dependencies,
collapse_on_start=multiple_seasons
) )
if not selected_ui_idx:
console.print(Padding(":x: Selection Cancelled...", (0, 5, 1, 5)))
return
selection_end = time.time() selection_end = time.time()
start_time += selection_end - selection_start start_time += selection_end - selection_start

View File

@@ -1,12 +1,10 @@
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" IS_WINDOWS = sys.platform == "win32"
@@ -17,7 +15,7 @@ if IS_WINDOWS:
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 +26,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 +38,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,22 +47,74 @@ 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
if idx < len(self.options):
option = self.options[idx] option = self.options[idx]
is_cursor = idx == self.cursor_index is_cursor = idx == self.cursor_index
is_selected = idx in self.selected_indices is_selected = idx in self.selected_indices
@@ -72,16 +123,28 @@ class Selector:
style = self.cursor_style if is_cursor else self.text_style style = self.cursor_style if is_cursor else self.text_style
indicator_text = Text(f"{symbol}", style=style) indicator_text = Text(f"{symbol}", style=style)
content_text = Text.from_markup(option) content_text = Text.from_markup(f"{option}")
content_text.style = style content_text.style = style
table.add_row(indicator_text, content_text) table.add_row(indicator_text, content_text)
else:
# Fill empty rows to maintain height
rows_rendered = len(render_indices)
for _ in range(self.page_size - rows_rendered):
table.add_row(Text(" "), Text(" ")) 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
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( info_text = Text(
f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})", f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})",
style="gray", style="gray",
@@ -91,34 +154,53 @@ class Selector:
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:
# 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: else:
self.cursor_index = len(self.options) - 1 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,10 +214,33 @@ class Selector:
else: else:
self.selected_indices.difference_update(target_indices) 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): 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()
@@ -148,18 +253,20 @@ class Selector:
Returns command strings like 'UP', 'DOWN', 'ENTER', etc. 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": # Arrow Up
return "UP" return "UP"
if key == b"P": if key == b"P": # Arrow Down
return "DOWN" return "DOWN"
if key == b"K": if key == b"K": # Arrow Left
return "LEFT" return "LEFT"
if key == b"M": if key == b"M": # Arrow Right
return "RIGHT" return "RIGHT"
except Exception: except Exception:
pass pass
@@ -177,6 +284,10 @@ class Selector:
return "QUIT" return "QUIT"
if char in ("a", "A"): if char in ("a", "A"):
return "ALL" return "ALL"
if char == "e":
return "EXPAND"
if char == "E":
return "EXPAND_ALL"
if char in ("w", "W", "k", "K"): if char in ("w", "W", "k", "K"):
return "UP" return "UP"
if char in ("s", "S", "j", "J"): if char in ("s", "S", "j", "J"):
@@ -193,28 +304,33 @@ class Selector:
Returns command strings like 'UP', 'DOWN', 'ENTER', etc. Returns command strings like 'UP', 'DOWN', 'ENTER', etc.
""" """
char = click.getchar() char = click.getchar()
# Ctrl+C
if char == "\x03": if char == "\x03":
return "CANCEL" 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": # Arrow Up
return "UP" return "UP"
if next2 == "B": if next2 == "B": # Arrow Down
return "DOWN" return "DOWN"
if next2 == "C": if next2 == "C": # Arrow Right
return "RIGHT" return "RIGHT"
if next2 == "D": if next2 == "D": # Arrow Left
return "LEFT" return "LEFT"
return "CANCEL" return "CANCEL"
except Exception: except Exception:
@@ -228,6 +344,10 @@ class Selector:
return "QUIT" return "QUIT"
if char in ("a", "A"): if char in ("a", "A"):
return "ALL" return "ALL"
if char == "e":
return "EXPAND"
if char == "E":
return "EXPAND_ALL"
if char in ("w", "W", "k", "K"): if char in ("w", "W", "k", "K"):
return "UP" return "UP"
if char in ("s", "S", "j", "J"): if char in ("s", "S", "j", "J"):
@@ -263,6 +383,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()
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,6 +406,7 @@ 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]:
""" """
@@ -293,6 +418,7 @@ def select_multiple(
page_size: Number of items per page. page_size: Number of items per page.
return_indices: If True, returns indices; otherwise returns the option strings. return_indices: If True, returns indices; otherwise returns the option strings.
cursor_style: Style color for the cursor. cursor_style: Style color for the cursor.
collapse_on_start: If True, child items are hidden initially.
""" """
selector = Selector( selector = Selector(
options=options, options=options,
@@ -300,6 +426,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,
) )