229 lines
7.5 KiB
Python
229 lines
7.5 KiB
Python
"""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)} 项 · 取消选择不卸载,卸载用 skiff 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
|