"""Terminal multi-select UI for skills.""" from __future__ import annotations import unicodedata from dataclasses import dataclass from typing import Callable, Iterable @dataclass(frozen=True) class SkillChoice: name: str kind: str description: str = "" installed: bool = False readonly_status: str = "" children: tuple[str, ...] = () indent: int = 0 def fit_to_width(text: str, width: int) -> str: """Trim text to terminal display width without splitting wide characters.""" if width <= 0: return "" result: list[str] = [] used = 0 for char in text: if unicodedata.combining(char): char_width = 0 else: char_width = 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1 if used + char_width > width: break result.append(char) used += char_width return "".join(result) def display_width(text: str) -> int: return sum( 0 if unicodedata.combining(char) else 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1 for char in text ) def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]: needle = query.casefold().strip() if not needle: return list(choices) return [ choice for choice in choices if needle in choice.name.casefold() or needle in choice.kind.casefold() or needle in choice.description.casefold() or needle in choice.readonly_status.casefold() ] def select_skills( choices: list[SkillChoice], *, scope_label: str = "当前作用域", wrapper: Callable[..., set[str] | None] | None = None, ) -> set[str] | None: """Open the selector. Return names, or None when cancelled.""" if not choices: return set() try: import curses except ImportError as exc: raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc selected = { choice.name for choice in choices if choice.installed and not choice.children } def mark_for(choice: SkillChoice) -> str: if not choice.children: return "x" if choice.name in selected else " " selected_children = selected.intersection(choice.children) if len(selected_children) == len(choice.children): return "x" return "-" if selected_children else " " def draw(stdscr: object) -> set[str] | None: curses.curs_set(0) stdscr.keypad(True) current = 0 query = "" source_attr = curses.A_NORMAL status_attr = curses.A_NORMAL description_attr = curses.A_DIM try: if curses.has_colors(): curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_CYAN, -1) curses.init_pair(2, curses.COLOR_GREEN, -1) source_attr = curses.color_pair(1) status_attr = curses.color_pair(2) except curses.error: pass while True: visible = filter_choices(choices, query) current = min(current, max(0, len(visible) - 1)) stdscr.erase() height, width = stdscr.getmaxyx() text_width = max(0, width - 1) stdscr.addnstr( 0, 0, fit_to_width(f"安装目标: {scope_label}", text_width), text_width, ) header = "↑↓ 移动 Space 选择 / 搜索 Enter 安装 q 退出" stdscr.addnstr(1, 0, fit_to_width(header, text_width), text_width) if query: stdscr.addnstr( 2, 0, fit_to_width(f"搜索: {query}", text_width), text_width, ) item_capacity = max(1, (height - 4) // 3) start = max(0, current - item_capacity + 1) for offset, choice in enumerate(visible[start : start + item_capacity]): index = start + offset row = 3 + offset * 3 mark = mark_for(choice) description = f" {choice.description}" if choice.description else "" focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL parts = [ (f"{' ' * choice.indent}[{mark}] ", focus_attr), (choice.name, focus_attr | curses.A_BOLD), (f" {choice.kind}", focus_attr | source_attr), ] if choice.readonly_status: parts.append( (f" {choice.readonly_status}", focus_attr | status_attr) ) column = 0 for text, attr in parts: available = text_width - column if available <= 0: break fitted = fit_to_width(text, available) stdscr.addnstr(row, column, fitted, available, attr) column += display_width(fitted) stdscr.addnstr( row + 1, 0, fit_to_width(description, text_width), text_width, focus_attr | description_attr, ) footer = ( f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 pouch remove" ) stdscr.addnstr( height - 1, 0, fit_to_width(footer, text_width), text_width, ) stdscr.refresh() key = stdscr.get_wch() if key in ("q", "Q", "\x1b"): return None if key in ("\n", "\r") or key == curses.KEY_ENTER: return set(selected) if key == curses.KEY_UP and visible: current = (current - 1) % len(visible) elif key == curses.KEY_DOWN and visible: current = (current + 1) % len(visible) elif key == " " and visible: choice = visible[current] if choice.children: children = set(choice.children) if children.issubset(selected): selected.difference_update(children) else: selected.update(children) else: selected.symmetric_difference_update({choice.name}) elif key == "/": curses.curs_set(1) query = _read_query(stdscr, curses, width, row=2) curses.curs_set(0) current = 0 runner = wrapper or curses.wrapper return runner(draw) def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str: query = "" while True: stdscr.move(row, 0) stdscr.clrtoeol() text_width = max(0, width - 1) stdscr.addnstr( row, 0, fit_to_width(f"搜索: {query}", text_width), text_width, ) stdscr.refresh() key = stdscr.get_wch() if key in ("\n", "\r") or key == curses.KEY_ENTER: return query if key == "\x1b": return "" if key in ("\b", "\x7f") or key == curses.KEY_BACKSPACE: query = query[:-1] elif isinstance(key, str) and key.isprintable(): query += key