178 lines
5.5 KiB
Python
178 lines
5.5 KiB
Python
"""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
|