From 2450c1855027bc82a69ba62dbdf27360d7555890 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 8 Aug 2026 16:48:35 +0530 Subject: [PATCH 1/7] fix(web): keep the width still while the keyboard settles Rows-only overflow now renders one-to-one with the bottom rows briefly clipped instead of scaling the whole surface down: the uniform factor pinched both axes for the settle window, a visible lurch on every keyboard open. Scaling remains for column overflow, where clipping would amputate lines. --- web/src/components/terminal.test.tsx | 32 ++++++++++++++++++++++++++++ web/src/components/terminal.tsx | 17 ++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 6da53b3..c9bf146 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -635,6 +635,38 @@ describe('Terminal', () => { expect(report.rows).toBeLessThan(47) }) + it('stays one-to-one when only rows stop fitting, so a keyboard cannot pinch the width', async () => { + const observers = resizeObservers() + const box = paneOf(800 + GUTTER_PX, 408) + const { sock, em } = mountTerminal((e) => ( + + )) + // 80x24 rendered at 800x408 puts a cell at 10 x 17. + em.live().measured = { width: 800, height: 408 } + act(() => sock.emitControl(attached({ ref: 1, id: 's1', cols: 80, rows: 24, primary: true }))) + + // The keyboard takes half the pane's height: same columns, half the rows. + box.mockReturnValue({ width: 800 + GUTTER_PX, height: 204 } as DOMRect) + act(() => observers.fire()) + + // The settled report proves the whole relayout → settle path ran… + await waitFor(() => + expect(sock.ofType('resize')).toContainEqual({ + type: 'resize', + ref: 1, + cols: 80, + rows: 12, + primary: true, + }), + ) + // …and through all of it the surface was never scaled or resized: the + // bottom rows clip behind the keyboard until the pty follows, and the + // width never moves. + expect(surfaceEl().style.scale).toBe('') + expect(surfaceEl().style.width).toBe('') + expect(surfaceEl().style.height).toBe('') + }) + it('reshapes nothing when promoted — the role moves voices, not sizes', async () => { // The daemon promotes the most recently active client when a primary // leaves, and the promotion arrives as a sizeChanged. Under the diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index 3a93741..b10b611 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -260,17 +260,24 @@ export function Terminal({ settleTimer = window.setTimeout(sendFittedSize, RESIZE_SETTLE_MS) } - if (want.cols >= dims.cols && want.rows >= dims.rows) { - // The pty fits this pane — whatever view set the size, this one can - // show it whole — so the surface simply fills the pane and nothing is - // transformed. + if (want.cols >= dims.cols) { + // Every column fits, so the surface lays out one-to-one whatever the + // row count says. Rows overflowing alone is almost always this view's + // own keyboard sliding over the pane: for the settle window the bottom + // rows clip behind it and then the pty takes the new height. Scaling + // here instead used to pinch both axes for that window — a lurch on + // every keyboard open. The cost is deliberate: a view whose columns + // fit while its rows do not shows the top of the screen until the pty + // follows, and in the rare enduring cross-device shape of that kind, + // scrollback still reaches what the pane cannot. surface.style.removeProperty('width') surface.style.removeProperty('height') surface.style.removeProperty('scale') return } - // A larger view is setting the size. Lay the surface out at the + // Columns overflow: a wider view is setting the size, and columns + // clipping would amputate lines mid-word. Lay the surface out at the // screen's true size and scale the whole thing down, rather than // reflowing text. // From b55c15151b8c2f7a252262a0c68668286c25a57d Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 8 Aug 2026 16:57:18 +0530 Subject: [PATCH 2/7] feat(web): encode what an on-screen key bar sends Arrows follow DECCKM (CSI for a shell, SS3 for vim and friends), Ctrl-arrows are the modified CSI form either way, and a sticky Ctrl folds the next typed byte onto its control code. Pure functions ahead of the bar that will press them. --- web/src/lib/keys.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++ web/src/lib/keys.ts | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 web/src/lib/keys.test.ts create mode 100644 web/src/lib/keys.ts diff --git a/web/src/lib/keys.test.ts b/web/src/lib/keys.test.ts new file mode 100644 index 0000000..11cab16 --- /dev/null +++ b/web/src/lib/keys.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { barKeyBytes, ctrlTransform } from './keys' + +const text = (b: Uint8Array) => new TextDecoder().decode(b) + +describe('barKeyBytes', () => { + it('encodes arrows as CSI when the program has not asked for more', () => { + expect(text(barKeyBytes('up', { appCursor: false, ctrl: false }))).toBe('\x1b[A') + expect(text(barKeyBytes('down', { appCursor: false, ctrl: false }))).toBe('\x1b[B') + expect(text(barKeyBytes('right', { appCursor: false, ctrl: false }))).toBe('\x1b[C') + expect(text(barKeyBytes('left', { appCursor: false, ctrl: false }))).toBe('\x1b[D') + }) + + it('switches arrows to SS3 under application cursor keys', () => { + expect(text(barKeyBytes('up', { appCursor: true, ctrl: false }))).toBe('\x1bOA') + expect(text(barKeyBytes('left', { appCursor: true, ctrl: false }))).toBe('\x1bOD') + }) + + it('encodes Ctrl-arrows as modified CSI, whatever the cursor mode', () => { + // xterm sends CSI 1;5 for ctrl-arrows even in application mode. + expect(text(barKeyBytes('up', { appCursor: false, ctrl: true }))).toBe('\x1b[1;5A') + expect(text(barKeyBytes('right', { appCursor: true, ctrl: true }))).toBe('\x1b[1;5C') + }) + + it('sends esc and tab as their single bytes, ctrl or not', () => { + expect(text(barKeyBytes('esc', { appCursor: false, ctrl: false }))).toBe('\x1b') + expect(text(barKeyBytes('tab', { appCursor: true, ctrl: true }))).toBe('\x09') + }) +}) + +describe('ctrlTransform', () => { + const of = (...b: number[]) => Uint8Array.from(b) + + it('folds letters onto control codes, either case', () => { + expect(ctrlTransform(of(0x63))).toEqual(of(0x03)) // c → ETX (Ctrl+C) + expect(ctrlTransform(of(0x43))).toEqual(of(0x03)) // C too + expect(ctrlTransform(of(0x64))).toEqual(of(0x04)) // d → EOT + }) + + it('covers the punctuation controls a terminal actually uses', () => { + expect(ctrlTransform(of(0x5b))).toEqual(of(0x1b)) // [ → ESC + expect(ctrlTransform(of(0x20))).toEqual(of(0x00)) // space → NUL + expect(ctrlTransform(of(0x3f))).toEqual(of(0x7f)) // ? → DEL + }) + + it('declines anything it cannot fold', () => { + expect(ctrlTransform(of(0x31))).toBeNull() // digit + expect(ctrlTransform(new TextEncoder().encode('é'))).toBeNull() // multi-byte + expect(ctrlTransform(new TextEncoder().encode('ls'))).toBeNull() // paste + }) +}) diff --git a/web/src/lib/keys.ts b/web/src/lib/keys.ts new file mode 100644 index 0000000..a05327a --- /dev/null +++ b/web/src/lib/keys.ts @@ -0,0 +1,42 @@ +/** The keys the on-screen bar offers. Ctrl is a modifier, not a key here. */ +export type BarKey = 'esc' | 'tab' | 'up' | 'down' | 'left' | 'right' + +const encoder = new TextEncoder() + +/** VT arrow finals: CSI/SS3 A B C D are up, down, right, left — in that order. */ +const ARROW_FINAL = { up: 'A', down: 'B', right: 'C', left: 'D' } as const + +/** + * The bytes a bar key sends. + * + * Arrows follow DECCKM — CSI for a shell, SS3 once a full-screen program has + * asked for application cursor keys — because a bar that always sent CSI + * would move the cursor in vim and type `A` in less. Ctrl-arrows are the + * modified CSI form whatever the mode, which is what xterm itself emits. + * Esc and tab are single bytes with no Ctrl form worth sending. + */ +export function barKeyBytes(key: BarKey, opts: { appCursor: boolean; ctrl: boolean }): Uint8Array { + if (key === 'esc') return encoder.encode('\x1b') + if (key === 'tab') return encoder.encode('\x09') + const fin = ARROW_FINAL[key] + if (opts.ctrl) return encoder.encode(`\x1b[1;5${fin}`) + return encoder.encode(opts.appCursor ? `\x1bO${fin}` : `\x1b[${fin}`) +} + +/** + * Fold one typed key onto its control code, for the Ctrl the bar latches. + * + * Touch keyboards carry no Ctrl, so the bar arms one and the next keystroke + * lands here. Null means "not foldable" — a digit, a paste, a multi-byte + * character — and the caller sends the bytes untouched; the arming is spent + * either way, as a latched modifier on a real keyboard would be. + */ +export function ctrlTransform(bytes: Uint8Array): Uint8Array | null { + if (bytes.length !== 1) return null + const b = bytes[0]! + if (b === 0x20) return Uint8Array.of(0x00) // Ctrl+Space + if (b === 0x3f) return Uint8Array.of(0x7f) // Ctrl+? + if (b >= 0x61 && b <= 0x7a) return Uint8Array.of(b & 0x1f) // a-z + if (b >= 0x40 && b <= 0x5f) return Uint8Array.of(b & 0x1f) // @, A-Z, [ \ ] ^ _ + return null +} From db45a98edcc3f9f06fa524b72b5e7fd1d2035f80 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 8 Aug 2026 17:07:05 +0530 Subject: [PATCH 3/7] feat(web): an on-screen key bar for touch devices Esc, tab, a sticky ctrl and the four arrows, floated over the terminal's bottom edge on coarse-pointer devices only. Arrows follow DECCKM through a new emulator seam method, ctrl folds the next keystroke on the input path, and presses ride pointerdown with the default prevented so the soft keyboard stays open. The inset reserves the bar's room, so it covers no rows. --- web/src/components/key-bar.tsx | 70 ++++++++++++++++++++++++ web/src/components/terminal.test.tsx | 82 ++++++++++++++++++++++++++++ web/src/components/terminal.tsx | 38 ++++++++++++- web/src/emulator/types.ts | 12 +++- web/src/emulator/xterm.ts | 2 + web/src/testing/emulator.ts | 5 ++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 web/src/components/key-bar.tsx diff --git a/web/src/components/key-bar.tsx b/web/src/components/key-bar.tsx new file mode 100644 index 0000000..70e3591 --- /dev/null +++ b/web/src/components/key-bar.tsx @@ -0,0 +1,70 @@ +import type { BarKey } from '@/lib/keys' +import { cn } from '@/lib/utils' + +const KEYS: ReadonlyArray<{ key: BarKey; label: string; name: string }> = [ + { key: 'esc', label: 'esc', name: 'Escape' }, + { key: 'tab', label: 'tab', name: 'Tab' }, + { key: 'left', label: '←', name: 'Arrow left' }, + { key: 'down', label: '↓', name: 'Arrow down' }, + { key: 'up', label: '↑', name: 'Arrow up' }, + { key: 'right', label: '→', name: 'Arrow right' }, +] + +/** + * The touch device's missing keys, floated over the terminal's bottom edge. + * + * Presses land on pointerdown, and the handler prevents the default so the + * press never takes focus from xterm's textarea — losing it would close the + * very keyboard the bar exists to work beside. Ctrl is latched: one press + * arms it for the next key, bar or typed, and the Terminal owns that state + * because the fold happens on the input path, not here. + */ +export function KeyBar(props: { + ctrl: boolean + onCtrl: () => void + onKey: (key: BarKey) => void +}) { + const chip = 'rounded-md px-2.5 py-1.5 font-mono text-sm/4 transition-colors select-none' + return ( +
+ + {KEYS.map((k) => ( + + ))} +
+ ) +} diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index c9bf146..0e130d1 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -1004,6 +1004,88 @@ describe('Terminal', () => { expect(sock.input()).toEqual([{ ref: 2, text: 'live' }]) }) }) + + describe('the key bar', () => { + /** jsdom has no matchMedia; a coarse pointer is claimed explicitly. */ + function coarsePointer() { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('coarse'), + addEventListener: () => {}, + removeEventListener: () => {}, + })) + } + const bar = () => document.querySelector('[data-flue-keybar]') + const key = (label: string) => + Array.from(document.querySelectorAll('[data-flue-keybar] button')).find( + (b) => b.textContent?.startsWith(label), + )! + + it('exists only for touch', () => { + const { sock } = mountTerminal((e) => ) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + expect(bar()).toBeNull() + }) + + it('sends CSI arrows for a shell and SS3 once the program asks', () => { + coarsePointer() + const { sock, em } = mountTerminal((e) => ( + + )) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + + fireEvent.pointerDown(key('↑')) + expect(sock.input()).toEqual([{ ref: 1, text: '\x1b[A' }]) + + em.live().appCursor = true + fireEvent.pointerDown(key('↓')) + expect(sock.input()).toEqual([ + { ref: 1, text: '\x1b[A' }, + { ref: 1, text: '\x1bOB' }, + ]) + }) + + it('arms Ctrl for exactly one following keystroke', () => { + coarsePointer() + const { sock, em } = mountTerminal((e) => ( + + )) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + + fireEvent.pointerDown(key('ctrl')) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('true') + act(() => em.live().send('c')) + act(() => em.live().send('c')) + expect(sock.input()).toEqual([ + { ref: 1, text: '\x03' }, + { ref: 1, text: 'c' }, + ]) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('false') + }) + + it('chords Ctrl with an arrow', () => { + coarsePointer() + const { sock } = mountTerminal((e) => ) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + + fireEvent.pointerDown(key('ctrl')) + fireEvent.pointerDown(key('→')) + expect(sock.input()).toEqual([{ ref: 1, text: '\x1b[1;5C' }]) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('false') + }) + + it('drops bar keys pressed before the attach comes back', () => { + coarsePointer() + const { sock } = mountTerminal((e) => ) + fireEvent.pointerDown(key('esc')) + expect(sock.input()).toEqual([]) + }) + + it('reserves bottom room in the inset so the bar covers no rows', () => { + coarsePointer() + mountTerminal((e) => ) + expect(inset().className).toContain('bottom-16') + }) + }) }) /** A complete SessionInfo, so a caller only names what it cares about. */ diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index b10b611..b881ac9 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -3,6 +3,7 @@ import { LayoutGridIcon, PlusIcon } from 'lucide-react' import { useFlueClient } from '@/client/provider' import { ExitOverlay } from '@/components/exit-overlay' +import { KeyBar } from '@/components/key-bar' import { ThemeMenu } from '@/components/theme-menu' import { DARK_SCHEME_QUERY, prefersDark } from '@/emulator/palette' import { controlColors, resolveTheme, THEME_SYSTEM } from '@/emulator/themes' @@ -18,6 +19,7 @@ import { type Dimensions, } from '@/lib/geometry' import { createKeyboardModes, type KeyboardMode } from '@/lib/keyboard' +import { barKeyBytes, ctrlTransform, type BarKey } from '@/lib/keys' import { cn } from '@/lib/utils' import { trackVisualViewport, zoomedIn } from '@/lib/viewport' @@ -131,6 +133,15 @@ export function Terminal({ client.status === 'reconnecting' ? 'reconnecting' : 'connecting', ) const [mode, setMode] = useState('tab') + // Coarse pointer once per mount: whether this device's primary pointer is a + // finger decides the key bar's existence, and a pointer does not change + // class mid-session in any way worth re-rendering for. + const [coarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)')?.matches ?? false) + // The latched Ctrl: state for the chip's pressed look, a ref for the input + // path, which lives inside the effect and must read it without re-running. + const [ctrlArmed, setCtrlArmed] = useState(false) + const ctrlArmedRef = useRef(ctrlArmed) + ctrlArmedRef.current = ctrlArmed const [exitCode, setExitCode] = useState(null) // This session's directory, for Restart and the new-session link. From the // session list, because `attached` does not carry it. @@ -156,6 +167,7 @@ export function Terminal({ const actionsRef = useRef<{ restart: (dir: string | null) => void applyTheme: (id: string) => void + sendKey: (key: BarKey) => void } | null>(null) // The latest onRestarted, readable from inside the effect without putting // a prop identity in its dependency array. @@ -323,7 +335,14 @@ export function Terminal({ emulator.onData((bytes) => { // No ref, no destination — and no input while the backlog replays. if (ref === null || consumed < muteUntil) return - client.sendInput(ref, bytes) + let out = bytes + if (ctrlArmedRef.current) { + // The bar's latched Ctrl folds this keystroke, and is spent on it + // whether or not it could fold — like a real latched modifier. + out = ctrlTransform(bytes) ?? bytes + setCtrlArmed(false) + } + client.sendInput(ref, out) }) // Touch scrolling, by hand: xterm's viewport scrolls on wheel events and @@ -563,6 +582,15 @@ export function Terminal({ emulator.setTheme(next) pane.style.backgroundColor = next.background ?? '' }, + sendKey: (key) => { + if (ref === null || consumed < muteUntil) return + const bytes = barKeyBytes(key, { + appCursor: emulator.applicationCursorKeys(), + ctrl: ctrlArmedRef.current, + }) + if (ctrlArmedRef.current) setCtrlArmed(false) + client.sendInput(ref, bytes) + }, } // Another tab choosing a theme lands here: the preference is global, and @@ -651,6 +679,7 @@ export function Terminal({ data-flue-inset="" className={cn( 'absolute inset-3 transition-opacity', + coarse && 'bottom-16', phase === 'exited' && 'opacity-60', )} > @@ -660,6 +689,13 @@ export function Terminal({ className="flue-term-surface absolute top-0 left-0 origin-top-left" /> + {coarse && ( + setCtrlArmed((v) => !v)} + onKey={(k) => actionsRef.current?.sendKey(k)} + /> + )} {/* z-10: xterm's own layers carry z-indexes, and an unindexed sibling loses to them — the controls must win the stack or the scrollbar eats their clicks. */} diff --git a/web/src/emulator/types.ts b/web/src/emulator/types.ts index 65b26d4..84dde6c 100644 --- a/web/src/emulator/types.ts +++ b/web/src/emulator/types.ts @@ -59,7 +59,7 @@ export interface TerminalTheme { * xterm.js implements this today. Keeping it small and free of xterm-specific * concepts is what keeps the protocol client and the terminal route testable * without a DOM, and is the whole reason this file exists: every part of flue - * that talks to a terminal talks to these ten methods. + * that talks to a terminal talks to this interface. * * The last three arrived with the terminal view, and each is here rather than * in the view because the alternative was the view reaching past the seam into @@ -144,6 +144,16 @@ export interface Emulator { * primary role. Off by default until the daemon says who is primary. */ answerQueries(on: boolean): void + /** + * Whether the program has asked for application cursor keys (DECCKM). + * + * The on-screen key bar synthesises arrow presses, and an arrow's encoding + * is the program's choice, not the bar's: CSI moves history at a shell, + * SS3 is what vim and friends expect once they have set the mode. Reading + * it here keeps the bar as mode-honest as a hardware keyboard through + * xterm would be. + */ + applicationCursorKeys(): boolean /** Test-only: simulate user input. */ injectForTest(data: string): void } diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts index fced945..59151c8 100644 --- a/web/src/emulator/xterm.ts +++ b/web/src/emulator/xterm.ts @@ -160,6 +160,8 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { answers = on }, + applicationCursorKeys: () => term.modes.applicationCursorKeysMode, + contentSize(): PixelSize | null { if (disposed) return null const screen = term.element?.querySelector(SCREEN_SELECTOR) diff --git a/web/src/testing/emulator.ts b/web/src/testing/emulator.ts index 056dd4e..2aada1f 100644 --- a/web/src/testing/emulator.ts +++ b/web/src/testing/emulator.ts @@ -23,6 +23,8 @@ export interface FakeEmulator extends Emulator { readonly scrolled: number /** What contentSize() reports. jsdom lays nothing out, so this is set by hand. */ measured: PixelSize | null + /** What applicationCursorKeys() reports; set by hand like measured. */ + appCursor: boolean /** Simulate the user typing. */ send(text: string): void } @@ -55,6 +57,7 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator focusCalls: 0, scrolled: 0, measured: null, + appCursor: false, text: () => written.join(''), @@ -109,6 +112,8 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator contentSize: () => self.measured, + applicationCursorKeys: () => self.appCursor, + injectForTest(data: string) { self.send(data) }, From 2edfb1a28b4191b78738d3b5a5de64f8e698f924 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 8 Aug 2026 17:17:39 +0530 Subject: [PATCH 4/7] fix(web): let assistive technology press the key bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VoiceOver's and TalkBack's double-tap synthesise a click and dispatch no pointer event at all, and so does Enter on a hybrid device's keyboard — so a bar wired to pointerdown alone offered a named, aria-pressed control that answered nothing, on exactly the devices it exists for. Each chip gains an onClick guarded on detail === 0, which is true only of a synthesised click, so a finger's own follow-up click still cannot send the key twice. Pointerdown and its prevented default are untouched: the soft keyboard stays open as before. Also pins the latched Ctrl's other half — the arming is spent even on a keystroke that cannot fold. Nothing distinguished that from moving the spend inside the fold check, which would latch Ctrl for good after a digit. --- web/src/components/key-bar.tsx | 15 ++++++++ web/src/components/terminal.test.tsx | 53 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/web/src/components/key-bar.tsx b/web/src/components/key-bar.tsx index 70e3591..4af1d45 100644 --- a/web/src/components/key-bar.tsx +++ b/web/src/components/key-bar.tsx @@ -18,6 +18,15 @@ const KEYS: ReadonlyArray<{ key: BarKey; label: string; name: string }> = [ * very keyboard the bar exists to work beside. Ctrl is latched: one press * arms it for the next key, bar or typed, and the Terminal owns that state * because the fold happens on the input path, not here. + * + * The onClick beside each is not a duplicate. VoiceOver's and TalkBack's + * double-tap synthesise a click and dispatch no pointer event at all, as does + * Enter or Space on a keyboard — so a bar wired to pointerdown alone offers + * assistive technology a named, pressable control that answers nothing, on + * exactly the devices this exists for. `detail` counts the presses behind a + * real click and is 0 for a synthesised one, which is what keeps a finger's + * own follow-up click from sending the key a second time: cancelling + * pointerdown suppresses the compatibility mouse events, never the click. */ export function KeyBar(props: { ctrl: boolean @@ -41,6 +50,9 @@ export function KeyBar(props: { e.preventDefault() props.onCtrl() }} + onClick={(e) => { + if (e.detail === 0) props.onCtrl() + }} className={cn( chip, props.ctrl @@ -59,6 +71,9 @@ export function KeyBar(props: { e.preventDefault() props.onKey(k.key) }} + onClick={(e) => { + if (e.detail === 0) props.onKey(k.key) + }} className={cn(chip, 'text-(--chip-dim) hover:text-(--chip-fg) active:bg-(--chip-wash)')} > {k.label} diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 0e130d1..2bbfb2b 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -1062,6 +1062,59 @@ describe('Terminal', () => { expect(key('ctrl').getAttribute('aria-pressed')).toBe('false') }) + it('spends the arming on a keystroke it cannot fold', () => { + // A digit has no control code, and neither does a paste or a multi-byte + // character: ctrlTransform returns null and the bytes go out untouched. + // The arming is spent all the same, as a latched modifier on a real + // keyboard would be — otherwise Ctrl stays armed for good and the next + // letter typed is silently folded into a control code nobody asked for. + coarsePointer() + const { sock, em } = mountTerminal((e) => ( + + )) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + + fireEvent.pointerDown(key('ctrl')) + act(() => em.live().send('7')) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('false') + + act(() => em.live().send('c')) + expect(sock.input()).toEqual([ + { ref: 1, text: '7' }, + { ref: 1, text: 'c' }, + ]) + }) + + it('answers a screen reader’s activation without double-sending a real tap', () => { + // VoiceOver's and TalkBack's double-tap, and Enter or Space on a hybrid + // device's keyboard, all synthesise a click and dispatch no pointer + // event at all. A bar wired to pointerdown alone advertises itself to + // assistive technology — aria-pressed, an accessible name each — and + // then does nothing when that technology activates it, on exactly the + // devices the bar exists for. `detail` is 0 for a synthesised click and + // counts the presses behind a real one, which is what keeps a finger's + // own follow-up click from sending the key twice. + coarsePointer() + const { sock } = mountTerminal((e) => ) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + + fireEvent.click(key('esc'), { detail: 0 }) + expect(sock.input()).toEqual([{ ref: 1, text: '\x1b' }]) + fireEvent.click(key('ctrl'), { detail: 0 }) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('true') + fireEvent.click(key('ctrl'), { detail: 0 }) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('false') + + // A finger: pointerdown, and then a click of its own that must not + // send a second tab. + fireEvent.pointerDown(key('tab')) + fireEvent.click(key('tab'), { detail: 1 }) + expect(sock.input()).toEqual([ + { ref: 1, text: '\x1b' }, + { ref: 1, text: '\x09' }, + ]) + }) + it('chords Ctrl with an arrow', () => { coarsePointer() const { sock } = mountTerminal((e) => ) From 56c277e2b95a1e40da78cf1416a91e4c710d5d22 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 8 Aug 2026 17:33:05 +0530 Subject: [PATCH 5/7] feat(web): let a flick glide the scrollback Drags scrolled line-for-line and stopped dead at the lift, which reads as rigid to a thumb calibrated by every other scrolling surface on a phone. A release velocity now decays at UIKit's 0.998-per-millisecond rate, emitting whole lines with the fraction carried, and dies at the next touch, a pinch, or unmount. --- web/src/components/terminal.test.tsx | 131 ++++++++++++++++++++++++++- web/src/components/terminal.tsx | 36 +++++++- web/src/lib/glide.test.ts | 101 +++++++++++++++++++++ web/src/lib/glide.ts | 58 ++++++++++++ 4 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 web/src/lib/glide.test.ts create mode 100644 web/src/lib/glide.ts diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 2bbfb2b..dd22cfb 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -77,14 +77,18 @@ const surfaceEl = () => document.querySelector('[data-flue-surface] * A touch event jsdom will really dispatch. * * jsdom ships no `Touch` constructor, so a genuine TouchEvent cannot be built - * with any points in it. The handlers under test read exactly three things — - * how many touches there are, the first one's clientY, and preventDefault — - * so this carries those. `cancelable` is the load-bearing part: whether the - * event comes back prevented is what decides if the browser may pan. + * with any points in it. The handlers under test read exactly four things — + * how many touches there are, the first one's clientY, the clock, and + * preventDefault — so this carries those. `cancelable` is the load-bearing + * part: whether the event comes back prevented is what decides if the browser + * may pan. `at` overrides the stamp jsdom writes at construction time, which + * is the only way consecutive events can be told apart within one tick; the + * drag tests that never lift a finger pass none and read a real clock. */ -function touch(type: 'touchstart' | 'touchmove' | 'touchend', ys: number[]) { +function touch(type: 'touchstart' | 'touchmove' | 'touchend', ys: number[], at?: number) { const e = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperty(e, 'touches', { value: ys.map((clientY) => ({ clientY })) }) + if (at !== undefined) Object.defineProperty(e, 'timeStamp', { value: at }) return e } @@ -464,6 +468,51 @@ describe('Terminal', () => { return mounted } + /** + * An animation frame queue that honours cancellation. + * + * A no-op cancelAnimationFrame would let a glide nobody wants keep running + * with no test able to tell, which is the one thing these are here to + * catch. Ids start high on purpose: the component cancels its own relayout + * frame on unmount, and that id was handed out by the real clock before + * these stubs existed, so a low counter here would collide with it and a + * glide would look cancelled by the wrong hand. + */ + function frameQueue() { + const queued = new Map void>() + let nextId = 1000 + let now = 48 + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => { + const id = nextId++ + queued.set(id, cb) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => void queued.delete(id)) + return { + pending: () => queued.size, + /** Runs up to `n` frames on a 16ms clock, each free to queue the next. */ + run(n: number) { + act(() => { + for (let i = 0; i < n && queued.size; i++) { + const [id, cb] = [...queued][0]! + queued.delete(id) + cb((now += 16)) + } + }) + }, + } + } + + /** A fast upward drag, lifted while still moving: 34px (2 lines) per 16ms. */ + function flick(surface: HTMLElement, liftAt = 48) { + act(() => { + surface.dispatchEvent(touch('touchstart', [300], 0)) + surface.dispatchEvent(touch('touchmove', [266], 16)) + surface.dispatchEvent(touch('touchmove', [232], 32)) + surface.dispatchEvent(touch('touchend', [], liftAt)) + }) + } + it('scrolls the scrollback, and takes the gesture from the browser to do it', () => { const { em } = mountDraggable() @@ -494,6 +543,78 @@ describe('Terminal', () => { expect(surfaceEl().style.touchAction).toBe('auto') }) + it('glides on after a flick, and a new touch stops the glide', () => { + // Mounted before the stubs: the relayout frame the mount schedules would + // otherwise land in this queue and be drained as if it were the glide. + const { em } = mountDraggable() + const frames = frameQueue() + const surface = surfaceEl() + + flick(surface) + // 68px of finger over a 17px line, all of it while the finger is down. + const dragged = em.live().scrolled + expect(dragged).toBe(4) + + // The glide keeps scrolling with no finger on the glass at all. + frames.run(4) + const glided = em.live().scrolled + expect(glided).toBeGreaterThan(dragged) + // Still asking for frames, which is what makes the next part a test. + expect(frames.pending()).toBe(1) + + // A finger back on the glass pins the content: no glide survives it. + act(() => void surface.dispatchEvent(touch('touchstart', [200], 400))) + expect(frames.pending()).toBe(0) + frames.run(4) + expect(em.live().scrolled).toBe(glided) + }) + + it('comes to rest on its own', () => { + const { em } = mountDraggable() + const frames = frameQueue() + + flick(surfaceEl()) + // Far more frames than a glide this size can use; it stops when the + // velocity dies, not when the caller runs out of patience. + frames.run(2000) + + expect(frames.pending()).toBe(0) + // 4 lines in 32ms is 125 lines/s, and 0.998^ms friction spends that over + // about 63 further lines — bounded rather than pinned, because the exact + // total is an artifact of the frame clock a test happens to crank. + expect(em.live().scrolled).toBeGreaterThan(40) + expect(em.live().scrolled).toBeLessThan(90) + }) + + it('does not glide when the finger came to rest before it lifted', () => { + // The samples still describe a fast drag; the release does not. A thumb + // that parks the content and lets go expects it to stay parked, and the + // moves stop arriving the moment the finger stops, so only the lift's + // own clock can tell this apart from a flick. + const { em } = mountDraggable() + const frames = frameQueue() + + flick(surfaceEl(), 400) + + expect(em.live().scrolled).toBe(4) + expect(frames.pending()).toBe(0) + }) + + it('drops the glide when the terminal goes away', () => { + // A glide outliving its effect would go on calling scrollLines into an + // emulator that has already been disposed. + const { view } = mountDraggable() + const frames = frameQueue() + + flick(surfaceEl()) + frames.run(2) + expect(frames.pending()).toBe(1) + + act(() => view.unmount()) + + expect(frames.pending()).toBe(0) + }) + it('drops a drag already under way when the zoom arrives mid-gesture', () => { const { em } = mountDraggable() diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index b881ac9..b5730f4 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -18,6 +18,7 @@ import { type Box, type Dimensions, } from '@/lib/geometry' +import { startGlide } from '@/lib/glide' import { createKeyboardModes, type KeyboardMode } from '@/lib/keyboard' import { barKeyBytes, ctrlTransform, type BarKey } from '@/lib/keys' import { cn } from '@/lib/utils' @@ -351,14 +352,24 @@ export function Terminal({ // remainder carried between moves so slow drags still add up. The // surface's own CSS sets touch-action: pinch-zoom (styles.css), which is // what keeps the browser from spending the gesture on panning the page - // while still leaving two fingers to the zoom. + // while still leaving two fingers to the zoom. A finger that lifts while + // still moving hands its speed to a glide (lib/glide.ts), which is what + // every other scrolling surface on a phone does. let touchY: number | null = null let touchCarry = 0 + // The flick record: the last few moves' clocks and positions, enough to + // read a release velocity from. Cleared whenever a gesture starts. + let flick: Array<{ t: number; y: number }> = [] + let glide: (() => void) | null = null const lineHeightPx = () => { const content = emulator.contentSize() return content && dims.rows > 0 ? content.height / dims.rows : 17 } const touchStart = (e: TouchEvent) => { + // A finger on the glass pins the content — any glide in flight ends. + glide?.() + glide = null + flick = [] // A magnified page belongs to the browser, and releasing touch-action // is not enough to give it back: touch-action only says the browser // *may* pan, while the preventDefault() below cancels that pan whatever @@ -369,6 +380,10 @@ export function Terminal({ } touchY = e.touches[0]!.clientY touchCarry = 0 + // The anchor counts as a sample. A flick is often three moves long at a + // 60Hz touch rate, and reading its speed from the moves alone would + // throw away a third of the evidence and most of the window. + flick.push({ t: e.timeStamp, y: touchY }) } const touchMove = (e: TouchEvent) => { if (touchY === null || e.touches.length !== 1) return @@ -388,11 +403,27 @@ export function Terminal({ const lines = Math.trunc(delta) touchCarry = delta - lines touchY = y + flick.push({ t: e.timeStamp, y }) + if (flick.length > 6) flick.shift() if (lines !== 0) emulator.scrollLines(lines) } - const touchEnd = () => { + const touchEnd = (e: TouchEvent) => { + const wasDragging = touchY !== null touchY = null touchCarry = 0 + // Velocity over the sample window. Two samples and thirty milliseconds + // are the floor, so a tap reads as no flick at all. The lift's own clock + // is the other half: moves stop arriving the instant the finger stops, + // so a thumb that parks the content and lets go a moment later leaves + // fast samples behind it, and only their age gives it away. + const a = flick[0] + const b = flick[flick.length - 1] + flick = [] + if (!wasDragging || !a || !b || b.t - a.t < 30) return + if (e.timeStamp - b.t > 100) return + const dt = (b.t - a.t) / 1000 + const lps = (a.y - b.y) / lineHeightPx() / dt + glide = startGlide({ velocity: lps, onLines: (n) => emulator.scrollLines(n) }) } surface.addEventListener('touchstart', touchStart, { passive: true }) surface.addEventListener('touchmove', touchMove, { passive: false }) @@ -617,6 +648,7 @@ export function Terminal({ surface.removeEventListener('touchmove', touchMove) surface.removeEventListener('touchend', touchEnd) surface.removeEventListener('touchcancel', touchEnd) + glide?.() untrackViewport() window.removeEventListener('storage', onStorage) window.removeEventListener('keydown', onKey, true) diff --git a/web/src/lib/glide.test.ts b/web/src/lib/glide.test.ts new file mode 100644 index 0000000..dfaf2fc --- /dev/null +++ b/web/src/lib/glide.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { startGlide } from './glide' + +/** A hand-cranked animation frame loop. step() advances the clock. */ +function frames() { + const queue: Array<{ id: number; cb: (t: number) => void }> = [] + let nextId = 1 + let now = 0 + return { + raf: (cb: (t: number) => void) => { + const id = nextId++ + queue.push({ id, cb }) + return id + }, + caf: (id: number) => { + const at = queue.findIndex((f) => f.id === id) + if (at >= 0) queue.splice(at, 1) + }, + step(ms: number) { + now += ms + const due = queue.splice(0, queue.length) + for (const f of due) f.cb(now) + }, + pending: () => queue.length, + } +} + +describe('startGlide', () => { + it('keeps scrolling after the finger lifts, in decaying whole lines', () => { + const f = frames() + let lines = 0 + startGlide({ velocity: 60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf }) + + f.step(16) // first frame establishes the clock; no time has passed yet + const after1 = lines + for (let i = 0; i < 30; i++) f.step(16) + const after31 = lines + + expect(after31).toBeGreaterThan(after1) + // Half a second of 0.998^ms friction eats most of 60 lines/s: the total + // lands well under what the starting velocity alone would cover… + expect(after31).toBeLessThan(30) + expect(after31).toBeGreaterThan(5) + }) + + it('carries fractions so slow glides still add up to whole lines', () => { + const f = frames() + let lines = 0 + startGlide({ velocity: 4, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf }) + f.step(16) + for (let i = 0; i < 30; i++) f.step(16) + // 4 lines/s decaying over ~0.5s comes to about 1.25 lines, and every + // single frame of it is 0.064 of a line: deliverable only by carry. + expect(lines).toBeGreaterThanOrEqual(1) + }) + + it('emits only whole lines, never fractions', () => { + const f = frames() + const emitted: number[] = [] + startGlide({ velocity: 25, onLines: (n) => emitted.push(n), raf: f.raf, caf: f.caf }) + f.step(16) + for (let i = 0; i < 10; i++) f.step(16) + for (const n of emitted) expect(Number.isInteger(n)).toBe(true) + }) + + it('scrolls the other way for a negative velocity', () => { + const f = frames() + let lines = 0 + startGlide({ velocity: -60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf }) + f.step(16) + for (let i = 0; i < 10; i++) f.step(16) + expect(lines).toBeLessThan(0) + }) + + it('comes to rest on its own and stops asking for frames', () => { + const f = frames() + startGlide({ velocity: 10, onLines: () => {}, raf: f.raf, caf: f.caf }) + for (let i = 0; i < 400 && f.pending(); i++) f.step(16) + expect(f.pending()).toBe(0) + }) + + it('cancel stops it mid-glide', () => { + const f = frames() + let lines = 0 + const cancel = startGlide({ velocity: 60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf }) + f.step(16) + f.step(16) + const before = lines + cancel() + f.step(16) + f.step(16) + expect(lines).toBe(before) + expect(f.pending()).toBe(0) + }) + + it('declines a velocity too small to glide', () => { + const f = frames() + startGlide({ velocity: 0.2, onLines: () => {}, raf: f.raf, caf: f.caf }) + expect(f.pending()).toBe(0) + }) +}) diff --git a/web/src/lib/glide.ts b/web/src/lib/glide.ts new file mode 100644 index 0000000..2bed3a9 --- /dev/null +++ b/web/src/lib/glide.ts @@ -0,0 +1,58 @@ +/** + * Friction per millisecond. UIKit's "normal" deceleration rate — velocity + * multiplied by 0.998 every millisecond — which is the feel a finger that + * has used any phone expects, and the reason the constant is not tunable. + */ +const FRICTION = 0.998 + +/** Below this many lines per second a glide has visibly stopped. */ +const REST = 0.5 + +/** + * Scroll on after the finger lifts. + * + * The drag handlers translate touch motion into whole-line scrolls while the + * finger is down; this carries the motion past the lift, decaying an initial + * lines-per-second velocity and emitting whole lines with the fraction + * carried between frames — the same carry trick the drag itself uses. + * Returns a cancel; the caller cancels on the next touch, on a pinch, and + * on unmount, because a glide must never outlive the surface it scrolls. + */ +export function startGlide(opts: { + velocity: number + onLines: (lines: number) => void + raf?: typeof requestAnimationFrame + caf?: typeof cancelAnimationFrame +}): () => void { + const raf = opts.raf ?? requestAnimationFrame + const caf = opts.caf ?? cancelAnimationFrame + let v = opts.velocity + if (Math.abs(v) < REST) return () => {} + + let carry = 0 + let last: number | null = null + let frame = 0 + + const tick = (t: number) => { + frame = 0 + if (last !== null) { + const dt = t - last + // Integrate at the frame's start velocity, then decay: at 60fps the + // difference from exact integration is under a line per flick. + const delta = (v * dt) / 1000 + carry + const lines = Math.trunc(delta) + carry = delta - lines + if (lines !== 0) opts.onLines(lines) + v *= FRICTION ** dt + if (Math.abs(v) < REST) return + } + last = t + frame = raf(tick) + } + + frame = raf(tick) + return () => { + if (frame) caf(frame) + frame = 0 + } +} From 6fe19b687e42806737bd06e955c511d3bef1cf57 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 8 Aug 2026 17:43:05 +0530 Subject: [PATCH 6/7] fix(web): read a flick's speed from the flick The release velocity was measured from touchdown, because the anchor sample stayed in the window for the whole of any flick shorter than six moves. Touching the glass to stop a glide, hesitating, then flicking is the commonest gesture there is, and the hesitation was being averaged into the answer: a 125 lines/s flick after a 200ms pause read as 17 and glided an eighth as far. Samples older than 100ms are now dropped, two always kept so a short flick still reads. --- web/src/components/terminal.test.tsx | 26 ++++++++++++++++++++++++++ web/src/components/terminal.tsx | 19 +++++++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index dd22cfb..1de60b0 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -586,6 +586,32 @@ describe('Terminal', () => { expect(em.live().scrolled).toBeLessThan(90) }) + it('reads the speed of the flick, not of the pause before it', () => { + // The commonest gesture there is: touch to stop the last glide, hesitate, + // then flick. The reaction gap belongs to the gesture but to no part of + // its speed, and a window measured from touchdown divides the answer by + // however long the thumb took to make up its mind. + const { em } = mountDraggable() + const frames = frameQueue() + const surface = surfaceEl() + + act(() => { + surface.dispatchEvent(touch('touchstart', [300], 0)) + surface.dispatchEvent(touch('touchmove', [283], 200)) + surface.dispatchEvent(touch('touchmove', [249], 216)) + surface.dispatchEvent(touch('touchmove', [215], 232)) + surface.dispatchEvent(touch('touchend', [], 240)) + }) + expect(em.live().scrolled).toBe(5) + + frames.run(2000) + + // 68px over the last 32ms is 125 lines/s, worth about 63 lines. Measured + // from touchdown it would read 21 lines/s instead and stop inside 15. + expect(em.live().scrolled).toBeGreaterThan(40) + expect(em.live().scrolled).toBeLessThan(90) + }) + it('does not glide when the finger came to rest before it lifted', () => { // The samples still describe a fast drag; the release does not. A thumb // that parks the content and lets go expects it to stay parked, and the diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index b5730f4..c4bbb84 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -382,7 +382,8 @@ export function Terminal({ touchCarry = 0 // The anchor counts as a sample. A flick is often three moves long at a // 60Hz touch rate, and reading its speed from the moves alone would - // throw away a third of the evidence and most of the window. + // throw away a third of the evidence and most of the window. The lift + // trims it back off again if the finger rested here before setting off. flick.push({ t: e.timeStamp, y: touchY }) } const touchMove = (e: TouchEvent) => { @@ -411,11 +412,17 @@ export function Terminal({ const wasDragging = touchY !== null touchY = null touchCarry = 0 - // Velocity over the sample window. Two samples and thirty milliseconds - // are the floor, so a tap reads as no flick at all. The lift's own clock - // is the other half: moves stop arriving the instant the finger stops, - // so a thumb that parks the content and lets go a moment later leaves - // fast samples behind it, and only their age gives it away. + // Velocity is the recent motion, not the whole gesture: a hesitation + // after touchdown, or a drag that turned around mid-way, is no part of + // the speed at the lift and averaging across it would divide the answer + // by the length of the pause. Two samples are always kept, so a flick + // short enough to be three samples still reads. + while (flick.length > 2 && flick[flick.length - 1]!.t - flick[0]!.t > 100) flick.shift() + // Two samples and thirty milliseconds are the floor, so a tap reads as + // no flick at all. The lift's own clock is the other half: moves stop + // arriving the instant the finger stops, so a thumb that parks the + // content and lets go a moment later leaves fast samples behind it, and + // only their age gives it away. const a = flick[0] const b = flick[flick.length - 1] flick = [] From 7b43daa58e88bf1b1fd7842260e33e2d4824bb07 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 8 Aug 2026 18:00:55 +0530 Subject: [PATCH 7/7] fix(web): close the three final-review items on the touch branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit touchcancel shared the lift handler, and a drag the system takes away — the notification shade, an alert, a call — leaves samples that read as a flick: fast, recent, promptly followed by an event. The scrollback would coast on with nothing on the glass. It gets its own handler now, one that clears the gesture and reaches startGlide by no path. The latched Ctrl was spent through state alone, so the ref the input path reads stayed armed until React flushed. Two onData deliveries in one task both folded, and one press of ctrl produced two control codes. The ref is now cleared by hand beside both setCtrlArmed(false) sites. applicationCursorKeys reached into a possibly-disposed terminal without the guard its three neighbours carry — harmless in the xterm shipped today, which is a fact about xterm's internals rather than about this seam. Two comments that named "the three below" and "the last three" had outlived the member sets they counted, and now say what is there. Co-Authored-By: Claude Fable 5 --- web/src/components/terminal.test.tsx | 52 +++++++++++++++++++++++++++- web/src/components/terminal.tsx | 29 ++++++++++++++-- web/src/emulator/emulator.test.ts | 13 ++++--- web/src/emulator/types.ts | 14 +++++--- web/src/emulator/xterm.ts | 16 +++++++-- 5 files changed, 108 insertions(+), 16 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 1de60b0..09c84ef 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -85,7 +85,11 @@ const surfaceEl = () => document.querySelector('[data-flue-surface] * is the only way consecutive events can be told apart within one tick; the * drag tests that never lift a finger pass none and read a real clock. */ -function touch(type: 'touchstart' | 'touchmove' | 'touchend', ys: number[], at?: number) { +function touch( + type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', + ys: number[], + at?: number, +) { const e = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperty(e, 'touches', { value: ys.map((clientY) => ({ clientY })) }) if (at !== undefined) Object.defineProperty(e, 'timeStamp', { value: at }) @@ -626,6 +630,28 @@ describe('Terminal', () => { expect(frames.pending()).toBe(0) }) + it('takes no glide from a gesture the system cancelled', () => { + // The notification shade, an alert, an incoming call: the browser fires + // touchcancel, promptly and with the drag's fast samples still on the + // record, so every test a lift is judged by passes. But no finger left + // the glass — nothing was thrown — and a scrollback that coasted here + // would be moving after the interruption put it down. + const { em } = mountDraggable() + const frames = frameQueue() + const surface = surfaceEl() + + act(() => { + surface.dispatchEvent(touch('touchstart', [300], 0)) + surface.dispatchEvent(touch('touchmove', [266], 16)) + surface.dispatchEvent(touch('touchmove', [232], 32)) + surface.dispatchEvent(touch('touchcancel', [], 48)) + }) + + // The 68px the finger did travel stand; nothing is added to them. + expect(em.live().scrolled).toBe(4) + expect(frames.pending()).toBe(0) + }) + it('drops the glide when the terminal goes away', () => { // A glide outliving its effect would go on calling scrollLines into an // emulator that has already been disposed. @@ -1209,6 +1235,30 @@ describe('Terminal', () => { expect(key('ctrl').getAttribute('aria-pressed')).toBe('false') }) + it('spends the arming on the first of two keystrokes in one task', () => { + // The spend cannot wait on a render. Both deliveries here land before + // React flushes the state change, so the input path reads the ref it was + // given — and a ref left true until the flush folds the second keystroke + // too, on one press of ctrl. + coarsePointer() + const { sock, em } = mountTerminal((e) => ( + + )) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + + fireEvent.pointerDown(key('ctrl')) + act(() => { + em.live().send('c') + em.live().send('c') + }) + + expect(sock.input()).toEqual([ + { ref: 1, text: '\x03' }, + { ref: 1, text: 'c' }, + ]) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('false') + }) + it('spends the arming on a keystroke it cannot fold', () => { // A digit has no control code, and neither does a paste or a multi-byte // character: ctrlTransform returns null and the bytes go out untouched. diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index c4bbb84..949717a 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -341,6 +341,10 @@ export function Terminal({ // The bar's latched Ctrl folds this keystroke, and is spent on it // whether or not it could fold — like a real latched modifier. out = ctrlTransform(bytes) ?? bytes + // The ref by hand, beside the state and not through it: the render + // that would re-sync it is a flush away, and two keystrokes delivered + // inside one task would both read the arming and both fold. + ctrlArmedRef.current = false setCtrlArmed(false) } client.sendInput(ref, out) @@ -432,10 +436,25 @@ export function Terminal({ const lps = (a.y - b.y) / lineHeightPx() / dt glide = startGlide({ velocity: lps, onLines: (n) => emulator.scrollLines(n) }) } + /** + * The gesture taken away rather than finished: a notification shade pulled + * down over it, an alert, a call. No finger ever lifted, so there is no + * release to read a speed from — the drag simply stops where it was. + * + * Its own handler rather than touchEnd's, because the samples a + * system-stolen drag leaves behind are indistinguishable from a flick's: + * fast, recent, and promptly followed by an event. Sharing the lift path + * would send the scrollback coasting with nothing on the glass. + */ + const touchCancel = () => { + touchY = null + touchCarry = 0 + flick = [] + } surface.addEventListener('touchstart', touchStart, { passive: true }) surface.addEventListener('touchmove', touchMove, { passive: false }) surface.addEventListener('touchend', touchEnd, { passive: true }) - surface.addEventListener('touchcancel', touchEnd, { passive: true }) + surface.addEventListener('touchcancel', touchCancel, { passive: true }) // The pane hugs the visual viewport: a phone keyboard shrinks it and the // ResizeObserver below refits the terminal above the keyboard. While @@ -626,7 +645,11 @@ export function Terminal({ appCursor: emulator.applicationCursorKeys(), ctrl: ctrlArmedRef.current, }) - if (ctrlArmedRef.current) setCtrlArmed(false) + if (ctrlArmedRef.current) { + // Spent here too, ref first: see the onData path above. + ctrlArmedRef.current = false + setCtrlArmed(false) + } client.sendInput(ref, bytes) }, } @@ -654,7 +677,7 @@ export function Terminal({ surface.removeEventListener('touchstart', touchStart) surface.removeEventListener('touchmove', touchMove) surface.removeEventListener('touchend', touchEnd) - surface.removeEventListener('touchcancel', touchEnd) + surface.removeEventListener('touchcancel', touchCancel) glide?.() untrackViewport() window.removeEventListener('storage', onStorage) diff --git a/web/src/emulator/emulator.test.ts b/web/src/emulator/emulator.test.ts index 66b2863..2909a19 100644 --- a/web/src/emulator/emulator.test.ts +++ b/web/src/emulator/emulator.test.ts @@ -138,16 +138,21 @@ describe('Emulator interface', () => { em.dispose() }) - it('survives focus, setTheme and contentSize after disposal', () => { - // All three are called from React effects and from event handlers that can - // outlive the view by a frame — a queued animation frame, a media-query - // change mid-teardown. xterm throws on a disposed terminal. + it('survives every read and write that can outlive disposal', () => { + // All of these are called from React effects and from event handlers that + // can outlive the view by a frame — a queued animation frame, a + // media-query change mid-teardown, a key bar tapped as the view goes + // away. xterm throws on a disposed terminal — from the mode reads only by + // luck of where they land today, which is why the answer this pins is the + // seam's own and not whatever the wreckage still holds. const em = createXtermEmulator({ cols: 20, rows: 4 }) em.dispose() expect(() => em.focus()).not.toThrow() expect(() => em.setTheme({ background: '#000000' })).not.toThrow() expect(em.contentSize()).toBeNull() + expect(() => em.answerQueries(true)).not.toThrow() + expect(em.applicationCursorKeys()).toBe(false) }) it('attaches to an element with no WebGL context available', () => { diff --git a/web/src/emulator/types.ts b/web/src/emulator/types.ts index 84dde6c..ddf955a 100644 --- a/web/src/emulator/types.ts +++ b/web/src/emulator/types.ts @@ -61,11 +61,15 @@ export interface TerminalTheme { * without a DOM, and is the whole reason this file exists: every part of flue * that talks to a terminal talks to this interface. * - * The last three arrived with the terminal view, and each is here rather than - * in the view because the alternative was the view reaching past the seam into - * xterm's own DOM and options — which is the one thing this file exists to - * prevent. All three are emulator-agnostic: every terminal emulator has a - * palette, a focus state, and a rendered size. + * `setTheme`, `focus` and `contentSize` arrived with the terminal view, and + * each is here rather than in the view because the alternative was the view + * reaching past the seam into xterm's own DOM and options — which is the one + * thing this file exists to prevent. All three are emulator-agnostic: every + * terminal emulator has a palette, a focus state, and a rendered size. The two + * that came after them, `answerQueries` and `applicationCursorKeys`, are the + * same bargain struck over terminal modes rather than over what is drawn: + * which client may answer a program's questions, and how a program wants its + * arrow keys encoded. */ export interface Emulator { /** diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts index 59151c8..8ccb51c 100644 --- a/web/src/emulator/xterm.ts +++ b/web/src/emulator/xterm.ts @@ -142,9 +142,13 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { term.dispose() }, - // The three below are all reachable after disposal: a queued animation + // Everything from here on can be called after disposal: a queued animation // frame, a media-query change landing mid-teardown, a focus effect racing - // an unmount. xterm throws from a disposed terminal, so each checks. + // an unmount, a key bar tap that arrives as the view goes away. xterm + // throws from a disposed terminal, so each of the four that reach into + // `term` checks first. answerQueries needs no check — it writes the local + // flag the parser handlers read and touches nothing of xterm's — and + // injectForTest is reached from tests alone, which own their teardown. setTheme(theme: TerminalTheme) { if (disposed) return @@ -160,7 +164,13 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { answers = on }, - applicationCursorKeys: () => term.modes.applicationCursorKeysMode, + applicationCursorKeys() { + // A key bar tap can outrun the unmount that disposed this. CSI is the + // honest answer for a terminal that no longer exists — and the bytes go + // nowhere anyway, because the view drops them with no ref to send on. + if (disposed) return false + return term.modes.applicationCursorKeysMode + }, contentSize(): PixelSize | null { if (disposed) return null