From 9511b82508796085d34ab6bd29c3a58561b8c3ab Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 4 Jun 2026 22:51:45 +1200 Subject: [PATCH] feat: add mobile controls and update help data --- src/App.svelte | 1 + src/lib/components/MatrixCanvas.svelte | 341 ++++++++++++++++-- .../components/__tests__/HelpModal.test.ts | 9 + .../components/__tests__/MatrixCanvas.test.ts | 131 ++++++- .../MatrixCanvasBindingWrapper.svelte | 10 +- .../__tests__/MockMatrixCanvas.svelte | 1 + src/lib/constants/HelpData.ts | 14 + 7 files changed, 470 insertions(+), 37 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 7dac95e..631276c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -199,6 +199,7 @@ bind:all8Directions={settings.all8Directions} bind:waveDistortion={settings.waveDistortion} bind:mouseInteractionMode={settings.mouseInteractionMode} + showMobileControls={false} /> {/if} diff --git a/src/lib/components/MatrixCanvas.svelte b/src/lib/components/MatrixCanvas.svelte index 0a9af49..51d7975 100644 --- a/src/lib/components/MatrixCanvas.svelte +++ b/src/lib/components/MatrixCanvas.svelte @@ -15,6 +15,7 @@ all8Directions = $bindable(false), waveDistortion = $bindable(false), mouseInteractionMode = $bindable('off' as MouseInteractionMode), + showMobileControls = true, } = $props<{ engine: CoreEngine; mode: 'normal' | 'square'; @@ -25,10 +26,16 @@ all8Directions: boolean; waveDistortion: boolean; mouseInteractionMode: MouseInteractionMode; + showMobileControls?: boolean; }>(); /* eslint-enable prefer-const, no-useless-assignment */ + const coarsePointerQuery = '(hover: none) and (pointer: coarse)'; + let canvas: HTMLCanvasElement; + let isCoarsePointer = $state(false); + + const shouldShowMobileControls = $derived(showMobileControls && isCoarsePointer); function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { @@ -54,7 +61,7 @@ switch (event.key) { case 'Escape': - onReturn(); + returnToMenu(); break; case 'ArrowLeft': handleDirectionInput('west'); @@ -69,25 +76,19 @@ handleDirectionInput('south'); break; case ' ': - if (engine.ctx != null) { - engine.pause(); - } + togglePause(); break; case 'c': - engine.clearScreen(); + clearCanvas(); break; case 'd': - discoOn = !discoOn; + toggleDisco(); break; case 'PageUp': - if (engine.ctx != null || !engine.squareAnimationOn) { - engine.speedController(true); - } + increaseSpeed(); break; case 'PageDown': - if (engine.ctx != null || !engine.squareAnimationOn) { - engine.speedController(false); - } + decreaseSpeed(); break; case '1': chosenColor = 'green'; @@ -115,7 +116,7 @@ chosenColor = 'random'; break; case 'w': - engine.controlFontSize(true); + increaseFontSize(); break; case 'x': waveDistortion = !waveDistortion; @@ -124,7 +125,7 @@ mouseInteractionMode = getNextMouseInteractionMode(mouseInteractionMode); break; case 's': - engine.controlFontSize(false); + decreaseFontSize(); break; case 'q': engine.controlStringSize(true); @@ -136,9 +137,7 @@ engine.rapidWordChangeControl(); break; case 'm': - if (engine.ctx) { - engine.switchMode(); - } + switchMode(); break; case 't': all4Directions = !all4Directions; @@ -169,6 +168,71 @@ } } + function returnToMenu(): void { + onReturn(); + } + + function togglePause(): void { + if (engine.ctx != null) { + engine.pause(); + } + } + + function clearCanvas(): void { + engine.clearScreen(); + } + + function toggleDisco(): void { + discoOn = !discoOn; + } + + function increaseSpeed(): void { + if (engine.ctx != null || !engine.squareAnimationOn) { + engine.speedController(true); + } + } + + function decreaseSpeed(): void { + if (engine.ctx != null || !engine.squareAnimationOn) { + engine.speedController(false); + } + } + + function increaseFontSize(): void { + engine.controlFontSize(true); + } + + function decreaseFontSize(): void { + engine.controlFontSize(false); + } + + function switchMode(): void { + if (engine.ctx) { + engine.switchMode(); + } + } + + function handleCanvasClick(): void { + if (!isCoarsePointer) { + returnToMenu(); + } + } + + function handleCanvasKeyDown(event: KeyboardEvent): void { + if (isCoarsePointer) { + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + returnToMenu(); + } + } + + function runMobileAction(event: MouseEvent, action: () => void): void { + event.stopPropagation(); + action(); + } + function handleDirectionInput(direction: Direction): void { if (engine.squareAnimationOn) { moveSquare(direction); @@ -299,6 +363,30 @@ return undefined; }); + $effect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const mediaQuery = window.matchMedia?.(coarsePointerQuery); + + if (!mediaQuery) { + isCoarsePointer = false; + return undefined; + } + + const updateCoarsePointer = (event?: MediaQueryListEvent) => { + isCoarsePointer = event?.matches ?? mediaQuery.matches; + }; + + updateCoarsePointer(); + mediaQuery.addEventListener('change', updateCoarsePointer); + + return () => { + mediaQuery.removeEventListener('change', updateCoarsePointer); + }; + }); + // Secondary effect to handle state changes that might happen from App.svelte $effect(() => { // These values are synced in App.svelte already, but we might want to trigger resetWordsArray @@ -311,22 +399,128 @@ { - if (e.key === 'Enter' || e.key === ' ') { - onReturn(); - } - }} - role="button" - tabindex="0" - aria-label="Return to settings" + onclick={handleCanvasClick} + onkeydown={handleCanvasKeyDown} + role={isCoarsePointer ? undefined : 'button'} + tabindex={isCoarsePointer ? undefined : 0} + aria-label={isCoarsePointer ? undefined : 'Return to settings'} style:display="block" style:background="var(--canvas-bg)" - style:cursor="pointer" + style:cursor={isCoarsePointer ? 'default' : 'pointer'} onpointermove={handlePointerMove} onpointerleave={handlePointerLeave} > +{#if shouldShowMobileControls} + + +
+
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+
+{/if} + diff --git a/src/lib/components/__tests__/HelpModal.test.ts b/src/lib/components/__tests__/HelpModal.test.ts index 6b58fb5..19bd0e6 100644 --- a/src/lib/components/__tests__/HelpModal.test.ts +++ b/src/lib/components/__tests__/HelpModal.test.ts @@ -55,6 +55,15 @@ describe('HelpModal', () => { expect(screen.getByText(/All 4 Directions/i)).toBeInTheDocument(); }); + it('lists mobile controls separately from keyboard controls', () => { + render(HelpModal, { props: { isOpen: true, onClose: vi.fn() } }); + + expect(screen.getByText('MOBILE CONTROLS')).toBeInTheDocument(); + expect(screen.getByText('Swipe')).toBeInTheDocument(); + expect(screen.getByText('Return to settings')).toBeInTheDocument(); + expect(screen.getByText('Direction controls')).toBeInTheDocument(); + }); + it('traps focus when open', async () => { const onClose = vi.fn(); render(HelpModal, { props: { isOpen: true, onClose } }); diff --git a/src/lib/components/__tests__/MatrixCanvas.test.ts b/src/lib/components/__tests__/MatrixCanvas.test.ts index ff9e79d..0e1a794 100644 --- a/src/lib/components/__tests__/MatrixCanvas.test.ts +++ b/src/lib/components/__tests__/MatrixCanvas.test.ts @@ -26,6 +26,8 @@ describe('MatrixCanvas', () => { configurable: true, value: 600, }); + + mockCoarsePointer(false); }); afterEach(() => { @@ -61,7 +63,38 @@ describe('MatrixCanvas', () => { } as unknown as CoreEngine; } - function renderCanvas(engine = createEngine(), mode: 'normal' | 'square' = 'normal') { + function mockCoarsePointer(matches: boolean) { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query === '(hover: none) and (pointer: coarse)' ? matches : false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + } + + function getCanvas(container: HTMLElement): HTMLElement { + const canvas = container.querySelector('canvas'); + + if (!(canvas instanceof HTMLElement)) { + throw new Error('Expected MatrixCanvas to render a canvas element'); + } + + return canvas; + } + + function renderCanvas( + engine = createEngine(), + mode: 'normal' | 'square' = 'normal', + showMobileControls = true, + ) { const onReturn = vi.fn(); const rendered = render(MatrixCanvas, { props: { @@ -74,6 +107,7 @@ describe('MatrixCanvas', () => { all8Directions: false, waveDistortion: false, mouseInteractionMode: 'off', + showMobileControls, }, }); @@ -201,8 +235,8 @@ describe('MatrixCanvas', () => { }); it('changes direction from canvas swipes', async () => { - const { engine } = renderCanvas(); - const canvas = screen.getByRole('button', { name: 'Return to settings' }); + const { container, engine } = renderCanvas(); + const canvas = getCanvas(container); await swipeCanvas(canvas, { clientX: 100, clientY: 100 }, { clientX: 20, clientY: 100 }); expect(engine.direction).toBe('west'); @@ -224,8 +258,8 @@ describe('MatrixCanvas', () => { it('moves the square from canvas swipes in square animation mode', async () => { const engine = createEngine(); engine.squareAnimationOn = true; - renderCanvas(engine); - const canvas = screen.getByRole('button', { name: 'Return to settings' }); + const { container } = renderCanvas(engine); + const canvas = getCanvas(container); await swipeCanvas(canvas, { clientX: 100, clientY: 100 }, { clientX: 20, clientY: 100 }); await swipeCanvas(canvas, { clientX: 100, clientY: 100 }, { clientX: 180, clientY: 100 }); @@ -239,8 +273,8 @@ describe('MatrixCanvas', () => { }); it('ignores tiny canvas swipes', async () => { - const { engine } = renderCanvas(); - const canvas = screen.getByRole('button', { name: 'Return to settings' }); + const { container, engine } = renderCanvas(); + const canvas = getCanvas(container); await swipeCanvas(canvas, { clientX: 100, clientY: 100 }, { clientX: 130, clientY: 100 }); @@ -249,8 +283,8 @@ describe('MatrixCanvas', () => { }); it('tracks mirrored pointer coordinates and clears pointer state on leave', async () => { - const { engine } = renderCanvas(); - const canvas = screen.getByRole('button', { name: 'Return to settings' }); + const { container, engine } = renderCanvas(); + const canvas = getCanvas(container); vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ x: 0, @@ -299,4 +333,83 @@ describe('MatrixCanvas', () => { expect(onReturn).toHaveBeenCalledTimes(2); }); + + it('does not return to settings on coarse-pointer canvas taps', async () => { + mockCoarsePointer(true); + const { container, onReturn } = renderCanvas(); + const canvas = getCanvas(container); + + await screen.findByRole('button', { name: 'Return to settings' }); + + await fireEvent.click(canvas); + await fireEvent.keyDown(canvas, { key: 'Enter' }); + + expect(onReturn).not.toHaveBeenCalled(); + expect(canvas).not.toHaveAttribute('role'); + expect(canvas).not.toHaveAttribute('aria-label'); + }); + + it('returns to settings from the coarse-pointer mobile menu button', async () => { + mockCoarsePointer(true); + const { onReturn } = renderCanvas(); + const menuButton = await screen.findByRole('button', { name: 'Return to settings' }); + + await fireEvent.click(menuButton); + + expect(onReturn).toHaveBeenCalledTimes(1); + }); + + it('hides coarse-pointer mobile controls when disabled', async () => { + mockCoarsePointer(true); + renderCanvas(createEngine(), 'normal', false); + + expect(screen.queryByRole('button', { name: 'Return to settings' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Pause or play animation' }), + ).not.toBeInTheDocument(); + }); + + it('runs shared actions from coarse-pointer mobile controls', async () => { + mockCoarsePointer(true); + const engine = createEngine(); + render(MatrixCanvasBindingWrapper, { props: { engine } }); + + await fireEvent.click(await screen.findByRole('button', { name: 'Pause or play animation' })); + await fireEvent.click(screen.getByRole('button', { name: 'Clear screen' })); + await fireEvent.click(screen.getByRole('button', { name: 'Toggle disco mode' })); + await fireEvent.click(screen.getByRole('button', { name: 'Switch animation mode' })); + await fireEvent.click(screen.getByRole('button', { name: 'Decrease speed' })); + await fireEvent.click(screen.getByRole('button', { name: 'Increase speed' })); + await fireEvent.click(screen.getByRole('button', { name: 'Decrease font size' })); + await fireEvent.click(screen.getByRole('button', { name: 'Increase font size' })); + await fireEvent.click(screen.getByRole('button', { name: 'Move west' })); + await fireEvent.click(screen.getByRole('button', { name: 'Move north' })); + + expect(engine.pause).toHaveBeenCalled(); + expect(engine.clearScreen).toHaveBeenCalled(); + expect(screen.getByTestId('disco-on')).toHaveTextContent('true'); + expect(engine.switchMode).toHaveBeenCalled(); + expect(engine.speedController).toHaveBeenCalledWith(false); + expect(engine.speedController).toHaveBeenCalledWith(true); + expect(engine.controlFontSize).toHaveBeenCalledWith(false); + expect(engine.controlFontSize).toHaveBeenCalledWith(true); + expect(engine.direction).toBe('north'); + }); + + it('moves the square from coarse-pointer direction controls in square animation mode', async () => { + mockCoarsePointer(true); + const engine = createEngine(); + engine.squareAnimationOn = true; + renderCanvas(engine); + + await fireEvent.click(await screen.findByRole('button', { name: 'Move west' })); + await fireEvent.click(screen.getByRole('button', { name: 'Move east' })); + await fireEvent.click(screen.getByRole('button', { name: 'Move north' })); + await fireEvent.click(screen.getByRole('button', { name: 'Move south' })); + + expect(engine.moveSquareLeft).toHaveBeenCalledWith(false); + expect(engine.moveSquareRight).toHaveBeenCalledWith(false); + expect(engine.moveSquareUp).toHaveBeenCalledWith(false); + expect(engine.moveSquareDown).toHaveBeenCalledWith(false); + }); }); diff --git a/src/lib/components/__tests__/MatrixCanvasBindingWrapper.svelte b/src/lib/components/__tests__/MatrixCanvasBindingWrapper.svelte index 0f79dc9..dcb2664 100644 --- a/src/lib/components/__tests__/MatrixCanvasBindingWrapper.svelte +++ b/src/lib/components/__tests__/MatrixCanvasBindingWrapper.svelte @@ -7,9 +7,15 @@ engine: CoreEngine; mode?: 'normal' | 'square'; onReturn?: () => void; + showMobileControls?: boolean; } - const { engine, mode = 'normal', onReturn = () => {} }: Props = $props(); + const { + engine, + mode = 'normal', + onReturn = () => {}, + showMobileControls = true, + }: Props = $props(); let discoOn = $state(false); let chosenColor = $state('green'); @@ -29,6 +35,8 @@ bind:all8Directions bind:waveDistortion bind:mouseInteractionMode + {showMobileControls} /> {chosenColor} +{String(discoOn)} diff --git a/src/lib/components/__tests__/MockMatrixCanvas.svelte b/src/lib/components/__tests__/MockMatrixCanvas.svelte index 2ddb8fc..b850c0c 100644 --- a/src/lib/components/__tests__/MockMatrixCanvas.svelte +++ b/src/lib/components/__tests__/MockMatrixCanvas.svelte @@ -6,6 +6,7 @@ onReturn: () => void; engine: CoreEngine; mode: 'normal' | 'square'; + showMobileControls?: boolean; }>(); onMount(() => { diff --git a/src/lib/constants/HelpData.ts b/src/lib/constants/HelpData.ts index 2bd0abb..c181f3c 100644 --- a/src/lib/constants/HelpData.ts +++ b/src/lib/constants/HelpData.ts @@ -31,4 +31,18 @@ export const helpGroups: HelpGroup[] = [ { key: 'Esc', description: 'Quit to menu' }, ], }, + { + title: 'MOBILE CONTROLS', + items: [ + { key: 'Swipe', description: 'Change rain direction / Move box' }, + { key: 'Menu', description: 'Return to settings' }, + { key: 'Pause', description: 'Pause or play animation' }, + { key: 'Clear', description: 'Clear screen' }, + { key: 'Disco', description: 'Toggle disco' }, + { key: 'Mode', description: 'Switch mode' }, + { key: 'Speed', description: 'Adjust speed' }, + { key: 'Font', description: 'Adjust font size' }, + { key: 'D-pad', description: 'Direction controls' }, + ], + }, ];