Files

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