core/tools: spell modified keys the way xterm does

The marker table only knew unmodified keys, so `<s-up>` and `<c-f5>` were
errors and the fallback was raw bytes: a TUI that wants Shift+Tab and
Ctrl+arrows could not be driven without hand-writing `\x1b[1;5A`.

A marker may now carry `c-`, `m-` (or `a-`), and `s-`, alone or combined, and
keys that have an xterm form — arrows, Home/End, Ins/Del, PgUp/PgDn, F1–F12 —
expand to the CSI sequence with the 1 + shift/alt/ctrl parameter. `<m-up>` is
`\x1b[1;3A`, not an ESC in front of an arrow, which no terminal sends. On a
plain character the old spellings stay: Ctrl is the control byte, Alt an ESC
before the character, and Alt alone still prefixes a key without a CSI form
(`<m-tab>`). `<ins>`, `<insert>`, and Ctrl+Space (NUL) come along; a modifier a
key has no sequence for is still an error rather than a guess.
This commit is contained in:
2026-10-24 02:06:51 +08:00
parent 1c319f13b6
commit 87f7166a77
2 changed files with 86 additions and 21 deletions
+82 -19
View File
@@ -26,16 +26,13 @@ _SIMPLE_ESCAPE_LEN = 2
#: ``<name>`` markers: the keys a terminal understands, and the bytes each sends. #: ``<name>`` markers: the keys a terminal understands, and the bytes each sends.
_KEY_NAMES: dict[str, str] = { _KEY_NAMES: dict[str, str] = {
"esc": "\x1b", "esc": "\x1b",
"escape": "\x1b",
"enter": "\r", "enter": "\r",
"return": "\r",
"tab": "\t", "tab": "\t",
"s-tab": "\x1b[Z", "s-tab": "\x1b[Z",
"space": " ", "space": " ",
"bs": "\x7f", "bs": "\x7f",
"backspace": "\x7f",
"del": "\x1b[3~", "del": "\x1b[3~",
"delete": "\x1b[3~", "ins": "\x1b[2~",
"home": "\x1b[H", "home": "\x1b[H",
"end": "\x1b[F", "end": "\x1b[F",
"up": "\x1b[A", "up": "\x1b[A",
@@ -44,8 +41,6 @@ _KEY_NAMES: dict[str, str] = {
"right": "\x1b[C", "right": "\x1b[C",
"pgup": "\x1b[5~", "pgup": "\x1b[5~",
"pgdn": "\x1b[6~", "pgdn": "\x1b[6~",
"pageup": "\x1b[5~",
"pagedown": "\x1b[6~",
"f1": "\x1bOP", "f1": "\x1bOP",
"f2": "\x1bOQ", "f2": "\x1bOQ",
"f3": "\x1bOR", "f3": "\x1bOR",
@@ -60,10 +55,50 @@ _KEY_NAMES: dict[str, str] = {
"f12": "\x1b[24~", "f12": "\x1b[24~",
} }
#: The longer spellings, so a marker can be written the way it is usually named.
_KEY_ALIASES: dict[str, str] = {
"escape": "esc",
"return": "enter",
"backspace": "bs",
"delete": "del",
"insert": "ins",
"pageup": "pgup",
"pagedown": "pgdn",
}
#: Keys a modifier applies to, as the xterm sequence with the modifier slot.
_MODIFIED: dict[str, str] = {
"up": "\x1b[1;{m}A",
"down": "\x1b[1;{m}B",
"right": "\x1b[1;{m}C",
"left": "\x1b[1;{m}D",
"home": "\x1b[1;{m}H",
"end": "\x1b[1;{m}F",
"ins": "\x1b[2;{m}~",
"del": "\x1b[3;{m}~",
"pgup": "\x1b[5;{m}~",
"pgdn": "\x1b[6;{m}~",
"f1": "\x1b[1;{m}P",
"f2": "\x1b[1;{m}Q",
"f3": "\x1b[1;{m}R",
"f4": "\x1b[1;{m}S",
"f5": "\x1b[15;{m}~",
"f6": "\x1b[17;{m}~",
"f7": "\x1b[18;{m}~",
"f8": "\x1b[19;{m}~",
"f9": "\x1b[20;{m}~",
"f10": "\x1b[21;{m}~",
"f11": "\x1b[23;{m}~",
"f12": "\x1b[24;{m}~",
}
#: Modifier prefixes a marker may carry: Ctrl, Alt (either spelling), Shift.
_MODIFIERS = frozenset({"c", "a", "m", "s"})
#: What a Ctrl may be combined with beyond A-Z: the rest of the C0 controls. #: What a Ctrl may be combined with beyond A-Z: the rest of the C0 controls.
_CONTROL_CHARS = "@[\\]^_" _CONTROL_CHARS = "@[\\]^_"
#: Longest marker content to look for, so a stray ``<`` stays a literal ``<``. #: Longest marker content to look for, so a stray ``<`` stays a literal ``<``.
_MAX_MARKER = 20 _MAX_MARKER = 32
# C0 controls except TAB/LF/CR (those stay for readable logs). # C0 controls except TAB/LF/CR (those stay for readable logs).
_UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") _UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
@@ -92,7 +127,7 @@ class KeySyntaxError(ValueError):
def _control(char: str) -> str: def _control(char: str) -> str:
"""The C0 byte for ``<c-*char*>``.""" """The C0 byte for a Ctrl and one character."""
if len(char) != 1: if len(char) != 1:
msg = f"ctrl takes one character, not {char!r} (write <c-a>)" msg = f"ctrl takes one character, not {char!r} (write <c-a>)"
raise KeySyntaxError(msg) raise KeySyntaxError(msg)
@@ -103,21 +138,47 @@ def _control(char: str) -> str:
return chr(ord(upper) & 0x1F) return chr(ord(upper) & 0x1F)
def _canonical(name: str) -> str:
lowered = name.lower()
return _KEY_ALIASES.get(lowered, lowered)
def _key_bytes(name: str) -> str: def _key_bytes(name: str) -> str:
"""What one ``<name>`` marker sends, ``c-`` / ``m-`` prefixes included.""" """What one ``<name>`` marker sends, modifiers and all."""
known = _KEY_NAMES.get(name.lower()) known = _KEY_NAMES.get(_canonical(name))
if known is not None: if known is not None:
return known return known
head, sep, rest = name.partition("-") *prefixes, base = name.split("-")
if sep and len(head) == 1 and rest: modifiers = {prefix.lower() for prefix in prefixes}
if head.lower() == "c": if prefixes and base and modifiers <= _MODIFIERS:
return _control(rest) return _modified(name, base, modifiers)
if head.lower() == "m":
return "\x1b" + (rest if len(rest) == 1 else _key_bytes(rest))
msg = f"unknown key <{name}> (see the write_pty_keys docs for the names)" msg = f"unknown key <{name}> (see the write_pty_keys docs for the names)"
raise KeySyntaxError(msg) raise KeySyntaxError(msg)
def _modified(name: str, base: str, modifiers: set[str]) -> str:
"""One key under Ctrl / Alt / Shift, spelled the way xterm spells it."""
ctrl = "c" in modifiers
alt = bool(modifiers & {"a", "m"})
shift = "s" in modifiers
template = _MODIFIED.get(_canonical(base))
if template is not None:
# xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
return template.format(m=1 + shift * 1 + alt * 2 + ctrl * 4)
if ctrl and _canonical(base) == "space":
return "\x00" # Ctrl+Space is NUL, not a CSI sequence
if len(base) == 1:
if shift:
msg = f"<s-{base}> is just {base.upper()!r} — drop the modifier"
raise KeySyntaxError(msg)
return ("\x1b" if alt else "") + (_control(base) if ctrl else base)
known = _KEY_NAMES.get(_canonical(base))
if known is not None and not (ctrl or shift):
return "\x1b" + known # Alt alone: an ESC before the key, as terminals send it
msg = f"no sequence for <{name}> (write the raw bytes with \\x1b)"
raise KeySyntaxError(msg)
def _expansion(content: str) -> str: def _expansion(content: str) -> str:
"""The bytes for the inside of one marker, honouring a ``*N`` repeat.""" """The bytes for the inside of one marker, honouring a ``*N`` repeat."""
name, _, count = content.partition("*") name, _, count = content.partition("*")
@@ -150,10 +211,12 @@ def decode_write_data(data: str) -> str:
Markers, written between angle brackets: Markers, written between angle brackets:
- ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>`` - ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>`` ``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>`` ``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
- ``<c-a>`` … ``<c-z>`` for Ctrl (plus ``<c-@>`` ``<c-[>`` ``<c-\\\\>`` - a modifier in front, alone or combined: ``<c-up>`` ``<s-home>``
``<c-]>`` ``<c-^>`` ``<c-_>``), ``<m-x>`` for Alt: an ESC before the key ``<m-tab>`` ``<c-s-f5>`` — the xterm sequence where a key has one. On a
plain character, Ctrl is its control byte (``<c-a>``, ``<c-[>``) and Alt
is an ESC before it (``<m-x>``)
- ``<up*3>`` repeats a key three times, ``<<`` is a literal ``<`` - ``<up*3>`` repeats a key three times, ``<<`` is a literal ``<``
An unknown name raises :class:`KeySyntaxError` rather than typing the marker An unknown name raises :class:`KeySyntaxError` rather than typing the marker
+4 -2
View File
@@ -17,9 +17,11 @@ async def write_pty_keys(session_id: int, data: str) -> str:
Markers, written between angle brackets: Markers, written between angle brackets:
- ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>`` - ``<esc>`` ``<enter>`` ``<tab>`` ``<s-tab>`` ``<space>`` ``<bs>`` ``<del>``
``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>`` ``<ins>`` ``<home>`` ``<end>`` ``<up>`` ``<down>`` ``<left>`` ``<right>``
``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>`` ``<pgup>`` ``<pgdn>`` ``<f1>`` … ``<f12>``
- ``<c-a>`` … ``<c-z>`` for Ctrl, ``<m-x>`` for Alt (an ESC first) - a modifier in front, alone or combined: ``<c-up>`` ``<s-home>``
``<m-tab>`` ``<c-s-f5>``; on a plain character Ctrl is its control byte
(``<c-a>``) and Alt an ESC before it (``<m-x>``)
- ``<up*3>`` repeats a key, ``<<`` is a literal ``<`` - ``<up*3>`` repeats a key, ``<<`` is a literal ``<``
An unknown marker is an error, not text typed into the terminal: a typo must An unknown marker is an error, not text typed into the terminal: a typo must