Add mobile touch and keybar improvements

This commit is contained in:
2026-05-11 20:39:32 -04:00
parent 3e0c9f87c8
commit be5df58698
3 changed files with 263 additions and 29 deletions
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "Building frontend..."
make build
echo "Building Go binary..."
make build-go
echo "Installing binary..."
cp bin/webterm ~/go/bin/webterm
echo "Restarting service..."
systemctl --user restart webterm.service
echo "Done. Status:"
systemctl --user status webterm.service --no-pager
File diff suppressed because one or more lines are too long
+211 -14
View File
@@ -1174,8 +1174,22 @@ class WebTerminal {
const canvas = this.element.querySelector("canvas");
if (!canvas) return;
const LONG_PRESS_MS = 300;
const MOVE_THRESHOLD = 10;
const SCROLL_SPEED = 1.5;
let mode: "undecided" | "scroll" | "select" = "undecided";
let startX = 0;
let startY = 0;
let lastY = 0;
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let scrollRemainder = 0;
// Momentum state
let velocityY = 0;
let lastMoveTime = 0;
let momentumFrame: number | null = null;
const dispatchMouse = (type: "mousedown" | "mousemove" | "mouseup", touch: Touch) => {
const rect = canvas.getBoundingClientRect();
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
@@ -1187,13 +1201,71 @@ class WebTerminal {
canvas.dispatchEvent(event);
};
const cancelLongPress = () => {
if (longPressTimer !== null) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
const stopMomentum = () => {
if (momentumFrame !== null) {
cancelAnimationFrame(momentumFrame);
momentumFrame = null;
}
velocityY = 0;
};
const lineHeight = () => {
const rect = canvas.getBoundingClientRect();
const rows = this.terminal.rows;
return rows > 0 ? rect.height / rows : 16;
};
const doMomentumScroll = () => {
const FRICTION = 0.92;
const MIN_VELOCITY = 0.5;
velocityY *= FRICTION;
if (Math.abs(velocityY) < MIN_VELOCITY) {
momentumFrame = null;
return;
}
const lh = lineHeight();
scrollRemainder += (velocityY * SCROLL_SPEED) / lh;
const lines = Math.trunc(scrollRemainder);
if (lines !== 0) {
scrollRemainder -= lines;
this.terminal.scrollLines(lines);
}
momentumFrame = requestAnimationFrame(doMomentumScroll);
};
this.addTrackedListener(
canvas,
"touchstart",
((e: TouchEvent) => {
if (e.touches.length !== 1) return;
dispatchMouse("mousedown", e.touches[0]);
e.preventDefault();
stopMomentum();
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
lastY = touch.clientY;
lastMoveTime = performance.now();
velocityY = 0;
scrollRemainder = 0;
mode = "undecided";
longPressTimer = setTimeout(() => {
longPressTimer = null;
if (mode === "undecided") {
mode = "select";
dispatchMouse("mousedown", touch);
}
}, LONG_PRESS_MS);
}) as EventListener,
{ passive: false }
);
@@ -1203,8 +1275,40 @@ class WebTerminal {
"touchmove",
((e: TouchEvent) => {
if (e.touches.length !== 1) return;
dispatchMouse("mousemove", e.touches[0]);
e.preventDefault();
const touch = e.touches[0];
const now = performance.now();
if (mode === "undecided") {
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
if (Math.abs(dy) > MOVE_THRESHOLD || Math.abs(dx) > MOVE_THRESHOLD) {
cancelLongPress();
mode = "scroll";
} else {
return;
}
}
if (mode === "scroll") {
const deltaY = lastY - touch.clientY;
const dt = now - lastMoveTime;
if (dt > 0) {
velocityY = deltaY / dt * 16; // normalize to ~per-frame
}
lastY = touch.clientY;
lastMoveTime = now;
const lh = lineHeight();
scrollRemainder += (deltaY * SCROLL_SPEED) / lh;
const lines = Math.trunc(scrollRemainder);
if (lines !== 0) {
scrollRemainder -= lines;
this.terminal.scrollLines(lines);
}
} else if (mode === "select") {
dispatchMouse("mousemove", touch);
}
}) as EventListener,
{ passive: false }
);
@@ -1215,18 +1319,35 @@ class WebTerminal {
((e: TouchEvent) => {
const touch = e.changedTouches[0];
if (!touch) return;
dispatchMouse("mouseup", touch);
e.preventDefault();
cancelLongPress();
if (mode === "select") {
dispatchMouse("mouseup", touch);
} else if (mode === "scroll") {
// Kick off momentum scrolling
if (Math.abs(velocityY) > 0.5) {
momentumFrame = requestAnimationFrame(doMomentumScroll);
}
}
// If still "undecided", it was a tap — no action needed (focus handled elsewhere)
mode = "undecided";
}) as EventListener,
{ passive: false }
);
}
private keybarButtonHeight = 44;
/** Setup bottom-docked mobile extended keyboard bar */
private setupMobileKeybar(): void {
const keybar = document.createElement("div");
keybar.className = "mobile-keybar";
keybar.innerHTML = `
const keysPanel = document.createElement("div");
keysPanel.className = "keybar-panel keybar-keys";
keysPanel.innerHTML = `
<button data-key="\\x1b" title="Escape">Esc</button>
<button data-modifier="ctrl" title="Ctrl modifier">Ctrl</button>
<button data-modifier="alt" title="Alt modifier">Alt</button>
@@ -1237,27 +1358,45 @@ class WebTerminal {
<button data-key="\\x1b[B" title="Down">↓</button>
<button data-key="\\x1b[D" title="Left">←</button>
<button data-key="\\x1b[C" title="Right">→</button>
<button data-key="\\x0d" title="Return"></button>
<button class="keybar-settings" title="Settings"></button>
`;
const settingsPanel = document.createElement("div");
settingsPanel.className = "keybar-panel keybar-settings-panel";
settingsPanel.style.display = "none";
settingsPanel.innerHTML = `
<button class="keybar-back" title="Back">◀</button>
<span class="keybar-label">Bar</span>
<button class="keybar-bar-shrink" title="Shrink bar"></button>
<button class="keybar-bar-grow" title="Grow bar">+</button>
<span class="keybar-label">Font</span>
<button class="keybar-font-shrink" title="Smaller font">A</button>
<button class="keybar-font-grow" title="Larger font">A+</button>
`;
keybar.appendChild(keysPanel);
keybar.appendChild(settingsPanel);
// Inject styles
const style = document.createElement("style");
style.textContent = `
.mobile-keybar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px;
background: rgba(40, 40, 40, 0.95);
flex-shrink: 0;
background: rgba(40, 40, 40, 0.95);
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.keybar-panel {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px;
}
.mobile-keybar button {
flex: 1 1 0;
min-width: 0;
height: 44px;
height: ${this.keybarButtonHeight}px;
padding: 0 4px;
border: 1px solid #555;
border-radius: 4px;
@@ -1275,14 +1414,72 @@ class WebTerminal {
background: #0066cc;
border-color: #0088ff;
}
.keybar-label {
display: flex;
align-items: center;
color: #999;
font-size: 13px;
font-family: system-ui, sans-serif;
padding: 0 4px;
}
`;
document.head.appendChild(style);
this.mobileKeybarStyle = style;
document.body.appendChild(keybar);
this.mobileKeybar = keybar;
// Toggle between keys and settings panels
const showPanel = (panel: "keys" | "settings") => {
keysPanel.style.display = panel === "keys" ? "" : "none";
settingsPanel.style.display = panel === "settings" ? "" : "none";
};
keysPanel.querySelector(".keybar-settings")!.addEventListener("touchstart", (e) => {
e.preventDefault();
showPanel("settings");
});
settingsPanel.querySelector(".keybar-back")!.addEventListener("touchstart", (e) => {
e.preventDefault();
showPanel("keys");
});
// Bar size controls
const updateButtonHeight = (delta: number) => {
this.keybarButtonHeight = Math.max(28, Math.min(72, this.keybarButtonHeight + delta));
keybar.querySelectorAll("button").forEach((btn) => {
(btn as HTMLElement).style.height = `${this.keybarButtonHeight}px`;
});
this.fit();
};
settingsPanel.querySelector(".keybar-bar-shrink")!.addEventListener("touchstart", (e) => {
e.preventDefault();
updateButtonHeight(-4);
});
settingsPanel.querySelector(".keybar-bar-grow")!.addEventListener("touchstart", (e) => {
e.preventDefault();
updateButtonHeight(4);
});
// Font size controls
settingsPanel.querySelector(".keybar-font-shrink")!.addEventListener("touchstart", (e) => {
e.preventDefault();
this.fontSize = Math.max(8, this.fontSize - 1);
this.terminal.options.fontSize = this.fontSize;
this.fit();
});
settingsPanel.querySelector(".keybar-font-grow")!.addEventListener("touchstart", (e) => {
e.preventDefault();
this.fontSize = Math.max(8, this.fontSize + 1);
this.terminal.options.fontSize = this.fontSize;
this.fit();
});
// Handle key button presses
keybar.querySelectorAll("button[data-key]").forEach((btn) => {
keysPanel.querySelectorAll("button[data-key]").forEach((btn) => {
btn.addEventListener("touchstart", (e) => {
e.preventDefault();
let key = (btn as HTMLElement).dataset.key || "";
@@ -1334,7 +1531,7 @@ class WebTerminal {
});
// Handle modifier toggles
keybar.querySelectorAll("button[data-modifier]").forEach((btn) => {
keysPanel.querySelectorAll("button[data-modifier]").forEach((btn) => {
btn.addEventListener("touchstart", (e) => {
e.preventDefault();
const modifier = (btn as HTMLElement).dataset.modifier;