From a5e8e32f1e428fe60d64e2a8a2d64107cfbbb3f2 Mon Sep 17 00:00:00 2001 From: Donald Date: Fri, 18 Sep 2026 14:32:58 +0000 Subject: [PATCH] Add shortcut help popup --- README.md | 6 +- herdr_hub/cli.py | 21 +++- herdr_hub/key_help.py | 177 +++++++++++++++++++++++++++++++++ tests/test_key_help.py | 55 ++++++++++ tests/test_tmux_integration.py | 17 +++- 5 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 herdr_hub/key_help.py create mode 100644 tests/test_key_help.py diff --git a/README.md b/README.md index 634cd8b..d02a1dc 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外 | 操作 | 快捷键 / 命令 | | --- | --- | +| 打开快捷键帮助 | `Ctrl+A ?` | | 点击切换 | 鼠标点击顶部 session | | 打开 Herdr 命令菜单 | `Ctrl+A h` | | 打开 Session 弹窗 | 点击左上角 `Sessions` 或 `Ctrl+A g` | @@ -243,10 +244,11 @@ sidebar_collapsed_mode = "hidden" `Ctrl+B b` 可在完整 sidebar 和零宽度隐藏之间切换;隐藏后再使用 Termux 原生选区即可。 `pane_scrollbars = false` 还会移除 pane 右侧滚动条并腾出一列空间。 -当访问端宽度小于 80 列时,Hub 会自动进入窄屏模式:隐藏右侧快捷键说明, +当访问端宽度小于 80 列时,Hub 会自动进入窄屏模式: 所有 Tab 都显示状态和最多 12 列的短名称,并优先显示主机:默认 Session 的本地与 远端分别显示 `local`、`host`,非默认 Session 才显示 `local/work`、`host/work`。 -宽度小于 50 列时左上角标题会缩成 `S`,继续给 Tab 留空间。点击 `Sessions`/`S` +顶部右侧只保留 `C-a ? 帮助`;宽度小于 50 列时缩成 `C-a ?`。左上角标题也会缩成 +`S`,继续给 Tab 留空间。点击 `Sessions`/`S` 可打开占 90% 屏幕的完整选择器。 `refresh` 是 add-only:它添加新发现的 endpoint,不自动删除已有 window,也不会改变 diff --git a/herdr_hub/cli.py b/herdr_hub/cli.py index 6b12843..c40906e 100644 --- a/herdr_hub/cli.py +++ b/herdr_hub/cli.py @@ -24,6 +24,7 @@ from urllib.parse import quote from .aliases import AliasRegistry, AliasRegistryError, default_alias_registry_path from .control import CONTROL_KEY, CONTROL_SHORTCUT, ControlMenu, HerdrControl from .domain import validate_session_name +from .key_help import KeyHelpPopup from .remotes import ( RegistryError, RemoteEntry, @@ -582,7 +583,7 @@ class TmuxHub: ["set-option", "-g", "mouse", "on"], ["set-option", "-g", "focus-events", "on"], ["set-option", "-g", "status-left-length", "18"], - ["set-option", "-g", "status-right-length", "64"], + ["set-option", "-g", "status-right-length", "14"], [ "set-option", "-g", @@ -595,9 +596,9 @@ class TmuxHub: "set-option", "-g", "status-right", - "#{?#{<:#{client_width},80},," - "#[dim]#{?pane_dead,连接已断开 · C-a R 重连," - f"{CONTROL_SHORTCUT} commands · C-a g choose · q close · r refresh}}#[default]}}", + "#{?#{<:#{client_width},50}," + "#[dim]C-a ?#[default]," + "#[dim]C-a ? 帮助#[default]}", ], ["set-option", "-g", "status-style", "bg=colour235,fg=colour250"], ["set-window-option", "-g", "window-status-separator", ""], @@ -640,6 +641,7 @@ class TmuxHub: [self.python_bin, self.script_path, "close", "--window", "#{window_id}"] ) control = shlex.join([self.python_bin, self.script_path, "control"]) + key_help = shlex.join([self.python_bin, self.script_path, "keys"]) confirm_close = shlex.join(["run-shell", "-b", close]) # Remove tmux's default/legacy '&' binding when reconfiguring both new # and already-running Hub servers. @@ -649,6 +651,8 @@ class TmuxHub: # prototype when reconfiguring an already-running Hub. self.run(["unbind-key", "-T", "root", "C-g"], check=False) binding_commands = [ + ["bind-key", "-T", "prefix", "?", "display-popup", "-EE", + "-w", "72%", "-h", "80%", "-T", "快捷键", key_help], ["bind-key", "-T", "prefix", CONTROL_KEY, "display-popup", "-EE", "-w", "80%", "-h", "50%", "-T", "Herdr 命令", control], ["bind-key", "-T", "prefix", "C-a", "send-prefix"], @@ -1451,6 +1455,13 @@ def command_control(arguments: argparse.Namespace) -> int: return curses.wrapper(ControlMenu(control).run) +def command_keys(arguments: argparse.Namespace) -> int: + try: + return curses.wrapper(KeyHelpPopup().run) + except curses.error as error: + raise HubError(f"无法打开快捷键帮助: {error}") from error + + def command_remote(arguments: argparse.Namespace) -> int: registry = make_registry() action = arguments.remote_command @@ -1600,6 +1611,7 @@ def build_parser() -> argparse.ArgumentParser: control_parser = subparsers.add_parser("control", help="open Herdr commands for the current Hub window") control_parser.add_argument("--window", help="originating tmux window ID") + subparsers.add_parser("keys", help="show Hub shortcut mappings") subparsers.add_parser("choose", help="browse and open local or remote sessions") remote_parser = subparsers.add_parser("remote", help="manage remembered remote targets") remote_subparsers = remote_parser.add_subparsers(dest="remote_command") @@ -1644,6 +1656,7 @@ def main(argv: Sequence[str] | None = None) -> int: "open": command_open, "choose": command_choose, "control": command_control, + "keys": command_keys, "remote": command_remote, "reconnect": command_reconnect, "close": command_close, diff --git a/herdr_hub/key_help.py b/herdr_hub/key_help.py new file mode 100644 index 0000000..cadf409 --- /dev/null +++ b/herdr_hub/key_help.py @@ -0,0 +1,177 @@ +"""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 diff --git a/tests/test_key_help.py b/tests/test_key_help.py new file mode 100644 index 0000000..2b25454 --- /dev/null +++ b/tests/test_key_help.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import argparse +import curses +import unittest +from unittest.mock import Mock, patch + +from herdr_hub.cli import build_parser, command_keys +from herdr_hub.key_help import KEY_SECTIONS, KeyHelpPopup + + +class KeyHelpTests(unittest.TestCase): + def test_reference_contains_primary_hub_mappings(self) -> None: + mappings = { + mapping.keys: mapping.action + for _, section in KEY_SECTIONS + for mapping in section + } + for keys in ( + "Ctrl+A ?", + "Ctrl+A g", + "Ctrl+A h", + "Ctrl+A r", + "Ctrl+A R", + "Ctrl+A q", + "Ctrl+A d", + ): + self.assertIn(keys, mappings) + + def test_narrow_layout_stacks_keys_and_actions(self) -> None: + wide = [value for value, _ in KeyHelpPopup.lines(70)] + narrow = [value for value, _ in KeyHelpPopup.lines(36)] + self.assertGreater(len(narrow), len(wide)) + self.assertIn(" Ctrl+A ?", narrow) + self.assertIn(" 打开快捷键帮助", narrow) + + def test_escape_closes_popup(self) -> None: + screen = Mock() + screen.getmaxyx.return_value = (24, 80) + screen.get_wch.return_value = "\x1b" + with patch("curses.set_escdelay"), patch("curses.curs_set"), patch( + "curses.use_default_colors" + ), patch("curses.mousemask"): + self.assertEqual(KeyHelpPopup().run(screen), 0) + + def test_cli_entry_uses_curses_wrapper(self) -> None: + arguments = build_parser().parse_args(["keys"]) + self.assertEqual(arguments.command, "keys") + with patch("herdr_hub.cli.curses.wrapper", return_value=0) as wrapper: + self.assertEqual(command_keys(argparse.Namespace()), 0) + wrapper.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tmux_integration.py b/tests/test_tmux_integration.py index acbab10..66f88c5 100644 --- a/tests/test_tmux_integration.py +++ b/tests/test_tmux_integration.py @@ -74,6 +74,19 @@ class TmuxIntegrationTests(unittest.TestCase): self.assertEqual(self.hub.run(["show-options", "-gv", "prefix2"]).stdout.strip(), "None") self.assertIn("choose", self.hub.run(["list-keys", "-T", "prefix", "g"]).stdout) + def test_help_popup_replaces_status_shortcut_list(self) -> None: + self.hub.ensure_server() + binding = self.hub.run(["list-keys", "-T", "prefix", "?"]).stdout + self.assertIn("display-popup", binding) + self.assertIn(" keys", binding) + status_right = self.hub.run( + ["show-options", "-gv", "status-right"] + ).stdout + self.assertIn("C-a ?", status_right) + self.assertNotIn("choose", status_right) + self.assertNotIn("refresh", status_right) + self.assertNotIn("C-a R", status_right) + def test_configures_isolated_server_and_adds_without_duplicates(self) -> None: self.assertTrue(self.hub.ensure_server()) endpoints = [Endpoint.local(), Endpoint.remote("orangepi")] @@ -136,9 +149,9 @@ class TmuxIntegrationTests(unittest.TestCase): self.assertEqual(position, "top") self.assertEqual(mouse, "on") self.assertEqual(clipboard, "on") - self.assertIn("连接已断开", status_right) + self.assertIn("C-a ?", status_right) self.assertTrue(expanded_status_left.startswith("#[bold,fg=colour81]")) - self.assertIn("C-a R", status_right) + self.assertNotIn("C-a R", status_right) self.assertIn("连接已断开", dead_format) self.assertIn("Ctrl+A R", dead_format) self.assertIn("client_width", inactive_window_format)