Add shortcut help popup
This commit is contained in:
@@ -92,6 +92,7 @@ Hub 使用独立 tmux socket `herdr-hub`,不会接管普通 tmux server。外
|
|||||||
|
|
||||||
| 操作 | 快捷键 / 命令 |
|
| 操作 | 快捷键 / 命令 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| 打开快捷键帮助 | `Ctrl+A ?` |
|
||||||
| 点击切换 | 鼠标点击顶部 session |
|
| 点击切换 | 鼠标点击顶部 session |
|
||||||
| 打开 Herdr 命令菜单 | `Ctrl+A h` |
|
| 打开 Herdr 命令菜单 | `Ctrl+A h` |
|
||||||
| 打开 Session 弹窗 | 点击左上角 `Sessions` 或 `Ctrl+A g` |
|
| 打开 Session 弹窗 | 点击左上角 `Sessions` 或 `Ctrl+A g` |
|
||||||
@@ -243,10 +244,11 @@ sidebar_collapsed_mode = "hidden"
|
|||||||
`Ctrl+B b` 可在完整 sidebar 和零宽度隐藏之间切换;隐藏后再使用 Termux 原生选区即可。
|
`Ctrl+B b` 可在完整 sidebar 和零宽度隐藏之间切换;隐藏后再使用 Termux 原生选区即可。
|
||||||
`pane_scrollbars = false` 还会移除 pane 右侧滚动条并腾出一列空间。
|
`pane_scrollbars = false` 还会移除 pane 右侧滚动条并腾出一列空间。
|
||||||
|
|
||||||
当访问端宽度小于 80 列时,Hub 会自动进入窄屏模式:隐藏右侧快捷键说明,
|
当访问端宽度小于 80 列时,Hub 会自动进入窄屏模式:
|
||||||
所有 Tab 都显示状态和最多 12 列的短名称,并优先显示主机:默认 Session 的本地与
|
所有 Tab 都显示状态和最多 12 列的短名称,并优先显示主机:默认 Session 的本地与
|
||||||
远端分别显示 `local`、`host`,非默认 Session 才显示 `local/work`、`host/work`。
|
远端分别显示 `local`、`host`,非默认 Session 才显示 `local/work`、`host/work`。
|
||||||
宽度小于 50 列时左上角标题会缩成 `S`,继续给 Tab 留空间。点击 `Sessions`/`S`
|
顶部右侧只保留 `C-a ? 帮助`;宽度小于 50 列时缩成 `C-a ?`。左上角标题也会缩成
|
||||||
|
`S`,继续给 Tab 留空间。点击 `Sessions`/`S`
|
||||||
可打开占 90% 屏幕的完整选择器。
|
可打开占 90% 屏幕的完整选择器。
|
||||||
|
|
||||||
`refresh` 是 add-only:它添加新发现的 endpoint,不自动删除已有 window,也不会改变
|
`refresh` 是 add-only:它添加新发现的 endpoint,不自动删除已有 window,也不会改变
|
||||||
|
|||||||
+17
-4
@@ -24,6 +24,7 @@ from urllib.parse import quote
|
|||||||
from .aliases import AliasRegistry, AliasRegistryError, default_alias_registry_path
|
from .aliases import AliasRegistry, AliasRegistryError, default_alias_registry_path
|
||||||
from .control import CONTROL_KEY, CONTROL_SHORTCUT, ControlMenu, HerdrControl
|
from .control import CONTROL_KEY, CONTROL_SHORTCUT, ControlMenu, HerdrControl
|
||||||
from .domain import validate_session_name
|
from .domain import validate_session_name
|
||||||
|
from .key_help import KeyHelpPopup
|
||||||
from .remotes import (
|
from .remotes import (
|
||||||
RegistryError,
|
RegistryError,
|
||||||
RemoteEntry,
|
RemoteEntry,
|
||||||
@@ -582,7 +583,7 @@ class TmuxHub:
|
|||||||
["set-option", "-g", "mouse", "on"],
|
["set-option", "-g", "mouse", "on"],
|
||||||
["set-option", "-g", "focus-events", "on"],
|
["set-option", "-g", "focus-events", "on"],
|
||||||
["set-option", "-g", "status-left-length", "18"],
|
["set-option", "-g", "status-left-length", "18"],
|
||||||
["set-option", "-g", "status-right-length", "64"],
|
["set-option", "-g", "status-right-length", "14"],
|
||||||
[
|
[
|
||||||
"set-option",
|
"set-option",
|
||||||
"-g",
|
"-g",
|
||||||
@@ -595,9 +596,9 @@ class TmuxHub:
|
|||||||
"set-option",
|
"set-option",
|
||||||
"-g",
|
"-g",
|
||||||
"status-right",
|
"status-right",
|
||||||
"#{?#{<:#{client_width},80},,"
|
"#{?#{<:#{client_width},50},"
|
||||||
"#[dim]#{?pane_dead,连接已断开 · C-a R 重连,"
|
"#[dim]C-a ?#[default],"
|
||||||
f"{CONTROL_SHORTCUT} commands · C-a g choose · q close · r refresh}}#[default]}}",
|
"#[dim]C-a ? 帮助#[default]}",
|
||||||
],
|
],
|
||||||
["set-option", "-g", "status-style", "bg=colour235,fg=colour250"],
|
["set-option", "-g", "status-style", "bg=colour235,fg=colour250"],
|
||||||
["set-window-option", "-g", "window-status-separator", ""],
|
["set-window-option", "-g", "window-status-separator", ""],
|
||||||
@@ -640,6 +641,7 @@ class TmuxHub:
|
|||||||
[self.python_bin, self.script_path, "close", "--window", "#{window_id}"]
|
[self.python_bin, self.script_path, "close", "--window", "#{window_id}"]
|
||||||
)
|
)
|
||||||
control = shlex.join([self.python_bin, self.script_path, "control"])
|
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])
|
confirm_close = shlex.join(["run-shell", "-b", close])
|
||||||
# Remove tmux's default/legacy '&' binding when reconfiguring both new
|
# Remove tmux's default/legacy '&' binding when reconfiguring both new
|
||||||
# and already-running Hub servers.
|
# and already-running Hub servers.
|
||||||
@@ -649,6 +651,8 @@ class TmuxHub:
|
|||||||
# prototype when reconfiguring an already-running Hub.
|
# prototype when reconfiguring an already-running Hub.
|
||||||
self.run(["unbind-key", "-T", "root", "C-g"], check=False)
|
self.run(["unbind-key", "-T", "root", "C-g"], check=False)
|
||||||
binding_commands = [
|
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",
|
["bind-key", "-T", "prefix", CONTROL_KEY, "display-popup", "-EE",
|
||||||
"-w", "80%", "-h", "50%", "-T", "Herdr 命令", control],
|
"-w", "80%", "-h", "50%", "-T", "Herdr 命令", control],
|
||||||
["bind-key", "-T", "prefix", "C-a", "send-prefix"],
|
["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)
|
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:
|
def command_remote(arguments: argparse.Namespace) -> int:
|
||||||
registry = make_registry()
|
registry = make_registry()
|
||||||
action = arguments.remote_command
|
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 = subparsers.add_parser("control", help="open Herdr commands for the current Hub window")
|
||||||
control_parser.add_argument("--window", help="originating tmux window ID")
|
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")
|
subparsers.add_parser("choose", help="browse and open local or remote sessions")
|
||||||
remote_parser = subparsers.add_parser("remote", help="manage remembered remote targets")
|
remote_parser = subparsers.add_parser("remote", help="manage remembered remote targets")
|
||||||
remote_subparsers = remote_parser.add_subparsers(dest="remote_command")
|
remote_subparsers = remote_parser.add_subparsers(dest="remote_command")
|
||||||
@@ -1644,6 +1656,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
"open": command_open,
|
"open": command_open,
|
||||||
"choose": command_choose,
|
"choose": command_choose,
|
||||||
"control": command_control,
|
"control": command_control,
|
||||||
|
"keys": command_keys,
|
||||||
"remote": command_remote,
|
"remote": command_remote,
|
||||||
"reconnect": command_reconnect,
|
"reconnect": command_reconnect,
|
||||||
"close": command_close,
|
"close": command_close,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -74,6 +74,19 @@ class TmuxIntegrationTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.hub.run(["show-options", "-gv", "prefix2"]).stdout.strip(), "None")
|
self.assertEqual(self.hub.run(["show-options", "-gv", "prefix2"]).stdout.strip(), "None")
|
||||||
self.assertIn("choose", self.hub.run(["list-keys", "-T", "prefix", "g"]).stdout)
|
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:
|
def test_configures_isolated_server_and_adds_without_duplicates(self) -> None:
|
||||||
self.assertTrue(self.hub.ensure_server())
|
self.assertTrue(self.hub.ensure_server())
|
||||||
endpoints = [Endpoint.local(), Endpoint.remote("orangepi")]
|
endpoints = [Endpoint.local(), Endpoint.remote("orangepi")]
|
||||||
@@ -136,9 +149,9 @@ class TmuxIntegrationTests(unittest.TestCase):
|
|||||||
self.assertEqual(position, "top")
|
self.assertEqual(position, "top")
|
||||||
self.assertEqual(mouse, "on")
|
self.assertEqual(mouse, "on")
|
||||||
self.assertEqual(clipboard, "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.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("连接已断开", dead_format)
|
||||||
self.assertIn("Ctrl+A R", dead_format)
|
self.assertIn("Ctrl+A R", dead_format)
|
||||||
self.assertIn("client_width", inactive_window_format)
|
self.assertIn("client_width", inactive_window_format)
|
||||||
|
|||||||
Reference in New Issue
Block a user