diff --git a/riocli/utils/selector.py b/riocli/utils/selector.py index bb153c0fe..5321db1f1 100644 --- a/riocli/utils/selector.py +++ b/riocli/utils/selector.py @@ -11,6 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import sys from typing import Any import click @@ -30,6 +31,10 @@ def show_selection( """ Show a selection prompt to the user. + When running in an interactive terminal, shows an fzf-style interactive + selector with fuzzy search and arrow-key navigation. Falls back to a + numbered list with integer prompt when not in a TTY. + :param ranger: List or dictionary of options :param header: Header to show before the options :param prompt: Prompt to show after the options @@ -38,13 +43,214 @@ def show_selection( :return: Selected option """ + # Use interactive fuzzy selector when stdout is a real TTY + if sys.stdout.isatty() and sys.stdin.isatty(): + if isinstance(ranger, dict): + return _fuzzy_selection_dict(ranger, header) + if isinstance(ranger, list): + return _fuzzy_selection_list(ranger, header) + + # Fallback: numbered list + integer prompt (for pipes / non-interactive use) if isinstance(ranger, dict): return _show_selection_dict(ranger, header, prompt, show_keys, highlight_item) - if isinstance(ranger, list): return _show_selection_list(ranger, header, prompt, highlight_item) +# --------------------------------------------------------------------------- +# Interactive fuzzy selectors (prompt_toolkit) +# --------------------------------------------------------------------------- + + +def _fuzzy_selection_list(items: list, header: str) -> Any: + """Interactive fuzzy-search selector for a plain list.""" + chosen = _run_fuzzy_picker(items, items, header) + return chosen + + +def _fuzzy_selection_dict(ranger: dict, header: str) -> Any: + """Interactive fuzzy-search selector for a dict (returns the key/GUID). + + Passes keys as ``return_items`` so the picker returns the selected key + directly, avoiding any label-to-key lookup that would break on duplicate + labels. + """ + keys = list(ranger.keys()) + labels = list(ranger.values()) + return _run_fuzzy_picker(labels, keys, header) + + +def _run_fuzzy_picker(display_items: list, return_items: list, header: str) -> Any: + """ + Renders an fzf-style interactive picker using prompt_toolkit. + + - Type to filter (fuzzy/substring match) + - Up/Down arrows or Ctrl-P/Ctrl-N to move cursor + - Enter to confirm selection + - Ctrl-C / Escape to abort + """ + from prompt_toolkit import Application + from prompt_toolkit.formatted_text import HTML, to_formatted_text + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.layout import Layout + from prompt_toolkit.layout.containers import HSplit, Window + from prompt_toolkit.layout.controls import FormattedTextControl + from prompt_toolkit.styles import Style + + style = Style.from_dict( + { + "header": "bold ansiyellow", + "selected": "bold ansiblue reverse", + "item": "", + "info": "ansigreen", + "prompt": "ansicyan bold", + "count": "ansibrightblack", + "separator": "ansibrightblack", + } + ) + + state = { + "query": "", + "cursor": 0, + "filtered": list(range(len(display_items))), + "result": None, + "aborted": False, + } + + def _matches(query: str, text: str) -> bool: + """Case-insensitive fuzzy match: every character of query must appear + in order somewhere in text (same algorithm as fzf's default mode).""" + query = query.lower() + text = text.lower() + it = iter(text) + return all(c in it for c in query) + + def _refilter(): + q = state["query"] + if not q: + state["filtered"] = list(range(len(display_items))) + else: + state["filtered"] = [ + i for i, item in enumerate(display_items) if _matches(q, str(item)) + ] + # Clamp cursor + if state["filtered"]: + state["cursor"] = min(state["cursor"], len(state["filtered"]) - 1) + else: + state["cursor"] = 0 + + def _get_content(): + # FormattedTextControl requires a flat list of (style, text) tuples. + # Convert each HTML fragment and concatenate them. + result = [] + + def _add(html_str: str): + result.extend(to_formatted_text(HTML(html_str))) + + if header: + _add(f"
{_escape(header)}
\n") + + filtered = state["filtered"] + total = len(display_items) + matched = len(filtered) + _add( + f" {matched}/{total} items " + f"↑↓ navigate · type to filter · Enter select · Esc abort\n" + ) + _add(f" > {_escape(state['query'])}\n") + _add(f" {'─' * 60}\n") + + for rank, orig_idx in enumerate(filtered): + label = str(display_items[orig_idx]) + if rank == state["cursor"]: + _add(f" » {_escape(label)}\n") + else: + _add(f" {_escape(label)}\n") + + return result + + def _escape(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + content_control = FormattedTextControl(text=_get_content, focusable=False) + content_window = Window(content=content_control, wrap_lines=False) + + kb = KeyBindings() + + @kb.add("up") + @kb.add("c-p") + def _up(event): + if state["filtered"] and state["cursor"] > 0: + state["cursor"] -= 1 + + @kb.add("down") + @kb.add("c-n") + def _down(event): + if state["filtered"] and state["cursor"] < len(state["filtered"]) - 1: + state["cursor"] += 1 + + @kb.add("enter") + def _enter(event): + if state["filtered"]: + orig_idx = state["filtered"][state["cursor"]] + state["result"] = return_items[orig_idx] + event.app.exit() + + @kb.add("escape") + @kb.add("c-c") + def _abort(event): + state["aborted"] = True + event.app.exit() + + @kb.add("backspace") + @kb.add("c-h") + def _backspace(event): + if state["query"]: + state["query"] = state["query"][:-1] + state["cursor"] = 0 + _refilter() + + @kb.add("c-u") + def _clear_line(event): + state["query"] = "" + state["cursor"] = 0 + _refilter() + + # Catch all printable characters for the search query + @kb.add("") + def _char(event): + data = event.data + if data and data.isprintable() and len(data) == 1: + state["query"] += data + state["cursor"] = 0 + _refilter() + + layout = Layout( + HSplit([content_window]), + focused_element=content_window, + ) + + app = Application( + layout=layout, + key_bindings=kb, + style=style, + full_screen=False, + mouse_support=False, + ) + + app.run() + + if state["aborted"] or state["result"] is None: + raise click.Abort() + + return state["result"] + + +# --------------------------------------------------------------------------- +# Legacy fallback selectors (numbered list + integer prompt) +# --------------------------------------------------------------------------- + + def _show_selection_list( ranger: list, header: str, @@ -62,7 +268,7 @@ def _show_selection_list( data.append([idx_column, opt_column]) - tabulate_data(data, header=(), table_format="plain") + tabulate_data(data, headers=(), table_format="plain") prompt = click.style(prompt, fg=Colors.BLUE) choice = click.prompt(prompt, type=types.IntParamType())