Archived
feat: add terminal soft keys
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add an optional terminal soft-key bar for common control, navigation, and Meta-style key sequences, with mobile-friendly defaults and a persistent toggle.
|
||||
@@ -5,6 +5,9 @@ import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
|
||||
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
|
||||
import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference";
|
||||
import "./TerminalSoftKeys";
|
||||
import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys";
|
||||
|
||||
const TERMINAL_OPTIONS_BASE: ITerminalOptions = {
|
||||
cursorBlink: true,
|
||||
@@ -31,6 +34,8 @@ export class TerminalPanel extends LitElement {
|
||||
@state() private visible = false;
|
||||
@state() private cancellingRunIds: string[] = [];
|
||||
@state() private continuingTerminalIds: string[] = [];
|
||||
@state() private defaultSoftKeysEnvironment = false;
|
||||
@state() private softKeysEnabled = initialTerminalSoftKeysEnabled();
|
||||
|
||||
private terminal: Terminal | undefined;
|
||||
private fitAddon: FitAddon | undefined;
|
||||
@@ -43,9 +48,16 @@ export class TerminalPanel extends LitElement {
|
||||
private loadedCwd: string | undefined;
|
||||
private autoStartConsumedCwd: string | undefined;
|
||||
private commandRunPollTimer: number | undefined;
|
||||
private readonly softKeysDefaultEnvironmentMedia = createTerminalSoftKeysDefaultEnvironmentMedia();
|
||||
private softKeysPreferenceStored = hasTerminalSoftKeysPreference();
|
||||
private readonly onSoftKeysDefaultEnvironmentChange = () => {
|
||||
this.syncDefaultSoftKeysEnvironment();
|
||||
};
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.syncDefaultSoftKeysEnvironment();
|
||||
this.softKeysDefaultEnvironmentMedia?.addEventListener("change", this.onSoftKeysDefaultEnvironmentChange);
|
||||
this.themeObserver = new MutationObserver(() => { this.applyTerminalTheme(); });
|
||||
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
|
||||
}
|
||||
@@ -62,11 +74,24 @@ export class TerminalPanel extends LitElement {
|
||||
this.intersectionObserver = undefined;
|
||||
this.themeObserver?.disconnect();
|
||||
this.themeObserver = undefined;
|
||||
this.softKeysDefaultEnvironmentMedia?.removeEventListener("change", this.onSoftKeysDefaultEnvironmentChange);
|
||||
this.updateCommandRunPolling(false);
|
||||
this.disposeTerminalView();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private syncDefaultSoftKeysEnvironment(): void {
|
||||
const nextDefaultEnvironment = isTerminalSoftKeysDefaultEnvironment(this.softKeysDefaultEnvironmentMedia);
|
||||
const previousSoftKeysEnabled = this.softKeysEnabled;
|
||||
this.defaultSoftKeysEnvironment = nextDefaultEnvironment;
|
||||
if (!this.softKeysPreferenceStored) this.softKeysEnabled = nextDefaultEnvironment;
|
||||
if (this.softKeysEnabled !== previousSoftKeysEnabled) this.scheduleFitAndNotify();
|
||||
}
|
||||
|
||||
private scheduleFitAndNotify(): void {
|
||||
void this.updateComplete.then(() => { this.fitAndNotify(); });
|
||||
}
|
||||
|
||||
override willUpdate(changed: PropertyValues<this>): void {
|
||||
const cwd = this.workspace?.path;
|
||||
if (cwd !== this.observedCwd) {
|
||||
@@ -286,8 +311,7 @@ export class TerminalPanel extends LitElement {
|
||||
this.resizeObserver.observe(terminalHost);
|
||||
terminal.onData((data) => {
|
||||
if (this.suppressTerminalInput) return;
|
||||
const filtered = filterTerminalInput(data);
|
||||
if (filtered !== "") this.send({ type: "input", data: filtered });
|
||||
this.sendTerminalInput(data);
|
||||
});
|
||||
const initialSize = this.fitTerminal();
|
||||
this.connectSocket(workspace.projectId, workspace.id, this.selectedId, terminal, initialSize);
|
||||
@@ -375,6 +399,23 @@ export class TerminalPanel extends LitElement {
|
||||
if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this);
|
||||
}
|
||||
|
||||
private sendTerminalInput(data: string): void {
|
||||
const filtered = filterTerminalInput(data);
|
||||
if (filtered !== "") this.send({ type: "input", data: filtered });
|
||||
}
|
||||
|
||||
private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void {
|
||||
this.sendTerminalInput(data);
|
||||
if (options.refocus) this.focusTerminal();
|
||||
}
|
||||
|
||||
private focusTerminal(): void {
|
||||
const terminal = this.terminal;
|
||||
if (terminal === undefined) return;
|
||||
terminal.focus();
|
||||
requestAnimationFrame(() => { terminal.focus(); });
|
||||
}
|
||||
|
||||
private send(message: { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
|
||||
}
|
||||
@@ -422,10 +463,61 @@ export class TerminalPanel extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
private selectedTerminalAcceptsInput(): boolean {
|
||||
const terminal = this.selectedTerminalInfo();
|
||||
return terminal !== undefined && !terminal.exited;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeys(): boolean {
|
||||
return this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeysToggle(): boolean {
|
||||
return this.selectedTerminalAcceptsInput();
|
||||
}
|
||||
|
||||
private toggleSoftKeys(): void {
|
||||
this.softKeysEnabled = !this.softKeysEnabled;
|
||||
this.softKeysPreferenceStored = true;
|
||||
writeTerminalSoftKeysPreference(this.softKeysEnabled);
|
||||
this.scheduleFitAndNotify();
|
||||
}
|
||||
|
||||
private renderSoftKeysToggle() {
|
||||
if (!this.shouldShowSoftKeysToggle()) return null;
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class=${this.softKeysEnabled ? "soft-keys-toggle selected" : "soft-keys-toggle"}
|
||||
title=${this.softKeysEnabled ? "Hide terminal soft keys" : "Show terminal soft keys"}
|
||||
aria-label=${this.softKeysEnabled ? "Hide terminal soft keys" : "Show terminal soft keys"}
|
||||
aria-pressed=${String(this.softKeysEnabled)}
|
||||
@click=${() => { this.toggleSoftKeys(); }}
|
||||
>
|
||||
<svg class="keyboard-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
|
||||
<path d="M7 9h.01M10 9h.01M13 9h.01M16 9h.01M7 12h.01M10 12h.01M13 12h.01M16 12h.01M8 16h8"></path>
|
||||
</svg>
|
||||
<span>Keys</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSoftKeys() {
|
||||
return html`
|
||||
<terminal-soft-keys
|
||||
.modes=${this.terminal?.modes}
|
||||
.refocusOnClick=${!this.defaultSoftKeysEnvironment}
|
||||
.onInput=${(data: string, options: TerminalSoftKeyInputOptions) => { this.sendSoftKeyInput(data, options); }}
|
||||
></terminal-soft-keys>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section class="terminal-shell">
|
||||
<div class="terminal-tabs">
|
||||
${this.renderSoftKeysToggle()}
|
||||
${this.terminals.map((terminal) => html`
|
||||
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
|
||||
<span>${terminal.name}${terminal.exited ? " · exited" : ""}</span>
|
||||
@@ -436,6 +528,7 @@ export class TerminalPanel extends LitElement {
|
||||
</div>
|
||||
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
|
||||
${this.renderCommandRunNotice()}
|
||||
${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null}
|
||||
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
|
||||
<div class="terminal-host"></div>
|
||||
</section>
|
||||
@@ -449,6 +542,8 @@ export class TerminalPanel extends LitElement {
|
||||
button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
|
||||
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
button.new { flex: 0 0 auto; color: var(--pi-muted); }
|
||||
.soft-keys-toggle { flex: 0 0 auto; }
|
||||
.soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
|
||||
button small:hover { color: var(--pi-danger); }
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { TERMINAL_SOFT_KEYS, terminalSoftKeySequence, type TerminalModesSnapshot, type TerminalSoftKeyDefinition } from "../terminalKeys";
|
||||
|
||||
const SOFT_KEY_TAP_MOVE_THRESHOLD_PX = 8;
|
||||
const SYNTHETIC_CLICK_SUPPRESSION_MS = 500;
|
||||
|
||||
export interface TerminalSoftKeyInputOptions {
|
||||
refocus: boolean;
|
||||
}
|
||||
|
||||
@customElement("terminal-soft-keys")
|
||||
export class TerminalSoftKeys extends LitElement {
|
||||
@property({ attribute: false }) modes: TerminalModesSnapshot | undefined;
|
||||
@property({ type: Boolean }) refocusOnClick = true;
|
||||
@property({ attribute: false }) onInput: (data: string, options: TerminalSoftKeyInputOptions) => void = () => undefined;
|
||||
|
||||
private pointerStart: SoftKeyPointerStart | undefined;
|
||||
private lastPointerFinishedAt = 0;
|
||||
|
||||
private sendSoftKey(key: TerminalSoftKeyDefinition, options: TerminalSoftKeyInputOptions): void {
|
||||
this.onInput(terminalSoftKeySequence(key.id, this.modes), options);
|
||||
}
|
||||
|
||||
private onSoftKeyPointerDown(event: PointerEvent, key: TerminalSoftKeyDefinition): void {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
this.pointerStart = { pointerId: event.pointerId, key, clientX: event.clientX, clientY: event.clientY };
|
||||
}
|
||||
|
||||
private onSoftKeyPointerMove(event: PointerEvent): void {
|
||||
const start = this.pointerStart;
|
||||
if (start?.pointerId !== event.pointerId) return;
|
||||
if (pointerMovedBeyondTap(start, event)) this.finishSoftKeyPointer();
|
||||
}
|
||||
|
||||
private onSoftKeyPointerUp(event: PointerEvent, key: TerminalSoftKeyDefinition): void {
|
||||
const start = this.pointerStart;
|
||||
if (start?.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
this.finishSoftKeyPointer();
|
||||
if (start.key.id !== key.id || pointerMovedBeyondTap(start, event)) return;
|
||||
this.sendSoftKey(key, { refocus: event.pointerType === "mouse" });
|
||||
}
|
||||
|
||||
private onSoftKeyPointerCancel(event: PointerEvent): void {
|
||||
if (this.pointerStart?.pointerId === event.pointerId) this.finishSoftKeyPointer();
|
||||
}
|
||||
|
||||
private finishSoftKeyPointer(): void {
|
||||
this.pointerStart = undefined;
|
||||
this.lastPointerFinishedAt = Date.now();
|
||||
}
|
||||
|
||||
private onSoftKeyClick(event: MouseEvent, key: TerminalSoftKeyDefinition): void {
|
||||
if (Date.now() - this.lastPointerFinishedAt < SYNTHETIC_CLICK_SUPPRESSION_MS) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
this.sendSoftKey(key, { refocus: this.refocusOnClick });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="terminal-soft-keys" role="toolbar" aria-label="Terminal soft keys">
|
||||
${TERMINAL_SOFT_KEYS.map((key) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="soft-key"
|
||||
title=${key.title}
|
||||
aria-label=${key.ariaLabel}
|
||||
@pointerdown=${(event: PointerEvent) => { this.onSoftKeyPointerDown(event, key); }}
|
||||
@pointermove=${(event: PointerEvent) => { this.onSoftKeyPointerMove(event); }}
|
||||
@pointerup=${(event: PointerEvent) => { this.onSoftKeyPointerUp(event, key); }}
|
||||
@pointercancel=${(event: PointerEvent) => { this.onSoftKeyPointerCancel(event); }}
|
||||
@click=${(event: MouseEvent) => { this.onSoftKeyClick(event, key); }}
|
||||
>${key.label}</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { flex: 0 0 auto; display: block; }
|
||||
.terminal-soft-keys { display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; touch-action: pan-x; }
|
||||
.terminal-soft-keys::-webkit-scrollbar { display: none; }
|
||||
button { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; max-width: none; min-height: 34px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; cursor: pointer; touch-action: pan-x; -webkit-touch-callout: none; user-select: none; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
`;
|
||||
}
|
||||
|
||||
interface SoftKeyPointerStart {
|
||||
pointerId: number;
|
||||
key: TerminalSoftKeyDefinition;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
function pointerMovedBeyondTap(start: SoftKeyPointerStart, event: PointerEvent): boolean {
|
||||
return Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) > SOFT_KEY_TAP_MOVE_THRESHOLD_PX;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { terminalSoftKeySequence, type TerminalModesSnapshot } from "./terminalKeys";
|
||||
|
||||
describe("terminalSoftKeySequence", () => {
|
||||
it("maps common control keys to terminal bytes", () => {
|
||||
expect(terminalSoftKeySequence("escape")).toBe("\x1b");
|
||||
expect(terminalSoftKeySequence("tab")).toBe("\t");
|
||||
expect(terminalSoftKeySequence("ctrl-c")).toBe("\x03");
|
||||
expect(terminalSoftKeySequence("ctrl-d")).toBe("\x04");
|
||||
expect(terminalSoftKeySequence("ctrl-z")).toBe("\x1a");
|
||||
expect(terminalSoftKeySequence("ctrl-l")).toBe("\x0c");
|
||||
expect(terminalSoftKeySequence("ctrl-r")).toBe("\x12");
|
||||
});
|
||||
|
||||
it("maps navigation keys to xterm-compatible sequences", () => {
|
||||
expect(terminalSoftKeySequence("arrow-up")).toBe("\x1b[A");
|
||||
expect(terminalSoftKeySequence("arrow-down")).toBe("\x1b[B");
|
||||
expect(terminalSoftKeySequence("arrow-right")).toBe("\x1b[C");
|
||||
expect(terminalSoftKeySequence("arrow-left")).toBe("\x1b[D");
|
||||
expect(terminalSoftKeySequence("home")).toBe("\x1b[H");
|
||||
expect(terminalSoftKeySequence("end")).toBe("\x1b[F");
|
||||
expect(terminalSoftKeySequence("page-up")).toBe("\x1b[5~");
|
||||
expect(terminalSoftKeySequence("page-down")).toBe("\x1b[6~");
|
||||
expect(terminalSoftKeySequence("delete")).toBe("\x1b[3~");
|
||||
expect(terminalSoftKeySequence("backspace")).toBe("\x7f");
|
||||
});
|
||||
|
||||
it("respects application cursor key mode", () => {
|
||||
const applicationCursorMode: TerminalModesSnapshot = { applicationCursorKeysMode: true };
|
||||
|
||||
expect(terminalSoftKeySequence("arrow-up", applicationCursorMode)).toBe("\x1bOA");
|
||||
expect(terminalSoftKeySequence("arrow-down", applicationCursorMode)).toBe("\x1bOB");
|
||||
expect(terminalSoftKeySequence("arrow-right", applicationCursorMode)).toBe("\x1bOC");
|
||||
expect(terminalSoftKeySequence("arrow-left", applicationCursorMode)).toBe("\x1bOD");
|
||||
expect(terminalSoftKeySequence("home", applicationCursorMode)).toBe("\x1bOH");
|
||||
expect(terminalSoftKeySequence("end", applicationCursorMode)).toBe("\x1bOF");
|
||||
});
|
||||
|
||||
it("maps meta word movement to escape-prefixed sequences", () => {
|
||||
expect(terminalSoftKeySequence("meta-backward-word")).toBe("\x1bb");
|
||||
expect(terminalSoftKeySequence("meta-forward-word")).toBe("\x1bf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
export type TerminalSoftKeyId =
|
||||
| "escape"
|
||||
| "tab"
|
||||
| "ctrl-c"
|
||||
| "ctrl-d"
|
||||
| "ctrl-z"
|
||||
| "ctrl-l"
|
||||
| "ctrl-r"
|
||||
| "ctrl-u"
|
||||
| "ctrl-w"
|
||||
| "arrow-left"
|
||||
| "arrow-up"
|
||||
| "arrow-down"
|
||||
| "arrow-right"
|
||||
| "home"
|
||||
| "end"
|
||||
| "page-up"
|
||||
| "page-down"
|
||||
| "delete"
|
||||
| "backspace"
|
||||
| "meta-backward-word"
|
||||
| "meta-forward-word";
|
||||
|
||||
export interface TerminalModesSnapshot {
|
||||
applicationCursorKeysMode: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalSoftKeyDefinition {
|
||||
id: TerminalSoftKeyId;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const TERMINAL_SOFT_KEYS: readonly TerminalSoftKeyDefinition[] = [
|
||||
{ id: "escape", label: "Esc", ariaLabel: "Escape", title: "Send Escape" },
|
||||
{ id: "tab", label: "Tab", ariaLabel: "Tab", title: "Send Tab" },
|
||||
{ id: "ctrl-c", label: "Ctrl+C", ariaLabel: "Control C", title: "Interrupt the foreground process" },
|
||||
{ id: "ctrl-d", label: "Ctrl+D", ariaLabel: "Control D", title: "Send EOF / close input" },
|
||||
{ id: "ctrl-z", label: "Ctrl+Z", ariaLabel: "Control Z", title: "Suspend the foreground process" },
|
||||
{ id: "ctrl-l", label: "Ctrl+L", ariaLabel: "Control L", title: "Clear / redraw the terminal" },
|
||||
{ id: "ctrl-r", label: "Ctrl+R", ariaLabel: "Control R", title: "Reverse search history" },
|
||||
{ id: "ctrl-u", label: "Ctrl+U", ariaLabel: "Control U", title: "Delete to the start of the line" },
|
||||
{ id: "ctrl-w", label: "Ctrl+W", ariaLabel: "Control W", title: "Delete the previous word" },
|
||||
{ id: "arrow-left", label: "←", ariaLabel: "Left arrow", title: "Move left" },
|
||||
{ id: "arrow-up", label: "↑", ariaLabel: "Up arrow", title: "Move up / previous command" },
|
||||
{ id: "arrow-down", label: "↓", ariaLabel: "Down arrow", title: "Move down / next command" },
|
||||
{ id: "arrow-right", label: "→", ariaLabel: "Right arrow", title: "Move right" },
|
||||
{ id: "home", label: "Home", ariaLabel: "Home", title: "Move to the start" },
|
||||
{ id: "end", label: "End", ariaLabel: "End", title: "Move to the end" },
|
||||
{ id: "page-up", label: "PgUp", ariaLabel: "Page up", title: "Page up" },
|
||||
{ id: "page-down", label: "PgDn", ariaLabel: "Page down", title: "Page down" },
|
||||
{ id: "delete", label: "Del", ariaLabel: "Delete", title: "Delete forward" },
|
||||
{ id: "backspace", label: "⌫", ariaLabel: "Backspace", title: "Backspace" },
|
||||
{ id: "meta-backward-word", label: "M-B", ariaLabel: "Meta B", title: "Move backward one word" },
|
||||
{ id: "meta-forward-word", label: "M-F", ariaLabel: "Meta F", title: "Move forward one word" },
|
||||
];
|
||||
|
||||
const ESC = "\x1b";
|
||||
const DEL = "\x7f";
|
||||
|
||||
export function terminalSoftKeySequence(key: TerminalSoftKeyId, modes?: TerminalModesSnapshot): string {
|
||||
switch (key) {
|
||||
case "escape": return ESC;
|
||||
case "tab": return "\t";
|
||||
case "ctrl-c": return controlSequence("c");
|
||||
case "ctrl-d": return controlSequence("d");
|
||||
case "ctrl-z": return controlSequence("z");
|
||||
case "ctrl-l": return controlSequence("l");
|
||||
case "ctrl-r": return controlSequence("r");
|
||||
case "ctrl-u": return controlSequence("u");
|
||||
case "ctrl-w": return controlSequence("w");
|
||||
case "arrow-left": return cursorSequence("D", modes);
|
||||
case "arrow-up": return cursorSequence("A", modes);
|
||||
case "arrow-down": return cursorSequence("B", modes);
|
||||
case "arrow-right": return cursorSequence("C", modes);
|
||||
case "home": return cursorEndpointSequence("H", modes);
|
||||
case "end": return cursorEndpointSequence("F", modes);
|
||||
case "page-up": return `${ESC}[5~`;
|
||||
case "page-down": return `${ESC}[6~`;
|
||||
case "delete": return `${ESC}[3~`;
|
||||
case "backspace": return DEL;
|
||||
case "meta-backward-word": return `${ESC}b`;
|
||||
case "meta-forward-word": return `${ESC}f`;
|
||||
}
|
||||
}
|
||||
|
||||
function controlSequence(letter: string): string {
|
||||
return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64);
|
||||
}
|
||||
|
||||
function cursorSequence(code: "A" | "B" | "C" | "D", modes: TerminalModesSnapshot | undefined): string {
|
||||
return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`;
|
||||
}
|
||||
|
||||
function cursorEndpointSequence(code: "F" | "H", modes: TerminalModesSnapshot | undefined): string {
|
||||
return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseTerminalSoftKeysPreference,
|
||||
readTerminalSoftKeysPreference,
|
||||
terminalSoftKeysEnabled,
|
||||
TERMINAL_SOFT_KEYS_STORAGE_KEY,
|
||||
writeTerminalSoftKeysPreference,
|
||||
} from "./terminalSoftKeysPreference";
|
||||
|
||||
describe("terminal soft key preferences", () => {
|
||||
it("uses stored preferences before environment defaults", () => {
|
||||
expect(terminalSoftKeysEnabled(true, false)).toBe(true);
|
||||
expect(terminalSoftKeysEnabled(false, true)).toBe(false);
|
||||
expect(terminalSoftKeysEnabled(undefined, true)).toBe(true);
|
||||
expect(terminalSoftKeysEnabled(undefined, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("parses boolean local storage values", () => {
|
||||
expect(parseTerminalSoftKeysPreference("true")).toBe(true);
|
||||
expect(parseTerminalSoftKeysPreference("false")).toBe(false);
|
||||
expect(parseTerminalSoftKeysPreference(null)).toBeUndefined();
|
||||
expect(parseTerminalSoftKeysPreference("yes")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads and writes the stored preference", () => {
|
||||
const storage = new FakeStorage();
|
||||
|
||||
expect(readTerminalSoftKeysPreference(storage)).toBeUndefined();
|
||||
writeTerminalSoftKeysPreference(true, storage);
|
||||
expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("true");
|
||||
expect(readTerminalSoftKeysPreference(storage)).toBe(true);
|
||||
|
||||
writeTerminalSoftKeysPreference(false, storage);
|
||||
expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("false");
|
||||
expect(readTerminalSoftKeysPreference(storage)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores storage failures", () => {
|
||||
const storage = new ThrowingStorage();
|
||||
|
||||
expect(readTerminalSoftKeysPreference(storage)).toBeUndefined();
|
||||
expect(() => { writeTerminalSoftKeysPreference(true, storage); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
class FakeStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
value(key: string): string | undefined {
|
||||
return this.values.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
class ThrowingStorage {
|
||||
getItem(): string | null {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
|
||||
setItem(): void {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export const TERMINAL_SOFT_KEYS_STORAGE_KEY = "pi-web.terminal.softKeys";
|
||||
export const TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA = "(pointer: coarse), (max-width: 760px)";
|
||||
|
||||
export type TerminalSoftKeysStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
export function terminalSoftKeysEnabled(preference: boolean | undefined, defaultEnabled: boolean): boolean {
|
||||
return preference ?? defaultEnabled;
|
||||
}
|
||||
|
||||
export function parseTerminalSoftKeysPreference(value: string | null): boolean | undefined {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createTerminalSoftKeysDefaultEnvironmentMedia(): MediaQueryList | undefined {
|
||||
return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA) : undefined;
|
||||
}
|
||||
|
||||
export function isTerminalSoftKeysDefaultEnvironment(media: MediaQueryList | undefined): boolean {
|
||||
return media?.matches === true;
|
||||
}
|
||||
|
||||
export function initialTerminalSoftKeysEnabled(media = createTerminalSoftKeysDefaultEnvironmentMedia()): boolean {
|
||||
return terminalSoftKeysEnabled(readTerminalSoftKeysPreference(), isTerminalSoftKeysDefaultEnvironment(media));
|
||||
}
|
||||
|
||||
export function hasTerminalSoftKeysPreference(): boolean {
|
||||
return readTerminalSoftKeysPreference() !== undefined;
|
||||
}
|
||||
|
||||
export function readTerminalSoftKeysPreference(storage = browserStorage()): boolean | undefined {
|
||||
if (storage === undefined) return undefined;
|
||||
try {
|
||||
return parseTerminalSoftKeysPreference(storage.getItem(TERMINAL_SOFT_KEYS_STORAGE_KEY));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTerminalSoftKeysPreference(enabled: boolean, storage = browserStorage()): void {
|
||||
if (storage === undefined) return;
|
||||
try {
|
||||
storage.setItem(TERMINAL_SOFT_KEYS_STORAGE_KEY, String(enabled));
|
||||
} catch {
|
||||
// Ignore storage failures; the per-page toggle still works for this session.
|
||||
}
|
||||
}
|
||||
|
||||
function browserStorage(): TerminalSoftKeysStorage | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user