Files
herdr-session-switch/herdr_hub/key_help.py
T
gitea-adminandClaude Fable 5.1 ac6bb30f47 Unify terminal display-width helpers
key_help, selector and control each carried their own East Asian width
and truncation logic with slightly different rules, and cli clipped tmux
labels by code point, so a CJK display name ran twice as wide as an
ASCII one before its ellipsis appeared.

Move the cell-width helpers into herdr_hub.text, make truncate_label
cell-aware, and sanitise control characters before curses draws a row
so an escape sequence in a remote error cannot be rendered as ^[ and
misalign the list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 03:09:28 +00:00

149 lines
4.9 KiB
Python

"""Compact, scrollable shortcut reference for the Hub popups."""
from __future__ import annotations
import curses
from dataclasses import dataclass
from .text import pad_display, truncate_display
@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", "关闭"),
),
),
)
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_display(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_display(value, max(0, width - x - 1)), attribute)
except curses.error:
pass