diff --git a/web/src/attach.ts b/web/src/attach.ts index de3bb03..d51e219 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -287,36 +287,42 @@ export function createAttachConnection( } /** - * One key in the mobile key bar. `seq` is the raw bytes sent to the PTY. - * `printable` marks single characters that a pending Ctrl can fold into a - * control code (e.g. `c` → `\x03`); `mod` marks the sticky Ctrl toggle itself. + * One key in the mobile key bar. + * - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt). + * - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a + * pending modifier can promote it to the `\x1b[1;` form. + * - `seq` is a plain key (Esc, Tab) sent verbatim. */ type KeyDef = - | { label: string; seq: string; printable?: boolean } - | { label: string; mod: "ctrl" }; + | { label: string; mod: Modifier } + | { label: string; cursor: CursorFinal } + | { label: string; seq: string }; + +type Modifier = "ctrl" | "alt"; +/** CSI final bytes: arrows A–D, Home `H`, End `F`. */ +type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F"; /** - * The keys the mobile soft keyboard hides: escape, tab, the sticky Ctrl - * modifier, cursor/navigation keys, and a few shell-common symbols. Cursor keys - * use the normal (DECCKM-off) sequences, which is what shells and full-screen - * apps expect by default. + * The keys the mobile soft keyboard hides, ordered to flow row-major into a + * 5-column / 2-row grid (NAP-342 review): + * + * Esc Tab Home ↑ End + * Ctrl Alt ← ↓ → + * + * — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, and the cursor keys as + * a real-keyboard inverted-T on the right, with Home/End filling its top corners. */ const KEY_DEFS: KeyDef[] = [ { label: "Esc", seq: "\x1b" }, { label: "Tab", seq: "\t" }, + { label: "Home", cursor: "H" }, + { label: "↑", cursor: "A" }, + { label: "End", cursor: "F" }, { label: "Ctrl", mod: "ctrl" }, - { label: "←", seq: "\x1b[D" }, - { label: "↑", seq: "\x1b[A" }, - { label: "↓", seq: "\x1b[B" }, - { label: "→", seq: "\x1b[C" }, - { label: "Home", seq: "\x1b[H" }, - { label: "End", seq: "\x1b[F" }, - { label: "PgUp", seq: "\x1b[5~" }, - { label: "PgDn", seq: "\x1b[6~" }, - { label: "-", seq: "-", printable: true }, - { label: "/", seq: "/", printable: true }, - { label: "|", seq: "|", printable: true }, - { label: "~", seq: "~", printable: true }, + { label: "Alt", mod: "alt" }, + { label: "←", cursor: "D" }, + { label: "↓", cursor: "B" }, + { label: "→", cursor: "C" }, ]; /** @@ -332,9 +338,25 @@ function toCtrl(ch: string): string { return ch; } +/** Apply pending modifiers to a single typed character: Ctrl → control code, + * Alt → ESC prefix (xterm meta). Both compose (Ctrl+Alt = ESC + control). */ +function applyModsToChar(ch: string, ctrl: boolean, alt: boolean): string { + let out = ctrl ? toCtrl(ch) : ch; + if (alt) out = "\x1b" + out; + return out; +} + +/** Cursor/nav-key sequence, promoted to the modified CSI form when Ctrl/Alt are + * pending — `\x1b[1;`, n = 1 + alt(2) + ctrl(4) (xterm convention). */ +function cursorSeq(final: string, ctrl: boolean, alt: boolean): string { + if (!ctrl && !alt) return `\x1b[${final}`; + const n = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0); + return `\x1b[1;${n}${final}`; +} + interface KeyBar { readonly el: HTMLElement; - /** Apply a pending Ctrl to typed input (soft keyboard), consuming the arm. */ + /** Fold pending modifiers into typed input (soft keyboard), consuming them. */ consume(data: string): string; } @@ -342,10 +364,10 @@ interface KeyBar { * Build the Termux-style special-key bar. Hidden by default; CSS reveals it on * coarse-pointer (touch) devices. `send` delivers bytes to the PTY. * - * Ctrl is a one-shot sticky modifier: arm it, and the very next character — - * whether tapped here or typed on the soft keyboard — is sent as its control - * code. Taps `preventDefault` on pointerdown so the terminal keeps focus and the - * keyboard stays open. + * Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key — + * tapped here or typed on the soft keyboard — carries the modifier, then they + * disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and + * the keyboard stays open. */ function buildKeyBar(send: (data: string) => void): KeyBar { const el = document.createElement("div"); @@ -353,12 +375,16 @@ function buildKeyBar(send: (data: string) => void): KeyBar { el.setAttribute("role", "toolbar"); el.setAttribute("aria-label", "Special keys"); - let ctrlArmed = false; - let ctrlBtn: HTMLButtonElement | null = null; + const armed: Record = { ctrl: false, alt: false }; + const modBtns: Partial> = {}; - const setCtrl = (on: boolean) => { - ctrlArmed = on; - ctrlBtn?.setAttribute("aria-pressed", String(on)); + const setMod = (mod: Modifier, on: boolean) => { + armed[mod] = on; + modBtns[mod]?.setAttribute("aria-pressed", String(on)); + }; + const clearMods = () => { + setMod("ctrl", false); + setMod("alt", false); }; for (const def of KEY_DEFS) { @@ -370,31 +396,41 @@ function buildKeyBar(send: (data: string) => void): KeyBar { if ("mod" in def) { btn.classList.add("keycap-mod"); btn.setAttribute("aria-pressed", "false"); - btn.setAttribute("aria-label", "Ctrl modifier"); - ctrlBtn = btn; - btn.addEventListener("click", () => setCtrl(!ctrlArmed)); + btn.setAttribute("aria-label", `${def.label} modifier`); + modBtns[def.mod] = btn; + btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod])); + } else if ("cursor" in def) { + btn.addEventListener("click", () => { + send(cursorSeq(def.cursor, armed.ctrl, armed.alt)); + clearMods(); + }); } else { btn.addEventListener("click", () => { - if (ctrlArmed) { - send(def.printable ? toCtrl(def.seq) : def.seq); - setCtrl(false); - } else { - send(def.seq); - } + send(def.seq); + clearMods(); }); } // Keep the terminal focused (soft keyboard up) — don't let the tap steal it. - btn.addEventListener("pointerdown", (e) => e.preventDefault()); + // preventDefault suppresses the native `:active` style, so flash an explicit + // press animation here for tactile feedback. + btn.addEventListener("pointerdown", (e) => { + e.preventDefault(); + btn.classList.remove("is-pressed"); + void btn.offsetWidth; // restart the animation if re-tapped mid-flash + btn.classList.add("is-pressed"); + }); + btn.addEventListener("animationend", () => btn.classList.remove("is-pressed")); el.appendChild(btn); } return { el, consume(data: string): string { - if (ctrlArmed && data.length === 1) { - setCtrl(false); - return toCtrl(data); + if ((armed.ctrl || armed.alt) && data.length === 1) { + const out = applyModsToChar(data, armed.ctrl, armed.alt); + clearMods(); + return out; } return data; }, diff --git a/web/src/styles.css b/web/src/styles.css index f0f73bb..73d2038 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1173,45 +1173,35 @@ textarea { /* ---------- mobile special-key bar (Termux-style) ---------- */ -/* Hidden on devices with a precise pointer (a physical keyboard already has - these keys). Revealed on touch, where the soft keyboard omits them. The shell - tracks the visual viewport (see `#app`), so this row lands above the keyboard. */ +/* Two compact rows (Termux layout) in a 5-column grid the 10 keys flow into: + Esc/Tab + Ctrl/Alt on the left, cursor keys as a real-keyboard inverted-T on + the right (Home/End fill the top corners). Layout is always defined; only + *visibility* is gated: hidden on precise-pointer devices (a physical keyboard + already has these keys), shown on touch where the soft keyboard omits them. + The shell tracks the visual viewport (see `#app`), so it lands above it. */ .attach-keybar { display: none; + grid-template-columns: repeat(5, 1fr); + gap: var(--sp-1); + padding: var(--sp-1) var(--sp-2); + padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--border); + background: var(--surface); } @media (pointer: coarse) { .attach-keybar { - display: flex; - flex: 0 0 auto; - align-items: stretch; - gap: var(--sp-1); - padding: var(--sp-1) var(--sp-2); - padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px)); - border-top: 1px solid var(--border); - background: var(--surface); - overflow-x: auto; - overflow-y: hidden; - overscroll-behavior-x: contain; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - } - - .attach-keybar::-webkit-scrollbar { - display: none; + display: grid; } } .attach-keybar .keycap { - flex: 0 0 auto; - min-width: var(--tap); - height: var(--tap); - padding: 0 var(--sp-3); + height: 32px; display: inline-flex; align-items: center; justify-content: center; font-family: var(--font-mono); - font-size: var(--fs-14); + font-size: var(--fs-13); color: var(--ink); background: var(--surface-2); border: 1px solid var(--border); @@ -1230,7 +1220,25 @@ textarea { border-color: var(--border-strong); } -/* Armed sticky Ctrl: lit in signal-green until it folds into the next key. */ +/* Explicit press feedback: `pointerdown` preventDefault (kept for terminal + focus) suppresses native `:active` on touch, so flash a quick spring. */ +@keyframes keycap-press { + 0% { + transform: scale(0.92); + background: var(--accent-tint); + border-color: var(--accent); + } + 100% { + transform: scale(1); + } +} + +.attach-keybar .keycap.is-pressed { + animation: keycap-press 200ms var(--ease-out); +} + +/* Armed sticky modifier (Ctrl/Alt): lit in signal-green until it folds into the + next key. */ .attach-keybar .keycap-mod[aria-pressed="true"] { color: var(--accent-ink); background: var(--accent);