refactor(maestro): refine mobile key bar per review (NAP-342)
Review feedback on the touch key bar:
- Pare to essentials: Esc, Tab, Ctrl, Alt + cursor keys (drop PgUp/PgDn
and the symbol keys).
- Two compact Termux-style rows in a 5-col grid; cursor keys as a
real-keyboard inverted-T with Home/End filling the top corners:
Esc Tab Home ↑ End
Ctrl Alt ← ↓ →
- Add Alt as a second sticky one-shot modifier; Ctrl+Alt compose, and
both promote cursor keys to the modified CSI form (\x1b[1;<n><final>).
- Explicit press-flash animation: the pointerdown preventDefault (kept so
the terminal stays focused) suppresses native :active feedback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+80
-44
@@ -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;<n><final>` 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><final>`, 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<Modifier, boolean> = { ctrl: false, alt: false };
|
||||
const modBtns: Partial<Record<Modifier, HTMLButtonElement>> = {};
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
+34
-26
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user