diff --git a/web/src/components/key-bar.tsx b/web/src/components/key-bar.tsx new file mode 100644 index 0000000..4af1d45 --- /dev/null +++ b/web/src/components/key-bar.tsx @@ -0,0 +1,85 @@ +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. + * + * 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 + 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 6da53b3..09c84ef 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -77,14 +77,22 @@ 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' | '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 }) return e } @@ -464,6 +472,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 +547,126 @@ 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('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 + // 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('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. + 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() @@ -635,6 +808,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 @@ -972,6 +1177,165 @@ 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('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. + // 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) => ) + 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 3a93741..949717a 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' @@ -17,7 +18,9 @@ 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' import { trackVisualViewport, zoomedIn } from '@/lib/viewport' @@ -131,6 +134,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 +168,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. @@ -260,17 +273,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. // @@ -316,7 +336,18 @@ 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 + // 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) }) // Touch scrolling, by hand: xterm's viewport scrolls on wheel events and @@ -325,14 +356,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 @@ -343,6 +384,11 @@ 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. 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) => { if (touchY === null || e.touches.length !== 1) return @@ -362,16 +408,53 @@ 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 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 = [] + 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) }) + } + /** + * 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 @@ -556,6 +639,19 @@ 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) { + // Spent here too, ref first: see the onData path above. + ctrlArmedRef.current = false + setCtrlArmed(false) + } + client.sendInput(ref, bytes) + }, } // Another tab choosing a theme lands here: the preference is global, and @@ -581,7 +677,8 @@ 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) window.removeEventListener('keydown', onKey, true) @@ -644,6 +741,7 @@ export function Terminal({ data-flue-inset="" className={cn( 'absolute inset-3 transition-opacity', + coarse && 'bottom-16', phase === 'exited' && 'opacity-60', )} > @@ -653,6 +751,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/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 65b26d4..ddf955a 100644 --- a/web/src/emulator/types.ts +++ b/web/src/emulator/types.ts @@ -59,13 +59,17 @@ 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 - * 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 { /** @@ -144,6 +148,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..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,6 +164,14 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { answers = on }, + 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 const screen = term.element?.querySelector(SCREEN_SELECTOR) 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 + } +} 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 +} 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) },