"""Compact, scrollable shortcut reference for the Hub popups.""" from __future__ import annotations import curses from dataclasses import dataclass import unicodedata @dataclass(frozen=True) class KeyMapping: keys: str action: str KEY_SECTIONS: tuple[tuple[str, tuple[KeyMapping, ...]], ...] = ( ( "Hub", ( KeyMapping("Ctrl+A ?", "打开快捷键帮助"), KeyMapping("Ctrl+A g", "打开 Sessions"), KeyMapping("Ctrl+A h", "打开 Herdr 命令"), KeyMapping("Ctrl+A p / n", "上一个 / 下一个 Hub Tab"), KeyMapping("Ctrl+A 0..9", "按编号切换 Hub Tab"), KeyMapping("Ctrl+A r", "扫描新增 Sessions"), KeyMapping("Ctrl+A R", "重连当前 Tab"), KeyMapping("Ctrl+A q", "关闭当前 Hub Tab(需确认)"), KeyMapping("Ctrl+A d", "Detach Hub"), ), ), ( "Sessions 弹窗", ( KeyMapping("↑ / ↓", "移动选择"), KeyMapping("Enter / 单击", "执行主操作"), KeyMapping("Space / [操作]", "打开管理菜单"), KeyMapping("/", "搜索"), KeyMapping("Ctrl+R", "刷新远端状态"), KeyMapping("Esc", "返回或关闭"), ), ), ( "Herdr 命令弹窗", ( KeyMapping("Enter / 单击", "执行命令"), KeyMapping("Esc", "关闭"), ), ), ) def display_width(value: str) -> int: return sum( 0 if unicodedata.combining(character) else 2 if unicodedata.east_asian_width(character) in {"W", "F"} else 1 for character in value ) def truncate(value: str, width: int) -> str: if width <= 0: return "" result = "" used = 0 for character in value: size = display_width(character) if used + size > width: break result += character used += size return result def pad(value: str, width: int) -> str: clipped = truncate(value, width) return clipped + " " * max(0, width - display_width(clipped)) class KeyHelpPopup: def __init__(self) -> None: self.scroll = 0 @staticmethod def lines(width: int) -> list[tuple[str, int]]: lines: list[tuple[str, int]] = [] wide = width >= 52 key_width = min(20, max(12, width // 3)) for section_index, (heading, mappings) in enumerate(KEY_SECTIONS): if section_index: lines.append(("", curses.A_NORMAL)) lines.append((heading, curses.A_BOLD)) for mapping in mappings: if wide: lines.append( ( f" {pad(mapping.keys, key_width)} {mapping.action}", curses.A_NORMAL, ) ) else: lines.append((f" {mapping.keys}", curses.A_BOLD)) lines.append((f" {mapping.action}", curses.A_DIM)) return lines def run(self, screen) -> int: # type: ignore[no-untyped-def] screen.keypad(True) screen.timeout(100) try: curses.set_escdelay(25) curses.curs_set(0) curses.use_default_colors() curses.mousemask(curses.ALL_MOUSE_EVENTS) except (AttributeError, curses.error): pass while True: screen.erase() height, width = screen.getmaxyx() content = self.lines(max(1, width - 4)) visible_height = max(1, height - 3) self.scroll = max( 0, min(self.scroll, max(0, len(content) - visible_height)), ) for offset, (value, attribute) in enumerate( content[self.scroll : self.scroll + visible_height] ): self._add(screen, 1 + offset, 2, value, attribute) footer = ( "↑↓ / PgUp PgDn 滚动 · Esc / q 关闭" if len(content) > visible_height else "Esc / q 关闭" ) self._add(screen, height - 1, 1, footer, curses.A_DIM) screen.refresh() try: key = screen.get_wch() except curses.error: continue if key in {"\x1b", "q", "Q", "\n", "\r", curses.KEY_ENTER}: return 0 if key == curses.KEY_UP: self.scroll -= 1 elif key == curses.KEY_DOWN: self.scroll += 1 elif key == curses.KEY_PPAGE: self.scroll -= visible_height elif key == curses.KEY_NPAGE: self.scroll += visible_height elif key == curses.KEY_MOUSE: try: _, _, _, _, state = curses.getmouse() except curses.error: continue if state & getattr(curses, "BUTTON4_PRESSED", 0): self.scroll -= 3 elif state & getattr(curses, "BUTTON5_PRESSED", 0): self.scroll += 3 @staticmethod def _add( screen, y: int, x: int, value: str, attribute: int = curses.A_NORMAL, ) -> None: # type: ignore[no-untyped-def] height, width = screen.getmaxyx() if y < 0 or y >= height or x >= width: return try: screen.addstr(y, x, truncate(value, max(0, width - x - 1)), attribute) except curses.error: pass