From b9bf8fddf5e7a2f2e44fcca35542b75ae67cd711 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Wed, 18 Feb 2026 02:41:00 +0900 Subject: [PATCH 01/11] Selector Update --- unshackle/core/utils/selector.py | 348 ++++++++++++++++++++----------- 1 file changed, 228 insertions(+), 120 deletions(-) diff --git a/unshackle/core/utils/selector.py b/unshackle/core/utils/selector.py index b6e00a5..8f522bb 100644 --- a/unshackle/core/utils/selector.py +++ b/unshackle/core/utils/selector.py @@ -1,23 +1,34 @@ 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 +""" +Select module for unshackle +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: """ 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__( @@ -28,7 +39,7 @@ class Selector: page_size: int = 8, minimal_count: int = 0, dependencies: dict[int, list[int]] = None, - prefixes: list[str] = None, + collapse_on_start: bool = False ): """ Initialize the Selector. @@ -40,6 +51,7 @@ class Selector: 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 @@ -47,78 +59,160 @@ class Selector: 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 i in range(self.page_size): - idx = self.scroll_offset + i + for idx in render_indices: + option = self.options[idx] + is_cursor = idx == self.cursor_index + is_selected = idx in self.selected_indices - 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) - 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 - content_text = Text.from_markup(option) - content_text.style = style + table.add_row(indicator_text, content_text) - table.add_row(indicator_text, content_text) - else: - table.add_row(Text(" "), 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_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 - info_text = Text( - f"\n[Space]: Toggle [a]: All [←/→]: Page [Enter]: Confirm (Page {current_page}/{total_pages})", - style="gray", - ) + 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 by the specified delta. - Updates the scroll offset if the cursor moves out of the current view. + Moves the cursor up or down through VISIBLE items only. """ - 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 + 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). - 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 - 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 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 + + # 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. - Propagates selection to children if defined in dependencies. """ target_indices = {self.cursor_index} @@ -131,36 +225,73 @@ class Selector: self.selected_indices.update(target_indices) else: 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): """ 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 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): - """ - 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": - return "UP" - if key == b"P": - return "DOWN" - if key == b"K": - return "LEFT" - if key == b"M": - return "RIGHT" + if key == b"H": return "UP" # Arrow Up + if key == b"P": return "DOWN" # Arrow Down + if key == b"K": return "LEFT" # Arrow Left + if key == b"M": return "RIGHT" # Arrow Right except Exception: pass @@ -169,83 +300,61 @@ class Selector: 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 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" + 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() - if char == "\x03": - return "CANCEL" + # Ctrl+C + if char == "\x03": return "CANCEL" + + # ANSI Escape Sequences for Arrow Keys mapping = { - "\x1b[A": "UP", - "\x1b[B": "DOWN", - "\x1b[C": "RIGHT", - "\x1b[D": "LEFT", + "\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] - if char == "\x1b": + if char in mapping: return mapping[char] + + # Handling manual Escape sequences + if char == "\x1b": # ESC try: next1 = click.getchar() - if next1 in ("[", "O"): + if next1 in ("[", "O"): # Sequence indicators next2 = click.getchar() - if next2 == "A": - return "UP" - if next2 == "B": - return "DOWN" - if next2 == "C": - return "RIGHT" - if next2 == "D": - return "LEFT" + if next2 == "A": return "UP" # Arrow Up + if next2 == "B": return "DOWN" # Arrow Down + if next2 == "C": return "RIGHT" # Arrow Right + if next2 == "D": return "LEFT" # Arrow 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 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" + 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. - """ + # Flush the input buffer before starting the Live context. + self.flush_input() try: with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live: while True: @@ -263,6 +372,10 @@ class Selector: self.change_page(-1) elif action == "RIGHT": self.change_page(1) + elif action == "EXPAND": + self.toggle_expand(expand=None) + elif action == "EXPAND_ALL": + self.toggle_expand_all() elif action == "SPACE": self.toggle_selection() elif action == "ALL": @@ -282,17 +395,11 @@ def select_multiple( 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. """ selector = Selector( options=options, @@ -300,6 +407,7 @@ def select_multiple( text_style="text", page_size=page_size, minimal_count=minimal_count, + collapse_on_start=collapse_on_start, **kwargs, ) From d21a59f306ef2fbf0f1fca005971f3be34bdd4e9 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Wed, 18 Feb 2026 02:43:58 +0900 Subject: [PATCH 02/11] dl.py Update --- unshackle/commands/dl.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 4137232..5fce0dd 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1050,6 +1050,7 @@ class dl: if list_titles: return + # The logic for the 'select-titles' feature below was referenced and adapted from code provided by "A_n_g_e_l_a". # Enables manual selection for Series when --select-titles is set if select_titles and isinstance(titles, Series): console.print(Padding(Rule("[rule.text]Select Titles"), (1, 2))) @@ -1076,7 +1077,7 @@ class dl: # Note: Headers are not mapped to actual title indices # 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 prefix = " " if multiple_seasons else "" @@ -1096,9 +1097,18 @@ class dl: # Execute selector with dependencies (headers select all children) 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: Selected Cancelled...", (0, 5, 1, 5))) + return + selection_end = time.time() start_time += selection_end - selection_start From 57ecddfeeb5c016b4c86076a2a5e890b5e04a748 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Wed, 18 Feb 2026 03:27:35 +0900 Subject: [PATCH 03/11] Delete terminal reset logic --- unshackle/core/utils/selector.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/unshackle/core/utils/selector.py b/unshackle/core/utils/selector.py index 8f522bb..49a93eb 100644 --- a/unshackle/core/utils/selector.py +++ b/unshackle/core/utils/selector.py @@ -23,7 +23,6 @@ were inspired by the 'beaupy' library (MIT License). IS_WINDOWS = sys.platform == "win32" if IS_WINDOWS: import msvcrt -else: import termios class Selector: """ @@ -267,18 +266,6 @@ class Selector: else: 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): key = msvcrt.getch() # Ctrl+C (0x03) or ESC (0x1b) @@ -353,8 +340,6 @@ class Selector: return None def run(self) -> list[int]: - # Flush the input buffer before starting the Live context. - self.flush_input() try: with Live(self.get_renderable(), console=console, auto_refresh=False, transient=True) as live: while True: From d2f221f3fc8c26587dbc8a7c431e93351aead30a Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Wed, 18 Feb 2026 03:36:54 +0900 Subject: [PATCH 04/11] Restore code comments --- unshackle/core/utils/selector.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/unshackle/core/utils/selector.py b/unshackle/core/utils/selector.py index 49a93eb..5968e11 100644 --- a/unshackle/core/utils/selector.py +++ b/unshackle/core/utils/selector.py @@ -267,6 +267,10 @@ class Selector: 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": @@ -300,6 +304,10 @@ class Selector: 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" @@ -340,6 +348,13 @@ class Selector: 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: @@ -385,6 +400,14 @@ def select_multiple( ) -> 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, From 772bacfc8f2c5696e7f05deb630acb688286a547 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Wed, 25 Feb 2026 19:26:35 +0900 Subject: [PATCH 05/11] Update selector.py --- unshackle/core/utils/selector.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/unshackle/core/utils/selector.py b/unshackle/core/utils/selector.py index 5968e11..a48699c 100644 --- a/unshackle/core/utils/selector.py +++ b/unshackle/core/utils/selector.py @@ -7,20 +7,6 @@ from rich.table import Table from rich.text import Text from unshackle.core.console import console -""" -Select module for unshackle -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 From b26d47fd9bcf9ebbac7487913bf6610a79abf446 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Wed, 25 Feb 2026 19:27:13 +0900 Subject: [PATCH 06/11] Update dl.py --- unshackle/commands/dl.py | 1 - 1 file changed, 1 deletion(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 5fce0dd..1bf1105 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1050,7 +1050,6 @@ class dl: if list_titles: return - # The logic for the 'select-titles' feature below was referenced and adapted from code provided by "A_n_g_e_l_a". # Enables manual selection for Series when --select-titles is set if select_titles and isinstance(titles, Series): console.print(Padding(Rule("[rule.text]Select Titles"), (1, 2))) From 65e6ae88d05c9712f6995ec1189b82da412d835f Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 26 Feb 2026 15:16:22 +0900 Subject: [PATCH 07/11] Update dl.py --- unshackle/commands/dl.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 1bf1105..0345563 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -918,6 +918,10 @@ class dl: self.log.error("--require-subs and --s-lang cannot be used together") 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 if any(r == Video.Range.HYBRID for r in range_): from unshackle.core.binaries import DoviTool @@ -1105,7 +1109,7 @@ class dl: ) if not selected_ui_idx: - console.print(Padding(":x: Selected Cancelled...", (0, 5, 1, 5))) + console.print(Padding(":x: Selection Cancelled...", (0, 5, 1, 5))) return selection_end = time.time() @@ -1730,7 +1734,6 @@ class dl: licence=partial( service.get_playready_license if (is_playready_cdm(self.cdm)) - and hasattr(service, "get_playready_license") else service.get_widevine_license, title=title, track=track, From 00b4f2cdd15ab95b99ff974be39ed97546e41de4 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 26 Feb 2026 15:16:34 +0900 Subject: [PATCH 08/11] Update selector.py --- unshackle/core/utils/selector.py | 129 ++++++++++++++++++------------- 1 file changed, 75 insertions(+), 54 deletions(-) diff --git a/unshackle/core/utils/selector.py b/unshackle/core/utils/selector.py index a48699c..4ad4f36 100644 --- a/unshackle/core/utils/selector.py +++ b/unshackle/core/utils/selector.py @@ -8,7 +8,8 @@ from rich.text import Text from unshackle.core.console import console IS_WINDOWS = sys.platform == "win32" -if IS_WINDOWS: import msvcrt +if IS_WINDOWS: + import msvcrt class Selector: """ @@ -44,7 +45,7 @@ class Selector: 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(): @@ -83,7 +84,7 @@ class Selector: 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) @@ -98,13 +99,13 @@ class Selector: # 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] @@ -133,7 +134,8 @@ class Selector: total_visible = len(visible_indices) total_pages = (total_visible + self.page_size - 1) // self.page_size - if total_pages == 0: total_pages = 1 + if total_pages == 0: + total_pages = 1 current_page = (self.scroll_offset // self.page_size) + 1 if self.dependencies: @@ -174,16 +176,16 @@ class Selector: 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 @@ -210,24 +212,16 @@ class Selector: self.selected_indices.update(target_indices) else: self.selected_indices.difference_update(target_indices) - - def toggle_expand(self, expand: bool = None): + + def toggle_expand(self): """ 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) + if self.cursor_index in self.expanded_headers: + self.expanded_headers.remove(self.cursor_index) else: - if self.cursor_index in self.expanded_headers: - self.expanded_headers.remove(self.cursor_index) + self.expanded_headers.add(self.cursor_index) def toggle_expand_all(self): """ @@ -265,10 +259,14 @@ class Selector: if key == b"\xe0" or key == b"\x00": try: key = msvcrt.getch() - if key == b"H": return "UP" # Arrow Up - if key == b"P": return "DOWN" # Arrow Down - if key == b"K": return "LEFT" # Arrow Left - if key == b"M": return "RIGHT" # Arrow Right + 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 @@ -277,16 +275,26 @@ class Selector: 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" + 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): @@ -297,7 +305,7 @@ class Selector: char = click.getchar() # Ctrl+C if char == "\x03": return "CANCEL" - + # ANSI Escape Sequences for Arrow Keys mapping = { "\x1b[A": "UP", # Escape + [ + A @@ -306,31 +314,45 @@ class Selector: "\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": return "UP" # Arrow Up - if next2 == "B": return "DOWN" # Arrow Down - if next2 == "C": return "RIGHT" # Arrow Right - if next2 == "D": return "LEFT" # Arrow Left + 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" + 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]: @@ -359,7 +381,7 @@ class Selector: elif action == "RIGHT": self.change_page(1) elif action == "EXPAND": - self.toggle_expand(expand=None) + self.toggle_expand() elif action == "EXPAND_ALL": self.toggle_expand_all() elif action == "SPACE": @@ -374,7 +396,6 @@ class Selector: except KeyboardInterrupt: return [] - def select_multiple( options: list[str], minimal_count: int = 1, From 0f25b0ce52be989ab20da6401ff78da7ed330d33 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 26 Feb 2026 15:18:21 +0900 Subject: [PATCH 09/11] Update selector.py --- unshackle/core/utils/selector.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/unshackle/core/utils/selector.py b/unshackle/core/utils/selector.py index 4ad4f36..da2bb61 100644 --- a/unshackle/core/utils/selector.py +++ b/unshackle/core/utils/selector.py @@ -8,7 +8,7 @@ from rich.text import Text from unshackle.core.console import console IS_WINDOWS = sys.platform == "win32" -if IS_WINDOWS: +if IS_WINDOWS: import msvcrt class Selector: @@ -304,7 +304,8 @@ class Selector: """ char = click.getchar() # Ctrl+C - if char == "\x03": return "CANCEL" + if char == "\x03": + return "CANCEL" # ANSI Escape Sequences for Arrow Keys mapping = { @@ -313,7 +314,8 @@ class Selector: "\x1b[C": "RIGHT", # Escape + [ + C "\x1b[D": "LEFT", # Escape + [ + D } - if char in mapping: return mapping[char] + if char in mapping: + return mapping[char] # Handling manual Escape sequences if char == "\x1b": # ESC From 1611fcc97128b1d3ec85e983fa9fcc1ad1e6d0a7 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 26 Feb 2026 15:18:41 +0900 Subject: [PATCH 10/11] Update dl.py --- unshackle/commands/dl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 0345563..41c65be 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1734,6 +1734,7 @@ class dl: licence=partial( service.get_playready_license if (is_playready_cdm(self.cdm)) + and hasattr(service, "get_playready_license") else service.get_widevine_license, title=title, track=track, From c5ef13df5d63e486cdaa39e6160d3bf1ee59d98d Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 26 Feb 2026 15:25:09 +0900 Subject: [PATCH 11/11] Update selector.py --- unshackle/core/utils/selector.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/unshackle/core/utils/selector.py b/unshackle/core/utils/selector.py index da2bb61..121e5ed 100644 --- a/unshackle/core/utils/selector.py +++ b/unshackle/core/utils/selector.py @@ -11,6 +11,7 @@ IS_WINDOWS = sys.platform == "win32" if IS_WINDOWS: import msvcrt + class Selector: """ A custom interactive selector class using the Rich library. @@ -25,7 +26,7 @@ class Selector: page_size: int = 8, minimal_count: int = 0, dependencies: dict[int, list[int]] = None, - collapse_on_start: bool = False + collapse_on_start: bool = False, ): """ Initialize the Selector. @@ -259,13 +260,13 @@ class Selector: if key == b"\xe0" or key == b"\x00": try: key = msvcrt.getch() - if key == b"H": # Arrow Up + if key == b"H": # Arrow Up return "UP" - if key == b"P": # Arrow Down + if key == b"P": # Arrow Down return "DOWN" - if key == b"K": # Arrow Left + if key == b"K": # Arrow Left return "LEFT" - if key == b"M": # Arrow Right + if key == b"M": # Arrow Right return "RIGHT" except Exception: pass @@ -309,27 +310,27 @@ class Selector: # 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 + "\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 + if char == "\x1b": # ESC try: next1 = click.getchar() - if next1 in ("[", "O"): # Sequence indicators + if next1 in ("[", "O"): # Sequence indicators next2 = click.getchar() - if next2 == "A": # Arrow Up + if next2 == "A": # Arrow Up return "UP" - if next2 == "B": # Arrow Down + if next2 == "B": # Arrow Down return "DOWN" - if next2 == "C": # Arrow Right + if next2 == "C": # Arrow Right return "RIGHT" - if next2 == "D": # Arrow Left + if next2 == "D": # Arrow Left return "LEFT" return "CANCEL" except Exception: @@ -398,6 +399,7 @@ class Selector: except KeyboardInterrupt: return [] + def select_multiple( options: list[str], minimal_count: int = 1,